feat: add stock quality management to StokOpname; update validation rules, database schema, and frontend components to support stock quality categorization (good, retail, reject) for improved inventory tracking and reporting
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Waiting to run
tests / ci (8.4) (push) Waiting to run
tests / ci (8.5) (push) Waiting to run

This commit is contained in:
Yoga Pangestu 2026-07-05 13:46:50 +07:00
parent 18ccd7a3c0
commit 0d77d6a4fd
11 changed files with 385 additions and 136 deletions

View File

@ -25,6 +25,7 @@ public function rules(): array
'notes' => ['nullable', 'string', 'max:1000'],
'items' => ['nullable', 'array'],
'items.*.product_variant_id' => ['required', 'integer', 'exists:product_variants,id'],
'items.*.stock_quality' => ['required', \Illuminate\Validation\Rule::enum(\App\Enums\ProductStockQuality::class)],
'items.*.physical_stock' => ['nullable', 'integer', 'min:0'],
'items.*.notes' => ['nullable', 'string', 'max:500'],
];

View File

@ -23,6 +23,7 @@ public function rules(): array
'notes' => ['nullable', 'string', 'max:1000'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_variant_id' => ['required', 'integer', 'exists:product_variants,id'],
'items.*.stock_quality' => ['required', \Illuminate\Validation\Rule::enum(\App\Enums\ProductStockQuality::class)],
'items.*.physical_stock' => ['required', 'integer', 'min:0'],
'items.*.notes' => ['nullable', 'string', 'max:500'],
];

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\ProductStockQuality;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -21,9 +22,23 @@ protected function casts(): array
'difference' => 'integer',
'physical_stock' => 'integer',
'system_stock' => 'integer',
'stock_quality' => ProductStockQuality::class,
];
}
protected $appends = ['product_name', 'variant_name'];
public function getProductNameAttribute(): string
{
return $this->productVariant->product->name;
}
public function getVariantNameAttribute(): string
{
$qualityLabel = $this->stock_quality ? ' (' . $this->stock_quality->label() . ')' : '';
return $this->productVariant->name . $qualityLabel;
}
// 3. Relation
public function productVariant(): BelongsTo
{

View File

@ -54,7 +54,7 @@ public function catalogItems(): array
->active()
->with([
'variants' => fn ($query) => $query
->select('id', 'product_id', 'name', 'stock')
->select('id', 'product_id', 'name', 'stock', 'retail_stock', 'reject_stock')
->orderBy('created_at'),
])
->orderBy('name')
@ -67,6 +67,10 @@ public function catalogItems(): array
'name' => $variant->name,
'stock' => $variant->stock,
'stock_formatted' => $variant->stock_formatted,
'retail_stock' => $variant->retail_stock,
'retail_stock_formatted' => $variant->retail_stock_formatted,
'reject_stock' => $variant->reject_stock,
'reject_stock_formatted' => $variant->reject_stock_formatted,
]),
])
->toArray();
@ -90,6 +94,7 @@ public function findForEdit(StokOpname $stokOpname): array
'product_variant_id' => $item->product_variant_id,
'variant_name' => $item->productVariant->name,
'product_name' => $item->productVariant->product->name,
'stock_quality' => $item->stock_quality->value,
'system_stock' => $item->system_stock,
'physical_stock' => $item->physical_stock,
'difference' => $item->difference,
@ -235,8 +240,13 @@ public function verify(StokOpname $stokOpname, User $user, ?string $verification
DB::transaction(function () use ($stokOpname, $user, $verificationNotes): void {
foreach ($stokOpname->items as $item) {
if ($item->difference !== 0) {
$column = match ($item->stock_quality) {
\App\Enums\ProductStockQuality::GOOD => 'stock',
\App\Enums\ProductStockQuality::RETAIL => 'retail_stock',
\App\Enums\ProductStockQuality::REJECT => 'reject_stock',
};
$item->productVariant()->update([
'stock' => $item->physical_stock,
$column => $item->physical_stock,
]);
}
}
@ -346,10 +356,23 @@ private function syncItems(StokOpname $stokOpname, array $items): void
$stokOpname->items()->delete();
foreach ($items as $item) {
$systemStock = ProductVariant::find($item['product_variant_id'])?->stock ?? 0;
$variant = ProductVariant::find($item['product_variant_id']);
if (!$variant) {
continue;
}
$quality = \App\Enums\ProductStockQuality::from($item['stock_quality']);
$column = match ($quality) {
\App\Enums\ProductStockQuality::GOOD => 'stock',
\App\Enums\ProductStockQuality::RETAIL => 'retail_stock',
\App\Enums\ProductStockQuality::REJECT => 'reject_stock',
};
$systemStock = $variant->$column ?? 0;
$stokOpname->items()->create([
'product_variant_id' => $item['product_variant_id'],
'stock_quality' => $quality->value,
'system_stock' => $systemStock,
'physical_stock' => $item['physical_stock'],
'difference' => $item['physical_stock'] - $systemStock,

View File

@ -325,39 +325,39 @@ public function getMonthlyRevenue(?Carbon $startDate = null, ?Carbon $endDate =
$deduction = $discount + $fees;
$monthItems = $monthlyItemsData->get($key, collect());
$gudangItem = $monthItems->first(fn ($item) => ($item->stock_quality instanceof ProductStockQuality ? $item->stock_quality->value : $item->stock_quality) === 'good'
$warehouseItem = $monthItems->first(fn ($item) => ($item->stock_quality instanceof ProductStockQuality ? $item->stock_quality->value : $item->stock_quality) === 'good'
);
$ecerItem = $monthItems->first(fn ($item) => ($item->stock_quality instanceof ProductStockQuality ? $item->stock_quality->value : $item->stock_quality) === 'retail'
$retailItem = $monthItems->first(fn ($item) => ($item->stock_quality instanceof ProductStockQuality ? $item->stock_quality->value : $item->stock_quality) === 'retail'
);
$rejectItem = $monthItems->first(fn ($item) => ($item->stock_quality instanceof ProductStockQuality ? $item->stock_quality->value : $item->stock_quality) === 'reject'
);
$gudangSubtotal = (int) ($gudangItem?->subtotal ?? 0);
$gudangHpp = (int) ($gudangItem?->hpp ?? 0);
$ecerSubtotal = (int) ($ecerItem?->subtotal ?? 0);
$ecerHpp = (int) ($ecerItem?->hpp ?? 0);
$warehouseSubtotal = (int) ($warehouseItem?->subtotal ?? 0);
$warehouseHpp = (int) ($warehouseItem?->hpp ?? 0);
$retailSubtotal = (int) ($retailItem?->subtotal ?? 0);
$retailHpp = (int) ($retailItem?->hpp ?? 0);
$rejectSubtotal = (int) ($rejectItem?->subtotal ?? 0);
$rejectHpp = (int) ($rejectItem?->hpp ?? 0);
$totalItemsSubtotal = $gudangSubtotal + $ecerSubtotal + $rejectSubtotal;
$hpp = $gudangHpp + $ecerHpp + $rejectHpp;
$totalItemsSubtotal = $warehouseSubtotal + $retailSubtotal + $rejectSubtotal;
$hpp = $warehouseHpp + $retailHpp + $rejectHpp;
$gudangDeduction = 0;
$ecerDeduction = 0;
$warehouseDeduction = 0;
$retailDeduction = 0;
if ($totalItemsSubtotal > 0) {
$gudangDeduction = ($gudangSubtotal / $totalItemsSubtotal) * $deduction;
$ecerDeduction = ($ecerSubtotal / $totalItemsSubtotal) * $deduction;
$warehouseDeduction = ($warehouseSubtotal / $totalItemsSubtotal) * $deduction;
$retailDeduction = ($retailSubtotal / $totalItemsSubtotal) * $deduction;
}
$netGudang = $gudangSubtotal - $gudangDeduction - $gudangHpp;
$netEcer = $ecerSubtotal - $ecerDeduction - $ecerHpp;
$netWarehouse = $warehouseSubtotal - $warehouseDeduction - $warehouseHpp;
$netRetail = $retailSubtotal - $retailDeduction - $retailHpp;
$result[] = [
'month' => $monthLabel,
'total' => (int) ($revenue->total_revenue ?? 0),
'net' => (int) ($revenue->total_revenue ?? 0) - $deduction - $hpp,
'net_gudang' => (int) round($netGudang),
'net_ecer' => (int) round($netEcer),
'net_warehouse' => (int) round($netWarehouse),
'net_retail' => (int) round($netRetail),
'deduction' => $deduction,
];

View File

@ -0,0 +1,45 @@
<?php
use App\Enums\ProductStockQuality;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('stok_opname_items', function (Blueprint $table) {
$table->dropForeign(['stok_opname_id']);
$table->dropForeign(['product_variant_id']);
$table->dropUnique('stok_opname_items_stok_opname_id_product_variant_id_unique');
$table->enum('stock_quality', ProductStockQuality::values())->default(ProductStockQuality::GOOD->value)->after('product_variant_id');
$table->unique(['stok_opname_id', 'product_variant_id', 'stock_quality'], 'stok_opname_items_opname_variant_quality_unique');
$table->foreign('stok_opname_id')->references('id')->on('stok_opnames')->cascadeOnDelete();
$table->foreign('product_variant_id')->references('id')->on('product_variants')->cascadeOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('stok_opname_items', function (Blueprint $table) {
$table->dropForeign(['stok_opname_id']);
$table->dropForeign(['product_variant_id']);
$table->dropUnique('stok_opname_items_opname_variant_quality_unique');
$table->dropColumn('stock_quality');
$table->unique(['stok_opname_id', 'product_variant_id']);
$table->foreign('stok_opname_id')->references('id')->on('stok_opnames')->cascadeOnDelete();
$table->foreign('product_variant_id')->references('id')->on('product_variants')->cascadeOnDelete();
});
}
};

View File

@ -97,8 +97,8 @@ const props = defineProps<{
month: string;
total: number;
net: number;
net_gudang: number;
net_ecer: number;
net_warehouse: number;
net_retail: number;
deduction: number;
}>;
expenseSummary: {
@ -200,8 +200,8 @@ type MonthlyRevenueData = {
month: string;
total: number;
net: number;
net_gudang: number;
net_ecer: number;
net_warehouse: number;
net_retail: number;
deduction: number;
};
@ -214,11 +214,11 @@ const revenueChartConfig = {
label: 'Bersih',
color: '#22c55e',
},
net_gudang: {
net_warehouse: {
label: 'Total Gudang',
color: '#10b981',
},
net_ecer: {
net_retail: {
label: 'Total Ecer',
color: '#06b6d4',
},
@ -229,16 +229,16 @@ const revenueChartConfig = {
} satisfies ChartConfig;
const revenueTotals = computed(() => {
const net_gudang = props.monthlyRevenue.reduce((sum, item) => sum + (item.net_gudang ?? 0), 0);
const net_ecer = props.monthlyRevenue.reduce((sum, item) => sum + (item.net_ecer ?? 0), 0);
const net_warehouse = props.monthlyRevenue.reduce((sum, item) => sum + (item.net_warehouse ?? 0), 0);
const net_retail = props.monthlyRevenue.reduce((sum, item) => sum + (item.net_retail ?? 0), 0);
return {
total: props.revenueSummary.total_revenue,
net:
props.revenueSummary.total_revenue -
props.revenueSummary.total_deduction -
props.profitMetrics.hpp,
net_gudang,
net_ecer,
net_warehouse,
net_retail,
deduction: props.revenueSummary.total_deduction,
};
});
@ -248,7 +248,7 @@ const visibleRevenueCharts = computed(() => {
const isCashier = hasRole('cashier');
if (isOwner) {
return ['total', 'net', 'net_gudang', 'net_ecer', 'deduction'] as const;
return ['total', 'net', 'net_warehouse', 'net_retail', 'deduction'] as const;
}
if (isCashier) {
@ -416,14 +416,14 @@ function revenueTooltip(d: any) {
` : ''}
${isOwner ? `
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
<span class="size-2.5 rounded-full" style="background-color: ${revenueChartConfig.net_gudang.color}"></span>
<span class="text-muted-foreground">${revenueChartConfig.net_gudang.label}</span>
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.net_gudang)}</span>
<span class="size-2.5 rounded-full" style="background-color: ${revenueChartConfig.net_warehouse.color}"></span>
<span class="text-muted-foreground">${revenueChartConfig.net_warehouse.label}</span>
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.net_warehouse)}</span>
</p>
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
<span class="size-2.5 rounded-full" style="background-color: ${revenueChartConfig.net_ecer.color}"></span>
<span class="text-muted-foreground">${revenueChartConfig.net_ecer.label}</span>
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.net_ecer)}</span>
<span class="size-2.5 rounded-full" style="background-color: ${revenueChartConfig.net_retail.color}"></span>
<span class="text-muted-foreground">${revenueChartConfig.net_retail.label}</span>
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.net_retail)}</span>
</p>
` : ''}
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">

View File

@ -2,6 +2,7 @@
import { NumberInput } from '@/components/form/number-input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { groupedTableRowNumber } from '@/lib/grouped-table';
import {
stokOpnameDifference,
@ -39,56 +40,74 @@ const emit = defineEmits<{
<div class="bg-muted/60 border-b px-4 py-2.5">
<span class="text-sm font-semibold">{{ group.product_name }}</span>
</div>
<table class="w-full text-sm">
<table class="w-full text-sm border-collapse">
<thead>
<tr class="border-b">
<th class="text-muted-foreground h-9 w-12 px-4 text-center text-xs font-medium">No.</th>
<th class="text-muted-foreground h-9 px-4 text-left text-xs font-medium">Varian</th>
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium">Stok Sistem</th>
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium">Stok Fisik</th>
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium">Selisih</th>
<tr class="border-b bg-muted/20">
<th class="text-muted-foreground h-9 w-12 px-4 text-center text-xs font-medium border-r">No.</th>
<th class="text-muted-foreground h-9 px-4 text-left text-xs font-medium border-r">Varian</th>
<th class="text-muted-foreground h-9 px-4 text-left text-xs font-medium border-r w-28">Kualitas</th>
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium border-r w-28">Stok Sistem</th>
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium border-r w-32">Stok Fisik</th>
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium border-r w-28">Selisih</th>
<th class="text-muted-foreground h-9 px-4 text-left text-xs font-medium">Catatan</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rIdx) in group.rows"
:key="row.variant_id"
class="border-b transition-colors last:border-b-0 hover:bg-muted/30"
>
<td class="text-muted-foreground p-3 text-center">
{{ groupedTableRowNumber(group.startIndex + 1, rIdx) }}
</td>
<td class="p-3">{{ row.variant_name }}</td>
<td class="p-3 text-right tabular-nums">
{{ row.system_stock_formatted ?? row.system_stock }}
</td>
<td class="p-3 text-right">
<NumberInput
:model-value="row.physical_stock"
class="ml-auto w-24 text-right tabular-nums"
@update:model-value="val => {
row.physical_stock = val === '' ? 0 : Number(val);
emit('row-change');
}"
/>
</td>
<td class="p-3 text-right tabular-nums">
<span
:class="stokOpnameDifferenceClass(stokOpnameDifference(row.physical_stock, row.system_stock))"
<template v-for="(vGroup, vIdx) in group.variants" :key="vGroup.variant_id">
<tr
v-for="(qRow, qIdx) in vGroup.qualities"
:key="`${vGroup.variant_id}_${qRow.quality_key}`"
class="border-b hover:bg-muted/10"
>
<td
v-if="qIdx === 0"
rowspan="3"
class="text-muted-foreground p-3 text-center border-r border-b align-middle font-medium"
>
{{ stokOpnameDifferenceText(stokOpnameDifference(row.physical_stock, row.system_stock)) }}
</span>
</td>
<td class="p-3">
<Input
v-model="row.notes"
placeholder="Catatan..."
class="w-full min-w-[120px]"
@input="emit('row-change')"
/>
</td>
</tr>
{{ groupedTableRowNumber(group.startIndex + 1, vIdx) }}
</td>
<td
v-if="qIdx === 0"
rowspan="3"
class="p-3 border-r border-b align-middle font-semibold bg-muted/5"
>
{{ vGroup.variant_name }}
</td>
<td class="p-3 border-r border-b text-left font-medium">
<Badge :variant="qRow.quality_key === 'good' ? 'outline' : (qRow.quality_key === 'retail' ? 'secondary' : 'destructive')" class="text-xs font-normal">
{{ qRow.quality_label }}
</Badge>
</td>
<td class="p-3 border-r border-b text-right tabular-nums">
{{ qRow.system_stock_formatted ?? qRow.system_stock }}
</td>
<td class="p-3 border-r border-b text-right">
<NumberInput
:model-value="qRow.row_ref.physical_stock"
class="ml-auto w-24 text-right tabular-nums h-8 text-xs"
@update:model-value="val => {
qRow.row_ref.physical_stock = val === '' ? 0 : Number(val);
emit('row-change');
}"
/>
</td>
<td class="p-3 border-r border-b text-right tabular-nums font-semibold">
<span
:class="stokOpnameDifferenceClass(stokOpnameDifference(qRow.row_ref.physical_stock, qRow.system_stock))"
>
{{ stokOpnameDifferenceText(stokOpnameDifference(qRow.row_ref.physical_stock, qRow.system_stock)) }}
</span>
</td>
<td class="p-3 border-b">
<Input
v-model="qRow.row_ref.notes"
placeholder="Catatan..."
class="w-full h-8 text-xs"
@input="emit('row-change')"
/>
</td>
</tr>
</template>
</tbody>
</table>
</div>

View File

@ -14,30 +14,54 @@ export function buildStokOpnameVariantRows(
catalog: CatalogProduct[],
existingItems: StokOpnameExistingItem[] = [],
): StokOpnameVariantRow[] {
const existingMap = new Map<number, { physical_stock: number; notes: string }>();
const existingMap = new Map<string, { physical_stock: number; notes: string }>();
for (const item of existingItems) {
existingMap.set(item.product_variant_id, {
const key = `${item.product_variant_id}_${item.stock_quality}`;
existingMap.set(key, {
physical_stock: item.physical_stock,
notes: item.notes ?? '',
});
}
const rows: StokOpnameVariantRow[] = [];
const qualities = [
{ key: 'good', label: 'Bagus' },
{ key: 'retail', label: 'Eceran' },
{ key: 'reject', label: 'Reject' },
] as const;
for (const product of catalog) {
for (const variant of product.variants) {
const existing = existingMap.get(variant.id);
for (const quality of qualities) {
const key = `${variant.id}_${quality.key}`;
const existing = existingMap.get(key);
rows.push({
product_name: product.name,
variant_id: variant.id,
variant_name: variant.name,
system_stock: variant.stock,
system_stock_formatted: variant.stock_formatted,
physical_stock: existing?.physical_stock ?? 0,
notes: existing?.notes ?? '',
});
let systemStock = 0;
let systemStockFormatted = '0';
if (quality.key === 'good') {
systemStock = variant.stock;
systemStockFormatted = variant.stock_formatted ?? '0';
} else if (quality.key === 'retail') {
systemStock = variant.retail_stock ?? 0;
systemStockFormatted = variant.retail_stock_formatted ?? '0';
} else if (quality.key === 'reject') {
systemStock = variant.reject_stock ?? 0;
systemStockFormatted = variant.reject_stock_formatted ?? '0';
}
rows.push({
product_name: product.name,
variant_id: variant.id,
variant_name: `${variant.name} (${quality.label})`,
variant_name_raw: variant.name,
stock_quality: quality.key,
system_stock: systemStock,
system_stock_formatted: systemStockFormatted,
physical_stock: existing?.physical_stock ?? systemStock,
notes: existing?.notes ?? '',
});
}
}
}
@ -66,9 +90,10 @@ export function useStokOpnameForm(options: {
const itemsPayload = computed(() =>
variantRows.value
.filter((row) => row.physical_stock > 0 || row.notes.trim() !== '')
.filter((row) => row.physical_stock !== row.system_stock || row.notes.trim() !== '')
.map((row) => ({
product_variant_id: row.variant_id,
stock_quality: row.stock_quality,
physical_stock: row.physical_stock,
notes: row.notes || null,
})),
@ -76,19 +101,46 @@ export function useStokOpnameForm(options: {
const groupedProducts = computed<StokOpnameProductGroup[]>(() => {
const groups: StokOpnameProductGroup[] = [];
let currentProduct = '';
let currentGroup: StokOpnameProductGroup | null = null;
let idx = 0;
const productMap = new Map<string, Map<number, { variant_name: string; rows: StokOpnameVariantRow[] }>>();
for (const row of variantRows.value) {
if (row.product_name !== currentProduct) {
currentProduct = row.product_name;
currentGroup = { product_name: row.product_name, rows: [], startIndex: idx };
groups.push(currentGroup);
if (!productMap.has(row.product_name)) {
productMap.set(row.product_name, new Map());
}
const variantMap = productMap.get(row.product_name)!;
if (!variantMap.has(row.variant_id)) {
variantMap.set(row.variant_id, { variant_name: row.variant_name_raw || row.variant_name, rows: [] });
}
variantMap.get(row.variant_id)!.rows.push(row);
}
let variantIndex = 0;
for (const [productName, variantMap] of productMap.entries()) {
const productGroup: StokOpnameProductGroup = {
product_name: productName,
variants: [],
startIndex: variantIndex,
};
for (const [variantId, vData] of variantMap.entries()) {
productGroup.variants.push({
variant_id: variantId,
variant_name: vData.variant_name,
qualities: vData.rows.map((row) => {
const qualityLabel = row.stock_quality === 'good' ? 'Bagus' : (row.stock_quality === 'retail' ? 'Eceran' : 'Reject');
return {
quality_key: row.stock_quality,
quality_label: qualityLabel,
system_stock: row.system_stock,
system_stock_formatted: row.system_stock_formatted,
row_ref: row,
};
}),
});
variantIndex++;
}
currentGroup!.rows.push(row);
idx++;
groups.push(productGroup);
}
return groups;

View File

@ -54,6 +54,53 @@ const paginationSummary = usePaginationSummary(() => props.pagination, showingCo
function rowNumber(index: number): number {
return groupedTableRowNumber(props.firstItem, index);
}
interface GroupedVariantItem {
variant_id: number;
variant_name: string;
product_name: string;
qualities: Array<{
id: number;
quality_label: string;
quality_key: string;
system_stock: number;
physical_stock: number;
difference: number;
notes: string | null;
}>;
}
function getGroupedItems(items: any[]): GroupedVariantItem[] {
const groups: Record<string, GroupedVariantItem> = {};
items.forEach((item) => {
const variantName = item.product_variant?.name || item.variant_name.replace(/\s*\([^)]*\)$/, '');
const key = `${item.product_variant_id}`;
if (!groups[key]) {
groups[key] = {
variant_id: item.product_variant_id,
variant_name: variantName,
product_name: item.product_name,
qualities: [],
};
}
const label = item.stock_quality === 'good' ? 'Bagus' : (item.stock_quality === 'retail' ? 'Eceran' : 'Reject');
groups[key].qualities.push({
id: item.id,
quality_key: item.stock_quality,
quality_label: label,
system_stock: item.system_stock,
physical_stock: item.physical_stock,
difference: item.difference,
notes: item.notes,
});
});
return Object.values(groups);
}
</script>
<template>
@ -107,47 +154,72 @@ function rowNumber(index: number): number {
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-12 text-center">No.</TableHead>
<TableHead>Produk</TableHead>
<TableHead>Varian</TableHead>
<TableHead class="text-right">Stok Sistem</TableHead>
<TableHead class="text-right">Stok Fisik</TableHead>
<TableHead class="text-right">Selisih</TableHead>
<TableRow class="bg-muted/10">
<TableHead class="w-12 text-center border-r">No.</TableHead>
<TableHead class="border-r">Produk</TableHead>
<TableHead class="border-r">Varian</TableHead>
<TableHead class="border-r w-28">Kualitas</TableHead>
<TableHead class="text-right border-r w-28">Stok Sistem</TableHead>
<TableHead class="text-right border-r w-28">Stok Fisik</TableHead>
<TableHead class="text-right border-r w-28">Selisih</TableHead>
<TableHead>Catatan</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="!stokOpname.items?.length" :key="`${stokOpname.id}-empty`">
<TableCell :colspan="7" class="text-muted-foreground text-center">
<TableCell :colspan="8" class="text-muted-foreground text-center">
Belum ada item
</TableCell>
</TableRow>
<TableRow v-for="(item, rIdx) in stokOpname.items" :key="item.id">
<TableCell class="text-center text-muted-foreground tabular-nums">
{{ rIdx + 1 }}
</TableCell>
<TableCell class="font-medium">
{{ item.product_name }}
</TableCell>
<TableCell>
{{ item.variant_name }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ item.system_stock }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ item.physical_stock }}
</TableCell>
<TableCell class="text-right tabular-nums">
<span :class="stokOpnameDifferenceClass(stokOpnameDifference(item.physical_stock, item.system_stock))">
{{ stokOpnameDifferenceText(stokOpnameDifference(item.physical_stock, item.system_stock)) }}
</span>
</TableCell>
<TableCell class="text-muted-foreground">
{{ item.notes || '-' }}
</TableCell>
</TableRow>
<template v-else v-for="(vGroup, vIdx) in getGroupedItems(stokOpname.items)" :key="vGroup.variant_id">
<TableRow
v-for="(qRow, qIdx) in vGroup.qualities"
:key="qRow.id"
class="border-b hover:bg-muted/10"
>
<td
v-if="qIdx === 0"
:rowspan="vGroup.qualities.length"
class="text-center text-muted-foreground tabular-nums border-r align-middle font-medium"
>
{{ vIdx + 1 }}
</td>
<td
v-if="qIdx === 0"
:rowspan="vGroup.qualities.length"
class="font-semibold border-r align-middle"
>
{{ vGroup.product_name }}
</td>
<td
v-if="qIdx === 0"
:rowspan="vGroup.qualities.length"
class="font-medium border-r align-middle bg-muted/5"
>
{{ vGroup.variant_name }}
</td>
<td class="p-3 border-r text-left font-medium">
<Badge :variant="qRow.quality_key === 'good' ? 'outline' : (qRow.quality_key === 'retail' ? 'secondary' : 'destructive')" class="text-xs font-normal">
{{ qRow.quality_label }}
</Badge>
</td>
<td class="p-3 border-r text-right tabular-nums">
{{ qRow.system_stock }}
</td>
<td class="p-3 border-r text-right tabular-nums">
{{ qRow.physical_stock }}
</td>
<td class="p-3 border-r text-right tabular-nums font-semibold">
<span :class="stokOpnameDifferenceClass(qRow.difference)">
{{ stokOpnameDifferenceText(qRow.difference) }}
</span>
</td>
<td class="p-3 text-muted-foreground">
{{ qRow.notes || '-' }}
</td>
</TableRow>
</template>
</TableBody>
</Table>
</div>

View File

@ -22,6 +22,7 @@ export type StokOpnameItem = {
product_variant_id: number;
variant_name: string;
product_name: string;
stock_quality: 'good' | 'retail' | 'reject';
system_stock: number;
physical_stock: number;
difference: number;
@ -45,6 +46,7 @@ export type StokOpnameFormData = {
notes: string;
items: Array<{
product_variant_id: number;
stock_quality: 'good' | 'retail' | 'reject';
physical_stock: number;
notes: string;
}>;
@ -55,6 +57,10 @@ export type CatalogVariant = {
name: string;
stock: number;
stock_formatted?: string;
retail_stock?: number;
retail_stock_formatted?: string;
reject_stock?: number;
reject_stock_formatted?: string;
};
export type CatalogProduct = {
@ -69,20 +75,35 @@ export type StokOpnameVariantRow = {
product_name: string;
variant_id: number;
variant_name: string;
variant_name_raw?: string;
stock_quality: 'good' | 'retail' | 'reject';
system_stock: number;
system_stock_formatted?: string;
physical_stock: number;
notes: string;
};
export type StokOpnameVariantGroup = {
variant_id: number;
variant_name: string;
qualities: Array<{
quality_key: 'good' | 'retail' | 'reject';
quality_label: string;
system_stock: number;
system_stock_formatted?: string;
row_ref: StokOpnameVariantRow;
}>;
};
export type StokOpnameProductGroup = {
product_name: string;
rows: StokOpnameVariantRow[];
variants: StokOpnameVariantGroup[];
startIndex: number;
};
export type StokOpnameExistingItem = {
product_variant_id: number;
stock_quality: 'good' | 'retail' | 'reject';
physical_stock: number;
notes: string | null;
};