889 lines
37 KiB
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>
|