From 9c205cf3d099ea6916537218a5366aa4093ab1ae Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Sun, 21 Jun 2026 10:29:45 +0700 Subject: [PATCH] feat: enhance cutting management by adding draft materials and results functionality, improving user experience with new API endpoints and UI components for better material handling --- .../Admin/Manage/CuttingController.php | 26 +- .../Manage/CuttingDraftItemController.php | 47 +++ .../Manage/CuttingDraftMaterialRequest.php | 31 ++ .../Manage/CuttingDraftResultRequest.php | 32 ++ .../Requests/Admin/Manage/CuttingRequest.php | 50 ++- app/Models/CuttingMaterial.php | 5 + app/Models/CuttingResult.php | 5 + app/Services/Manage/CuttingService.php | 326 +++++++++++++-- ...pport_to_cutting_materials_and_results.php | 36 ++ .../js/pages/admin/manage/cuttings/Create.vue | 6 +- .../manage/cuttings/form/CuttingPosForm.vue | 377 ++++++++++++++---- .../cuttings/table/CuttingGroupedTable.vue | 37 +- routes/web.php | 17 + 13 files changed, 844 insertions(+), 151 deletions(-) create mode 100644 app/Http/Controllers/Admin/Manage/CuttingDraftItemController.php create mode 100644 app/Http/Requests/Admin/Manage/CuttingDraftMaterialRequest.php create mode 100644 app/Http/Requests/Admin/Manage/CuttingDraftResultRequest.php create mode 100644 database/migrations/2026_06_21_030000_add_draft_support_to_cutting_materials_and_results.php diff --git a/app/Http/Controllers/Admin/Manage/CuttingController.php b/app/Http/Controllers/Admin/Manage/CuttingController.php index ebcd067..470faf8 100644 --- a/app/Http/Controllers/Admin/Manage/CuttingController.php +++ b/app/Http/Controllers/Admin/Manage/CuttingController.php @@ -36,9 +36,13 @@ public function index(Request $request): Response public function create(): Response { + $user = request()->user(); + return Inertia::render('admin/manage/cuttings/Create', [ - 'rawMaterialCatalog' => $this->cuttingService->rawMaterialCatalog(), - 'productCatalog' => $this->cuttingService->productCatalog(), + 'rawMaterialCatalog' => $this->cuttingService->rawMaterialCatalog(user: $user), + 'productCatalog' => $this->cuttingService->productCatalog(user: $user), + 'draftMaterials' => $this->cuttingService->draftMaterialsForUser($user), + 'draftResults' => $this->cuttingService->draftResultsForUser($user), ]); } @@ -46,7 +50,7 @@ public function store(CuttingRequest $request): RedirectResponse { $this->cuttingService->create($request->validated(), $request->user()); - $this->flashCreated('Proses potong'); + $this->flashCreated('Proses cutting'); return redirect()->route('admin.manage.cuttings.index'); } @@ -54,7 +58,7 @@ public function store(CuttingRequest $request): RedirectResponse public function edit(Cutting $cutting): Response|RedirectResponse { if (! $cutting->status->isEditable()) { - $this->flashError('Proses potong tidak dapat diubah.'); + $this->flashError('Proses cutting tidak dapat diubah.'); return redirect()->route('admin.manage.cuttings.index'); } @@ -70,7 +74,7 @@ public function update(CuttingRequest $request, Cutting $cutting): RedirectRespo { $this->cuttingService->update($cutting, $request->validated()); - $this->flashUpdated('Proses potong'); + $this->flashUpdated('Proses cutting'); return redirect()->route('admin.manage.cuttings.index'); } @@ -79,7 +83,7 @@ public function destroy(Cutting $cutting): RedirectResponse { $this->cuttingService->delete($cutting); - $this->flashDeleted('Proses potong'); + $this->flashDeleted('Proses cutting'); return redirect()->route('admin.manage.cuttings.index'); } @@ -99,11 +103,11 @@ public function transitionStatus(CuttingStatusTransitionRequest $request, Cuttin ); $message = match ($status) { - CuttingStatus::COMPLETED => 'Proses potong berhasil diselesaikan. Menunggu verifikasi admin toko.', - CuttingStatus::VERIFIED => 'Proses potong berhasil diverifikasi. Stok produk telah diperbarui.', - CuttingStatus::REJECTED => 'Proses potong berhasil ditolak.', - CuttingStatus::IN_PROGRESS => 'Proses potong dikembalikan ke proses.', - default => 'Status proses potong berhasil diperbarui.', + CuttingStatus::COMPLETED => 'Proses cutting berhasil diselesaikan. Menunggu verifikasi admin toko.', + CuttingStatus::VERIFIED => 'Proses cutting berhasil diverifikasi. Stok produk telah diperbarui.', + CuttingStatus::REJECTED => 'Proses cutting berhasil ditolak.', + CuttingStatus::IN_PROGRESS => 'Proses cutting dikembalikan ke proses.', + default => 'Status proses cutting berhasil diperbarui.', }; $this->flashSuccess($message); diff --git a/app/Http/Controllers/Admin/Manage/CuttingDraftItemController.php b/app/Http/Controllers/Admin/Manage/CuttingDraftItemController.php new file mode 100644 index 0000000..e17dad8 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/CuttingDraftItemController.php @@ -0,0 +1,47 @@ +cuttingService->syncDraftMaterial($request->validated(), $request->user()); + + return response()->json(['item' => $item]); + } + + public function storeResult(CuttingDraftResultRequest $request): JsonResponse + { + $item = $this->cuttingService->syncDraftResult($request->validated(), $request->user()); + + return response()->json(['item' => $item]); + } + + public function destroyMaterial(Request $request, RawMaterialPrice $rawMaterialPrice): JsonResponse + { + $this->cuttingService->removeDraftMaterial($request->user(), $rawMaterialPrice); + + return response()->json(['ok' => true]); + } + + public function destroyResult(Request $request, ProductVariant $productVariant): JsonResponse + { + $this->cuttingService->removeDraftResult($request->user(), $productVariant); + + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Requests/Admin/Manage/CuttingDraftMaterialRequest.php b/app/Http/Requests/Admin/Manage/CuttingDraftMaterialRequest.php new file mode 100644 index 0000000..078bcaa --- /dev/null +++ b/app/Http/Requests/Admin/Manage/CuttingDraftMaterialRequest.php @@ -0,0 +1,31 @@ +user()?->can(Permission::CUTTINGS_CREATE->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'raw_material_price_id' => [ + 'required', + 'integer', + Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'), + ], + 'material_usage' => ['required', 'numeric', 'decimal:0,2', 'gt:0'], + 'remaining_material' => ['required', 'numeric', 'decimal:0,2', 'gte:0'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Manage/CuttingDraftResultRequest.php b/app/Http/Requests/Admin/Manage/CuttingDraftResultRequest.php new file mode 100644 index 0000000..3630809 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/CuttingDraftResultRequest.php @@ -0,0 +1,32 @@ +user()?->can(Permission::CUTTINGS_CREATE->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'product_variant_id' => [ + 'required', + 'integer', + Rule::exists('product_variants', 'id')->whereNull('deleted_at'), + ], + 'cutting_result' => ['required', 'integer', 'min:1'], + 'warehouse_stock' => ['required', 'integer', 'min:0'], + 'cutting_reject' => ['required', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Manage/CuttingRequest.php b/app/Http/Requests/Admin/Manage/CuttingRequest.php index 7db3f2f..ad7bfc0 100644 --- a/app/Http/Requests/Admin/Manage/CuttingRequest.php +++ b/app/Http/Requests/Admin/Manage/CuttingRequest.php @@ -22,33 +22,39 @@ public function authorize(): bool */ public function rules(): array { - return [ + $isCreate = $this->isMethod('POST'); + + $rules = [ 'description' => ['nullable', 'string', 'max:100'], - 'materials' => ['required', 'array', 'min:1'], - 'materials.*.raw_material_price_id' => [ - 'required', - 'integer', - 'distinct', - Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'), - ], - 'materials.*.material_usage' => ['required', 'numeric', 'decimal:0,2', 'gt:0'], - 'materials.*.remaining_material' => ['required', 'numeric', 'decimal:0,2', 'gte:0'], - - 'results' => ['required', 'array', 'min:1'], - 'results.*.product_variant_id' => [ - 'required', - 'integer', - 'distinct', - Rule::exists('product_variants', 'id')->whereNull('deleted_at'), - ], - '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'], ]; + + if (! $isCreate) { + $rules['materials'] = ['required', 'array', 'min:1']; + $rules['materials.*.raw_material_price_id'] = [ + 'required', + 'integer', + 'distinct', + Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'), + ]; + $rules['materials.*.material_usage'] = ['required', 'numeric', 'decimal:0,2', 'gt:0']; + $rules['materials.*.remaining_material'] = ['required', 'numeric', 'decimal:0,2', 'gte:0']; + + $rules['results'] = ['required', 'array', 'min:1']; + $rules['results.*.product_variant_id'] = [ + 'required', + 'integer', + 'distinct', + Rule::exists('product_variants', 'id')->whereNull('deleted_at'), + ]; + $rules['results.*.cutting_result'] = ['required', 'integer', 'min:1']; + $rules['results.*.warehouse_stock'] = ['required', 'integer', 'min:0']; + $rules['results.*.cutting_reject'] = ['required', 'integer', 'min:0']; + } + + return $rules; } /** diff --git a/app/Models/CuttingMaterial.php b/app/Models/CuttingMaterial.php index f168b7f..8f37aba 100644 --- a/app/Models/CuttingMaterial.php +++ b/app/Models/CuttingMaterial.php @@ -36,6 +36,11 @@ protected function casts(): array ]; } + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + public function cutting(): BelongsTo { return $this->belongsTo(Cutting::class); diff --git a/app/Models/CuttingResult.php b/app/Models/CuttingResult.php index 8ad4ad5..d8eadcb 100644 --- a/app/Models/CuttingResult.php +++ b/app/Models/CuttingResult.php @@ -23,6 +23,11 @@ protected function casts(): array ]; } + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + public function cutting(): BelongsTo { return $this->belongsTo(Cutting::class); diff --git a/app/Services/Manage/CuttingService.php b/app/Services/Manage/CuttingService.php index 9a6ecb6..ff20b76 100644 --- a/app/Services/Manage/CuttingService.php +++ b/app/Services/Manage/CuttingService.php @@ -17,6 +17,7 @@ use App\Support\Media\MediaPresenter; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -133,11 +134,11 @@ public function getCompletedCuttings(User $user): Collection /** * @return Collection */ - public function rawMaterialCatalog(?Cutting $cutting = null): Collection + public function rawMaterialCatalog(?Cutting $cutting = null, ?User $user = null): Collection { $selectedPriceIds = $cutting ? $cutting->materials()->pluck('raw_material_price_id')->all() - : []; + : ($user ? $this->draftMaterialsQuery($user)->pluck('raw_material_price_id')->all() : []); return RawMaterial::query() ->with([ @@ -170,11 +171,11 @@ public function rawMaterialCatalog(?Cutting $cutting = null): Collection /** * @return Collection */ - public function productCatalog(?Cutting $cutting = null): Collection + public function productCatalog(?Cutting $cutting = null, ?User $user = null): Collection { $selectedVariantIds = $cutting ? $cutting->results()->pluck('product_variant_id')->all() - : []; + : ($user ? $this->draftResultsQuery($user)->pluck('product_variant_id')->all() : []); return Product::query() ->with([ @@ -240,14 +241,197 @@ public function findForEdit(Cutting $cutting): Cutting return $cutting; } + /** + * @return list> + */ + public function draftMaterialsForUser(User $user): array + { + return $this->draftMaterialsQuery($user) + ->with([ + 'rawMaterialPrice.rawMaterial:id,name,unit', + 'rawMaterialPrice.media', + ]) + ->get() + ->map(fn (CuttingMaterial $item) => $this->presentDraftMaterial($item)) + ->values() + ->all(); + } + + /** + * @return list> + */ + public function draftResultsForUser(User $user): array + { + return $this->draftResultsQuery($user) + ->with([ + 'productVariant.product:id,name', + 'productVariant.media', + ]) + ->get() + ->map(fn (CuttingResult $item) => $this->presentDraftResult($item)) + ->values() + ->all(); + } + + /** + * @param array $validated + * @return array + */ + public function syncDraftMaterial(array $validated, User $user): array + { + $price = RawMaterialPrice::query() + ->with('rawMaterial') + ->findOrFail($validated['raw_material_price_id']); + + $unit = $price->rawMaterial?->unit; + $usageInput = round((float) $validated['material_usage'], 2); + $remainingInput = round((float) $validated['remaining_material'], 2); + + if ($unit !== null && $unit->usesLengthUnit()) { + $materialUsage = round($unit->fromCm($usageInput), 2); + $remainingMaterial = round($unit->fromCm($remainingInput), 2); + } else { + $materialUsage = $usageInput; + $remainingMaterial = $remainingInput; + } + + if ($materialUsage <= 0) { + throw ValidationException::withMessages([ + 'material_usage' => 'Pemakaian bahan harus lebih dari 0.', + ]); + } + + if ($remainingMaterial < 0) { + throw ValidationException::withMessages([ + 'remaining_material' => 'Sisa bahan tidak boleh negatif.', + ]); + } + + $item = CuttingMaterial::query()->updateOrCreate( + [ + 'user_id' => $user->id, + 'raw_material_price_id' => $price->id, + 'cutting_id' => null, + ], + [ + 'material_usage' => $materialUsage, + 'remaining_material' => $remainingMaterial, + ], + ); + + $item->load([ + 'rawMaterialPrice.rawMaterial:id,name,unit', + 'rawMaterialPrice.media', + ]); + + return $this->presentDraftMaterial($item); + } + + /** + * @param array $validated + * @return array + */ + public function syncDraftResult(array $validated, User $user): array + { + $variant = ProductVariant::query() + ->with('product:id,name') + ->findOrFail($validated['product_variant_id']); + + $cuttingResult = (int) $validated['cutting_result']; + $warehouseStock = (int) $validated['warehouse_stock']; + $cuttingReject = (int) ($validated['cutting_reject'] ?? 0); + + if ($cuttingResult < 1) { + throw ValidationException::withMessages([ + 'cutting_result' => 'Hasil cutting minimal 1 pcs.', + ]); + } + + if ($warehouseStock < 0) { + throw ValidationException::withMessages([ + 'warehouse_stock' => 'Stok gudang tidak boleh negatif.', + ]); + } + + if ($cuttingReject < 0) { + throw ValidationException::withMessages([ + 'cutting_reject' => 'Reject tidak boleh negatif.', + ]); + } + + if (($warehouseStock + $cuttingReject) !== $cuttingResult) { + throw ValidationException::withMessages([ + 'cutting_result' => 'Hasil cutting harus sama dengan stok gudang ditambah reject.', + ]); + } + + $item = CuttingResult::query()->updateOrCreate( + [ + 'user_id' => $user->id, + 'product_variant_id' => $variant->id, + 'cutting_id' => null, + ], + [ + 'cutting_result' => $cuttingResult, + 'warehouse_stock' => $warehouseStock, + 'cutting_reject' => $cuttingReject, + ], + ); + + $item->load([ + 'productVariant.product:id,name', + 'productVariant.media', + ]); + + return $this->presentDraftResult($item); + } + + public function removeDraftMaterial(User $user, RawMaterialPrice $rawMaterialPrice): void + { + CuttingMaterial::query() + ->whereNull('cutting_id') + ->where('user_id', $user->id) + ->where('raw_material_price_id', $rawMaterialPrice->id) + ->delete(); + } + + public function removeDraftResult(User $user, ProductVariant $productVariant): void + { + CuttingResult::query() + ->whereNull('cutting_id') + ->where('user_id', $user->id) + ->where('product_variant_id', $productVariant->id) + ->delete(); + } + /** * @param array $validated */ public function create(array $validated, User $user): Cutting { $cutting = DB::transaction(function () use ($validated, $user): Cutting { - $materials = $this->buildMaterials($validated['materials']); - $results = $this->buildResults($validated['results']); + /** @var EloquentCollection $draftMaterials */ + $draftMaterials = $this->draftMaterialsQuery($user) + ->with('rawMaterialPrice.rawMaterial') + ->lockForUpdate() + ->get(); + + /** @var EloquentCollection $draftResults */ + $draftResults = $this->draftResultsQuery($user) + ->lockForUpdate() + ->get(); + + if ($draftMaterials->isEmpty()) { + throw ValidationException::withMessages([ + 'materials' => 'Tambahkan minimal satu bahan baku.', + ]); + } + + if ($draftResults->isEmpty()) { + throw ValidationException::withMessages([ + 'results' => 'Tambahkan minimal satu hasil produk.', + ]); + } $cutting = Cutting::create([ 'status' => CuttingStatus::IN_PROGRESS, @@ -257,12 +441,16 @@ public function create(array $validated, User $user): Cutting 'created_by_id' => $user->id, ]); - foreach ($materials as $materialData) { - $cutting->materials()->create($materialData); + foreach ($draftMaterials as $material) { + $material->cutting_id = $cutting->id; + $material->user_id = null; + $material->save(); } - foreach ($results as $resultData) { - $cutting->results()->create($resultData); + foreach ($draftResults as $result) { + $result->cutting_id = $cutting->id; + $result->user_id = null; + $result->save(); } $cutting->load('materials.rawMaterialPrice.rawMaterial'); @@ -272,8 +460,8 @@ public function create(array $validated, User $user): Cutting }); $this->pushNotificationService->sendToRoles( - '✂️ Proses Potong Baru', - "Proses potong dengan deskripsi ({$cutting->description}) telah dimulai oleh {$user->profile?->full_name}.", + '✂️ Proses Cutting Baru', + "Proses cutting dengan deskripsi ({$cutting->description}) telah dimulai oleh {$user->profile?->full_name}.", ['owner', 'developer'], '/admin/manage/cuttings', ); @@ -288,7 +476,7 @@ public function update(Cutting $cutting, array $validated): void { if (! $cutting->status->isEditable()) { throw ValidationException::withMessages([ - 'status' => 'Proses potong tidak dapat diubah.', + 'status' => 'Proses cutting tidak dapat diubah.', ]); } @@ -335,7 +523,7 @@ public function delete(Cutting $cutting): void { if (! in_array($cutting->status, [CuttingStatus::IN_PROGRESS, CuttingStatus::REJECTED], true)) { throw ValidationException::withMessages([ - 'status' => 'Proses potong hanya dapat dihapus saat masih proses atau ditolak.', + 'status' => 'Proses cutting hanya dapat dihapus saat masih proses atau ditolak.', ]); } @@ -356,8 +544,8 @@ public function delete(Cutting $cutting): void }); $this->pushNotificationService->sendToRoles( - '🗑️ Proses Potong Dihapus', - "Proses potong dengan deskripsi {$description} telah dihapus.", + '🗑️ Proses Cutting Dihapus', + "Proses cutting dengan deskripsi {$description} telah dihapus.", ['owner', 'developer'], '/admin/manage/cuttings', ); @@ -374,7 +562,7 @@ public function transitionStatus( ): void { if (! $cutting->status->canTransitionTo($status)) { throw ValidationException::withMessages([ - 'status' => 'Status proses potong tidak dapat diubah.', + 'status' => 'Status proses cutting tidak dapat diubah.', ]); } @@ -425,19 +613,19 @@ public function transitionStatus( $description = $cutting->description ?? '-'; $message = match ($status) { - CuttingStatus::COMPLETED => "Proses potong dengan deskripsi '{$description}' telah selesai dan menunggu verifikasi.", - CuttingStatus::VERIFIED => "Proses potong dengan deskripsi '{$description}' telah diverifikasi.", - CuttingStatus::REJECTED => "Proses potong dengan deskripsi '{$description}' ditolak".($reason ? " dengan alasan: '{$reason}'" : '').'.', - CuttingStatus::IN_PROGRESS => "Proses potong dengan deskripsi '{$description}' dikembalikan ke proses.", - default => "Status proses potong dengan deskripsi '{$description}' telah diperbarui ke: {$status->label()}.", + CuttingStatus::COMPLETED => "Proses cutting dengan deskripsi '{$description}' telah selesai dan menunggu verifikasi.", + CuttingStatus::VERIFIED => "Proses cutting dengan deskripsi '{$description}' telah diverifikasi.", + CuttingStatus::REJECTED => "Proses cutting dengan deskripsi '{$description}' ditolak".($reason ? " dengan alasan: '{$reason}'" : '').'.', + CuttingStatus::IN_PROGRESS => "Proses cutting dengan deskripsi '{$description}' dikembalikan ke proses.", + default => "Status proses cutting dengan deskripsi '{$description}' telah diperbarui ke: {$status->label()}.", }; $title = match ($status) { - CuttingStatus::COMPLETED => '✂️ Proses Potong Selesai', - CuttingStatus::VERIFIED => '✂️ Proses Potong Terverifikasi', - CuttingStatus::REJECTED => '✂️ Proses Potong Ditolak', - CuttingStatus::IN_PROGRESS => '✂️ Proses Potong Dikembalikan', - default => '✂️ Proses Potong Diperbarui', + CuttingStatus::COMPLETED => '✂️ Proses Cutting Selesai', + CuttingStatus::VERIFIED => '✂️ Proses Cutting Terverifikasi', + CuttingStatus::REJECTED => '✂️ Proses Cutting Ditolak', + CuttingStatus::IN_PROGRESS => '✂️ Proses Cutting Dikembalikan', + default => '✂️ Proses Cutting Diperbarui', }; $this->pushNotificationService->sendToRoles( @@ -521,7 +709,7 @@ private function buildResults(array $results): array if ($cuttingResult < 1) { throw ValidationException::withMessages([ - "results.{$index}.cutting_result" => 'Hasil potong minimal 1 pcs.', + "results.{$index}.cutting_result" => 'Hasil cutting minimal 1 pcs.', ]); } @@ -539,7 +727,7 @@ private function buildResults(array $results): array if (($warehouseStock + $cuttingReject) !== $cuttingResult) { throw ValidationException::withMessages([ - "results.{$index}.cutting_result" => 'Hasil potong harus sama dengan stok gudang ditambah reject.', + "results.{$index}.cutting_result" => 'Hasil cutting harus sama dengan stok gudang ditambah reject.', ]); } @@ -677,6 +865,86 @@ private function applySorting(Builder $query, string $sort, string $direction): $query->latest(); } + /** + * @return Builder + */ + private function draftMaterialsQuery(User $user): Builder + { + return CuttingMaterial::query() + ->whereNull('cutting_id') + ->where('user_id', $user->id); + } + + /** + * @return Builder + */ + private function draftResultsQuery(User $user): Builder + { + return CuttingResult::query() + ->whereNull('cutting_id') + ->where('user_id', $user->id); + } + + /** + * @return array + */ + private function presentDraftMaterial(CuttingMaterial $item): array + { + $price = $item->rawMaterialPrice; + $rawMaterial = $price?->rawMaterial; + $unit = $rawMaterial?->unit; + + $usesLengthUnit = $unit?->usesLengthUnit() ?? false; + + // Convert stored native unit back to cm for display + if ($usesLengthUnit && $unit !== null) { + $materialUsageCm = round($unit->toCm((float) $item->material_usage), 2); + $remainingMaterialCm = round($unit->toCm((float) $item->remaining_material), 2); + $materialUsageDisplay = (string) $materialUsageCm; + $remainingMaterialDisplay = (string) $remainingMaterialCm; + } else { + $materialUsageDisplay = $this->formatQuantityInput((float) $item->material_usage); + $remainingMaterialDisplay = $this->formatQuantityInput((float) $item->remaining_material); + } + + return [ + 'raw_material_price_id' => $item->raw_material_price_id, + 'raw_material_name' => $rawMaterial?->name ?? '', + 'variant' => $price?->variant ?? '', + 'unit' => $rawMaterial?->unit?->value ?? '', + 'uses_length_unit' => $usesLengthUnit, + 'unit_abbreviation' => $rawMaterial?->unit?->abbreviation() ?? '', + 'stock_input' => $price?->stock_input ?? '', + 'material_usage' => $materialUsageDisplay, + 'remaining_material' => $remainingMaterialDisplay, + 'images' => $price ? MediaPresenter::collection($price, 'images') : [], + ]; + } + + /** + * @return array + */ + private function presentDraftResult(CuttingResult $item): array + { + $variant = $item->productVariant; + + return [ + 'product_variant_id' => $item->product_variant_id, + 'product_name' => $variant?->product?->name ?? '', + 'variant_name' => $variant?->name ?? '', + 'stock' => $variant?->stock ?? 0, + 'cutting_result' => (string) $item->cutting_result, + 'warehouse_stock' => (string) $item->warehouse_stock, + 'cutting_reject' => (string) $item->cutting_reject, + 'images' => $variant ? MediaPresenter::collection($variant, 'images') : [], + ]; + } + + private function formatQuantityInput(float $value): string + { + return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); + } + private function appendCostPreview(Cutting $cutting): void { $cutting->setAttribute('total_result_pieces', (int) $cutting->results->sum('cutting_result')); diff --git a/database/migrations/2026_06_21_030000_add_draft_support_to_cutting_materials_and_results.php b/database/migrations/2026_06_21_030000_add_draft_support_to_cutting_materials_and_results.php new file mode 100644 index 0000000..b0cb723 --- /dev/null +++ b/database/migrations/2026_06_21_030000_add_draft_support_to_cutting_materials_and_results.php @@ -0,0 +1,36 @@ +foreignId('cutting_id')->nullable()->change(); + $table->foreignId('user_id')->nullable()->after('id')->constrained()->cascadeOnDelete(); + }); + + Schema::table('cutting_results', function (Blueprint $table): void { + $table->foreignId('cutting_id')->nullable()->change(); + $table->foreignId('user_id')->nullable()->after('id')->constrained()->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::table('cutting_materials', function (Blueprint $table): void { + $table->dropForeign(['user_id']); + $table->dropColumn('user_id'); + $table->foreignId('cutting_id')->nullable(false)->change(); + }); + + Schema::table('cutting_results', function (Blueprint $table): void { + $table->dropForeign(['user_id']); + $table->dropColumn('user_id'); + $table->foreignId('cutting_id')->nullable(false)->change(); + }); + } +}; diff --git a/resources/js/pages/admin/manage/cuttings/Create.vue b/resources/js/pages/admin/manage/cuttings/Create.vue index 4278d94..d3da4f8 100644 --- a/resources/js/pages/admin/manage/cuttings/Create.vue +++ b/resources/js/pages/admin/manage/cuttings/Create.vue @@ -4,11 +4,13 @@ import { ArrowLeft } from '@lucide/vue'; import CuttingPosForm from './form/CuttingPosForm.vue'; import { Button } from '@/components/ui/button'; import AdminLayout from '@/layouts/AdminLayout.vue'; -import type { CuttingProductCatalogItem, CuttingRawMaterialCatalogItem } from '@/types/cutting'; +import type { CuttingMaterialCartItem, CuttingProductCatalogItem, CuttingRawMaterialCatalogItem, CuttingResultCartItem } from '@/types/cutting'; defineProps<{ rawMaterialCatalog: CuttingRawMaterialCatalogItem[]; productCatalog: CuttingProductCatalogItem[]; + draftMaterials: CuttingMaterialCartItem[]; + draftResults: CuttingResultCartItem[]; }>(); @@ -34,6 +36,8 @@ defineProps<{ (); +const isCreateMode = computed(() => props.method === 'post'); + const materialSearch = ref(''); const productSearch = ref(''); const materialCart = ref([]); @@ -179,6 +184,20 @@ function populateForm() { resultCart.value = props.initialData.results.map((item) => ({ ...item })); } +function populateDraftItems() { + if (!isCreateMode.value) { + return; + } + + if (props.draftMaterials?.length) { + materialCart.value = props.draftMaterials.map((item) => ({ ...item })); + } + + if (props.draftResults?.length) { + resultCart.value = props.draftResults.map((item) => ({ ...item })); + } +} + watch( () => props.initialData, () => { @@ -187,6 +206,8 @@ watch( { immediate: true }, ); +populateDraftItems(); + type CatalogPrice = CuttingRawMaterialCatalogItem['prices'][number]; const filteredRawMaterials = computed(() => { @@ -221,6 +242,61 @@ const filteredProducts = computed(() => { ); }); +function upsertMaterialCartItem(item: CuttingMaterialCartItem) { + const index = materialCart.value.findIndex( + (cartItem) => cartItem.raw_material_price_id === item.raw_material_price_id, + ); + + if (index === -1) { + materialCart.value.push({ ...item }); + + return; + } + + materialCart.value[index] = { ...item }; +} + +function upsertResultCartItem(item: CuttingResultCartItem) { + const index = resultCart.value.findIndex( + (cartItem) => cartItem.product_variant_id === item.product_variant_id, + ); + + if (index === -1) { + resultCart.value.push({ ...item }); + + return; + } + + resultCart.value[index] = { ...item }; +} + +async function syncDraftMaterial(rawMaterial: CuttingRawMaterialCatalogItem, price: CatalogPrice, materialUsage: string, remainingMaterial: string) { + const { item } = await apiFetch<{ item: CuttingMaterialCartItem }>('/admin/manage/cuttings/draft-materials', { + method: 'POST', + body: JSON.stringify({ + raw_material_price_id: price.id, + material_usage: materialUsage, + remaining_material: remainingMaterial, + }), + }); + + upsertMaterialCartItem(item); +} + +async function syncDraftResult(product: CuttingProductCatalogItem, variant: CuttingProductCatalogItem['variants'][number], cuttingResult: string, warehouseStock: string, cuttingReject: string) { + const { item } = await apiFetch<{ item: CuttingResultCartItem }>('/admin/manage/cuttings/draft-results', { + method: 'POST', + body: JSON.stringify({ + product_variant_id: variant.id, + cutting_result: cuttingResult, + warehouse_stock: warehouseStock, + cutting_reject: cuttingReject, + }), + }); + + upsertResultCartItem(item); +} + function getMaterialCartItem( priceId: number, ): CuttingMaterialCartItem | undefined { @@ -229,24 +305,48 @@ function getMaterialCartItem( ); } -function decreaseMaterialQty(priceId: number) { - const index = materialCart.value.findIndex( - (item) => item.raw_material_price_id === priceId, +async function decreaseMaterialQty(priceId: number) { + const item = materialCart.value.find( + (i) => i.raw_material_price_id === priceId, ); - if (index !== -1) { - const item = materialCart.value[index]; - const nextQty = (Number(item.material_usage) || 0) - 1; - - if (nextQty <= 0) { - removeMaterial(index); - } else { - item.material_usage = String(nextQty); - } + if (!item) { + return; } + + const nextQty = (Number(item.material_usage) || 0) - 1; + + if (nextQty <= 0) { + const index = materialCart.value.findIndex( + (i) => i.raw_material_price_id === priceId, + ); + + if (index !== -1) { + await removeMaterial(index); + } + + return; + } + + if (isCreateMode.value) { + try { + await syncDraftMaterial( + { id: 0, name: '', unit: '', unit_label: '', unit_abbreviation: '', prices: [] } as CuttingRawMaterialCatalogItem, + { id: priceId, variant: '', price: 0, price_formatted: '', stock_input: '', stock_formatted: '', images: [] } as CatalogPrice, + String(nextQty), + item.remaining_material, + ); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.'); + } + + return; + } + + item.material_usage = String(nextQty); } -function addMaterial( +async function addMaterial( rawMaterial: CuttingRawMaterialCatalogItem, price: CatalogPrice, ) { @@ -254,10 +354,24 @@ function addMaterial( (item) => item.raw_material_price_id === price.id, ); + const defaultUsage = usesLengthUnit(rawMaterial.unit) ? '100' : '1'; + const nextUsage = existing + ? String((Number(existing.material_usage) || 0) + 1) + : defaultUsage; + const remaining = existing?.remaining_material ?? '0'; + + if (isCreateMode.value) { + try { + await syncDraftMaterial(rawMaterial, price, nextUsage, remaining); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Gagal menyimpan item ke keranjang.'); + } + + return; + } + if (existing) { - existing.material_usage = String( - (Number(existing.material_usage) || 0) + 1, - ); + existing.material_usage = nextUsage; return; } @@ -270,13 +384,28 @@ function addMaterial( uses_length_unit: usesLengthUnit(rawMaterial.unit), unit_abbreviation: rawMaterial.unit_abbreviation, stock_input: price.stock_input, - material_usage: usesLengthUnit(rawMaterial.unit) ? '100' : '1', + material_usage: defaultUsage, remaining_material: '0', images: price.images ?? [], }); } -function removeMaterial(index: number) { +async function removeMaterial(index: number) { + const item = materialCart.value[index]; + + if (isCreateMode.value) { + try { + await apiFetch( + `/admin/manage/cuttings/draft-materials/${item.raw_material_price_id}`, + { method: 'DELETE' }, + ); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Gagal menghapus item dari keranjang.'); + + return; + } + } + materialCart.value.splice(index, 1); } @@ -288,29 +417,54 @@ function getResultCartItem( ); } -function decreaseResultQty(variantId: number) { - const index = resultCart.value.findIndex( - (item) => item.product_variant_id === variantId, +async function decreaseResultQty(variantId: number) { + const item = resultCart.value.find( + (i) => i.product_variant_id === variantId, ); - if (index !== -1) { - const item = resultCart.value[index]; - const nextQty = (Number(item.cutting_result) || 0) - 1; - - if (nextQty <= 0) { - removeResult(index); - } else { - item.cutting_result = String(nextQty); - // also adjust warehouse stock or run sync totals - item.warehouse_stock = String( - Math.max(0, (Number(item.warehouse_stock) || 0) - 1), - ); - syncResultTotals(item); - } + if (!item) { + return; } + + const nextQty = (Number(item.cutting_result) || 0) - 1; + + if (nextQty <= 0) { + const index = resultCart.value.findIndex( + (i) => i.product_variant_id === variantId, + ); + + if (index !== -1) { + await removeResult(index); + } + + return; + } + + const nextWarehouse = Math.max(0, (Number(item.warehouse_stock) || 0) - 1); + const nextReject = Math.max(nextQty - nextWarehouse, 0); + + if (isCreateMode.value) { + try { + await syncDraftResult( + { id: 0, name: '', variants: [] } as CuttingProductCatalogItem, + { id: variantId, name: '', stock: 0, images: [] } as CuttingProductCatalogItem['variants'][number], + String(nextQty), + String(nextWarehouse), + String(nextReject), + ); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.'); + } + + return; + } + + item.cutting_result = String(nextQty); + item.warehouse_stock = String(nextWarehouse); + syncResultTotals(item); } -function addResult( +async function addResult( product: CuttingProductCatalogItem, variant: CuttingProductCatalogItem['variants'][number], ) { @@ -318,13 +472,23 @@ function addResult( (item) => item.product_variant_id === variant.id, ); + const nextQty = existing ? (Number(existing.cutting_result) || 0) + 1 : 1; + const nextWarehouse = existing ? (Number(existing.warehouse_stock) || 0) + 1 : 1; + const nextReject = Math.max(nextQty - nextWarehouse, 0); + + if (isCreateMode.value) { + try { + await syncDraftResult(product, variant, String(nextQty), String(nextWarehouse), String(nextReject)); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Gagal menyimpan item ke keranjang.'); + } + + return; + } + if (existing) { - existing.cutting_result = String( - (Number(existing.cutting_result) || 0) + 1, - ); - existing.warehouse_stock = String( - (Number(existing.warehouse_stock) || 0) + 1, - ); + existing.cutting_result = String(nextQty); + existing.warehouse_stock = String(nextWarehouse); syncResultTotals(existing); return; @@ -342,7 +506,22 @@ function addResult( }); } -function removeResult(index: number) { +async function removeResult(index: number) { + const item = resultCart.value[index]; + + if (isCreateMode.value) { + try { + await apiFetch( + `/admin/manage/cuttings/draft-results/${item.product_variant_id}`, + { method: 'DELETE' }, + ); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Gagal menghapus item dari keranjang.'); + + return; + } + } + resultCart.value.splice(index, 1); } @@ -354,71 +533,90 @@ function syncResultTotals(item: CuttingResultCartItem) { item.cutting_reject = String(reject); } +async function syncMaterialField(index: number) { + if (!isCreateMode.value) { + return; + } + + const item = materialCart.value[index]; + + try { + await apiFetch<{ item: CuttingMaterialCartItem }>('/admin/manage/cuttings/draft-materials', { + method: 'POST', + body: JSON.stringify({ + raw_material_price_id: item.raw_material_price_id, + material_usage: item.material_usage, + remaining_material: item.remaining_material, + }), + }); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Gagal memperbarui item.'); + } +} + +async function syncResultField(index: number) { + if (!isCreateMode.value) { + return; + } + + const item = resultCart.value[index]; + + try { + await apiFetch<{ item: CuttingResultCartItem }>('/admin/manage/cuttings/draft-results', { + method: 'POST', + body: JSON.stringify({ + product_variant_id: item.product_variant_id, + cutting_result: item.cutting_result, + warehouse_stock: item.warehouse_stock, + cutting_reject: item.cutting_reject, + }), + }); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Gagal memperbarui item.'); + } +} + function buildPayload() { - return { + const payload: Record = { 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) => ({ + }; + + if (!isCreateMode.value) { + payload.materials = materialCart.value.map((item) => ({ raw_material_price_id: item.raw_material_price_id, material_usage: item.material_usage, remaining_material: item.remaining_material, - })), - results: resultCart.value.map((item) => ({ + })); + payload.results = resultCart.value.map((item) => ({ product_variant_id: item.product_variant_id, cutting_result: item.cutting_result, warehouse_stock: item.warehouse_stock, cutting_reject: item.cutting_reject, - })), - }; + })); + } + + return payload; } -function validateBeforeSave(): boolean { +function submit() { if (materialCart.value.length === 0) { toast.error('Tambahkan minimal satu bahan baku.'); - return false; + return; } 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( @@ -433,9 +631,6 @@ function confirmSubmit() { } form.transform(() => payload).post(props.submitUrl, { - onSuccess: () => { - costModalOpen.value = false; - }, onError: (errors) => { const message = Object.values(errors)[0]; toast.error( @@ -817,6 +1012,11 @@ function confirmSubmit() { item.material_usage " class="h-8" + @change=" + syncMaterialField( + index, + ) + " /> @@ -828,6 +1028,11 @@ function confirmSubmit() { item.remaining_material " class="h-8" + @change=" + syncMaterialField( + index, + ) + " /> @@ -910,7 +1115,8 @@ function confirmSubmit() { " class="h-8" @change=" - syncResultTotals(item) + syncResultTotals(item); + syncResultField(index) " /> @@ -924,7 +1130,8 @@ function confirmSubmit() { " class="h-8" @change=" - syncResultTotals(item) + syncResultTotals(item); + syncResultField(index) " /> diff --git a/resources/js/pages/admin/manage/cuttings/table/CuttingGroupedTable.vue b/resources/js/pages/admin/manage/cuttings/table/CuttingGroupedTable.vue index 7c38fab..5183003 100644 --- a/resources/js/pages/admin/manage/cuttings/table/CuttingGroupedTable.vue +++ b/resources/js/pages/admin/manage/cuttings/table/CuttingGroupedTable.vue @@ -137,6 +137,37 @@ function getMaterialUnit(materials: any[]): string | undefined { return materials[0]?.raw_material_price?.raw_material?.unit_abbreviation; } +const CM_PER_YARD = 91.44; +const CM_PER_METER = 100; + +function formatTotalMaterialUsage(totalUsage: number | null | undefined, materials: any[]): string { + if (!totalUsage || !materials.length) { + return '0'; + } + + const unit = materials[0]?.raw_material_price?.raw_material?.unit; + + if (unit === 'yard') { + return (totalUsage * CM_PER_YARD).toFixed(4); + } + + if (unit === 'meter') { + return (totalUsage * CM_PER_METER).toFixed(4); + } + + return totalUsage.toFixed(4); +} + +function getTotalMaterialUsageUnit(materials: any[]): string { + const unit = materials[0]?.raw_material_price?.raw_material?.unit; + + if (unit === 'yard' || unit === 'meter') { + return 'cm'; + } + + return materials[0]?.raw_material_price?.raw_material?.unit_abbreviation ?? ''; +} +