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:
parent
e522b5f922
commit
9c205cf3d0
@ -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);
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/Admin/Manage/CuttingDraftResultRequest.php
Normal file
32
app/Http/Requests/Admin/Manage/CuttingDraftResultRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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'));
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">{{
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user