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

889 lines
37 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 PosCatalogCard from '@/components/admin/manage/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/admin/manage/PosCatalogVariantThumb.vue';
import { DecimalInput } from '@/components/form/decimal-input';
import { NumberInput } from '@/components/form/number-input';
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 { Textarea } from '@/components/ui/textarea';
import { getFirstCoverImage } from '@/lib/catalog-cover';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import { formatRupiah } from '@/lib/rupiah';
import type {
CuttingMaterialCartItem,
CuttingProductCatalogItem,
CuttingRawMaterialCatalogItem,
CuttingResultCartItem,
} from '@/types/cutting';
function usesLengthUnit(unit: string): boolean {
return unit === 'yard' || unit === 'meter';
}
const CM_PER_YARD = 91.44;
const CM_PER_METER = 100;
function findCatalogPrice(priceId: number) {
for (const rawMaterial of props.rawMaterialCatalog) {
const price = rawMaterial.prices.find((item) => item.id === priceId);
if (price) {
return {
price: price.price,
unit: rawMaterial.unit,
};
}
}
return null;
}
function usageInNativeUnit(usageInput: number, unit: string): number {
if (unit === 'yard') {
return usageInput / CM_PER_YARD;
}
if (unit === 'meter') {
return usageInput / CM_PER_METER;
}
return usageInput;
}
const totalMaterialCost = computed(() =>
materialCart.value.reduce((sum, item) => {
const catalogPrice = findCatalogPrice(item.raw_material_price_id);
if (!catalogPrice) {
return sum;
}
const usage = usageInNativeUnit(
Number(item.material_usage) || 0,
catalogPrice.unit,
);
return sum + Math.round(usage * catalogPrice.price);
}, 0),
);
const totalResultPieces = computed(() =>
resultCart.value.reduce(
(sum, item) => sum + (Number(item.cutting_result) || 0),
0,
),
);
const estimatedCostPerUnit = computed(() => {
if (totalResultPieces.value <= 0) {
return 0;
}
return Math.round(totalMaterialCost.value / totalResultPieces.value);
});
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: rawMaterial.unit,
uses_length_unit: usesLengthUnit(rawMaterial.unit),
unit_abbreviation: rawMaterial.unit_abbreviation,
stock_input: price.stock_input,
material_usage: usesLengthUnit(rawMaterial.unit) ? '100' : '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 bagus + 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: (errors) => {
const message = Object.values(errors)[0];
toast.error(
typeof message === 'string'
? message
: 'Gagal menyimpan data. Periksa kembali formulir.',
);
},
});
return;
}
form.transform(() => payload).post(props.submitUrl, {
onError: (errors) => {
const message = Object.values(errors)[0];
toast.error(
typeof message === 'string'
? message
: '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)
? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/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 text-muted-foreground tabular-nums"
>
Stok: {{ price.stock_formatted }}
</p>
</div>
<div
v-if="getMaterialCartItem(price.id)"
class="flex shrink-0 items-center gap-1.5"
>
<Button
type="button"
variant="outline"
size="icon-sm"
@click.stop="
decreaseMaterialQty(price.id)
"
>
<Minus class="size-3.5" />
</Button>
<span
class="min-w-[1.25rem] text-center text-xs font-semibold 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)
? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/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 shrink-0 items-center gap-1.5"
>
<Button
type="button"
variant="outline"
size="icon-sm"
@click.stop="
decreaseResultQty(variant.id)
"
>
<Minus class="size-3.5" />
</Button>
<span
class="min-w-[1.25rem] text-center text-xs font-semibold 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="size-7 shrink-0 text-destructive hover:text-destructive"
@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.uses_length_unit ? 'cm' : item.unit_abbreviation }})
</FieldLabel>
<DecimalInput
v-model="
item.material_usage
"
class="h-8"
/>
</Field>
<Field>
<FieldLabel class="text-xs">
Sisa ({{ item.uses_length_unit ? 'cm' : item.unit_abbreviation }})
</FieldLabel>
<DecimalInput
v-model="
item.remaining_material
"
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="size-7 shrink-0 text-destructive hover:text-destructive"
@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
>
<NumberInput
v-model="
item.cutting_result
"
class="h-8"
@change="
syncResultTotals(item)
"
/>
</Field>
<Field>
<FieldLabel class="text-xs"
>Bagus</FieldLabel
>
<NumberInput
v-model="
item.warehouse_stock
"
class="h-8"
@change="
syncResultTotals(item)
"
/>
</Field>
<Field>
<FieldLabel class="text-xs"
>Reject</FieldLabel
>
<NumberInput
v-model="
item.cutting_reject
"
class="h-8"
/>
</Field>
</div>
</div>
</div>
</div>
<div
v-if="materialCart.length > 0"
class="rounded-lg border bg-muted/40 p-3 text-sm"
>
<p class="font-medium">Estimasi Harga Modal</p>
<p class="text-muted-foreground">
Total bahan baku:
{{ formatRupiah(totalMaterialCost) }}
</p>
<p
v-if="totalResultPieces > 0"
class="font-semibold tabular-nums"
>
{{ formatRupiah(estimatedCostPerUnit) }} / pcs
</p>
<p
v-else
class="text-xs text-muted-foreground"
>
Tambahkan hasil produk untuk estimasi per pcs.
</p>
</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>