feat: add sewing and other cost fields to cutting management, including validation, calculations, and UI updates for total production cost

This commit is contained in:
Yoga Pangestu 2026-06-19 19:11:58 +07:00
parent 2db6dfece9
commit db3f6887ea
8 changed files with 331 additions and 84 deletions

View File

@ -45,6 +45,9 @@ public function rules(): array
'results.*.cutting_result' => ['required', 'integer', 'min:1'],
'results.*.warehouse_stock' => ['required', 'integer', 'min:0'],
'results.*.cutting_reject' => ['required', 'integer', 'min:0'],
'sewing_cost' => ['nullable', 'integer', 'min:0'],
'other_cost' => ['nullable', 'integer', 'min:0'],
];
}
@ -62,8 +65,10 @@ public function attributes(): array
'results' => 'hasil produk',
'results.*.product_variant_id' => 'varian produk',
'results.*.cutting_result' => 'hasil',
'results.*.warehouse_stock' => 'gudang',
'results.*.warehouse_stock' => 'bagus',
'results.*.cutting_reject' => 'reject',
'sewing_cost' => 'jasa jahit',
'other_cost' => 'biaya lainnya',
];
}
}

View File

@ -31,6 +31,8 @@ protected function casts(): array
return [
'status' => CuttingStatus::class,
'total_material_cost' => 'integer',
'sewing_cost' => 'integer',
'other_cost' => 'integer',
'cost_per_unit' => 'integer',
];
}

View File

