286 lines
12 KiB
Vue
286 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import BackButton from '@/components/button/BackButton.vue';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { DatePicker } from '@/components/ui/date-picker';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
|
import type { CatalogProduct } from '@/types/stok-opname';
|
|
import { Head, router } from '@inertiajs/vue3';
|
|
import { useDebounceFn } from '@vueuse/core';
|
|
import { CheckCircle, Loader2, Send } from '@lucide/vue';
|
|
import { computed, onMounted, ref, watch } from 'vue';
|
|
import { index, auto_save, submit } from '@/routes/admin/manage/stok-opnames';
|
|
|
|
const props = defineProps<{
|
|
catalog: CatalogProduct[];
|
|
}>();
|
|
|
|
const opnameDate = ref(new Date().toISOString().split('T')[0]);
|
|
const notes = ref('');
|
|
const stokOpnameId = ref<number | null>(null);
|
|
const saving = ref(false);
|
|
const lastSaved = ref<string | null>(null);
|
|
const submitting = ref(false);
|
|
|
|
// Flatten all variants into a list
|
|
interface VariantRow {
|
|
product_name: string;
|
|
variant_id: number;
|
|
variant_name: string;
|
|
system_stock: number;
|
|
physical_stock: number;
|
|
notes: string;
|
|
}
|
|
|
|
const variantRows = ref<VariantRow[]>([]);
|
|
|
|
onMounted(() => {
|
|
const rows: VariantRow[] = [];
|
|
for (const product of props.catalog) {
|
|
for (const variant of product.variants) {
|
|
rows.push({
|
|
product_name: product.name,
|
|
variant_id: variant.id,
|
|
variant_name: variant.name,
|
|
system_stock: variant.stock,
|
|
physical_stock: 0,
|
|
notes: '',
|
|
});
|
|
}
|
|
}
|
|
variantRows.value = rows;
|
|
});
|
|
|
|
const itemsPayload = computed(() =>
|
|
variantRows.value
|
|
.filter((row) => row.physical_stock > 0 || row.notes.trim() !== '')
|
|
.map((row) => ({
|
|
product_variant_id: row.variant_id,
|
|
physical_stock: row.physical_stock,
|
|
notes: row.notes || null,
|
|
}))
|
|
);
|
|
|
|
interface ProductGroup {
|
|
product_name: string;
|
|
rows: VariantRow[];
|
|
startIndex: number;
|
|
}
|
|
|
|
const groupedProducts = computed<ProductGroup[]>(() => {
|
|
const groups: ProductGroup[] = [];
|
|
let currentProduct = '';
|
|
let currentGroup: ProductGroup | null = null;
|
|
let idx = 0;
|
|
|
|
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);
|
|
}
|
|
currentGroup!.rows.push(row);
|
|
idx++;
|
|
}
|
|
|
|
return groups;
|
|
});
|
|
|
|
const autoSave = useDebounceFn(async () => {
|
|
if (variantRows.value.length === 0) return;
|
|
|
|
saving.value = true;
|
|
try {
|
|
const response = await fetch(auto_save.url(), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '',
|
|
},
|
|
body: JSON.stringify({
|
|
stok_opname_id: stokOpnameId.value,
|
|
opname_date: opnameDate.value,
|
|
notes: notes.value || null,
|
|
items: itemsPayload.value,
|
|
}),
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
stokOpnameId.value = data.stok_opname_id;
|
|
lastSaved.value = new Date().toLocaleTimeString('id-ID');
|
|
}
|
|
} catch (e) {
|
|
console.error('Auto-save failed:', e);
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}, 1000);
|
|
|
|
watch([opnameDate, notes], () => autoSave());
|
|
|
|
function onPhysicalStockChange() {
|
|
autoSave();
|
|
}
|
|
|
|
function submitForVerification() {
|
|
if (!stokOpnameId.value) return;
|
|
submitting.value = true;
|
|
router.post(submit.url(stokOpnameId.value), {}, {
|
|
onFinish: () => {
|
|
submitting.value = false;
|
|
},
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Head title="Tambah Stok Opname" />
|
|
|
|
<AdminLayout>
|
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div class="space-y-1">
|
|
<h2 class="text-2xl font-bold tracking-tight">Stok Opname</h2>
|
|
<p class="text-muted-foreground text-sm">
|
|
Hitung stok fisik gudang dan bandingkan dengan stok sistem
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3">
|
|
<div v-if="saving" class="text-muted-foreground flex items-center gap-1.5 text-sm">
|
|
<Loader2 class="size-3.5 animate-spin" />
|
|
Menyimpan...
|
|
</div>
|
|
<div v-else-if="lastSaved" class="text-muted-foreground flex items-center gap-1.5 text-sm">
|
|
<CheckCircle class="size-3.5 text-green-600" />
|
|
Tersimpan {{ lastSaved }}
|
|
</div>
|
|
<BackButton :href="index.url()" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Informasi Dasar</CardTitle>
|
|
</CardHeader>
|
|
<CardContent class="space-y-4">
|
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
<div class="space-y-2">
|
|
<Label for="opname_date">Tanggal Opname</Label>
|
|
<DatePicker
|
|
id="opname_date"
|
|
v-model="opnameDate"
|
|
placeholder="Pilih tanggal opname"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label for="notes">Catatan</Label>
|
|
<Textarea
|
|
id="notes"
|
|
v-model="notes"
|
|
placeholder="Catatan stok opname (opsional)"
|
|
rows="2"
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Daftar Produk</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div class="space-y-2">
|
|
<div
|
|
v-for="(group, gIdx) in groupedProducts"
|
|
:key="group.product_name"
|
|
class="overflow-hidden rounded-md border"
|
|
>
|
|
<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">
|
|
<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>
|
|
<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">{{ group.startIndex + rIdx + 1 }}</td>
|
|
<td class="p-3">{{ row.variant_name }}</td>
|
|
<td class="p-3 text-right tabular-nums">{{ row.system_stock }}</td>
|
|
<td class="p-3 text-right">
|
|
<Input
|
|
v-model.number="row.physical_stock"
|
|
type="number"
|
|
min="0"
|
|
class="ml-auto w-24 text-right tabular-nums"
|
|
@input="onPhysicalStockChange"
|
|
/>
|
|
</td>
|
|
<td class="p-3 text-right tabular-nums">
|
|
<span
|
|
:class="{
|
|
'text-green-600 font-semibold': row.physical_stock - row.system_stock > 0,
|
|
'text-red-600 font-semibold': row.physical_stock - row.system_stock < 0,
|
|
'text-muted-foreground': row.physical_stock - row.system_stock === 0,
|
|
}"
|
|
>
|
|
{{ row.physical_stock - row.system_stock > 0 ? '+' : '' }}{{ 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="onPhysicalStockChange"
|
|
/>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="variantRows.length === 0"
|
|
class="text-muted-foreground py-8 text-center"
|
|
>
|
|
Tidak ada produk aktif.
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div class="flex justify-end gap-3">
|
|
<BackButton :href="index.url()" label="Kembali" />
|
|
<Button
|
|
:disabled="!stokOpnameId || itemsPayload.length === 0 || submitting"
|
|
@click="submitForVerification"
|
|
>
|
|
<Send v-if="!submitting" class="mr-1.5 size-4" />
|
|
<Loader2 v-else class="mr-1.5 size-4 animate-spin" />
|
|
{{ submitting ? 'Mengajukan...' : 'Ajukan Verifikasi' }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</AdminLayout>
|
|
</template>
|