diff --git a/app/Http/Requests/Admin/Manage/CuttingRequest.php b/app/Http/Requests/Admin/Manage/CuttingRequest.php index a9be074..7db3f2f 100644 --- a/app/Http/Requests/Admin/Manage/CuttingRequest.php +++ b/app/Http/Requests/Admin/Manage/CuttingRequest.php @@ -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', ]; } } diff --git a/app/Models/Cutting.php b/app/Models/Cutting.php index 3324ea0..76e7fa5 100644 --- a/app/Models/Cutting.php +++ b/app/Models/Cutting.php @@ -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', ]; } diff --git a/app/Services/Manage/CuttingService.php b/app/Services/Manage/CuttingService.php index 71376e8..f52a5cb 100644 --- a/app/Services/Manage/CuttingService.php +++ b/app/Services/Manage/CuttingService.php @@ -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); } /** diff --git a/database/migrations/2026_06_12_110001_create_cuttings_table.php b/database/migrations/2026_06_12_110001_create_cuttings_table.php index 3557b4d..0705dc3 100644 --- a/database/migrations/2026_06_12_110001_create_cuttings_table.php +++ b/database/migrations/2026_06_12_110001_create_cuttings_table.php @@ -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(); diff --git a/resources/js/components/admin/manage/cuttings/CuttingPosForm.vue b/resources/js/components/admin/manage/cuttings/CuttingPosForm.vue index 0c52c41..1fd62e4 100644 --- a/resources/js/components/admin/manage/cuttings/CuttingPosForm.vue +++ b/resources/js/components/admin/manage/cuttings/CuttingPosForm.vue @@ -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([]); +const resultCart = ref([]); +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([]); -const resultCart = ref([]); - -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() { - - - Ringkasan Cutting + + + + Ringkasan Cutting + + + Total: {{ totalResultPieces }} pcs + @@ -722,7 +806,7 @@ function submit() { -
+
Pemakaian ({{ item.uses_length_unit ? 'cm' : item.unit_abbreviation }}) @@ -745,6 +829,16 @@ function submit() { class="h-8" /> + + + Harga Modal + +
+ {{ formatRupiah(materialLineCost(item)) }} +
+
@@ -753,7 +847,16 @@ function submit() {
-

Hasil Produk

+
+

Hasil Produk

+ + {{ totalResultPieces }} pcs + +
-
-

Estimasi Harga Modal

-

- Total bahan baku: - {{ formatRupiah(totalMaterialCost) }} -

-

- {{ formatRupiah(estimatedCostPerUnit) }} / pcs -

-

- Tambahkan hasil produk untuk estimasi per pcs. -

+ + +
+ + Jasa Jahit + + + + + Biaya Lainnya + + +
+ +
+ + + + + Ringkasan Harga Modal + + +
+
+ Total Hasil Cutting + {{ totalResultPieces }} pcs +
+ +
+
+ Bahan Baku + {{ formatRupiah(totalMaterialCost) }} +
+
+ + {{ item.raw_material_name }} ({{ item.variant }}) + + + {{ formatRupiah(materialLineCost(item)) }} + +
+
+ Jasa Jahit + {{ formatRupiah(sewingCostAmount) }} +
+
+ Biaya Lainnya + {{ formatRupiah(otherCostAmount) }} +
+
+ Total Harga Modal + {{ formatRupiah(totalProductionCost) }} +
+
+ +
+ Harga Modal / pcs + {{ formatRupiah(estimatedCostPerUnit) }} +
+

+ Tambahkan hasil produk untuk menghitung harga modal per pcs. +

+
+ + + + + +
+
diff --git a/resources/js/components/admin/manage/cuttings/data-table-actions.vue b/resources/js/components/admin/manage/cuttings/data-table-actions.vue index 7a1b0ff..1b72434 100644 --- a/resources/js/components/admin/manage/cuttings/data-table-actions.vue +++ b/resources/js/components/admin/manage/cuttings/data-table-actions.vue @@ -479,13 +479,30 @@ function actionIcon(status: string) {

Harga Modal Batch

-

- Total bahan baku: {{ cutting.total_material_cost_formatted ?? '-' }} +

+ Bahan baku + {{ cutting.total_material_cost_formatted ?? '-' }}

-

+

+ Jasa jahit + {{ cutting.sewing_cost_formatted ?? '-' }} +

+

+ Biaya lainnya + {{ cutting.other_cost_formatted ?? '-' }} +

+

+ Total + {{ cutting.total_production_cost_formatted ?? '-' }} +

+

+ Total hasil + {{ cutting.total_result_pieces ?? 0 }} pcs +

+

{{ cutting.estimated_cost_per_unit_formatted }} / pcs

diff --git a/resources/js/pages/admin/manage/cuttings/Edit.vue b/resources/js/pages/admin/manage/cuttings/Edit.vue index 6a21a72..7ec1318 100644 --- a/resources/js/pages/admin/manage/cuttings/Edit.vue +++ b/resources/js/pages/admin/manage/cuttings/Edit.vue @@ -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); diff --git a/resources/js/types/cutting.ts b/resources/js/types/cutting.ts index b8beca9..f169f2b 100644 --- a/resources/js/types/cutting.ts +++ b/resources/js/types/cutting.ts @@ -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;