store/resources/js/components/admin/manage/cuttings/CuttingPosForm.vue

507 lines
22 KiB
Vue

<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Plus, Save, Scissors, Search, Trash2 } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Textarea } from '@/components/ui/textarea';
import type {
CuttingMaterialCartItem,
CuttingProductCatalogItem,
CuttingRawMaterialCatalogItem,
CuttingResultCartItem,
} from '@/types/cutting';
const props = defineProps<{
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
productCatalog: CuttingProductCatalogItem[];
initialData?: {
description: string;
materials: CuttingMaterialCartItem[];
results: CuttingResultCartItem[];
};
submitUrl: string;
method: 'post' | 'put';
submitLabel: string;
}>();
const materialSearch = ref('');
const productSearch = ref('');
const materialCart = ref<CuttingMaterialCartItem[]>([]);
const resultCart = ref<CuttingResultCartItem[]>([]);
const form = useForm({
description: '',
});
function populateForm() {
if (!props.initialData) {
return;
}
form.description = props.initialData.description;
materialCart.value = props.initialData.materials.map((item) => ({ ...item }));
resultCart.value = props.initialData.results.map((item) => ({ ...item }));
}
watch(
() => props.initialData,
() => {
populateForm();
},
{ immediate: true },
);
type CatalogPrice = CuttingRawMaterialCatalogItem['prices'][number];
const filteredRawMaterials = computed(() => {
const keyword = materialSearch.value.trim().toLowerCase();
if (!keyword) {
return props.rawMaterialCatalog;
}
return props.rawMaterialCatalog.filter((rawMaterial) =>
rawMaterial.name.toLowerCase().includes(keyword)
|| rawMaterial.prices.some((price) => price.variant.toLowerCase().includes(keyword)),
);
});
const filteredProducts = computed(() => {
const keyword = productSearch.value.trim().toLowerCase();
if (!keyword) {
return props.productCatalog;
}
return props.productCatalog.filter((product) =>
product.name.toLowerCase().includes(keyword)
|| product.variants.some((variant) => variant.name.toLowerCase().includes(keyword)),
);
});
function addMaterial(rawMaterial: CuttingRawMaterialCatalogItem, price: CatalogPrice) {
const existing = materialCart.value.find(
(item) => item.raw_material_price_id === price.id,
);
if (existing) {
toast.error('Bahan baku ini sudah ditambahkan.');
return;
}
materialCart.value.push({
raw_material_price_id: price.id,
raw_material_name: rawMaterial.name,
variant: price.variant,
unit_abbreviation: rawMaterial.unit_abbreviation,
stock_input: price.stock_input,
material_usage: '1',
remaining_material: '0',
images: price.images ?? [],
});
}
function removeMaterial(index: number) {
materialCart.value.splice(index, 1);
}
function addResult(product: CuttingProductCatalogItem, variant: CuttingProductCatalogItem['variants'][number]) {
const existing = resultCart.value.find(
(item) => item.product_variant_id === variant.id,
);
if (existing) {
toast.error('Varian produk ini sudah ditambahkan.');
return;
}
resultCart.value.push({
product_variant_id: variant.id,
product_name: product.name,
variant_name: variant.name,
stock: variant.stock,
cutting_result: '1',
warehouse_stock: '1',
cutting_reject: '0',
images: variant.images ?? [],
});
}
function removeResult(index: number) {
resultCart.value.splice(index, 1);
}
function syncResultTotals(item: CuttingResultCartItem) {
const total = Number(item.cutting_result) || 0;
const warehouse = Number(item.warehouse_stock) || 0;
const reject = Math.max(total - warehouse, 0);
item.cutting_reject = String(reject);
}
function submit() {
if (materialCart.value.length === 0) {
toast.error('Tambahkan minimal satu bahan baku.');
return;
}
if (resultCart.value.length === 0) {
toast.error('Tambahkan minimal satu hasil produk.');
return;
}
for (const item of resultCart.value) {
const total = Number(item.cutting_result) || 0;
const warehouse = Number(item.warehouse_stock) || 0;
const reject = Number(item.cutting_reject) || 0;
if (warehouse + reject !== total) {
toast.error(`Hasil cutting ${item.product_name} - ${item.variant_name} harus sama dengan stok gudang + reject.`);
return;
}
}
const payload = {
description: form.description,
materials: materialCart.value.map((item) => ({
raw_material_price_id: item.raw_material_price_id,
material_usage: item.material_usage,
remaining_material: item.remaining_material,
})),
results: resultCart.value.map((item) => ({
product_variant_id: item.product_variant_id,
cutting_result: item.cutting_result,
warehouse_stock: item.warehouse_stock,
cutting_reject: item.cutting_reject,
})),
};
if (props.method === 'put') {
form.transform(() => payload).put(props.submitUrl, {
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
},
});
return;
}
form.transform(() => payload).post(props.submitUrl, {
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
},
});
}
</script>
<template>
<div class="grid gap-4 xl:grid-cols-[1fr_400px]">
<div class="space-y-4">
<Card class="min-w-0">
<CardHeader class="pb-3">
<CardTitle class="text-base">Pilih Bahan Baku</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-4">
<div class="relative">
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="materialSearch" placeholder="Cari bahan baku..." class="pl-9" />
</div>
<div v-if="filteredRawMaterials.length === 0" class="py-8">
<Empty>
<EmptyHeader>
<EmptyTitle>Tidak ada bahan baku ditemukan</EmptyTitle>
<EmptyDescription>
Silakan lakukan pencarian untuk menemukan bahan baku.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-else class="space-y-4">
<div v-for="rawMaterial in filteredRawMaterials" :key="rawMaterial.id"
class="overflow-hidden rounded-md border">
<div class="border-b bg-muted/30 px-4 py-3">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-medium leading-tight">
{{ rawMaterial.name }}
</h3>
<Badge variant="secondary">
{{ rawMaterial.unit_label }}
</Badge>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Varian</TableHead>
<TableHead>Stok</TableHead>
<TableHead>Foto</TableHead>
<TableHead class="w-24 text-right" />
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="price in rawMaterial.prices" :key="price.id"
class="cursor-pointer hover:bg-muted/30"
@click="addMaterial(rawMaterial, price)">
<TableCell class="font-medium">
{{ price.variant }}
</TableCell>
<TableCell class="tabular-nums">
{{ price.stock_formatted }}
</TableCell>
<TableCell>
<MediaThumbnailCell :items="price.images ?? []" />
</TableCell>
<TableCell class="text-right">
<Button type="button" variant="outline" size="sm"
@click.stop="addMaterial(rawMaterial, price)">
<Plus class="size-3.5" />
Tambah
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</div>
</CardContent>
</Card>
<Card class="min-w-0">
<CardHeader class="pb-3">
<CardTitle class="text-base">Pilih Produk Hasil</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-4">
<div class="relative">
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="productSearch" placeholder="Cari produk..." class="pl-9" />
</div>
<div v-if="filteredProducts.length === 0" class="py-8">
<Empty>
<EmptyHeader>
<EmptyTitle>Tidak ada produk ditemukan</EmptyTitle>
<EmptyDescription>
Silakan lakukan pencarian untuk menemukan produk.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-else class="space-y-4">
<div v-for="product in filteredProducts" :key="product.id"
class="overflow-hidden rounded-md border">
<div class="border-b bg-muted/30 px-4 py-3">
<h3 class="font-medium leading-tight">
{{ product.name }}
</h3>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Varian</TableHead>
<TableHead>Stok</TableHead>
<TableHead>Foto</TableHead>
<TableHead class="w-24 text-right" />
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="variant in product.variants" :key="variant.id"
class="cursor-pointer hover:bg-muted/30"
@click="addResult(product, variant)">
<TableCell class="font-medium">
{{ variant.name }}
</TableCell>
<TableCell class="tabular-nums">
{{ variant.stock }} pcs
</TableCell>
<TableCell>
<MediaThumbnailCell :items="variant.images ?? []" />
</TableCell>
<TableCell class="text-right">
<Button type="button" variant="outline" size="sm"
@click.stop="addResult(product, variant)">
<Plus class="size-3.5" />
Tambah
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<Card class="h-fit xl:sticky xl:top-4">
<CardHeader class="pb-3">
<CardTitle class="flex items-center gap-2 text-base">
<Scissors class="size-4" />
Ringkasan Cutting
</CardTitle>
</CardHeader>
<CardContent>
<form @submit.prevent="submit">
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="description">Keterangan</FieldLabel>
<Textarea id="description" v-model="form.description"
placeholder="Contoh: Cutting batch pagi" rows="2" />
<FieldError :errors="form.errors.description ? [form.errors.description] : []" />
</Field>
<div class="space-y-2">
<p class="text-sm font-medium">
Bahan Baku
</p>
<div v-if="materialCart.length === 0"
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
Belum ada bahan baku dipilih.
</div>
<div v-else class="space-y-3">
<div v-for="(item, index) in materialCart" :key="item.raw_material_price_id"
class="rounded-lg border p-3">
<div class="mb-2 flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">
{{ item.raw_material_name }}
</p>
<p class="truncate text-xs text-muted-foreground">
{{ item.variant }} · Stok {{ item.stock_input }} {{ item.unit_abbreviation }}
</p>
</div>
<Button type="button" variant="ghost" size="icon"
class="text-destructive hover:text-destructive size-7 shrink-0"
@click="removeMaterial(index)">
<Trash2 class="size-3.5" />
</Button>
</div>
<div class="grid grid-cols-2 gap-2">
<Field>
<FieldLabel class="text-xs">
Pemakaian ({{ item.unit_abbreviation }})
</FieldLabel>
<Input v-model="item.material_usage" type="number" min="0.0001"
step="any" class="h-8" />
</Field>
<Field>
<FieldLabel class="text-xs">
Sisa ({{ item.unit_abbreviation }})
</FieldLabel>
<Input v-model="item.remaining_material" type="number" min="0"
step="any" class="h-8" />
</Field>
</div>
</div>
</div>
</div>
<Separator />
<div class="space-y-2">
<p class="text-sm font-medium">
Hasil Produk
</p>
<div v-if="resultCart.length === 0"
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
Belum ada produk hasil dipilih.
</div>
<div v-else class="space-y-3">
<div v-for="(item, index) in resultCart" :key="item.product_variant_id"
class="rounded-lg border p-3">
<div class="mb-2 flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">
{{ item.product_name }}
</p>
<p class="truncate text-xs text-muted-foreground">
{{ item.variant_name }} · Stok {{ item.stock }} pcs
</p>
</div>
<Button type="button" variant="ghost" size="icon"
class="text-destructive hover:text-destructive size-7 shrink-0"
@click="removeResult(index)">
<Trash2 class="size-3.5" />
</Button>
</div>
<div class="grid grid-cols-3 gap-2">
<Field>
<FieldLabel class="text-xs">Hasil</FieldLabel>
<Input v-model="item.cutting_result" type="number" min="1"
class="h-8" @change="syncResultTotals(item)" />
</Field>
<Field>
<FieldLabel class="text-xs">Gudang</FieldLabel>
<Input v-model="item.warehouse_stock" type="number" min="0"
class="h-8" @change="syncResultTotals(item)" />
</Field>
<Field>
<FieldLabel class="text-xs">Reject</FieldLabel>
<Input v-model="item.cutting_reject" type="number" min="0"
class="h-8" />
</Field>
</div>
</div>
</div>
</div>
<Button type="submit" class="w-full"
:disabled="form.processing || materialCart.length === 0 || resultCart.length === 0">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : submitLabel }}
</Button>
</FieldSet>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
</template>