507 lines
22 KiB
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>
|