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(() => ({
description: props.cutting.description ?? '',
images: props.cutting.images ?? [],
sewing_cost: String(props.cutting.sewing_cost ?? 0),
other_cost: String(props.cutting.other_cost ?? 0),
materials: props.cutting.materials.map((item) => ({
@ -52,7 +53,9 @@ const initialData = computed(() => ({
<Head title="Ubah Cutting" />
<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">
<h2 class="text-2xl font-bold tracking-tight">Ubah Cutting</h2>
</div>
@ -60,9 +63,15 @@ const initialData = computed(() => ({
<BackButton :href="index.url()" />
</div>
<CuttingPosForm :raw-material-catalog="rawMaterialCatalog" :product-catalog="productCatalog"
:categories="categories" :units="units"
:initial-data="initialData" :submit-url="update.url(props.cutting.id)" method="put"
submit-label="Perbarui" />
<CuttingPosForm
:raw-material-catalog="rawMaterialCatalog"
:product-catalog="productCatalog"
:categories="categories"
:units="units"
:initial-data="initialData"
:submit-url="update.url(props.cutting.id)"
method="put"
submit-label="Perbarui"
/>
</AdminLayout>
</template>

View File

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

View File

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

View File

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

View File

@ -37,7 +37,11 @@ const emit = defineEmits<{
}>();
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 {
return groupedTableRowNumber(props.firstItem, index);
@ -61,7 +65,8 @@ function getGroupedMaterials(materials: any[]): GroupedCuttingMaterials[] {
materials.forEach((item) => {
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;
if (!groups[rawMaterialId]) {
@ -100,59 +105,126 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
return Object.values(groups);
}
</script>
<template>
<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-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
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">
<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) }}
</span>
<div class="min-w-0 space-y-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 }}
</h3>
<Badge :variant="cuttingStatusBadgeVariant(cutting.status)">
<Badge
:variant="
cuttingStatusBadgeVariant(
cutting.status,
)
"
>
{{ cutting.status_label }}
</Badge>
</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>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>
<p v-if="cutting.description" class="text-muted-foreground text-sm">
<p
v-if="cutting.description"
class="text-sm text-muted-foreground"
>
{{ cutting.description }}
</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">
<span>Total Hasil Cutting <strong class="text-primary">{{ cutting.total_result_pieces ??
0 }} pcs</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>
<span
>Total Hasil Cutting
<strong class="text-primary"
>{{
cutting.total_result_pieces ?? 0
}}
pcs</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 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" />
</div>
</div>
<div class="space-y-6 p-4">
<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">
<Table>
<TableHeader>
@ -163,31 +235,60 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="!cutting.materials.length" :key="`${cutting.id}-material-empty`">
<TableCell colspan="3" class="text-muted-foreground">
<TableRow
v-if="!cutting.materials.length"
:key="`${cutting.id}-material-empty`"
>
<TableCell
colspan="3"
class="text-muted-foreground"
>
Belum ada bahan baku
</TableCell>
</TableRow>
<template v-else v-for="group in getGroupedMaterials(cutting.materials)"
:key="group.rawMaterialId">
<TableRow class="bg-muted/20 hover:bg-muted/20">
<TableCell colspan="3" class="font-semibold text-foreground">
<template
v-else
v-for="group in getGroupedMaterials(
cutting.materials,
)"
:key="group.rawMaterialId"
>
<TableRow
class="bg-muted/20 hover:bg-muted/20"
>
<TableCell
colspan="3"
class="font-semibold text-foreground"
>
{{ group.rawMaterialName }}
<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 }}
</Badge>
</TableCell>
</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">
{{ material.variant }}
</TableCell>
<TableCell>
<MediaThumbnailCell :items="material.images ?? []" :max-visible="1" />
<MediaThumbnailCell
:items="
material.images ?? []
"
:max-visible="1"
/>
</TableCell>
<TableCell class="tabular-nums">
{{ material.material_usage_formatted }}
{{
material.material_usage_formatted
}}
</TableCell>
</TableRow>
</template>
@ -197,7 +298,11 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
</div>
<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">
<Table>
<TableHeader>
@ -210,24 +315,51 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="!cutting.results.length" :key="`${cutting.id}-result-empty`">
<TableCell colspan="5" class="text-muted-foreground">
<TableRow
v-if="!cutting.results.length"
:key="`${cutting.id}-result-empty`"
>
<TableCell
colspan="5"
class="text-muted-foreground"
>
Belum ada hasil produk
</TableCell>
</TableRow>
<template v-else v-for="group in getGroupedResults(cutting.results)"
:key="group.productId">
<TableRow class="bg-muted/20 hover:bg-muted/20">
<TableCell colspan="5" class="font-semibold text-foreground">
<template
v-else
v-for="group in getGroupedResults(
cutting.results,
)"
:key="group.productId"
>
<TableRow
class="bg-muted/20 hover:bg-muted/20"
>
<TableCell
colspan="5"
class="font-semibold text-foreground"
>
{{ group.productName }}
</TableCell>
</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">
{{ result.product_variant?.name }}
{{
result.product_variant?.name
}}
</TableCell>
<TableCell>
<MediaThumbnailCell :items="result.product_variant?.images ?? []" :max-visible="1" />
<MediaThumbnailCell
:items="
result.product_variant
?.images ?? []
"
:max-visible="1"
/>
</TableCell>
<TableCell class="tabular-nums">
{{ result.cutting_result }} pcs
@ -236,7 +368,10 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
{{ result.sample }} pcs
</TableCell>
<TableCell class="tabular-nums">
{{ result.original_outside_sample }} pcs
{{
result.original_outside_sample
}}
pcs
</TableCell>
</TableRow>
</template>
@ -248,8 +383,15 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
</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>
</template>