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

This commit is contained in:
Yoga Pangestu 2026-06-21 10:29:45 +07:00
parent e522b5f922
commit 9c205cf3d0
13 changed files with 844 additions and 151 deletions

View File

@ -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);

View File

@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Admin\Manage;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\CuttingDraftMaterialRequest;
use App\Http\Requests\Admin\Manage\CuttingDraftResultRequest;
use App\Models\ProductVariant;
use App\Models\RawMaterialPrice;
use App\Services\Manage\CuttingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CuttingDraftItemController extends Controller
{
public function __construct(
private readonly CuttingService $cuttingService,
) {}
public function storeMaterial(CuttingDraftMaterialRequest $request): JsonResponse
{
$item = $this->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]);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CuttingDraftMaterialRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can(Permission::CUTTINGS_CREATE->value) ?? false;
}
/**
* @return array<string, mixed>
*/
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'],
];
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CuttingDraftResultRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can(Permission::CUTTINGS_CREATE->value) ?? false;
}
/**
* @return array<string, mixed>
*/
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'],
];
}
}

View File

@ -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;
}
/**

View File

@ -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);

View File

@ -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);

View File

@ -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<int, RawMaterial>
*/
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<int, Product>
*/
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<array<string, mixed>>
*/
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<array<string, mixed>>
*/
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<string, mixed> $validated
* @return array<string, mixed>
*/
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<string, mixed> $validated
* @return array<string, mixed>
*/
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<string, mixed> $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<int, CuttingMaterial> $draftMaterials */
$draftMaterials = $this->draftMaterialsQuery($user)
->with('rawMaterialPrice.rawMaterial')
->lockForUpdate()
->get();
/** @var EloquentCollection<int, CuttingResult> $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<CuttingMaterial>
*/
private function draftMaterialsQuery(User $user): Builder
{
return CuttingMaterial::query()
->whereNull('cutting_id')
->where('user_id', $user->id);
}
/**
* @return Builder<CuttingResult>
*/
private function draftResultsQuery(User $user): Builder
{
return CuttingResult::query()
->whereNull('cutting_id')
->where('user_id', $user->id);
}
/**
* @return array<string, mixed>
*/
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<string, mixed>
*/
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'));

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('cutting_materials', function (Blueprint $table): void {
$table->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();
});
}
};

View File

@ -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[];
}>();
</script>
@ -34,6 +36,8 @@ defineProps<{
<CuttingPosForm
:raw-material-catalog="rawMaterialCatalog"
:product-catalog="productCatalog"
:draft-materials="draftMaterials"
:draft-results="draftResults"
submit-url="/admin/manage/cuttings"
method="post"
submit-label="Simpan"

View File

@ -34,6 +34,7 @@ import {
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api';
import { getFirstCoverImage } from '@/lib/catalog-cover';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
@ -59,11 +60,15 @@ const props = defineProps<{
materials: CuttingMaterialCartItem[];
results: CuttingResultCartItem[];
};
draftMaterials?: CuttingMaterialCartItem[];
draftResults?: CuttingResultCartItem[];
submitUrl: string;
method: 'post' | 'put';
submitLabel: string;
}>();
const isCreateMode = computed(() => props.method === 'post');
const materialSearch = ref('');
const productSearch = ref('');
const materialCart = ref<CuttingMaterialCartItem[]>([]);
@ -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<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,
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,
)
"
/>
</Field>
<Field>
@ -828,6 +1028,11 @@ function confirmSubmit() {
item.remaining_material
"
class="h-8"
@change="
syncMaterialField(
index,
)
"
/>
</Field>
<Field>
@ -910,7 +1115,8 @@ function confirmSubmit() {
"
class="h-8"
@change="
syncResultTotals(item)
syncResultTotals(item);
syncResultField(index)
"
/>
</Field>
@ -924,7 +1130,8 @@ function confirmSubmit() {
"
class="h-8"
@change="
syncResultTotals(item)
syncResultTotals(item);
syncResultField(index)
"
/>
</Field>

View File

@ -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 ?? '';
}
</script>
<template>
@ -173,9 +204,9 @@ function getMaterialUnit(materials: any[]): string | undefined {
<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>Total Pemakaian Bahan <strong class="text-primary">{{ cutting.total_material_usage
??
0 }} {{ getMaterialUnit(cutting.materials) }}</strong></span>
<span>Total Pemakaian Bahan <strong class="text-primary">{{
formatTotalMaterialUsage(cutting.total_material_usage, cutting.materials) }}
{{ getTotalMaterialUsageUnit(cutting.materials) }}</strong></span>
<span>Total Biaya Produksi <strong class="text-primary">{{
cutting.total_production_cost_formatted ?? 0 }}</strong></span>
<span>Harga Modal <strong class="text-primary">{{

View File

@ -13,6 +13,7 @@
use App\Http\Controllers\Admin\Hr\EmployeeController;
use App\Http\Controllers\Admin\Hr\LeaveRequestController;
use App\Http\Controllers\Admin\Manage\CuttingController;
use App\Http\Controllers\Admin\Manage\CuttingDraftItemController;
use App\Http\Controllers\Admin\Manage\OrderController;
use App\Http\Controllers\Admin\Manage\OrderDraftItemController;
use App\Http\Controllers\Admin\Manage\PurchaseController;
@ -258,6 +259,22 @@
->middleware('permission:'.Permission::CUTTINGS_VIEW->value.'|'.Permission::CUTTINGS_CREATE->value)
->name('create');
Route::post('draft-materials', [CuttingDraftItemController::class, 'storeMaterial'])
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
->name('draft-materials.store');
Route::delete('draft-materials/{rawMaterialPrice}', [CuttingDraftItemController::class, 'destroyMaterial'])
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
->name('draft-materials.destroy');
Route::post('draft-results', [CuttingDraftItemController::class, 'storeResult'])
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
->name('draft-results.store');
Route::delete('draft-results/{productVariant}', [CuttingDraftItemController::class, 'destroyResult'])
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
->name('draft-results.destroy');
Route::post('/', [CuttingController::class, 'store'])
->middleware('permission:'.Permission::CUTTINGS_VIEW->value.'|'.Permission::CUTTINGS_CREATE->value)
->name('store');