feat: enhance Cutting module by adding image upload functionality and improving form handling; update CuttingPosForm and related components to support image management and improve user experience
Some checks failed
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

This commit is contained in:
Yoga Pangestu 2026-07-02 23:03:27 +07:00
parent c3db522c87
commit 33afec2513
5 changed files with 490 additions and 141 deletions

View File

@ -23,6 +23,7 @@ const props = defineProps<{
const initialData = computed(() => ({ const initialData = computed(() => ({
description: props.cutting.description ?? '', description: props.cutting.description ?? '',
images: props.cutting.images ?? [],
sewing_cost: String(props.cutting.sewing_cost ?? 0), sewing_cost: String(props.cutting.sewing_cost ?? 0),
other_cost: String(props.cutting.other_cost ?? 0), other_cost: String(props.cutting.other_cost ?? 0),
materials: props.cutting.materials.map((item) => ({ materials: props.cutting.materials.map((item) => ({
@ -52,7 +53,9 @@ const initialData = computed(() => ({
<Head title="Ubah Cutting" /> <Head title="Ubah Cutting" />
<AdminLayout> <AdminLayout>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div
class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
>
<div class="space-y-1"> <div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">Ubah Cutting</h2> <h2 class="text-2xl font-bold tracking-tight">Ubah Cutting</h2>
</div> </div>
@ -60,9 +63,15 @@ const initialData = computed(() => ({
<BackButton :href="index.url()" /> <BackButton :href="index.url()" />
</div> </div>
<CuttingPosForm :raw-material-catalog="rawMaterialCatalog" :product-catalog="productCatalog" <CuttingPosForm
:categories="categories" :units="units" :raw-material-catalog="rawMaterialCatalog"
:initial-data="initialData" :submit-url="update.url(props.cutting.id)" method="put" :product-catalog="productCatalog"
submit-label="Perbarui" /> :categories="categories"
:units="units"
:initial-data="initialData"
:submit-url="update.url(props.cutting.id)"
method="put"
submit-label="Perbarui"
/>
</AdminLayout> </AdminLayout>
</template> </template>

View File

@ -38,7 +38,12 @@ const groupedMaterials = computed<GroupedMaterials[]>(() => {
const name = item.raw_material_name || 'Bahan Baku Tidak Diketahui'; const name = item.raw_material_name || 'Bahan Baku Tidak Diketahui';
if (!groups[key]) { if (!groups[key]) {
groups[key] = { key, name, unitLabel: item.raw_material_unit_label, items: [] }; groups[key] = {
key,
name,
unitLabel: item.raw_material_unit_label,
items: [],
};
} }
groups[key].items.push(item); groups[key].items.push(item);
@ -71,8 +76,12 @@ const groupedResults = computed<GroupedResults[]>(() => {
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8"> <div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Header --> <!-- Header -->
<div class="mb-8 text-center"> <div class="mb-8 text-center">
<h1 class="text-2xl font-bold tracking-tight">Detail Cutting</h1> <h1 class="text-2xl font-bold tracking-tight">
<p class="mt-1 text-muted-foreground">Rincian data proses cutting</p> Detail Cutting
</h1>
<p class="mt-1 text-muted-foreground">
Rincian data proses cutting
</p>
</div> </div>
<!-- Main Card --> <!-- Main Card -->
@ -80,18 +89,37 @@ const groupedResults = computed<GroupedResults[]>(() => {
<!-- Cutting Info Header --> <!-- Cutting Info Header -->
<div class="border-b bg-muted/30 p-6"> <div class="border-b bg-muted/30 p-6">
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<h2 class="text-xl font-semibold">Cutting #{{ cutting.id }}</h2> <h2 class="text-xl font-semibold">
<Badge :variant="cuttingStatusBadgeVariant(cutting.status)"> Cutting #{{ cutting.id }}
</h2>
<Badge
:variant="cuttingStatusBadgeVariant(cutting.status)"
>
{{ cutting.status_label }} {{ cutting.status_label }}
</Badge> </Badge>
</div> </div>
<div class="mt-3 space-y-1 text-sm text-muted-foreground"> <div class="mt-3 space-y-1 text-sm text-muted-foreground">
<p>{{ cutting.created_at_formatted }}</p> <p>{{ cutting.created_at_formatted }}</p>
<p>Oleh {{ cutting.created_by?.profile?.full_name ?? cutting.created_by?.username }}</p> <p>
Oleh
{{
cutting.created_by?.profile?.full_name ??
cutting.created_by?.username
}}
</p>
</div> </div>
<p v-if="cutting.description" class="mt-3 text-sm"> <p v-if="cutting.description" class="mt-3 text-sm">
{{ cutting.description }} {{ cutting.description }}
</p> </p>
<div
v-if="cutting.images && cutting.images.length > 0"
class="mt-3 flex flex-wrap gap-2"
>
<MediaThumbnailCell
:items="cutting.images"
:max-visible="10"
/>
</div>
</div> </div>
<!-- Summary --> <!-- Summary -->
@ -99,15 +127,26 @@ const groupedResults = computed<GroupedResults[]>(() => {
<h3 class="mb-3 text-sm font-semibold">Ringkasan</h3> <h3 class="mb-3 text-sm font-semibold">Ringkasan</h3>
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<div class="rounded-lg border p-3"> <div class="rounded-lg border p-3">
<p class="text-xs text-muted-foreground">Total Hasil Cutting</p> <p class="text-xs text-muted-foreground">
Total Hasil Cutting
</p>
<p class="mt-1 text-lg font-semibold text-primary"> <p class="mt-1 text-lg font-semibold text-primary">
{{ cutting.total_result_pieces ?? 0 }} pcs {{ cutting.total_result_pieces ?? 0 }} pcs
</p> </p>
</div> </div>
<div v-if="cutting.total_material_usage_summary_formatted" class="rounded-lg border p-3"> <div
<p class="text-xs text-muted-foreground">Total Pemakaian Bahan</p> v-if="
cutting.total_material_usage_summary_formatted
"
class="rounded-lg border p-3"
>
<p class="text-xs text-muted-foreground">
Total Pemakaian Bahan
</p>
<p class="mt-1 text-lg font-semibold text-primary"> <p class="mt-1 text-lg font-semibold text-primary">
{{ cutting.total_material_usage_summary_formatted }} {{
cutting.total_material_usage_summary_formatted
}}
</p> </p>
</div> </div>
</div> </div>
@ -122,28 +161,52 @@ const groupedResults = computed<GroupedResults[]>(() => {
<TableRow> <TableRow>
<TableHead>Varian</TableHead> <TableHead>Varian</TableHead>
<TableHead>Foto</TableHead> <TableHead>Foto</TableHead>
<TableHead class="text-right">Pemakaian</TableHead> <TableHead class="text-right"
>Pemakaian</TableHead
>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<template v-for="group in groupedMaterials" :key="group.key"> <template
<TableRow class="bg-muted/20 hover:bg-muted/20"> v-for="group in groupedMaterials"
<TableCell colspan="3" class="font-semibold"> :key="group.key"
>
<TableRow
class="bg-muted/20 hover:bg-muted/20"
>
<TableCell
colspan="3"
class="font-semibold"
>
{{ group.name }} {{ group.name }}
<Badge v-if="group.unitLabel" variant="secondary" class="ml-2 font-normal"> <Badge
v-if="group.unitLabel"
variant="secondary"
class="ml-2 font-normal"
>
{{ group.unitLabel }} {{ group.unitLabel }}
</Badge> </Badge>
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow v-for="material in group.items" :key="material.id"> <TableRow
v-for="material in group.items"
:key="material.id"
>
<TableCell class="pl-6"> <TableCell class="pl-6">
{{ material.variant }} {{ material.variant }}
</TableCell> </TableCell>
<TableCell> <TableCell>
<MediaThumbnailCell :items="material.images ?? []" :max-visible="1" /> <MediaThumbnailCell
:items="material.images ?? []"
:max-visible="1"
/>
</TableCell> </TableCell>
<TableCell class="text-right tabular-nums"> <TableCell
{{ material.material_usage_formatted }} class="text-right tabular-nums"
>
{{
material.material_usage_formatted
}}
</TableCell> </TableCell>
</TableRow> </TableRow>
</template> </template>
@ -161,33 +224,63 @@ const groupedResults = computed<GroupedResults[]>(() => {
<TableRow> <TableRow>
<TableHead>Varian</TableHead> <TableHead>Varian</TableHead>
<TableHead>Foto</TableHead> <TableHead>Foto</TableHead>
<TableHead class="text-right">Hasil</TableHead> <TableHead class="text-right"
<TableHead class="text-right">Sample</TableHead> >Hasil</TableHead
<TableHead class="text-right">Diluar Sample</TableHead> >
<TableHead class="text-right"
>Sample</TableHead
>
<TableHead class="text-right"
>Diluar Sample</TableHead
>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<template v-for="group in groupedResults" :key="group.key"> <template
<TableRow class="bg-muted/20 hover:bg-muted/20"> v-for="group in groupedResults"
<TableCell colspan="5" class="font-semibold"> :key="group.key"
>
<TableRow
class="bg-muted/20 hover:bg-muted/20"
>
<TableCell
colspan="5"
class="font-semibold"
>
{{ group.name }} {{ group.name }}
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow v-for="result in group.items" :key="result.id"> <TableRow
v-for="result in group.items"
:key="result.id"
>
<TableCell class="pl-6"> <TableCell class="pl-6">
{{ result.product_variant?.name }} {{ result.product_variant?.name }}
</TableCell> </TableCell>
<TableCell> <TableCell>
<MediaThumbnailCell :items="result.product_variant?.images ?? []" :max-visible="1" /> <MediaThumbnailCell
:items="
result.product_variant
?.images ?? []
"
:max-visible="1"
/>
</TableCell> </TableCell>
<TableCell class="text-right tabular-nums"> <TableCell
class="text-right tabular-nums"
>
{{ result.cutting_result }} pcs {{ result.cutting_result }} pcs
</TableCell> </TableCell>
<TableCell class="text-right tabular-nums"> <TableCell
class="text-right tabular-nums"
>
{{ result.sample }} pcs {{ result.sample }} pcs
</TableCell> </TableCell>
<TableCell class="text-right tabular-nums"> <TableCell
{{ result.original_outside_sample }} pcs class="text-right tabular-nums"
>
{{ result.original_outside_sample }}
pcs
</TableCell> </TableCell>
</TableRow> </TableRow>
</template> </template>

View File

@ -11,6 +11,12 @@ import type {
} from '@/types/cutting'; } from '@/types/cutting';
import type { CategoryOption } from '@/types/product'; import type { CategoryOption } from '@/types/product';
import type { EnumOption } from '@/types/raw-material'; import type { EnumOption } from '@/types/raw-material';
import type { MediaItem } from '@/types/media';
import {
appendRootPhotosToFormData,
createMediaUploadState,
} from '@/types/media';
import type { MediaUploadState } from '@/types/media';
import CuttingPosCartDetailDialog from './CuttingPosCartDetailDialog.vue'; import CuttingPosCartDetailDialog from './CuttingPosCartDetailDialog.vue';
import CuttingPosMaterialCatalogPanel from './CuttingPosMaterialCatalogPanel.vue'; import CuttingPosMaterialCatalogPanel from './CuttingPosMaterialCatalogPanel.vue';
import CuttingPosResultCatalogPanel from './CuttingPosResultCatalogPanel.vue'; import CuttingPosResultCatalogPanel from './CuttingPosResultCatalogPanel.vue';
@ -24,6 +30,7 @@ const props = defineProps<{
units: EnumOption[]; units: EnumOption[];
initialData?: { initialData?: {
description: string; description: string;
images?: MediaItem[];
sewing_cost?: string; sewing_cost?: string;
other_cost?: string; other_cost?: string;
materials: CuttingMaterialCartItem[]; materials: CuttingMaterialCartItem[];
@ -39,12 +46,29 @@ const props = defineProps<{
const isCreateMode = computed(() => props.method === 'post'); const isCreateMode = computed(() => props.method === 'post');
const cartDetailOpen = ref(false); const cartDetailOpen = ref(false);
// Make catalogs mutable so we can add new items from quick-create dialogs const imageState = ref<MediaUploadState>(createMediaUploadState());
const rawMaterialCatalogState = ref<CuttingRawMaterialCatalogItem[]>([...props.rawMaterialCatalog]); const isUploading = computed(() => imageState.value.pendingUploads > 0);
const productCatalogState = ref<CuttingProductCatalogItem[]>([...props.productCatalog]);
watch(() => props.rawMaterialCatalog, (val) => { rawMaterialCatalogState.value = [...val]; }); // Make catalogs mutable so we can add new items from quick-create dialogs
watch(() => props.productCatalog, (val) => { productCatalogState.value = [...val]; }); const rawMaterialCatalogState = ref<CuttingRawMaterialCatalogItem[]>([
...props.rawMaterialCatalog,
]);
const productCatalogState = ref<CuttingProductCatalogItem[]>([
...props.productCatalog,
]);
watch(
() => props.rawMaterialCatalog,
(val) => {
rawMaterialCatalogState.value = [...val];
},
);
watch(
() => props.productCatalog,
(val) => {
productCatalogState.value = [...val];
},
);
const form = useForm({ const form = useForm({
description: '', description: '',
@ -83,39 +107,68 @@ const {
function populateForm() { function populateForm() {
if (!props.initialData) { if (!props.initialData) {
imageState.value = createMediaUploadState();
return; return;
} }
form.description = props.initialData.description; form.description = props.initialData.description;
form.sewing_cost = props.initialData.sewing_cost ?? '0'; form.sewing_cost = props.initialData.sewing_cost ?? '0';
form.other_cost = props.initialData.other_cost ?? '0'; form.other_cost = props.initialData.other_cost ?? '0';
imageState.value = createMediaUploadState(props.initialData.images ?? []);
setCarts(props.initialData.materials, props.initialData.results); setCarts(props.initialData.materials, props.initialData.results);
} }
watch(() => props.initialData, populateForm, { immediate: true }); watch(() => props.initialData, populateForm, { immediate: true });
loadDraftItems(props.draftMaterials ?? [], props.draftResults ?? []); loadDraftItems(props.draftMaterials ?? [], props.draftResults ?? []);
function buildPayload() { function buildFormData(): FormData {
const payload: Record<string, unknown> = { const formData = new FormData();
description: form.description,
sewing_cost: Number.parseInt(parseRupiah(form.sewing_cost), 10) || 0,
other_cost: Number.parseInt(parseRupiah(form.other_cost), 10) || 0,
};
if (!isCreateMode.value) { if (props.method === 'put') {
payload.materials = materialCart.value.map((item) => ({ formData.append('_method', 'PUT');
raw_material_price_id: item.raw_material_price_id,
material_usage: item.material_usage,
}));
payload.results = resultCart.value.map((item) => ({
product_variant_id: item.product_variant_id,
cutting_result: item.cutting_result,
sample: item.sample,
original_outside_sample: item.original_outside_sample,
}));
} }
return payload; formData.append('description', form.description);
formData.append(
'sewing_cost',
String(Number.parseInt(parseRupiah(form.sewing_cost), 10) || 0),
);
formData.append(
'other_cost',
String(Number.parseInt(parseRupiah(form.other_cost), 10) || 0),
);
appendRootPhotosToFormData(formData, imageState.value);
if (props.method === 'put') {
materialCart.value.forEach((item, index) => {
formData.append(
`materials[${index}][raw_material_price_id]`,
String(item.raw_material_price_id),
);
formData.append(
`materials[${index}][material_usage]`,
item.material_usage,
);
});
resultCart.value.forEach((item, index) => {
formData.append(
`results[${index}][product_variant_id]`,
String(item.product_variant_id),
);
formData.append(
`results[${index}][cutting_result]`,
String(item.cutting_result),
);
formData.append(`results[${index}][sample]`, String(item.sample));
formData.append(
`results[${index}][original_outside_sample]`,
String(item.original_outside_sample),
);
});
}
return formData;
} }
function submit() { function submit() {
@ -131,21 +184,10 @@ function submit() {
return; return;
} }
const payload = buildPayload(); const payload = buildFormData();
if (props.method === 'put') {
form.transform(() => payload).put(props.submitUrl, {
onError: (errors: Record<string, string>) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
return;
}
form.transform(() => payload).post(props.submitUrl, { form.transform(() => payload).post(props.submitUrl, {
forceFormData: true,
onError: (errors: Record<string, string>) => { onError: (errors: Record<string, string>) => {
if (errors.system) { if (errors.system) {
toast.error(errors.system); toast.error(errors.system);
@ -182,29 +224,53 @@ function onProductCreated(product: CuttingProductCatalogItem) {
<template> <template>
<div class="grid gap-4 xl:grid-cols-[1fr_400px]"> <div class="grid gap-4 xl:grid-cols-[1fr_400px]">
<div class="space-y-4"> <div class="space-y-4">
<CuttingPosMaterialCatalogPanel v-model:material-search="materialSearch" <CuttingPosMaterialCatalogPanel
:filtered-raw-materials="filteredRawMaterials" :get-material-cart-item="getMaterialCartItem" v-model:material-search="materialSearch"
:filtered-raw-materials="filteredRawMaterials"
:get-material-cart-item="getMaterialCartItem"
:units="units" :units="units"
@add-material="addMaterial" @decrease-material-qty="decreaseMaterialQty" @add-material="addMaterial"
@raw-material-created="onRawMaterialCreated" /> @decrease-material-qty="decreaseMaterialQty"
@raw-material-created="onRawMaterialCreated"
/>
<CuttingPosResultCatalogPanel v-model:product-search="productSearch" :filtered-products="filteredProducts" <CuttingPosResultCatalogPanel
:get-result-cart-item="getResultCartItem" @add-result="addResult" v-model:product-search="productSearch"
:filtered-products="filteredProducts"
:get-result-cart-item="getResultCartItem"
@add-result="addResult"
:is-create-mode="isCreateMode" :is-create-mode="isCreateMode"
:categories="categories" :categories="categories"
@decrease-result-qty="decreaseResultQty" @decrease-result-qty="decreaseResultQty"
@product-created="onProductCreated" /> @product-created="onProductCreated"
/>
</div> </div>
<CuttingPosSummaryPanel :form="form" :material-cart="materialCart" :result-cart="resultCart" <CuttingPosSummaryPanel
:total-result-pieces="totalResultPieces" :submit-label="submitLabel" @submit="submit" v-model:image-state="imageState"
:form="form"
:material-cart="materialCart"
:result-cart="resultCart"
:total-result-pieces="totalResultPieces"
:submit-label="submitLabel"
@submit="submit"
:is-create-mode="isCreateMode" :is-create-mode="isCreateMode"
@open-detail="cartDetailOpen = true" @remove-material="removeMaterial" :is-uploading="isUploading"
@sync-material-field="syncMaterialField" @remove-result="removeResult" @open-detail="cartDetailOpen = true"
@sync-result-totals="syncResultTotals" @sync-result-field="syncResultField" /> @remove-material="removeMaterial"
@sync-material-field="syncMaterialField"
@remove-result="removeResult"
@sync-result-totals="syncResultTotals"
@sync-result-field="syncResultField"
/>
</div> </div>
<CuttingPosCartDetailDialog v-model:open="cartDetailOpen" :material-cart="materialCart" :result-cart="resultCart" <CuttingPosCartDetailDialog
:material-line-cost="materialLineCost" :total-material-cost="totalMaterialCost" v-model:open="cartDetailOpen"
:is-create-mode="isCreateMode" /> :material-cart="materialCart"
:result-cart="resultCart"
:material-line-cost="materialLineCost"
:total-material-cost="totalMaterialCost"
:is-create-mode="isCreateMode"
/>
</template> </template>

View File

@ -14,9 +14,14 @@ import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { FIELD_LIMITS } from '@/lib/field-limits'; import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors, type FormWithErrors } from '@/lib/form'; import { formErrors, type FormWithErrors } from '@/lib/form';
import type { CuttingMaterialCartItem, CuttingResultCartItem } from '@/types/cutting'; import type {
CuttingMaterialCartItem,
CuttingResultCartItem,
} from '@/types/cutting';
import CuttingPosMaterialSummaryItems from './CuttingPosMaterialSummaryItems.vue'; import CuttingPosMaterialSummaryItems from './CuttingPosMaterialSummaryItems.vue';
import CuttingPosResultSummaryItems from './CuttingPosResultSummaryItems.vue'; import CuttingPosResultSummaryItems from './CuttingPosResultSummaryItems.vue';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import type { MediaUploadState } from '@/types/media';
defineProps<{ defineProps<{
form: FormWithErrors & { description: string; processing?: boolean }; form: FormWithErrors & { description: string; processing?: boolean };
@ -25,6 +30,7 @@ defineProps<{
totalResultPieces: number; totalResultPieces: number;
submitLabel: string; submitLabel: string;
isCreateMode: boolean; isCreateMode: boolean;
isUploading?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -36,12 +42,18 @@ const emit = defineEmits<{
'sync-result-totals': [item: CuttingResultCartItem]; 'sync-result-totals': [item: CuttingResultCartItem];
'sync-result-field': [index: number]; 'sync-result-field': [index: number];
}>(); }>();
const imageState = defineModel<MediaUploadState>('imageState', {
required: true,
});
</script> </script>
<template> <template>
<Card class="h-fit xl:sticky xl:top-4"> <Card class="h-fit xl:sticky xl:top-4">
<CardHeader class="pb-3"> <CardHeader class="pb-3">
<CardTitle class="flex items-center justify-between gap-2 text-base"> <CardTitle
class="flex items-center justify-between gap-2 text-base"
>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<Scissors class="size-4" /> <Scissors class="size-4" />
Ringkasan Cutting Ringkasan Cutting
@ -55,7 +67,11 @@ const emit = defineEmits<{
> >
Lihat Detail Lihat Detail
</button> </button>
<Badge v-if="!isCreateMode && totalResultPieces > 0" variant="secondary" class="tabular-nums font-semibold"> <Badge
v-if="!isCreateMode && totalResultPieces > 0"
variant="secondary"
class="font-semibold tabular-nums"
>
Total: {{ totalResultPieces }} pcs Total: {{ totalResultPieces }} pcs
</Badge> </Badge>
</span> </span>
@ -66,7 +82,9 @@ const emit = defineEmits<{
<FieldGroup> <FieldGroup>
<FieldSet class="grid gap-4"> <FieldSet class="grid gap-4">
<Field> <Field>
<FieldLabel for="description">Keterangan</FieldLabel> <FieldLabel for="description"
>Keterangan</FieldLabel
>
<Textarea <Textarea
id="description" id="description"
v-model="form.description" v-model="form.description"
@ -74,9 +92,19 @@ const emit = defineEmits<{
rows="2" rows="2"
:maxlength="FIELD_LIMITS.description" :maxlength="FIELD_LIMITS.description"
/> />
<FieldError :errors="formErrors(form, 'description')" /> <FieldError
:errors="formErrors(form, 'description')"
/>
</Field> </Field>
<MediaDropzone
id="cutting-images"
v-model="imageState"
label="Foto Cutting"
:max-files="10"
:errors="formErrors(form, 'images')"
/>
<CuttingPosMaterialSummaryItems <CuttingPosMaterialSummaryItems
:material-cart="materialCart" :material-cart="materialCart"
@remove="emit('remove-material', $event)" @remove="emit('remove-material', $event)"
@ -97,10 +125,21 @@ const emit = defineEmits<{
<Button <Button
type="submit" type="submit"
class="w-full" class="w-full"
:disabled="form.processing || materialCart.length === 0 || resultCart.length === 0" :disabled="
form.processing ||
isUploading ||
materialCart.length === 0 ||
resultCart.length === 0
"
> >
<Save class="size-4" /> <Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : submitLabel }} {{
isUploading
? 'Mengunggah...'
: form.processing
? 'Menyimpan...'
: submitLabel
}}
</Button> </Button>
</FieldSet> </FieldSet>
</FieldGroup> </FieldGroup>

View File

@ -37,7 +37,11 @@ const emit = defineEmits<{
}>(); }>();
const showingCount = computed(() => props.cuttings.length); const showingCount = computed(() => props.cuttings.length);
const paginationSummary = usePaginationSummary(() => props.pagination, showingCount, 'cutting'); const paginationSummary = usePaginationSummary(
() => props.pagination,
showingCount,
'cutting',
);
function rowNumber(index: number): number { function rowNumber(index: number): number {
return groupedTableRowNumber(props.firstItem, index); return groupedTableRowNumber(props.firstItem, index);
@ -61,7 +65,8 @@ function getGroupedMaterials(materials: any[]): GroupedCuttingMaterials[] {
materials.forEach((item) => { materials.forEach((item) => {
const rawMaterialId = item.raw_material_id ?? 0; const rawMaterialId = item.raw_material_id ?? 0;
const rawMaterialName = item.raw_material_name || 'Bahan Baku Tidak Diketahui'; const rawMaterialName =
item.raw_material_name || 'Bahan Baku Tidak Diketahui';
const unitLabel = item.raw_material_unit_label; const unitLabel = item.raw_material_unit_label;
if (!groups[rawMaterialId]) { if (!groups[rawMaterialId]) {
@ -100,59 +105,126 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
return Object.values(groups); return Object.values(groups);
} }
</script> </script>
<template> <template>
<div class="space-y-4"> <div class="space-y-4">
<DataTableToolbar v-model:search="search" @filters-reset="emit('filters-reset')" /> <DataTableToolbar
v-model:search="search"
@filters-reset="emit('filters-reset')"
/>
<div v-if="cuttings.length" class="space-y-4"> <div v-if="cuttings.length" class="space-y-4">
<div v-for="(cutting, index) in cuttings" :key="cutting.id" class="overflow-hidden rounded-md border"> <div
v-for="(cutting, index) in cuttings"
:key="cutting.id"
class="overflow-hidden rounded-md border"
>
<div <div
class="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 sm:flex-row sm:items-start sm:justify-between"> class="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 sm:flex-row sm:items-start sm:justify-between"
>
<div class="flex min-w-0 items-start gap-3"> <div class="flex min-w-0 items-start gap-3">
<span class="text-muted-foreground w-8 shrink-0 pt-0.5 text-center text-sm tabular-nums"> <span
class="w-8 shrink-0 pt-0.5 text-center text-sm text-muted-foreground tabular-nums"
>
{{ rowNumber(index) }} {{ rowNumber(index) }}
</span> </span>
<div class="min-w-0 space-y-2"> <div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<h3 class="font-medium leading-tight"> <h3 class="leading-tight font-medium">
Cutting #{{ cutting.id }} Cutting #{{ cutting.id }}
</h3> </h3>
<Badge :variant="cuttingStatusBadgeVariant(cutting.status)"> <Badge
:variant="
cuttingStatusBadgeVariant(
cutting.status,
)
"
>
{{ cutting.status_label }} {{ cutting.status_label }}
</Badge> </Badge>
</div> </div>
<div class="text-muted-foreground space-y-1 text-sm"> <div
class="space-y-1 text-sm text-muted-foreground"
>
<p>{{ cutting.created_at_formatted }}</p> <p>{{ cutting.created_at_formatted }}</p>
<p>Oleh {{ cutting.created_by?.profile?.full_name ?? cutting.created_by?.username }}</p> <p>
Oleh
{{
cutting.created_by?.profile
?.full_name ??
cutting.created_by?.username
}}
</p>
</div> </div>
<p v-if="cutting.description" class="text-muted-foreground text-sm"> <p
v-if="cutting.description"
class="text-sm text-muted-foreground"
>
{{ cutting.description }} {{ cutting.description }}
</p> </p>
<div
v-if="
cutting.images && cutting.images.length > 0
"
class="flex flex-wrap gap-2 pt-1"
>
<MediaThumbnailCell
:items="cutting.images"
:max-visible="10"
/>
</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm"> <div class="flex flex-wrap gap-x-4 gap-y-1 text-sm">
<span>Total Hasil Cutting <strong class="text-primary">{{ cutting.total_result_pieces ?? <span
0 }} pcs</strong></span> >Total Hasil Cutting
<span v-if="cutting.total_material_usage_summary_formatted">Total Pemakaian Bahan <strong class="text-primary"
<strong class="text-primary">{{ cutting.total_material_usage_summary_formatted >{{
}}</strong></span> cutting.total_result_pieces ?? 0
<span>Biaya Bahan <strong class="text-primary">{{ }}
cutting.total_material_cost_formatted ?? 'Rp 0' }}</strong></span> pcs</strong
<span>Biaya per Produk <strong class="text-primary">{{ ></span
cutting.material_cost_per_product_formatted ?? 'Rp 0' }}</strong></span> >
<span
v-if="
cutting.total_material_usage_summary_formatted
"
>Total Pemakaian Bahan
<strong class="text-primary">{{
cutting.total_material_usage_summary_formatted
}}</strong></span
>
<span
>Biaya Bahan
<strong class="text-primary">{{
cutting.total_material_cost_formatted ??
'Rp 0'
}}</strong></span
>
<span
>Biaya per Produk
<strong class="text-primary">{{
cutting.material_cost_per_product_formatted ??
'Rp 0'
}}</strong></span
>
</div> </div>
</div> </div>
</div> </div>
<div class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5"> <div
class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5"
>
<DataTableActions :cutting="cutting" /> <DataTableActions :cutting="cutting" />
</div> </div>
</div> </div>
<div class="space-y-6 p-4"> <div class="space-y-6 p-4">
<div class="space-y-2"> <div class="space-y-2">
<h4 class="text-sm font-semibold tracking-tight text-foreground">Bahan Baku</h4> <h4
class="text-sm font-semibold tracking-tight text-foreground"
>
Bahan Baku
</h4>
<div class="rounded-md border"> <div class="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
@ -163,31 +235,60 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow v-if="!cutting.materials.length" :key="`${cutting.id}-material-empty`"> <TableRow
<TableCell colspan="3" class="text-muted-foreground"> v-if="!cutting.materials.length"
:key="`${cutting.id}-material-empty`"
>
<TableCell
colspan="3"
class="text-muted-foreground"
>
Belum ada bahan baku Belum ada bahan baku
</TableCell> </TableCell>
</TableRow> </TableRow>
<template v-else v-for="group in getGroupedMaterials(cutting.materials)" <template
:key="group.rawMaterialId"> v-else
<TableRow class="bg-muted/20 hover:bg-muted/20"> v-for="group in getGroupedMaterials(
<TableCell colspan="3" class="font-semibold text-foreground"> cutting.materials,
)"
:key="group.rawMaterialId"
>
<TableRow
class="bg-muted/20 hover:bg-muted/20"
>
<TableCell
colspan="3"
class="font-semibold text-foreground"
>
{{ group.rawMaterialName }} {{ group.rawMaterialName }}
<Badge v-if="group.unitLabel" variant="secondary" <Badge
class="ml-2 font-normal"> v-if="group.unitLabel"
variant="secondary"
class="ml-2 font-normal"
>
{{ group.unitLabel }} {{ group.unitLabel }}
</Badge> </Badge>
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow v-for="material in group.items" :key="material.id"> <TableRow
v-for="material in group.items"
:key="material.id"
>
<TableCell class="pl-6 font-medium"> <TableCell class="pl-6 font-medium">
{{ material.variant }} {{ material.variant }}
</TableCell> </TableCell>
<TableCell> <TableCell>
<MediaThumbnailCell :items="material.images ?? []" :max-visible="1" /> <MediaThumbnailCell
:items="
material.images ?? []
"
:max-visible="1"
/>
</TableCell> </TableCell>
<TableCell class="tabular-nums"> <TableCell class="tabular-nums">
{{ material.material_usage_formatted }} {{
material.material_usage_formatted
}}
</TableCell> </TableCell>
</TableRow> </TableRow>
</template> </template>
@ -197,7 +298,11 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<h4 class="text-sm font-semibold tracking-tight text-foreground">Hasil Produk</h4> <h4
class="text-sm font-semibold tracking-tight text-foreground"
>
Hasil Produk
</h4>
<div class="rounded-md border"> <div class="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
@ -210,24 +315,51 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow v-if="!cutting.results.length" :key="`${cutting.id}-result-empty`"> <TableRow
<TableCell colspan="5" class="text-muted-foreground"> v-if="!cutting.results.length"
:key="`${cutting.id}-result-empty`"
>
<TableCell
colspan="5"
class="text-muted-foreground"
>
Belum ada hasil produk Belum ada hasil produk
</TableCell> </TableCell>
</TableRow> </TableRow>
<template v-else v-for="group in getGroupedResults(cutting.results)" <template
:key="group.productId"> v-else
<TableRow class="bg-muted/20 hover:bg-muted/20"> v-for="group in getGroupedResults(
<TableCell colspan="5" class="font-semibold text-foreground"> cutting.results,
)"
:key="group.productId"
>
<TableRow
class="bg-muted/20 hover:bg-muted/20"
>
<TableCell
colspan="5"
class="font-semibold text-foreground"
>
{{ group.productName }} {{ group.productName }}
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow v-for="result in group.items" :key="result.id"> <TableRow
v-for="result in group.items"
:key="result.id"
>
<TableCell class="pl-6 font-medium"> <TableCell class="pl-6 font-medium">
{{ result.product_variant?.name }} {{
result.product_variant?.name
}}
</TableCell> </TableCell>
<TableCell> <TableCell>
<MediaThumbnailCell :items="result.product_variant?.images ?? []" :max-visible="1" /> <MediaThumbnailCell
:items="
result.product_variant
?.images ?? []
"
:max-visible="1"
/>
</TableCell> </TableCell>
<TableCell class="tabular-nums"> <TableCell class="tabular-nums">
{{ result.cutting_result }} pcs {{ result.cutting_result }} pcs
@ -236,7 +368,10 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
{{ result.sample }} pcs {{ result.sample }} pcs
</TableCell> </TableCell>
<TableCell class="tabular-nums"> <TableCell class="tabular-nums">
{{ result.original_outside_sample }} pcs {{
result.original_outside_sample
}}
pcs
</TableCell> </TableCell>
</TableRow> </TableRow>
</template> </template>
@ -248,8 +383,15 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
</div> </div>
</div> </div>
<DataTableEmpty v-else description="Silakan lakukan pencarian untuk menemukan data yang Anda cari." /> <DataTableEmpty
v-else
description="Silakan lakukan pencarian untuk menemukan data yang Anda cari."
/>
<GroupedTableFooter :summary="paginationSummary" :pagination="pagination" :pagination-links="paginationLinks" /> <GroupedTableFooter
:summary="paginationSummary"
:pagination="pagination"
:pagination-links="paginationLinks"
/>
</div> </div>
</template> </template>