store/resources/js/pages/admin/manage/stok-opnames/Create.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>