554 lines
25 KiB
Vue
554 lines
25 KiB
Vue
<script setup lang="ts">
|
|
import { useForm } from '@inertiajs/vue3';
|
|
import { Minus, 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 PosCatalogCard from '@/components/admin/manage/PosCatalogCard.vue';
|
|
import PosCatalogVariantThumb from '@/components/admin/manage/PosCatalogVariantThumb.vue';
|
|
import { getFirstCoverImage } from '@/lib/catalog-cover';
|
|
import { FIELD_LIMITS } from '@/lib/field-limits';
|
|
import { formErrors } from '@/lib/form';
|
|
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 getMaterialCartItem(priceId: number): CuttingMaterialCartItem | undefined {
|
|
return materialCart.value.find((item) => item.raw_material_price_id === priceId);
|
|
}
|
|
|
|
function decreaseMaterialQty(priceId: number) {
|
|
const index = materialCart.value.findIndex(
|
|
(item) => item.raw_material_price_id === priceId,
|
|
);
|
|
if (index !== -1) {
|
|
const item = materialCart.value[index];
|
|
const nextQty = (Number(item.material_usage) || 0) - 1;
|
|
if (nextQty <= 0) {
|
|
removeMaterial(index);
|
|
} else {
|
|
item.material_usage = String(nextQty);
|
|
}
|
|
}
|
|
}
|
|
|
|
function addMaterial(rawMaterial: CuttingRawMaterialCatalogItem, price: CatalogPrice) {
|
|
const existing = materialCart.value.find(
|
|
(item) => item.raw_material_price_id === price.id,
|
|
);
|
|
|
|
if (existing) {
|
|
existing.material_usage = String((Number(existing.material_usage) || 0) + 1);
|
|
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 getResultCartItem(variantId: number): CuttingResultCartItem | undefined {
|
|
return resultCart.value.find((item) => item.product_variant_id === variantId);
|
|
}
|
|
|
|
function decreaseResultQty(variantId: number) {
|
|
const index = resultCart.value.findIndex(
|
|
(item) => item.product_variant_id === variantId,
|
|
);
|
|
if (index !== -1) {
|
|
const item = resultCart.value[index];
|
|
const nextQty = (Number(item.cutting_result) || 0) - 1;
|
|
if (nextQty <= 0) {
|
|
removeResult(index);
|
|
} else {
|
|
item.cutting_result = String(nextQty);
|
|
// also adjust warehouse stock or run sync totals
|
|
item.warehouse_stock = String(Math.max(0, (Number(item.warehouse_stock) || 0) - 1));
|
|
syncResultTotals(item);
|
|
}
|
|
}
|
|
}
|
|
|
|
function addResult(product: CuttingProductCatalogItem, variant: CuttingProductCatalogItem['variants'][number]) {
|
|
const existing = resultCart.value.find(
|
|
(item) => item.product_variant_id === variant.id,
|
|
);
|
|
|
|
if (existing) {
|
|
existing.cutting_result = String((Number(existing.cutting_result) || 0) + 1);
|
|
existing.warehouse_stock = String((Number(existing.warehouse_stock) || 0) + 1);
|
|
syncResultTotals(existing);
|
|
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="columns-1 gap-4 sm:columns-2">
|
|
<PosCatalogCard v-for="rawMaterial in filteredRawMaterials" :key="rawMaterial.id"
|
|
:title="rawMaterial.name" :cover-image="getFirstCoverImage(rawMaterial.prices)">
|
|
<template #header-extra>
|
|
<Badge variant="secondary" class="mt-1.5">
|
|
{{ rawMaterial.unit_label }}
|
|
</Badge>
|
|
</template>
|
|
|
|
<p v-if="!rawMaterial.prices.length" class="px-3 py-4 text-sm text-muted-foreground">
|
|
Belum ada varian
|
|
</p>
|
|
<div v-for="price in rawMaterial.prices" :key="price.id"
|
|
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200"
|
|
:class="[
|
|
'cursor-pointer hover:bg-muted/30',
|
|
getMaterialCartItem(price.id) ? 'border-2 border-primary bg-primary/5 rounded-md mx-1 my-0.5' : ''
|
|
]"
|
|
@click="!getMaterialCartItem(price.id) && addMaterial(rawMaterial, price)">
|
|
<PosCatalogVariantThumb :items="price.images" />
|
|
<div class="min-w-0 flex-1">
|
|
<p class="truncate text-sm font-medium">
|
|
{{ price.variant }}
|
|
</p>
|
|
<p class="text-xs tabular-nums text-muted-foreground">
|
|
Stok: {{ price.stock_formatted }}
|
|
</p>
|
|
</div>
|
|
<div v-if="getMaterialCartItem(price.id)" class="flex items-center gap-1.5 shrink-0">
|
|
<Button type="button" variant="outline" size="icon-sm"
|
|
@click.stop="decreaseMaterialQty(price.id)">
|
|
<Minus class="size-3.5" />
|
|
</Button>
|
|
<span class="text-xs font-semibold min-w-[1.25rem] text-center tabular-nums">
|
|
{{ getMaterialCartItem(price.id)!.material_usage }}
|
|
</span>
|
|
<Button type="button" variant="outline" size="icon-sm"
|
|
@click.stop="addMaterial(rawMaterial, price)">
|
|
<Plus class="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
<Button v-else type="button" variant="outline" size="icon-sm" class="shrink-0"
|
|
@click.stop="addMaterial(rawMaterial, price)">
|
|
<Plus class="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
</PosCatalogCard>
|
|
</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="columns-1 gap-4 sm:columns-2">
|
|
<PosCatalogCard v-for="product in filteredProducts" :key="product.id" :title="product.name"
|
|
:cover-image="getFirstCoverImage(product.variants)">
|
|
<p v-if="!product.variants.length" class="px-3 py-4 text-sm text-muted-foreground">
|
|
Belum ada varian
|
|
</p>
|
|
<div v-for="variant in product.variants" :key="variant.id"
|
|
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200"
|
|
:class="[
|
|
'cursor-pointer hover:bg-muted/30',
|
|
getResultCartItem(variant.id) ? 'border-2 border-primary bg-primary/5 rounded-md mx-1 my-0.5' : ''
|
|
]"
|
|
@click="!getResultCartItem(variant.id) && addResult(product, variant)">
|
|
<PosCatalogVariantThumb :items="variant.images" />
|
|
<div class="min-w-0 flex-1">
|
|
<p class="truncate text-sm font-medium">
|
|
{{ variant.name }}
|
|
</p>
|
|
<p class="text-xs text-muted-foreground">
|
|
<span class="tabular-nums">Stok: {{ variant.stock }} pcs</span>
|
|
</p>
|
|
</div>
|
|
<div v-if="getResultCartItem(variant.id)" class="flex items-center gap-1.5 shrink-0">
|
|
<Button type="button" variant="outline" size="icon-sm"
|
|
@click.stop="decreaseResultQty(variant.id)">
|
|
<Minus class="size-3.5" />
|
|
</Button>
|
|
<span class="text-xs font-semibold min-w-[1.25rem] text-center tabular-nums">
|
|
{{ getResultCartItem(variant.id)!.cutting_result }}
|
|
</span>
|
|
<Button type="button" variant="outline" size="icon-sm"
|
|
@click.stop="addResult(product, variant)">
|
|
<Plus class="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
<Button v-else type="button" variant="outline" size="icon-sm" class="shrink-0"
|
|
@click.stop="addResult(product, variant)">
|
|
<Plus class="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
</PosCatalogCard>
|
|
</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"
|
|
:maxlength="FIELD_LIMITS.description" />
|
|
<FieldError :errors="formErrors(form, '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="0.0001" 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="0.0001" 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" step="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" step="1"
|
|
class="h-8" @change="syncResultTotals(item)" />
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel class="text-xs">Reject</FieldLabel>
|
|
<Input v-model="item.cutting_reject" type="number" min="0" step="1"
|
|
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>
|