@ -249,6 +249,8 @@ public function create(array $validated, User $user): Cutting
$cutting = Cutting::create([
'status' => CuttingStatus::IN_PROGRESS,
'description' => $validated['description'] ?? null,
'sewing_cost' => (int) ($validated['sewing_cost'] ?? 0),
'other_cost' => (int) ($validated['other_cost'] ?? 0),
'created_by_id' => $user->id,
]);
@ -303,6 +305,8 @@ public function update(Cutting $cutting, array $validated): void
$results = $this->buildResults($validated['results']);
$cutting->description = $validated['description'] ?? null;
$cutting->sewing_cost = (int) ($validated['sewing_cost'] ?? 0);
$cutting->other_cost = (int) ($validated['other_cost'] ?? 0);
if ($cutting->status === CuttingStatus::REJECTED) {
$cutting->status = CuttingStatus::IN_PROGRESS;
$cutting->rejection()?->delete();
@ -677,12 +681,22 @@ private function appendCostPreview(Cutting $cutting): void
}
$totalMaterialCost = $cutting->total_material_cost ?? $this->calculateTotalMaterialCost($cutting);
$sewingCost = (int) ($cutting->sewing_cost ?? 0);
$otherCost = (int) ($cutting->other_cost ?? 0);
$totalProductionCost = $totalMaterialCost + $sewingCost + $otherCost;
$costPerUnit = $cutting->cost_per_unit ?? $this->calculateCostPerUnit($cutting);
$cutting->setAttribute('total_material_cost', $totalMaterialCost);
$cutting->setAttribute('total_material_cost_formatted', 'Rp '.number_format($totalMaterialCost, 0, ',', '.'));
$cutting->setAttribute('sewing_cost', $sewingCost);
$cutting->setAttribute('sewing_cost_formatted', 'Rp '.number_format($sewingCost, 0, ',', '.'));
$cutting->setAttribute('other_cost', $otherCost);
$cutting->setAttribute('other_cost_formatted', 'Rp '.number_format($otherCost, 0, ',', '.'));
$cutting->setAttribute('total_production_cost', $totalProductionCost);
$cutting->setAttribute('total_production_cost_formatted', 'Rp '.number_format($totalProductionCost, 0, ',', '.'));
$cutting->setAttribute('estimated_cost_per_unit', $costPerUnit);
$cutting->setAttribute('estimated_cost_per_unit_formatted', 'Rp '.number_format($costPerUnit, 0, ',', '.'));
$cutting->setAttribute('total_result_pieces', (int) $cutting->results->sum('cutting_result'));
}
public function calculateTotalMaterialCost(Cutting $cutting): int
@ -690,6 +704,13 @@ public function calculateTotalMaterialCost(Cutting $cutting): int
return (int) $cutting->materials->sum(fn (CuttingMaterial $material) => $material->materialCost());
}
public function calculateTotalProductionCost(Cutting $cutting): int
{
return $this->calculateTotalMaterialCost($cutting)
+ (int) ($cutting->sewing_cost ?? 0)
+ (int) ($cutting->other_cost ?? 0);
}
public function calculateCostPerUnit(Cutting $cutting): int
{
$totalPieces = (int) $cutting->results->sum('cutting_result');
@ -698,7 +719,7 @@ public function calculateCostPerUnit(Cutting $cutting): int
return 0;
}
return (int) round($this->calculateTotalMaterialCost($cutting) / $totalPieces);
return (int) round($this->calculateTotalProductionCost($cutting) / $totalPieces);
}
/**

View File

@ -17,6 +17,8 @@ public function up(): void
$table->string('description', 100)->nullable();
$table->unsignedBigInteger('total_material_cost')->nullable();
$table->unsignedBigInteger('sewing_cost')->default(0);
$table->unsignedBigInteger('other_cost')->default(0);
$table->unsignedBigInteger('cost_per_unit')->nullable();
$table->foreignId('created_by_id')->constrained('users')->restrictOnDelete();

View File

@ -7,9 +7,17 @@ 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 { RupiahInput } from '@/components/form/rupiah-input';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Empty,
EmptyDescription,
@ -29,7 +37,7 @@ 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 { formatRupiah, parseRupiah } from '@/lib/rupiah';
import type {
CuttingMaterialCartItem,
CuttingProductCatalogItem,
@ -41,6 +49,33 @@ function usesLengthUnit(unit: string): boolean {
return unit === 'yard' || unit === 'meter';
}
const props = defineProps<{
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
productCatalog: CuttingProductCatalogItem[];
initialData?: {
description: string;
sewing_cost?: string;
other_cost?: 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 costModalOpen = ref(false);
const form = useForm({
description: '',
sewing_cost: '0',
other_cost: '0',
});
const CM_PER_YARD = 91.44;
const CM_PER_METER = 100;
@ -95,34 +130,38 @@ const totalResultPieces = computed(() =>
),
);
function materialLineCost(item: CuttingMaterialCartItem): number {
const catalogPrice = findCatalogPrice(item.raw_material_price_id);
if (!catalogPrice) {
return 0;
}
const usage = usageInNativeUnit(
Number(item.material_usage) || 0,
catalogPrice.unit,
);
return Math.round(usage * catalogPrice.price);
}
const sewingCostAmount = computed(
() => Number.parseInt(parseRupiah(form.sewing_cost), 10) || 0,
);
const otherCostAmount = computed(
() => Number.parseInt(parseRupiah(form.other_cost), 10) || 0,
);
const totalProductionCost = computed(
() => totalMaterialCost.value + sewingCostAmount.value + otherCostAmount.value,
);
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: '',
return Math.round(totalProductionCost.value / totalResultPieces.value);
});
function populateForm() {
@ -131,6 +170,8 @@ function populateForm() {
}
form.description = props.initialData.description;
form.sewing_cost = props.initialData.sewing_cost ?? '0';
form.other_cost = props.initialData.other_cost ?? '0';
materialCart.value = props.initialData.materials.map((item) => ({
...item,
}));
@ -145,6 +186,16 @@ watch(
{ immediate: true },
);
watch(
[materialCart, resultCart],
() => {
if (materialCart.value.length > 0 && resultCart.value.length > 0) {
costModalOpen.value = true;
}
},
{ deep: true },
);
type CatalogPrice = CuttingRawMaterialCatalogItem['prices'][number];
const filteredRawMaterials = computed(() => {
@ -312,35 +363,11 @@ function syncResultTotals(item: CuttingResultCartItem) {
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 = {
function buildPayload() {
return {
description: form.description,
sewing_cost: Number.parseInt(parseRupiah(form.sewing_cost), 10) || 0,
other_cost: Number.parseInt(parseRupiah(form.other_cost), 10) || 0,
materials: materialCart.value.map((item) => ({
raw_material_price_id: item.raw_material_price_id,
material_usage: item.material_usage,
@ -353,9 +380,54 @@ function submit() {
cutting_reject: item.cutting_reject,
})),
};
}
function validateBeforeSave(): boolean {
if (materialCart.value.length === 0) {
toast.error('Tambahkan minimal satu bahan baku.');
return false;
}
if (resultCart.value.length === 0) {
toast.error('Tambahkan minimal satu hasil produk.');
return false;
}
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 false;
}
}
return true;
}
function submit() {
if (!validateBeforeSave()) {
return;
}
costModalOpen.value = true;
}
function confirmSubmit() {
const payload = buildPayload();
if (props.method === 'put') {
form.transform(() => payload).put(props.submitUrl, {
onSuccess: () => {
costModalOpen.value = false;
},
onError: (errors) => {
const message = Object.values(errors)[0];
toast.error(
@ -370,6 +442,9 @@ function submit() {
}
form.transform(() => payload).post(props.submitUrl, {
onSuccess: () => {
costModalOpen.value = false;
},
onError: (errors) => {
const message = Object.values(errors)[0];
toast.error(
@ -653,9 +728,18 @@ function submit() {
<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 class="flex items-center justify-between gap-2 text-base">
<span class="flex items-center gap-2">
<Scissors class="size-4" />
Ringkasan Cutting
</span>
<Badge
v-if="totalResultPieces > 0"
variant="secondary"
class="tabular-nums font-semibold"
>
Total: {{ totalResultPieces }} pcs
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
@ -722,7 +806,7 @@ function submit() {
</Button>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="grid grid-cols-3 gap-2">
<Field>
<FieldLabel class="text-xs">
Pemakaian ({{ item.uses_length_unit ? 'cm' : item.unit_abbreviation }})
@ -745,6 +829,16 @@ function submit() {
class="h-8"
/>
</Field>
<Field>
<FieldLabel class="text-xs">
Harga Modal
</FieldLabel>
<div
class="flex h-8 items-center rounded-md border bg-muted/40 px-2 text-xs font-medium tabular-nums"
>
{{ formatRupiah(materialLineCost(item)) }}
</div>
</Field>
</div>
</div>
</div>
@ -753,7 +847,16 @@ function submit() {
<Separator />
<div class="space-y-2">
<p class="text-sm font-medium">Hasil Produk</p>
<div class="flex items-center justify-between gap-2">
<p class="text-sm font-medium">Hasil Produk</p>
<Badge
v-if="totalResultPieces > 0"
variant="outline"
class="tabular-nums text-xs"
>
{{ totalResultPieces }} pcs
</Badge>
</div>
<div
v-if="resultCart.length === 0"
@ -840,29 +943,41 @@ function submit() {
</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>
<Separator />
<div class="grid gap-3 sm:grid-cols-2">
<Field>
<FieldLabel for="sewing_cost">Jasa Jahit</FieldLabel>
<RupiahInput
id="sewing_cost"
v-model="form.sewing_cost"
/>
<FieldError
:errors="formErrors(form, 'sewing_cost')"
/>
</Field>
<Field>
<FieldLabel for="other_cost">Biaya Lainnya</FieldLabel>
<RupiahInput
id="other_cost"
v-model="form.other_cost"
/>
<FieldError
:errors="formErrors(form, 'other_cost')"
/>
</Field>
</div>
<Button
type="button"
variant="outline"
class="w-full"
:disabled="materialCart.length === 0"
@click="costModalOpen = true"
>
Lihat Harga Modal
</Button>
<Button
type="submit"
class="w-full"
@ -885,4 +1000,78 @@ function submit() {
</CardContent>
</Card>
</div>
<Dialog v-model:open="costModalOpen">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Ringkasan Harga Modal</DialogTitle>
</DialogHeader>
<div class="space-y-3 text-sm">
<div class="flex items-center justify-between rounded-lg border bg-muted/40 px-3 py-2">
<span class="text-muted-foreground">Total Hasil Cutting</span>
<span class="font-semibold tabular-nums">{{ totalResultPieces }} pcs</span>
</div>
<div class="space-y-2 rounded-lg border p-3">
<div class="flex items-center justify-between gap-3">
<span class="text-muted-foreground">Bahan Baku</span>
<span class="font-medium tabular-nums">{{ formatRupiah(totalMaterialCost) }}</span>
</div>
<div
v-for="item in materialCart"
:key="`cost-${item.raw_material_price_id}`"
class="flex items-center justify-between gap-3 text-xs"
>
<span class="truncate text-muted-foreground">
{{ item.raw_material_name }} ({{ item.variant }})
</span>
<span class="shrink-0 tabular-nums">
{{ formatRupiah(materialLineCost(item)) }}
</span>
</div>
<div class="flex items-center justify-between gap-3 border-t pt-2">
<span class="text-muted-foreground">Jasa Jahit</span>
<span class="font-medium tabular-nums">{{ formatRupiah(sewingCostAmount) }}</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-muted-foreground">Biaya Lainnya</span>
<span class="font-medium tabular-nums">{{ formatRupiah(otherCostAmount) }}</span>
</div>
<div class="flex items-center justify-between gap-3 border-t pt-2 font-semibold">
<span>Total Harga Modal</span>
<span class="tabular-nums">{{ formatRupiah(totalProductionCost) }}</span>
</div>
</div>
<div
v-if="totalResultPieces > 0"
class="flex items-center justify-between rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 font-semibold"
>
<span>Harga Modal / pcs</span>
<span class="tabular-nums">{{ formatRupiah(estimatedCostPerUnit) }}</span>
</div>
<p v-else class="text-xs text-muted-foreground">
Tambahkan hasil produk untuk menghitung harga modal per pcs.
</p>
</div>
<DialogFooter class="gap-2 sm:justify-end">
<Button
type="button"
variant="outline"
@click="costModalOpen = false"
>
Tutup
</Button>
<Button
type="button"
:disabled="form.processing"
@click="confirmSubmit"
>
{{ form.processing ? 'Menyimpan...' : submitLabel }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@ -479,13 +479,30 @@ function actionIcon(status: string) {
<div
v-if="cutting.estimated_cost_per_unit_formatted"
class="rounded-lg border bg-muted/40 p-3 text-sm"
class="rounded-lg border bg-muted/40 p-3 text-sm space-y-1"
>
<p class="font-medium">Harga Modal Batch</p>
<p class="text-muted-foreground">
Total bahan baku: {{ cutting.total_material_cost_formatted ?? '-' }}
<p class="flex justify-between gap-2 text-muted-foreground">
<span>Bahan baku</span>
<span>{{ cutting.total_material_cost_formatted ?? '-' }}</span>
</p>
<p class="font-semibold tabular-nums">
<p class="flex justify-between gap-2 text-muted-foreground">
<span>Jasa jahit</span>
<span>{{ cutting.sewing_cost_formatted ?? '-' }}</span>
</p>
<p class="flex justify-between gap-2 text-muted-foreground">
<span>Biaya lainnya</span>
<span>{{ cutting.other_cost_formatted ?? '-' }}</span>
</p>
<p class="flex justify-between gap-2 font-medium">
<span>Total</span>
<span>{{ cutting.total_production_cost_formatted ?? '-' }}</span>
</p>
<p class="flex justify-between gap-2 text-muted-foreground">
<span>Total hasil</span>
<span>{{ cutting.total_result_pieces ?? 0 }} pcs</span>
</p>
<p class="font-semibold tabular-nums pt-1 border-t">
{{ cutting.estimated_cost_per_unit_formatted }} / pcs
</p>
</div>

View File

@ -23,6 +23,8 @@ function usesLengthUnit(unit?: string): boolean {
const initialData = computed(() => ({
description: props.cutting.description ?? '',
sewing_cost: String(props.cutting.sewing_cost ?? 0),
other_cost: String(props.cutting.other_cost ?? 0),
materials: props.cutting.materials.map((item) => {
const unit = item.raw_material_price?.raw_material?.unit ?? 'kilogram';
const usesCm = usesLengthUnit(unit);

View File

@ -49,6 +49,13 @@ export type CuttingListItem = {
created_at_formatted: string;
total_material_cost?: number;
total_material_cost_formatted?: string;
sewing_cost?: number;
sewing_cost_formatted?: string;
other_cost?: number;
other_cost_formatted?: string;
total_production_cost?: number;
total_production_cost_formatted?: string;
total_result_pieces?: number;
estimated_cost_per_unit?: number;
estimated_cost_per_unit_formatted?: string;
created_by?: {
@ -97,6 +104,8 @@ export type CuttingEditItem = {
id: number;
status: string;
description: string | null;
sewing_cost?: number;
other_cost?: number;
materials: Array<{
raw_material_price_id: number;
material_usage_input: string;