refactor: remove StockController and related stock verification logic
- Deleted `data-table-actions.vue` component as it is no longer needed. - Removed `CuttingResultPriceItem` type and related properties from `cutting.ts`. - Updated routes in `web.php` to eliminate stock verification routes and permissions. - Removed `StockTest.php` file and its associated tests for stock verification and management. - Adjusted `CuttingTest.php` to reflect changes in cutting results handling and removed references to product variants.
This commit is contained in:
parent
ebba4b3463
commit
9539ff7042
@ -11,33 +11,24 @@ enum CuttingStatus: string
|
||||
|
||||
case IN_PROGRESS = 'in_progress';
|
||||
case COMPLETED = 'completed';
|
||||
case PENDING_VERIFICATION = 'pending_verification';
|
||||
case VERIFIED = 'verified';
|
||||
case REJECTED = 'rejected';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::IN_PROGRESS => 'Proses',
|
||||
self::COMPLETED => 'Selesai',
|
||||
self::PENDING_VERIFICATION => 'Menunggu Verifikasi',
|
||||
self::VERIFIED => 'Terverifikasi',
|
||||
self::REJECTED => 'Ditolak',
|
||||
};
|
||||
}
|
||||
|
||||
public function isEditable(): bool
|
||||
{
|
||||
return in_array($this, [self::IN_PROGRESS, self::REJECTED], true);
|
||||
return $this === self::IN_PROGRESS;
|
||||
}
|
||||
|
||||
public function transitionStatusMessage(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::COMPLETED => 'Proses cutting berhasil diselesaikan. Menunggu verifikasi admin toko.',
|
||||
self::VERIFIED => 'Proses cutting berhasil diverifikasi. Stok produk telah diperbarui.',
|
||||
self::REJECTED => 'Proses cutting berhasil ditolak.',
|
||||
self::IN_PROGRESS => 'Proses cutting dikembalikan ke proses.',
|
||||
self::COMPLETED => 'Proses cutting berhasil diselesaikan.',
|
||||
default => 'Status proses cutting berhasil diperbarui.',
|
||||
};
|
||||
}
|
||||
@ -46,9 +37,6 @@ public function canTransitionTo(self $status): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::IN_PROGRESS => $status === self::COMPLETED,
|
||||
self::COMPLETED => in_array($status, [self::PENDING_VERIFICATION, self::REJECTED, self::IN_PROGRESS, self::VERIFIED], true),
|
||||
self::PENDING_VERIFICATION => in_array($status, [self::VERIFIED, self::REJECTED, self::COMPLETED], true),
|
||||
self::REJECTED => $status === self::IN_PROGRESS,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
@ -57,9 +45,6 @@ public function transitionPermission(): Permission
|
||||
{
|
||||
return match ($this) {
|
||||
self::COMPLETED => Permission::CUTTINGS_COMPLETE,
|
||||
self::PENDING_VERIFICATION => Permission::CUTTINGS_VERIFY,
|
||||
self::VERIFIED => Permission::CUTTINGS_VERIFY,
|
||||
self::REJECTED => Permission::CUTTINGS_REJECT,
|
||||
self::IN_PROGRESS => Permission::CUTTINGS_UPDATE,
|
||||
default => throw new InvalidArgumentException('Status tidak mendukung transisi.'),
|
||||
};
|
||||
@ -80,25 +65,6 @@ public function availableActions(): array
|
||||
'icon_only' => false,
|
||||
],
|
||||
],
|
||||
self::COMPLETED => [
|
||||
[
|
||||
'status' => self::VERIFIED->value,
|
||||
'label' => 'Verifikasi',
|
||||
'destructive' => false,
|
||||
'permission' => Permission::CUTTINGS_VERIFY->value,
|
||||
'icon_only' => false,
|
||||
],
|
||||
],
|
||||
self::PENDING_VERIFICATION => [],
|
||||
self::REJECTED => [
|
||||
[
|
||||
'status' => self::IN_PROGRESS->value,
|
||||
'label' => 'Kembalikan ke Proses',
|
||||
'destructive' => false,
|
||||
'permission' => Permission::CUTTINGS_UPDATE->value,
|
||||
'icon_only' => false,
|
||||
],
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
@ -103,8 +103,6 @@ enum Permission: string
|
||||
case CUTTINGS_UPDATE = 'cuttings.update';
|
||||
case CUTTINGS_DELETE = 'cuttings.delete';
|
||||
case CUTTINGS_COMPLETE = 'cuttings.complete';
|
||||
case CUTTINGS_VERIFY = 'cuttings.verify';
|
||||
case CUTTINGS_REJECT = 'cuttings.reject';
|
||||
|
||||
case OWNER_VERIFICATIONS_VIEW = 'owner_verifications.view';
|
||||
case OWNER_VERIFICATIONS_VERIFY = 'owner_verifications.verify';
|
||||
@ -256,8 +254,6 @@ public function label(): string
|
||||
self::CUTTINGS_UPDATE => 'Ubah Cutting',
|
||||
self::CUTTINGS_DELETE => 'Hapus Cutting',
|
||||
self::CUTTINGS_COMPLETE => 'Selesaikan Cutting',
|
||||
self::CUTTINGS_VERIFY => 'Verifikasi Cutting',
|
||||
self::CUTTINGS_REJECT => 'Tolak Cutting',
|
||||
|
||||
self::OWNER_VERIFICATIONS_VIEW => 'Lihat Verifikasi Owner',
|
||||
self::OWNER_VERIFICATIONS_VERIFY => 'Setujui Verifikasi Owner',
|
||||
@ -349,8 +345,7 @@ public function group(): string
|
||||
self::ORDERS_DELETE, self::ORDERS_SEND, self::ORDERS_COMPLETE,
|
||||
self::ORDERS_CANCEL => 'Pesanan',
|
||||
self::CUTTINGS_VIEW, self::CUTTINGS_CREATE, self::CUTTINGS_UPDATE,
|
||||
self::CUTTINGS_DELETE, self::CUTTINGS_COMPLETE, self::CUTTINGS_VERIFY,
|
||||
self::CUTTINGS_REJECT => 'Cutting',
|
||||
self::CUTTINGS_DELETE, self::CUTTINGS_COMPLETE,
|
||||
self::OWNER_VERIFICATIONS_VIEW, self::OWNER_VERIFICATIONS_VERIFY,
|
||||
self::OWNER_VERIFICATIONS_REJECT => 'Verifikasi Owner',
|
||||
self::STOCKS_VIEW,
|
||||
|
||||
@ -141,8 +141,6 @@ public function permissions(): array
|
||||
|
||||
Permission::STOK_OPNAMES_VIEW,
|
||||
|
||||
Permission::CUTTINGS_VERIFY,
|
||||
|
||||
Permission::ATTENDANCES_VIEW,
|
||||
Permission::ATTENDANCES_CREATE,
|
||||
Permission::ATTENDANCES_DELETE,
|
||||
@ -307,8 +305,6 @@ public function permissions(): array
|
||||
Permission::CUTTINGS_UPDATE,
|
||||
Permission::CUTTINGS_DELETE,
|
||||
Permission::CUTTINGS_COMPLETE,
|
||||
Permission::CUTTINGS_VERIFY,
|
||||
|
||||
Permission::PURCHASES_VIEW,
|
||||
Permission::PURCHASES_CREATE,
|
||||
Permission::PURCHASES_UPDATE,
|
||||
|
||||
@ -9,7 +9,6 @@
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Manage\CuttingRequest;
|
||||
use App\Http\Requests\Admin\Manage\CuttingStatusTransitionRequest;
|
||||
use App\Models\Category;
|
||||
use App\Models\Cutting;
|
||||
use App\Services\Manage\CuttingService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@ -43,10 +42,8 @@ public function create(Request $request): Response
|
||||
|
||||
return Inertia::render('admin/manage/cuttings/Create', [
|
||||
'rawMaterialCatalog' => $this->cuttingService->rawMaterialCatalog(user: $user),
|
||||
'productCatalog' => $this->cuttingService->productCatalog(user: $user),
|
||||
'draftMaterials' => $this->cuttingService->draftMaterialsForUser($user),
|
||||
'draftResults' => $this->cuttingService->draftResultsForUser($user),
|
||||
'categories' => Category::query()->orderBy('name')->get(['id', 'name'])->map(fn ($c) => ['value' => $c->id, 'label' => $c->name])->all(),
|
||||
'units' => collect(RawMaterialUnit::cases())->map(fn ($u) => ['value' => $u->value, 'label' => $u->label()])->all(),
|
||||
]);
|
||||
}
|
||||
@ -73,8 +70,6 @@ public function edit(Cutting $cutting): Response|RedirectResponse
|
||||
return Inertia::render('admin/manage/cuttings/Edit', [
|
||||
'cutting' => $this->cuttingService->findForEdit($cutting),
|
||||
'rawMaterialCatalog' => $this->cuttingService->rawMaterialCatalog($cutting),
|
||||
'productCatalog' => $this->cuttingService->productCatalog($cutting),
|
||||
'categories' => Category::query()->orderBy('name')->get(['id', 'name'])->map(fn ($c) => ['value' => $c->id, 'label' => $c->name])->all(),
|
||||
'units' => collect(RawMaterialUnit::cases())->map(fn ($u) => ['value' => $u->value, 'label' => $u->label()])->all(),
|
||||
]);
|
||||
}
|
||||
@ -106,9 +101,6 @@ public function transitionStatus(CuttingStatusTransitionRequest $request, Cuttin
|
||||
$status,
|
||||
$request->user(),
|
||||
$request->validated('reason'),
|
||||
$request->validated('verification_note'),
|
||||
$request->validated('results'),
|
||||
$request->validated('result_prices'),
|
||||
);
|
||||
|
||||
$this->flashSuccess($status->transitionStatusMessage());
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
use App\Http\Requests\Admin\Manage\CuttingDraftMaterialRequest;
|
||||
use App\Http\Requests\Admin\Manage\CuttingDraftResultRequest;
|
||||
use App\Http\Requests\Admin\Manage\CuttingQuickCreateProductRequest;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\CuttingResult;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Services\Manage\CuttingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@ -40,9 +40,9 @@ public function destroyMaterial(Request $request, RawMaterialPrice $rawMaterialP
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function destroyResult(Request $request, ProductVariant $productVariant): JsonResponse
|
||||
public function destroyResult(Request $request, CuttingResult $cuttingResult): JsonResponse
|
||||
{
|
||||
$this->cuttingService->removeDraftResult($request->user(), $productVariant);
|
||||
$this->cuttingService->removeDraftResult($request->user(), $cuttingResult);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
@ -6,10 +6,8 @@
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Manage\ApproveVerificationRequest;
|
||||
use App\Http\Requests\Admin\Manage\RejectVerificationRequest;
|
||||
use App\Models\Cutting;
|
||||
use App\Models\OwnerVerificationRequest;
|
||||
use App\Services\Manage\OwnerVerificationService;
|
||||
use App\Services\Manage\StockService;
|
||||
use App\Support\ActivityLog\ModelLabel;
|
||||
use App\Support\OwnerVerification\VerificationChangeFormatter;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@ -21,7 +19,6 @@ class OwnerVerificationController extends Controller
|
||||
|
||||
public function __construct(
|
||||
private readonly OwnerVerificationService $ownerVerificationService,
|
||||
private readonly StockService $stockService,
|
||||
) {}
|
||||
|
||||
public function show(OwnerVerificationRequest $ownerVerificationRequest): JsonResponse
|
||||
@ -40,32 +37,6 @@ public function show(OwnerVerificationRequest $ownerVerificationRequest): JsonRe
|
||||
]);
|
||||
}
|
||||
|
||||
public function approveCutting(ApproveVerificationRequest $request, Cutting $cutting): RedirectResponse
|
||||
{
|
||||
$this->stockService->approveVerification(
|
||||
$cutting,
|
||||
$request->user(),
|
||||
$request->validated('approval_note'),
|
||||
);
|
||||
|
||||
$this->flashSuccess('Verifikasi cutting berhasil disetujui. Stok produk telah ditambahkan ke toko.');
|
||||
|
||||
return back(302);
|
||||
}
|
||||
|
||||
public function rejectCutting(RejectVerificationRequest $request, Cutting $cutting): RedirectResponse
|
||||
{
|
||||
$this->stockService->rejectVerification(
|
||||
$cutting,
|
||||
$request->user(),
|
||||
$request->validated('reason'),
|
||||
);
|
||||
|
||||
$this->flashSuccess('Verifikasi cutting berhasil ditolak.');
|
||||
|
||||
return back(302);
|
||||
}
|
||||
|
||||
public function approveRequest(
|
||||
ApproveVerificationRequest $request,
|
||||
OwnerVerificationRequest $ownerVerificationRequest,
|
||||
|
||||
@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Manage\Stock;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Http\Controllers\Concerns\FlashesEntityMessage;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Manage\StockVerifyRequest;
|
||||
use App\Models\Cutting;
|
||||
use App\Services\Manage\StockService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class StockController extends Controller
|
||||
{
|
||||
use FlashesEntityMessage;
|
||||
|
||||
public function __construct(
|
||||
private readonly StockService $stockService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return Inertia::render('admin/manage/stocks/Index', [
|
||||
'pendingCuttings' => $this->stockService->getPendingVerificationCuttings($user),
|
||||
'pendingApprovalCuttings' => $this->stockService->getPendingApprovalCuttings($user),
|
||||
]);
|
||||
}
|
||||
|
||||
public function verify(StockVerifyRequest $request, Cutting $cutting): RedirectResponse
|
||||
{
|
||||
$this->stockService->submitVerification(
|
||||
$cutting,
|
||||
$request->user(),
|
||||
$request->validated('verification_note'),
|
||||
$request->validated('results'),
|
||||
$request->validated('result_prices'),
|
||||
);
|
||||
|
||||
if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
|
||||
$this->flashSuccess('Verifikasi berhasil disimpan. Stok produk telah ditambahkan ke toko.');
|
||||
} else {
|
||||
$this->flashSuccess('Verifikasi berhasil diajukan. Menunggu persetujuan owner.');
|
||||
}
|
||||
|
||||
return redirect()->route('admin.manage.stocks.index');
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Cutting;
|
||||
use App\Models\EmployeeAdvance;
|
||||
use App\Models\LeaveRequest;
|
||||
use App\Services\System\Setting\SystemService;
|
||||
@ -70,7 +69,6 @@ public function share(Request $request): array
|
||||
'vapidPublicKey' => config('webpush.vapid.public_key'),
|
||||
'pendingLeaveRequests' => fn () => $this->pendingLeaveRequests($request),
|
||||
'pendingEmployeeAdvances' => fn () => $this->pendingEmployeeAdvances($request),
|
||||
'pendingCuttings' => fn () => $this->pendingCuttings($request),
|
||||
];
|
||||
}
|
||||
|
||||
@ -99,23 +97,4 @@ private function pendingEmployeeAdvances(Request $request): int
|
||||
->pending()
|
||||
->count();
|
||||
}
|
||||
|
||||
private function pendingCuttings(Request $request): int
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user === null || ! $user->can('stocks.view')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($user->can('owner_verifications.verify')) {
|
||||
return Cutting::query()
|
||||
->pendingVerification()
|
||||
->count();
|
||||
}
|
||||
|
||||
return Cutting::query()
|
||||
->completed()
|
||||
->count();
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
use App\Enums\Permission;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CuttingDraftResultRequest extends FormRequest
|
||||
{
|
||||
@ -19,11 +18,7 @@ public function authorize(): bool
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'product_variant_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('product_variants', 'id')->whereNull('deleted_at'),
|
||||
],
|
||||
'product_name' => ['required', 'string', 'max:255'],
|
||||
'cutting_result' => ['nullable', 'integer', 'min:1'],
|
||||
'sample' => ['nullable', 'integer', 'min:0'],
|
||||
'original_outside_sample' => ['nullable', 'integer', 'min:0'],
|
||||
|
||||
@ -45,19 +45,15 @@ public function rules(): array
|
||||
$rules['materials.*.material_result'] = ['nullable', 'integer', 'min:0'];
|
||||
$rules['materials.*.combination_id'] = ['nullable', 'integer'];
|
||||
$rules['materials.*.combination_material_result'] = ['nullable', 'integer', 'min: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.*.sample'] = ['required', 'integer', 'min:0'];
|
||||
$rules['results.*.original_outside_sample'] = ['required', 'integer', 'min:0'];
|
||||
}
|
||||
|
||||
$rules['results'] = ['required', 'array', 'min:1'];
|
||||
$rules['results.*.product_name'] = ['required', 'string', 'max:255'];
|
||||
|
||||
$rules['results.*.cutting_result'] = ['required', 'integer', 'min:1'];
|
||||
$rules['results.*.sample'] = ['required', 'integer', 'min:0'];
|
||||
$rules['results.*.original_outside_sample'] = ['required', 'integer', 'min:0'];
|
||||
|
||||
return array_merge(
|
||||
$rules,
|
||||
$this->photoRules('photos', 10),
|
||||
@ -78,7 +74,7 @@ public function attributes(): array
|
||||
'materials.*.material_result' => 'hasil',
|
||||
'materials.*.combination_material_result' => 'hasil kombinasi',
|
||||
'results' => 'hasil produk',
|
||||
'results.*.product_variant_id' => 'varian produk',
|
||||
'results.*.product_name' => 'nama produk',
|
||||
'results.*.cutting_result' => 'hasil',
|
||||
'results.*.sample' => 'sample',
|
||||
'results.*.original_outside_sample' => 'diluar sample',
|
||||
|
||||
@ -3,9 +3,6 @@
|
||||
namespace App\Http\Requests\Admin\Manage;
|
||||
|
||||
use App\Enums\CuttingStatus;
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\PriceType;
|
||||
use App\Enums\Role;
|
||||
use App\Models\Cutting;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
@ -21,12 +18,6 @@ public function authorize(): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($status === CuttingStatus::IN_PROGRESS) {
|
||||
return $this->user()?->can(Permission::CUTTINGS_REJECT->value)
|
||||
|| $this->user()?->can(Permission::CUTTINGS_UPDATE->value)
|
||||
|| false;
|
||||
}
|
||||
|
||||
try {
|
||||
$permission = $status->transitionPermission();
|
||||
} catch (\InvalidArgumentException) {
|
||||
@ -43,29 +34,6 @@ public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['required', Rule::enum(CuttingStatus::class)],
|
||||
'reason' => ['nullable', 'string', 'max:500'],
|
||||
'verification_note' => ['nullable', 'string', 'max:500'],
|
||||
'results' => ['nullable', 'array'],
|
||||
'results.*.product_variant_id' => ['required_with:results', 'integer', 'exists:product_variants,id'],
|
||||
'results.*.sample' => ['required_with:results', 'integer', 'min:0'],
|
||||
'results.*.original_outside_sample' => ['required_with:results', 'integer', 'min:0'],
|
||||
'result_prices' => ['nullable', 'array'],
|
||||
'result_prices.*.product_variant_id' => ['required_with:result_prices', 'integer', 'exists:product_variants,id'],
|
||||
'result_prices.*.prices' => ['required_with:result_prices', 'array', 'min:1'],
|
||||
'result_prices.*.prices.*.type' => ['required', Rule::enum(PriceType::class)],
|
||||
'result_prices.*.prices.*.price' => ['required', 'integer', 'gt:0'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'status' => 'status',
|
||||
'reason' => 'alasan penolakan',
|
||||
'verification_note' => 'catatan verifikasi',
|
||||
];
|
||||
}
|
||||
|
||||
@ -83,57 +51,6 @@ public function withValidator(Validator $validator): void
|
||||
if (! $cutting->status->canTransitionTo($status)) {
|
||||
$validator->errors()->add('status', 'Status cutting tidak dapat diubah.');
|
||||
}
|
||||
|
||||
// Only owner/developer can verify directly from COMPLETED status
|
||||
if ($cutting->status === CuttingStatus::COMPLETED && $status === CuttingStatus::VERIFIED) {
|
||||
$user = $this->user();
|
||||
if (! $user || ! $user->hasRole([Role::OWNER->value, Role::DEVELOPER->value])) {
|
||||
$validator->errors()->add('status', 'Hanya owner yang dapat langsung memverifikasi cutting.');
|
||||
}
|
||||
}
|
||||
|
||||
if ($status === CuttingStatus::REJECTED && trim((string) $this->input('reason')) === '') {
|
||||
$validator->errors()->add('reason', 'Alasan penolakan wajib diisi.');
|
||||
}
|
||||
|
||||
if ($status === CuttingStatus::VERIFIED) {
|
||||
if ($this->has('results')) {
|
||||
$cuttingResults = $cutting->results->keyBy('product_variant_id');
|
||||
foreach ($this->input('results', []) as $index => $item) {
|
||||
$variantId = $item['product_variant_id'] ?? 0;
|
||||
$sample = (int) ($item['sample'] ?? 0);
|
||||
$originalOutsideSample = (int) ($item['original_outside_sample'] ?? 0);
|
||||
|
||||
$originalResult = $cuttingResults->get($variantId);
|
||||
if ($originalResult === null) {
|
||||
$validator->errors()->add("results.{$index}.product_variant_id", 'Varian produk tidak ditemukan pada cutting ini.');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($sample + $originalOutsideSample) !== (int) $originalResult->cutting_result) {
|
||||
$validator->errors()->add("results.{$index}.sample", "Total jumlah (sample + hasil cutting diluar sample) harus sama dengan hasil cutting asli ({$originalResult->cutting_result} pcs).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->has('result_prices') || $this->input('result_prices') === []) {
|
||||
$validator->errors()->add('result_prices', 'Harga jual wajib diisi saat verifikasi.');
|
||||
} else {
|
||||
$variantIds = $cutting->results->pluck('product_variant_id')->all();
|
||||
$submittedVariantIds = collect($this->input('result_prices', []))
|
||||
->pluck('product_variant_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
|
||||
foreach ($variantIds as $variantId) {
|
||||
if (! in_array($variantId, $submittedVariantIds, true)) {
|
||||
$validator->errors()->add('result_prices', 'Harga jual wajib diisi untuk semua varian hasil cutting.');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,108 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage;
|
||||
|
||||
use App\Enums\CuttingStatus;
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\PriceType;
|
||||
use App\Models\Cutting;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class StockVerifyRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can(Permission::CUTTINGS_VERIFY->value) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'verification_note' => ['nullable', 'string', 'max:500'],
|
||||
'results' => ['nullable', 'array'],
|
||||
'results.*.product_variant_id' => ['required_with:results', 'integer', 'exists:product_variants,id'],
|
||||
'results.*.good' => ['required_with:results', 'integer', 'min:0'],
|
||||
'results.*.reject' => ['required_with:results', 'integer', 'min:0'],
|
||||
'result_prices' => ['nullable', 'array'],
|
||||
'result_prices.*.product_variant_id' => ['required_with:result_prices', 'integer', 'exists:product_variants,id'],
|
||||
'result_prices.*.prices' => ['required_with:result_prices', 'array', 'min:1'],
|
||||
'result_prices.*.prices.*.type' => ['required', Rule::enum(PriceType::class)],
|
||||
'result_prices.*.prices.*.price' => ['required', 'integer', 'min:0'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'verification_note' => 'catatan verifikasi',
|
||||
'result_prices' => 'harga jual',
|
||||
'result_prices.*.product_variant_id' => 'varian produk',
|
||||
'result_prices.*.prices' => 'harga jual',
|
||||
'result_prices.*.prices.*.type' => 'tipe harga',
|
||||
'result_prices.*.prices.*.price' => 'harga jual',
|
||||
'results' => 'hasil cutting',
|
||||
'results.*.product_variant_id' => 'varian produk',
|
||||
'results.*.good' => 'bagus',
|
||||
'results.*.reject' => 'reject',
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
/** @var Cutting $cutting */
|
||||
$cutting = $this->route('cutting');
|
||||
|
||||
if ($cutting->status !== CuttingStatus::COMPLETED) {
|
||||
$validator->errors()->add('status', 'Hanya cutting yang sudah selesai yang dapat diverifikasi.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->has('results')) {
|
||||
$cuttingResults = $cutting->results->keyBy('product_variant_id');
|
||||
foreach ($this->input('results', []) as $index => $item) {
|
||||
$variantId = $item['product_variant_id'] ?? 0;
|
||||
$good = (int) ($item['good'] ?? 0);
|
||||
$reject = (int) ($item['reject'] ?? 0);
|
||||
|
||||
$originalResult = $cuttingResults->get($variantId);
|
||||
if ($originalResult === null) {
|
||||
$validator->errors()->add("results.{$index}.product_variant_id", 'Varian produk tidak ditemukan pada cutting ini.');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($good + $reject) > (int) $originalResult->cutting_result) {
|
||||
$validator->errors()->add("results.{$index}.good", "Total jumlah (bagus + reject) tidak boleh melebihi hasil cutting asli ({$originalResult->cutting_result} pcs).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->has('result_prices') || $this->input('result_prices') === []) {
|
||||
$validator->errors()->add('result_prices', 'Harga jual wajib diisi saat verifikasi.');
|
||||
} else {
|
||||
$variantIds = $cutting->results->pluck('product_variant_id')->all();
|
||||
$submittedVariantIds = collect($this->input('result_prices', []))
|
||||
->pluck('product_variant_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
|
||||
foreach ($variantIds as $variantId) {
|
||||
if (! in_array($variantId, $submittedVariantIds, true)) {
|
||||
$validator->errors()->add('result_prices', 'Harga jual wajib diisi untuk semua varian hasil cutting.');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -55,18 +55,6 @@ protected function inProgress(Builder $query): void
|
||||
$query->where('status', CuttingStatus::IN_PROGRESS);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function pendingVerification(Builder $query): void
|
||||
{
|
||||
$query->where('status', CuttingStatus::PENDING_VERIFICATION);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function verified(Builder $query): void
|
||||
{
|
||||
$query->where('status', CuttingStatus::VERIFIED);
|
||||
}
|
||||
|
||||
// 4. Attribute
|
||||
public function createdAtFormatted(): Attribute
|
||||
{
|
||||
@ -118,11 +106,6 @@ public function combinations(): HasMany
|
||||
return $this->hasMany(CuttingMaterialCombination::class);
|
||||
}
|
||||
|
||||
public function resultPrices(): HasMany
|
||||
{
|
||||
return $this->hasMany(CuttingResultPrice::class);
|
||||
}
|
||||
|
||||
public function results(): HasMany
|
||||
{
|
||||
return $this->hasMany(CuttingResult::class);
|
||||
|
||||
@ -31,11 +31,6 @@ public function cutting(): BelongsTo
|
||||
return $this->belongsTo(Cutting::class)->withTrashed();
|
||||
}
|
||||
|
||||
public function productVariant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductVariant::class)->withTrashed();
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class)->withTrashed();
|
||||
|
||||
@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PriceType;
|
||||
use App\Models\Concerns\InteractsWithActivityLog;
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends(['cost_per_unit_formatted', 'price_formatted', 'type_label'])]
|
||||
class CuttingResultPrice extends Model
|
||||
{
|
||||
// 1. Use Trait
|
||||
use HasFactory, InteractsWithActivityLog;
|
||||
|
||||
// 2. Casting
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'cost_per_unit' => 'integer',
|
||||
'price' => 'integer',
|
||||
'price_type' => PriceType::class,
|
||||
];
|
||||
}
|
||||
|
||||
// 3. Attribute
|
||||
public function costPerUnitFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => 'Rp '.number_format($this->cost_per_unit, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
public function priceFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => 'Rp '.number_format($this->price, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
public function typeLabel(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->price_type->label(),
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Relation
|
||||
public function cutting(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cutting::class)->withTrashed();
|
||||
}
|
||||
|
||||
public function productVariant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductVariant::class)->withTrashed();
|
||||
}
|
||||
}
|
||||
@ -72,11 +72,6 @@ public function registerMediaCollections(): void
|
||||
}
|
||||
|
||||
// 6. Relation
|
||||
public function cuttingResultPrices(): HasMany
|
||||
{
|
||||
return $this->hasMany(CuttingResultPrice::class);
|
||||
}
|
||||
|
||||
public function cuttingResults(): HasMany
|
||||
{
|
||||
return $this->hasMany(CuttingResult::class);
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
namespace App\Services\Manage;
|
||||
|
||||
use App\Enums\PriceType;
|
||||
use App\Models\CuttingResultPrice;
|
||||
use App\Models\ProductPrice;
|
||||
use App\Services\Concerns\CachesQuery;
|
||||
use Illuminate\Support\Collection;
|
||||
@ -12,56 +11,27 @@ class CuttingResultPriceResolver
|
||||
{
|
||||
use CachesQuery;
|
||||
|
||||
public function resolve(int $productVariantId, PriceType $priceType): ?CuttingResultPrice
|
||||
public function resolve(int $productVariantId, PriceType $priceType): ?ProductPrice
|
||||
{
|
||||
$price = CuttingResultPrice::query()
|
||||
->where('product_variant_id', $productVariantId)
|
||||
->where('price_type', $priceType)
|
||||
->whereHas('cutting', fn ($query) => $query->verified())
|
||||
->join('cuttings', 'cutting_result_prices.cutting_id', '=', 'cuttings.id')
|
||||
->orderByDesc('cuttings.created_at')
|
||||
->select('cutting_result_prices.*')
|
||||
->first();
|
||||
|
||||
if ($price !== null) {
|
||||
return $price;
|
||||
}
|
||||
|
||||
$productPrice = ProductPrice::query()
|
||||
return ProductPrice::query()
|
||||
->where('variant_id', $productVariantId)
|
||||
->where('type', $priceType)
|
||||
->first();
|
||||
|
||||
if ($productPrice !== null) {
|
||||
$cp = new CuttingResultPrice;
|
||||
$cp->product_variant_id = $productVariantId;
|
||||
$cp->price_type = $priceType;
|
||||
$cp->price = $productPrice->price;
|
||||
|
||||
return $cp;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function latestPricesForVariant(int $productVariantId): array
|
||||
public function latestPricesForVariant(int $productVariantId): Collection
|
||||
{
|
||||
return $this->cacheRemember("prices:variant:{$productVariantId}", 900, function () use ($productVariantId) {
|
||||
$prices = [];
|
||||
|
||||
foreach (PriceType::cases() as $priceType) {
|
||||
$price = $this->resolve($productVariantId, $priceType);
|
||||
|
||||
if ($price !== null) {
|
||||
$prices[] = [
|
||||
'price_type' => $price->price_type->value,
|
||||
'price' => (int) $price->price,
|
||||
'price_formatted' => $price->price_formatted,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $prices;
|
||||
return ProductPrice::query()
|
||||
->where('variant_id', $productVariantId)
|
||||
->get()
|
||||
->map(fn (ProductPrice $price) => (object) [
|
||||
'price_type' => $price->type,
|
||||
'price' => $price->price,
|
||||
'price_formatted' => $price->price_formatted,
|
||||
'cost_per_unit' => 0,
|
||||
'cost_per_unit_formatted' => 'Rp 0',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@ -72,40 +42,21 @@ public function latestPricesForVariants(array $variantIds): Collection
|
||||
return [];
|
||||
}
|
||||
|
||||
$cuttingPrices = CuttingResultPrice::query()
|
||||
->whereIn('product_variant_id', $variantIds)
|
||||
->whereHas('cutting', fn ($query) => $query->verified())
|
||||
->join('cuttings', 'cutting_result_prices.cutting_id', '=', 'cuttings.id')
|
||||
->orderByDesc('cuttings.created_at')
|
||||
->select('cutting_result_prices.*')
|
||||
->get()
|
||||
->groupBy(fn (CuttingResultPrice $price) => $price->product_variant_id.'-'.$price->price_type->value)
|
||||
->map(fn (Collection $group) => $group->first());
|
||||
|
||||
$productPrices = ProductPrice::query()
|
||||
->whereIn('variant_id', $variantIds)
|
||||
->get()
|
||||
->groupBy(fn (ProductPrice $price) => $price->variant_id.'-'.$price->type->value);
|
||||
|
||||
// Store only plain arrays — never Eloquent models — to avoid
|
||||
// __PHP_Incomplete_Class when Redis deserializes across requests.
|
||||
$results = [];
|
||||
foreach ($variantIds as $variantId) {
|
||||
foreach (PriceType::cases() as $priceType) {
|
||||
$key = $variantId.'-'.$priceType->value;
|
||||
if ($cuttingPrices->has($key)) {
|
||||
$cp = $cuttingPrices->get($key);
|
||||
$results[$variantId][] = [
|
||||
'price_type' => $cp->price_type->value,
|
||||
'price' => (int) $cp->price,
|
||||
'price_formatted' => $cp->price_formatted,
|
||||
];
|
||||
} elseif ($productPrices->has($key)) {
|
||||
if ($productPrices->has($key)) {
|
||||
$pp = $productPrices->get($key)->first();
|
||||
$results[$variantId][] = [
|
||||
'price_type' => $priceType->value,
|
||||
'price_type' => $pp->type->value,
|
||||
'price' => (int) $pp->price,
|
||||
'price_formatted' => 'Rp '.number_format((int) $pp->price, 0, ',', '.'),
|
||||
'price_formatted' => $pp->price_formatted,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -114,7 +65,6 @@ public function latestPricesForVariants(array $variantIds): Collection
|
||||
return $results;
|
||||
});
|
||||
|
||||
// Rebuild as a Collection keyed by variant ID (matching original contract)
|
||||
return collect(is_array($cached) ? $cached : [])->map(fn ($prices) => collect($prices));
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,14 +4,11 @@
|
||||
|
||||
use App\Enums\CuttingStatus;
|
||||
use App\Enums\RawMaterialUnit;
|
||||
use App\Enums\Role;
|
||||
use App\Models\Cutting;
|
||||
use App\Models\CuttingMaterial;
|
||||
use App\Models\CuttingMaterialCombination;
|
||||
use App\Models\CuttingResult;
|
||||
use App\Models\CuttingResultPrice;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\RawMaterial;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Models\User;
|
||||
@ -44,8 +41,6 @@ public function paginateForIndex(array $tableQuery, User $user): LengthAwarePagi
|
||||
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||
'materials.rawMaterialPrice.media',
|
||||
'materials.combination',
|
||||
'results.productVariant.product:id,name',
|
||||
'results.productVariant.media',
|
||||
])
|
||||
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
|
||||
$search = $tableQuery['search'];
|
||||
@ -55,9 +50,8 @@ public function paginateForIndex(array $tableQuery, User $user): LengthAwarePagi
|
||||
$query->where('variant', 'like', "%{$search}%")
|
||||
->orWhereHas('rawMaterial', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"));
|
||||
})
|
||||
->orWhereHas('results.productVariant', function (Builder $query) use ($search): void {
|
||||
$query->where('name', 'like', "%{$search}%")
|
||||
->orWhereHas('product', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"));
|
||||
->orWhereHas('results', function (Builder $query) use ($search): void {
|
||||
$query->where('product_name', 'like', "%{$search}%");
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -88,8 +82,6 @@ public function getInProgressCuttings(User $user): Collection
|
||||
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||
'materials.rawMaterialPrice.media',
|
||||
'materials.combination',
|
||||
'results.productVariant.product:id,name',
|
||||
'results.productVariant.media',
|
||||
])
|
||||
->inProgress()
|
||||
->latest()
|
||||
@ -113,8 +105,6 @@ public function getCompletedCuttings(User $user): Collection
|
||||
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||
'materials.rawMaterialPrice.media',
|
||||
'materials.combination',
|
||||
'results.productVariant.product:id,name',
|
||||
'results.productVariant.media',
|
||||
])
|
||||
->completed()
|
||||
->latest()
|
||||
@ -162,40 +152,6 @@ public function rawMaterialCatalog(?Cutting $cutting = null, ?User $user = null)
|
||||
});
|
||||
}
|
||||
|
||||
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([
|
||||
'variants' => fn ($query) => $query
|
||||
->with('media')
|
||||
->orderBy('created_at'),
|
||||
])
|
||||
->where(function (Builder $query) use ($selectedVariantIds): void {
|
||||
$query->active();
|
||||
|
||||
if ($selectedVariantIds !== []) {
|
||||
$query->orWhereHas(
|
||||
'variants',
|
||||
fn (Builder $query) => $query->whereIn('id', $selectedVariantIds),
|
||||
);
|
||||
}
|
||||
})
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->each(function (Product $product): void {
|
||||
$product->variants->each(function (ProductVariant $variant): void {
|
||||
$variant->setAttribute(
|
||||
'images',
|
||||
MediaPresenter::collection($variant, 'images'),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public function findForEdit(Cutting $cutting): Cutting
|
||||
{
|
||||
$cutting->load([
|
||||
@ -203,8 +159,6 @@ public function findForEdit(Cutting $cutting): Cutting
|
||||
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||
'materials.rawMaterialPrice.media',
|
||||
'materials.combination',
|
||||
'results.productVariant.product:id,name',
|
||||
'results.productVariant.media',
|
||||
'rejection.rejectedBy.profile',
|
||||
]);
|
||||
|
||||
@ -222,8 +176,6 @@ public function findForShare(Cutting $cutting): Cutting
|
||||
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||
'materials.rawMaterialPrice.media',
|
||||
'materials.combination',
|
||||
'results.productVariant.product:id,name',
|
||||
'results.productVariant.media',
|
||||
]);
|
||||
|
||||
$this->appendCostPreview($cutting);
|
||||
@ -246,14 +198,7 @@ private function appendImages(Cutting $cutting): void
|
||||
});
|
||||
|
||||
$cutting->results->each(function (CuttingResult $result): void {
|
||||
$variant = $result->productVariant;
|
||||
|
||||
if ($variant) {
|
||||
$variant->setAttribute(
|
||||
'images',
|
||||
MediaPresenter::collection($variant, 'images'),
|
||||
);
|
||||
}
|
||||
$result->setAttribute('product_name', $result->product_name ?? '');
|
||||
});
|
||||
}
|
||||
|
||||
@ -279,10 +224,6 @@ public function draftMaterialsForUser(User $user): array
|
||||
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()
|
||||
@ -332,9 +273,7 @@ public function syncDraftMaterial(array $validated, User $user): array
|
||||
|
||||
public function syncDraftResult(array $validated, User $user): array
|
||||
{
|
||||
$variant = ProductVariant::query()
|
||||
->with('product:id,name')
|
||||
->findOrFail($validated['product_variant_id']);
|
||||
$productName = $validated['product_name'] ?? null;
|
||||
|
||||
$cuttingResult = array_key_exists('cutting_result', $validated) && $validated['cutting_result'] !== null
|
||||
? (int) $validated['cutting_result']
|
||||
@ -372,22 +311,13 @@ public function syncDraftResult(array $validated, User $user): array
|
||||
}
|
||||
}
|
||||
|
||||
$item = CuttingResult::query()->updateOrCreate(
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'product_variant_id' => $variant->id,
|
||||
'cutting_id' => null,
|
||||
],
|
||||
[
|
||||
'cutting_result' => $cuttingResult,
|
||||
'sample' => $sample,
|
||||
'original_outside_sample' => $originalOutsideSample,
|
||||
],
|
||||
);
|
||||
|
||||
$item->load([
|
||||
'productVariant.product:id,name',
|
||||
'productVariant.media',
|
||||
$item = CuttingResult::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'cutting_id' => null,
|
||||
'product_name' => $productName,
|
||||
'cutting_result' => $cuttingResult,
|
||||
'sample' => $sample,
|
||||
'original_outside_sample' => $originalOutsideSample,
|
||||
]);
|
||||
|
||||
return $this->presentDraftResult($item);
|
||||
@ -402,13 +332,13 @@ public function removeDraftMaterial(User $user, RawMaterialPrice $rawMaterialPri
|
||||
->delete();
|
||||
}
|
||||
|
||||
public function removeDraftResult(User $user, ProductVariant $productVariant): void
|
||||
public function removeDraftResult(User $user, CuttingResult $cuttingResult): void
|
||||
{
|
||||
CuttingResult::query()
|
||||
->whereNull('cutting_id')
|
||||
->where('user_id', $user->id)
|
||||
->where('product_variant_id', $productVariant->id)
|
||||
->delete();
|
||||
if ($cuttingResult->cutting_id !== null || $cuttingResult->user_id !== $user->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cuttingResult->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -491,22 +421,12 @@ function () use ($validated, $user): Cutting {
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
$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,
|
||||
'description' => $validated['description'] ?? null,
|
||||
@ -536,10 +456,9 @@ function () use ($validated, $user): Cutting {
|
||||
$material->save();
|
||||
}
|
||||
|
||||
foreach ($draftResults as $result) {
|
||||
$result->cutting_id = $cutting->id;
|
||||
$result->user_id = null;
|
||||
$result->save();
|
||||
$results = $this->buildResults($validated['results']);
|
||||
foreach ($results as $resultData) {
|
||||
$cutting->results()->create($resultData);
|
||||
}
|
||||
|
||||
$cutting->load('materials.rawMaterialPrice.rawMaterial');
|
||||
@ -576,8 +495,6 @@ function () use ($cutting, $validated): void {
|
||||
|
||||
if ($cutting->status === CuttingStatus::IN_PROGRESS) {
|
||||
$this->reverseTotalMaterialStock($cutting);
|
||||
} elseif ($cutting->status === CuttingStatus::REJECTED) {
|
||||
$this->reverseMaterialStock($cutting);
|
||||
}
|
||||
|
||||
$cutting->materials()->delete();
|
||||
@ -590,10 +507,6 @@ function () use ($cutting, $validated): void {
|
||||
$cutting->description = $validated['description'] ?? null;
|
||||
$cutting->sewing_cost = (int) ($validated['sewing_cost'] ?? 0);
|
||||
$cutting->other_cost = (int) ($validated['other_cost'] ?? 0);
|
||||
if ($cutting->status === CuttingStatus::REJECTED) {
|
||||
$cutting->status = CuttingStatus::IN_PROGRESS;
|
||||
$cutting->rejection()?->delete();
|
||||
}
|
||||
$cutting->save();
|
||||
|
||||
$this->syncImages($cutting, $validated);
|
||||
@ -660,9 +573,9 @@ function () use ($cutting, $validated): void {
|
||||
|
||||
public function delete(Cutting $cutting, User $user): void
|
||||
{
|
||||
if (! in_array($cutting->status, [CuttingStatus::IN_PROGRESS, CuttingStatus::REJECTED], true)) {
|
||||
if ($cutting->status !== CuttingStatus::IN_PROGRESS) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => 'Proses cutting hanya dapat dihapus saat masih proses atau ditolak.',
|
||||
'status' => 'Proses cutting hanya dapat dihapus saat masih proses.',
|
||||
]);
|
||||
}
|
||||
|
||||
@ -672,11 +585,7 @@ public function delete(Cutting $cutting, User $user): void
|
||||
function () use ($cutting): void {
|
||||
$cutting->load(['materials.rawMaterialPrice.rawMaterial']);
|
||||
|
||||
if ($cutting->status === CuttingStatus::IN_PROGRESS) {
|
||||
$this->reverseTotalMaterialStock($cutting);
|
||||
} elseif ($cutting->status === CuttingStatus::REJECTED) {
|
||||
$this->reverseMaterialStock($cutting);
|
||||
}
|
||||
$this->reverseTotalMaterialStock($cutting);
|
||||
|
||||
$cutting->materials()->delete();
|
||||
$cutting->results()->delete();
|
||||
@ -700,9 +609,6 @@ public function transitionStatus(
|
||||
CuttingStatus $status,
|
||||
User $user,
|
||||
?string $reason = null,
|
||||
?string $verificationNote = null,
|
||||
?array $results = null,
|
||||
?array $resultPrices = null,
|
||||
): void {
|
||||
if (! $cutting->status->canTransitionTo($status)) {
|
||||
throw ValidationException::withMessages([
|
||||
@ -711,7 +617,7 @@ public function transitionStatus(
|
||||
}
|
||||
|
||||
$this->runInTransaction(
|
||||
function () use ($cutting, $status, $verificationNote, $results, $resultPrices, $user, $reason): void {
|
||||
function () use ($cutting, $status): void {
|
||||
$cutting->load(['materials.rawMaterialPrice', 'results']);
|
||||
|
||||
if ($status === CuttingStatus::COMPLETED) {
|
||||
@ -719,37 +625,6 @@ function () use ($cutting, $status, $verificationNote, $results, $resultPrices,
|
||||
$cutting->cost_per_unit = $this->calculateCostPerUnit($cutting);
|
||||
}
|
||||
|
||||
if ($status === CuttingStatus::IN_PROGRESS) {
|
||||
$cutting->rejection()?->delete();
|
||||
}
|
||||
|
||||
if ($status === CuttingStatus::REJECTED) {
|
||||
$this->storeRejection($cutting, $reason, $user);
|
||||
}
|
||||
|
||||
if ($status === CuttingStatus::VERIFIED) {
|
||||
if ($results !== null) {
|
||||
foreach ($results as $item) {
|
||||
$cutting->results()
|
||||
->where('product_variant_id', $item['product_variant_id'])
|
||||
->update([
|
||||
'sample' => $item['sample'],
|
||||
'original_outside_sample' => $item['original_outside_sample'],
|
||||
]);
|
||||
}
|
||||
$cutting->load('results');
|
||||
}
|
||||
$this->applyProductStockOnVerify($cutting);
|
||||
$this->storeResultPrices($cutting, $resultPrices ?? []);
|
||||
|
||||
if ($verificationNote !== null && trim($verificationNote) !== '') {
|
||||
$cutting->rejection()->create([
|
||||
'reason' => trim($verificationNote),
|
||||
'rejected_by_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$cutting->status = $status;
|
||||
$cutting->save();
|
||||
},
|
||||
@ -758,41 +633,24 @@ function () use ($cutting, $status, $verificationNote, $results, $resultPrices,
|
||||
|
||||
$description = $cutting->description ?? '-';
|
||||
$message = match ($status) {
|
||||
CuttingStatus::COMPLETED => "Proses cutting dengan deskripsi '{$description}' telah selesai oleh {$user->profile?->full_name} dan menunggu verifikasi.",
|
||||
CuttingStatus::VERIFIED => "Proses cutting dengan deskripsi '{$description}' telah diverifikasi oleh {$user->profile?->full_name}.",
|
||||
CuttingStatus::REJECTED => "Proses cutting dengan deskripsi '{$description}' ditolak oleh {$user->profile?->full_name}".($reason ? " dengan alasan: '{$reason}'" : '').'.',
|
||||
CuttingStatus::COMPLETED => "Proses cutting dengan deskripsi '{$description}' telah selesai oleh {$user->profile?->full_name}.",
|
||||
CuttingStatus::IN_PROGRESS => "Proses cutting dengan deskripsi '{$description}' dikembalikan ke proses oleh {$user->profile?->full_name}.",
|
||||
default => "Status proses cutting dengan deskripsi '{$description}' telah diperbarui ke: {$status->label()} oleh {$user->profile?->full_name}.",
|
||||
};
|
||||
|
||||
$title = match ($status) {
|
||||
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',
|
||||
};
|
||||
|
||||
$roles = $status === CuttingStatus::COMPLETED
|
||||
? ['owner', 'developer', 'direktur']
|
||||
: ['owner', 'developer', 'direktur'];
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
$title,
|
||||
$message,
|
||||
$roles,
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.manage.cuttings.index'),
|
||||
);
|
||||
|
||||
if ($status === CuttingStatus::COMPLETED) {
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
$title,
|
||||
$message,
|
||||
['admin-toko'],
|
||||
route('admin.manage.stocks.index'),
|
||||
);
|
||||
}
|
||||
|
||||
$this->cacheForgetByPattern('manage:cuttings:*');
|
||||
$this->cacheForgetByPattern('prices:*');
|
||||
}
|
||||
@ -838,13 +696,7 @@ private function buildResults(array $results): array
|
||||
{
|
||||
return collect($results)
|
||||
->map(function (array $itemData, int $index) {
|
||||
$variant = ProductVariant::query()->find($itemData['product_variant_id']);
|
||||
|
||||
if ($variant === null) {
|
||||
throw ValidationException::withMessages([
|
||||
"results.{$index}.product_variant_id" => 'Varian produk tidak ditemukan.',
|
||||
]);
|
||||
}
|
||||
$productName = $itemData['product_name'] ?? null;
|
||||
|
||||
$cuttingResult = (int) $itemData['cutting_result'];
|
||||
$sample = (int) $itemData['sample'];
|
||||
@ -875,7 +727,7 @@ private function buildResults(array $results): array
|
||||
}
|
||||
|
||||
return [
|
||||
'product_variant_id' => $variant->id,
|
||||
'product_name' => $productName,
|
||||
'cutting_result' => $cuttingResult,
|
||||
'sample' => $sample,
|
||||
'original_outside_sample' => $originalOutsideSample,
|
||||
@ -915,65 +767,6 @@ private function reverseTotalMaterialStock(Cutting $cutting): void
|
||||
}
|
||||
}
|
||||
|
||||
private function reverseMaterialStock(Cutting $cutting): void
|
||||
{
|
||||
foreach ($cutting->materials as $material) {
|
||||
RawMaterialPrice::query()
|
||||
->whereKey($material->raw_material_price_id)
|
||||
->increment('stock', (float) $material->material_usage);
|
||||
}
|
||||
}
|
||||
|
||||
private function applyProductStockOnVerify(Cutting $cutting): void
|
||||
{
|
||||
foreach ($cutting->results as $result) {
|
||||
if ($result->sample > 0) {
|
||||
ProductVariant::query()
|
||||
->whereKey($result->product_variant_id)
|
||||
->increment('stock', $result->sample);
|
||||
}
|
||||
|
||||
if ($result->original_outside_sample > 0) {
|
||||
ProductVariant::query()
|
||||
->whereKey($result->product_variant_id)
|
||||
->increment('reject_stock', $result->original_outside_sample);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function reverseProductStock(Cutting $cutting): void
|
||||
{
|
||||
foreach ($cutting->results as $result) {
|
||||
if ($result->sample > 0) {
|
||||
ProductVariant::query()
|
||||
->whereKey($result->product_variant_id)
|
||||
->decrement('stock', $result->sample);
|
||||
}
|
||||
|
||||
if ($result->original_outside_sample > 0) {
|
||||
ProductVariant::query()
|
||||
->whereKey($result->product_variant_id)
|
||||
->decrement('reject_stock', $result->original_outside_sample);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function storeRejection(Cutting $cutting, ?string $reason, User $user): void
|
||||
{
|
||||
if ($reason === null || trim($reason) === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'reason' => 'Alasan penolakan wajib diisi.',
|
||||
]);
|
||||
}
|
||||
|
||||
$cutting->rejection()?->delete();
|
||||
|
||||
$cutting->rejection()->create([
|
||||
'reason' => trim($reason),
|
||||
'rejected_by_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
private function breakMaterialCircularReference(CuttingMaterial $material): void
|
||||
{
|
||||
$price = $material->rawMaterialPrice;
|
||||
@ -1056,17 +849,11 @@ private function presentDraftMaterial(CuttingMaterial $item): 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,
|
||||
'product_name' => $item->product_name ?? '',
|
||||
'cutting_result' => $item->cutting_result !== null ? (string) $item->cutting_result : null,
|
||||
'sample' => $item->sample !== null ? (string) $item->sample : null,
|
||||
'original_outside_sample' => $item->original_outside_sample !== null ? (string) $item->original_outside_sample : null,
|
||||
'images' => $variant ? MediaPresenter::collection($variant, 'images') : [],
|
||||
];
|
||||
}
|
||||
|
||||
@ -1101,14 +888,6 @@ private function filterActionsForUser(Cutting $cutting, User $user): array
|
||||
{
|
||||
return collect($cutting->status->availableActions())
|
||||
->filter(fn (array $action) => $user->can($action['permission']))
|
||||
->filter(function (array $action) use ($cutting, $user): bool {
|
||||
// Only owner/developer can verify directly from COMPLETED status
|
||||
if ($cutting->status === CuttingStatus::COMPLETED && $action['status'] === CuttingStatus::VERIFIED->value) {
|
||||
return $user->hasRole([Role::OWNER->value, Role::DEVELOPER->value]);
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
@ -1164,23 +943,6 @@ public function calculateCostPerUnit(Cutting $cutting): int
|
||||
return (int) round($this->calculateTotalProductionCost($cutting) / $totalPieces);
|
||||
}
|
||||
|
||||
private function storeResultPrices(Cutting $cutting, array $resultPrices): void
|
||||
{
|
||||
$costPerUnit = (int) ($cutting->cost_per_unit ?? $this->calculateCostPerUnit($cutting));
|
||||
|
||||
foreach ($resultPrices as $resultData) {
|
||||
foreach ($resultData['prices'] as $priceData) {
|
||||
CuttingResultPrice::query()->create([
|
||||
'cutting_id' => $cutting->id,
|
||||
'product_variant_id' => $resultData['product_variant_id'],
|
||||
'price_type' => $priceData['type'],
|
||||
'price' => (int) $priceData['price'],
|
||||
'cost_per_unit' => $costPerUnit,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick-create a product with variants and prices, bypassing owner verification.
|
||||
*/
|
||||
|
||||
@ -2,16 +2,12 @@
|
||||
|
||||
namespace App\Services\Manage;
|
||||
|
||||
use App\Enums\OwnerVerificationAction;
|
||||
use App\Enums\OwnerVerificationStatus;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Cutting;
|
||||
use App\Models\OwnerVerificationRequest;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\Purchase;
|
||||
use App\Models\RawMaterial;
|
||||
use App\Models\Restock;
|
||||
use App\Models\User;
|
||||
use App\Services\Concerns\CachesQuery;
|
||||
use App\Services\Master\ProductService;
|
||||
@ -23,8 +19,6 @@
|
||||
use App\Support\OwnerVerification\VerificationChangeFormatter;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Pagination\LengthAwarePaginator as Paginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@ -34,7 +28,6 @@ class OwnerVerificationService
|
||||
use CachesQuery;
|
||||
|
||||
public function __construct(
|
||||
private readonly StockService $stockService,
|
||||
private readonly ProductService $productService,
|
||||
private readonly RawMaterialService $rawMaterialService,
|
||||
private readonly PurchaseService $purchaseService,
|
||||
@ -60,44 +53,8 @@ public function paginateForIndex(
|
||||
string $action = '',
|
||||
): LengthAwarePaginator {
|
||||
$perPage = 25;
|
||||
$page = Paginator::resolveCurrentPage();
|
||||
$includeCuttings = $this->shouldIncludeCuttings($user, $status, $subjectType, $action);
|
||||
$cuttingRows = $includeCuttings
|
||||
? $this->pendingCuttingRows($user, $tableQuery)
|
||||
: collect();
|
||||
$cuttingCount = $cuttingRows->count();
|
||||
|
||||
$requestsOnly = $action === OwnerVerificationAction::STOCK_VERIFY->value
|
||||
|| $subjectType === Cutting::class;
|
||||
|
||||
$query = $this->buildVerificationRequestQuery($user, $tableQuery, $status, $subjectType, $action);
|
||||
$requestTotal = $requestsOnly ? 0 : (clone $query)->count();
|
||||
$total = $cuttingCount + $requestTotal;
|
||||
|
||||
if ($requestsOnly) {
|
||||
$items = $cuttingRows->forPage($page, $perPage)->values();
|
||||
|
||||
return $this->makePaginator($items, $total, $perPage, $page);
|
||||
}
|
||||
|
||||
if ($cuttingCount > 0) {
|
||||
if ($page === 1) {
|
||||
$requestLimit = max(0, $perPage - $cuttingCount);
|
||||
$requestItems = $requestLimit > 0
|
||||
? $query->take($requestLimit)->get()->map(fn (OwnerVerificationRequest $request) => $this->presentRequestRow($request))
|
||||
: collect();
|
||||
$items = $cuttingRows->concat($requestItems)->values();
|
||||
} else {
|
||||
$requestOffset = ($page - 1) * $perPage - $cuttingCount;
|
||||
$items = $query
|
||||
->skip(max(0, $requestOffset))
|
||||
->take($perPage)
|
||||
->get()
|
||||
->map(fn (OwnerVerificationRequest $request) => $this->presentRequestRow($request));
|
||||
}
|
||||
|
||||
return $this->makePaginator($items, $total, $perPage, $page);
|
||||
}
|
||||
|
||||
return $query
|
||||
->paginate($perPage)
|
||||
@ -105,52 +62,19 @@ public function paginateForIndex(
|
||||
->through(fn (OwnerVerificationRequest $request) => $this->presentRequestRow($request));
|
||||
}
|
||||
|
||||
private function pendingCuttingRows(User $user, array $tableQuery): Collection
|
||||
{
|
||||
$rows = $this->stockService
|
||||
->getPendingApprovalCuttings($user)
|
||||
->map(fn (Cutting $cutting) => $this->presentCuttingRow($cutting));
|
||||
|
||||
if ($tableQuery['search'] === '') {
|
||||
return $rows->values();
|
||||
}
|
||||
|
||||
$search = mb_strtolower($tableQuery['search']);
|
||||
|
||||
return $rows
|
||||
->filter(function (array $row) use ($search): bool {
|
||||
foreach (['title', 'summary', 'submitted_by_name', 'action_label', 'subject_label', 'status_label'] as $field) {
|
||||
$value = mb_strtolower((string) ($row[$field] ?? ''));
|
||||
|
||||
if ($value !== '' && str_contains($value, $search)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
->values();
|
||||
}
|
||||
|
||||
public function pendingCountForUser(User $user): int
|
||||
{
|
||||
return $this->cacheRemember("verification:pending_count:{$user->id}", 30, function () use ($user) {
|
||||
$requestCount = OwnerVerificationRequest::query()
|
||||
return OwnerVerificationRequest::query()
|
||||
->pending()
|
||||
->visibleTo($user)
|
||||
->count();
|
||||
|
||||
if (! $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
|
||||
return $requestCount;
|
||||
}
|
||||
|
||||
return $requestCount + Cutting::query()->pendingVerification()->count();
|
||||
});
|
||||
}
|
||||
|
||||
public function subjectTypeOptions(User $user): array
|
||||
{
|
||||
$options = OwnerVerificationRequest::query()
|
||||
return OwnerVerificationRequest::query()
|
||||
->visibleTo($user)
|
||||
->distinct()
|
||||
->pluck('subject_type')
|
||||
@ -158,17 +82,7 @@ public function subjectTypeOptions(User $user): array
|
||||
->map(fn (string $type) => [
|
||||
'value' => $type,
|
||||
'label' => ModelLabel::for($type),
|
||||
]);
|
||||
|
||||
if ($user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)
|
||||
&& Cutting::query()->pendingVerification()->exists()) {
|
||||
$options->push([
|
||||
'value' => Cutting::class,
|
||||
'label' => ModelLabel::for(Cutting::class),
|
||||
]);
|
||||
}
|
||||
|
||||
return $options
|
||||
])
|
||||
->unique('value')
|
||||
->sortBy('label')
|
||||
->values()
|
||||
@ -355,37 +269,6 @@ private function presentRequestRow(OwnerVerificationRequest $request): array
|
||||
];
|
||||
}
|
||||
|
||||
private function presentCuttingRow(Cutting $cutting): array
|
||||
{
|
||||
$totalPieces = $cutting->results->sum('cutting_result');
|
||||
|
||||
return [
|
||||
'id' => $cutting->id,
|
||||
'source' => 'cutting',
|
||||
'subject_type' => Cutting::class,
|
||||
'subject_label' => ModelLabel::for(Cutting::class),
|
||||
'subject_id' => $cutting->id,
|
||||
'action' => OwnerVerificationAction::STOCK_VERIFY->value,
|
||||
'action_label' => OwnerVerificationAction::STOCK_VERIFY->label(),
|
||||
'status' => OwnerVerificationStatus::PENDING->value,
|
||||
'status_label' => OwnerVerificationStatus::PENDING->label(),
|
||||
'title' => "Cutting #{$cutting->id}",
|
||||
'summary' => $cutting->description ?? "Total hasil {$totalPieces} pcs",
|
||||
'submitted_by_name' => $cutting->submittedBy?->profile?->full_name
|
||||
?? $cutting->submittedBy?->username
|
||||
?? '-',
|
||||
'created_at' => $cutting->created_at?->toIso8601String(),
|
||||
'created_at_formatted' => $cutting->created_at?->translatedFormat('l, d F Y H:i'),
|
||||
'changes' => [],
|
||||
'is_pending' => true,
|
||||
'detail' => [
|
||||
'results' => $cutting->results,
|
||||
'result_prices' => $cutting->result_prices,
|
||||
'total_result_pieces' => $cutting->total_result_pieces,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function buildVerificationRequestQuery(
|
||||
User $user,
|
||||
array $tableQuery,
|
||||
@ -393,10 +276,6 @@ private function buildVerificationRequestQuery(
|
||||
string $subjectType,
|
||||
string $action,
|
||||
): Builder {
|
||||
if ($action === OwnerVerificationAction::STOCK_VERIFY->value || $subjectType === Cutting::class) {
|
||||
return OwnerVerificationRequest::query()->whereRaw('0 = 1');
|
||||
}
|
||||
|
||||
$query = OwnerVerificationRequest::query()
|
||||
->visibleTo($user)
|
||||
->with([
|
||||
@ -435,46 +314,6 @@ private function buildVerificationRequestQuery(
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function shouldIncludeCuttings(
|
||||
User $user,
|
||||
string $status,
|
||||
string $subjectType,
|
||||
string $action,
|
||||
): bool {
|
||||
if (! $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($status !== '' && $status !== OwnerVerificationStatus::PENDING->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($subjectType !== '' && $subjectType !== Cutting::class) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($action !== '' && $action !== OwnerVerificationAction::STOCK_VERIFY->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Cutting::query()->pendingVerification()->exists();
|
||||
}
|
||||
|
||||
private function makePaginator(
|
||||
Collection $items,
|
||||
int $total,
|
||||
int $perPage,
|
||||
int $page,
|
||||
): LengthAwarePaginator {
|
||||
return (new Paginator(
|
||||
$items,
|
||||
$total,
|
||||
$perPage,
|
||||
$page,
|
||||
['path' => Paginator::resolveCurrentPath(), 'pageName' => 'page'],
|
||||
))->withQueryString();
|
||||
}
|
||||
|
||||
private function requestTitle(OwnerVerificationRequest $request): string
|
||||
{
|
||||
if ($request->subject_type === MarketplaceSettings::class) {
|
||||
|
||||
@ -2,424 +2,4 @@
|
||||
|
||||
namespace App\Services\Manage;
|
||||
|
||||
use App\Enums\CuttingStatus;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Cutting;
|
||||
use App\Models\CuttingMaterial;
|
||||
use App\Models\CuttingResult;
|
||||
use App\Models\CuttingResultPrice;
|
||||
use App\Models\ProductPrice;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\User;
|
||||
use App\Services\Concerns\CachesQuery;
|
||||
use App\Services\System\PushNotificationService;
|
||||
use App\Support\Media\MediaPresenter;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class StockService
|
||||
{
|
||||
use CachesQuery;
|
||||
|
||||
public function __construct(
|
||||
private readonly PushNotificationService $pushNotificationService,
|
||||
) {}
|
||||
|
||||
public function getPendingVerificationCuttings(User $user): Collection
|
||||
{
|
||||
return Cutting::query()
|
||||
->with([
|
||||
'createdBy.profile',
|
||||
'rejection.rejectedBy.profile',
|
||||
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||
'materials.rawMaterialPrice.media',
|
||||
'materials.combination',
|
||||
'results.productVariant.product:id,name',
|
||||
'results.productVariant.media',
|
||||
])
|
||||
->completed()
|
||||
->latest()
|
||||
->get()
|
||||
->each(function (Cutting $cutting): void {
|
||||
$this->appendCostPreview($cutting);
|
||||
$this->appendPhotos($cutting);
|
||||
$cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m));
|
||||
});
|
||||
}
|
||||
|
||||
public function getPendingApprovalCuttings(User $user): Collection
|
||||
{
|
||||
return Cutting::query()
|
||||
->with([
|
||||
'createdBy.profile',
|
||||
'submittedBy.profile',
|
||||
'rejection.rejectedBy.profile',
|
||||
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||
'materials.rawMaterialPrice.media',
|
||||
'materials.combination',
|
||||
'results.productVariant.product:id,name',
|
||||
'results.productVariant.media',
|
||||
'resultPrices.productVariant:id,product_id,name',
|
||||
])
|
||||
->pendingVerification()
|
||||
->latest()
|
||||
->get()
|
||||
->each(function (Cutting $cutting): void {
|
||||
$this->appendCostPreview($cutting);
|
||||
$this->appendPhotos($cutting);
|
||||
$cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m));
|
||||
});
|
||||
}
|
||||
|
||||
public function submitVerification(
|
||||
Cutting $cutting,
|
||||
User $user,
|
||||
?string $verificationNote = null,
|
||||
?array $results = null,
|
||||
?array $resultPrices = null,
|
||||
): void {
|
||||
if ($cutting->status !== CuttingStatus::COMPLETED) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => 'Hanya cutting yang sudah selesai yang dapat diverifikasi.',
|
||||
]);
|
||||
}
|
||||
|
||||
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($cutting, $user, $verificationNote, $results, $resultPrices, $isOwner): void {
|
||||
$cutting->load(['materials.rawMaterialPrice', 'results']);
|
||||
|
||||
if ($results !== null) {
|
||||
foreach ($results as $item) {
|
||||
$cutting->results()
|
||||
->where('product_variant_id', $item['product_variant_id'])
|
||||
->update([
|
||||
'sample' => $item['good'],
|
||||
'original_outside_sample' => $item['reject'],
|
||||
]);
|
||||
}
|
||||
$cutting->load('results');
|
||||
}
|
||||
|
||||
$this->storeResultPrices($cutting, $resultPrices ?? []);
|
||||
|
||||
if ($verificationNote !== null && trim($verificationNote) !== '') {
|
||||
$cutting->rejection()->create([
|
||||
'reason' => trim($verificationNote),
|
||||
'rejected_by_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
$cutting->submitted_by_id = $user->id;
|
||||
|
||||
if ($isOwner) {
|
||||
$this->applyProductStockOnVerify($cutting);
|
||||
$this->applyResultPricesToProducts($cutting);
|
||||
$cutting->status = CuttingStatus::VERIFIED;
|
||||
} else {
|
||||
$cutting->status = CuttingStatus::PENDING_VERIFICATION;
|
||||
}
|
||||
|
||||
$cutting->save();
|
||||
});
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gagal mengajukan verifikasi stok: '.$e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
|
||||
]);
|
||||
}
|
||||
|
||||
$description = $cutting->description ?? '-';
|
||||
|
||||
if ($isOwner) {
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'📦 Stok Cutting Diverifikasi',
|
||||
"Cutting dengan deskripsi '{$description}' telah disetujui oleh {$user->profile?->full_name} dan stok produk telah ditambahkan ke toko.",
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.manage.stocks.index'),
|
||||
);
|
||||
} else {
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'📦 Verifikasi Stok Menunggu Persetujuan',
|
||||
"Cutting dengan deskripsi '{$description}' telah diajukan verifikasi oleh {$user->profile?->full_name} dan menunggu persetujuan owner.",
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.manage.stocks.index'),
|
||||
);
|
||||
}
|
||||
|
||||
$this->cacheForgetByPattern('manage:stocks:*');
|
||||
$this->cacheForgetByPattern('prices:*');
|
||||
}
|
||||
|
||||
public function approveVerification(
|
||||
Cutting $cutting,
|
||||
User $user,
|
||||
?string $approvalNote = null,
|
||||
): void {
|
||||
if ($cutting->status !== CuttingStatus::PENDING_VERIFICATION) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => 'Hanya cutting yang menunggu verifikasi yang dapat disetujui.',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($cutting, $user, $approvalNote): void {
|
||||
$cutting->load(['materials.rawMaterialPrice', 'results', 'resultPrices']);
|
||||
|
||||
$this->applyProductStockOnVerify($cutting);
|
||||
$this->applyResultPricesToProducts($cutting);
|
||||
|
||||
if ($approvalNote !== null && trim($approvalNote) !== '') {
|
||||
$cutting->rejection()->create([
|
||||
'reason' => trim($approvalNote),
|
||||
'rejected_by_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
$cutting->status = CuttingStatus::VERIFIED;
|
||||
$cutting->save();
|
||||
});
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gagal menyetujui verifikasi stok: '.$e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
|
||||
]);
|
||||
}
|
||||
|
||||
$description = $cutting->description ?? '-';
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'📦 Stok Cutting Diverifikasi',
|
||||
"Cutting dengan deskripsi '{$description}' telah disetujui oleh {$user->profile?->full_name} dan stok produk telah ditambahkan ke toko.",
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.manage.stocks.index'),
|
||||
);
|
||||
|
||||
$this->cacheForgetByPattern('manage:stocks:*');
|
||||
$this->cacheForgetByPattern('prices:*');
|
||||
}
|
||||
|
||||
public function rejectVerification(
|
||||
Cutting $cutting,
|
||||
User $user,
|
||||
string $reason,
|
||||
): void {
|
||||
if ($cutting->status !== CuttingStatus::PENDING_VERIFICATION) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => 'Hanya cutting yang menunggu verifikasi yang dapat ditolak.',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($cutting, $user, $reason): void {
|
||||
$cutting->rejection()->create([
|
||||
'reason' => trim($reason),
|
||||
'rejected_by_id' => $user->id,
|
||||
]);
|
||||
|
||||
$cutting->status = CuttingStatus::COMPLETED;
|
||||
$cutting->save();
|
||||
});
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gagal menolak verifikasi stok: '.$e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
|
||||
]);
|
||||
}
|
||||
|
||||
$description = $cutting->description ?? '-';
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'📦 Verifikasi Cutting Ditolak Owner',
|
||||
"Cutting dengan deskripsi '{$description}' ditolak oleh {$user->profile?->full_name} dengan alasan: '{$reason}'.",
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.manage.stocks.index'),
|
||||
);
|
||||
|
||||
if ($cutting->submitted_by_id) {
|
||||
$this->pushNotificationService->sendToUser(
|
||||
'📦 Verifikasi Cutting Ditolak Owner',
|
||||
"Cutting dengan deskripsi '{$description}' yang Anda verifikasi ditolak oleh owner dengan alasan: '{$reason}'.",
|
||||
$cutting->submitted_by_id,
|
||||
route('admin.manage.stocks.index'),
|
||||
);
|
||||
}
|
||||
|
||||
$this->cacheForgetByPattern('manage:stocks:*');
|
||||
}
|
||||
|
||||
private function breakMaterialCircularReference(CuttingMaterial $material): void
|
||||
{
|
||||
$price = $material->rawMaterialPrice;
|
||||
|
||||
// Always set these attributes regardless of price
|
||||
$material->setAttribute('material_result', $material->material_result);
|
||||
$material->setAttribute('material_result_input', $material->material_result);
|
||||
$material->setAttribute('combination_id', $material->combination_id);
|
||||
$material->setAttribute('combination_material_result', $material->combination?->material_result);
|
||||
|
||||
if ($price) {
|
||||
$rawMaterial = $price->rawMaterial;
|
||||
|
||||
$material->setAttribute('variant', $price->variant);
|
||||
$material->setAttribute('stock_input', $price->stock_input);
|
||||
$material->setAttribute('images', $price->getAttribute('images') ?? []);
|
||||
|
||||
if ($rawMaterial) {
|
||||
$unitAbbreviation = $rawMaterial->unit->abbreviation();
|
||||
$price->setAttribute('unit_abbreviation', $unitAbbreviation);
|
||||
$material->setAttribute('unit_abbreviation', $unitAbbreviation);
|
||||
$material->setAttribute('unit', $rawMaterial->unit->value);
|
||||
$material->setAttribute('raw_material_id', $rawMaterial->id);
|
||||
$material->setAttribute('raw_material_name', $rawMaterial->name);
|
||||
$material->setAttribute('raw_material_unit_label', $rawMaterial->unit->label());
|
||||
}
|
||||
|
||||
$price->unsetRelation('rawMaterial');
|
||||
}
|
||||
|
||||
$material->unsetRelation('rawMaterialPrice');
|
||||
$material->unsetRelation('combination');
|
||||
}
|
||||
|
||||
private function applyProductStockOnVerify(Cutting $cutting): void
|
||||
{
|
||||
foreach ($cutting->results as $result) {
|
||||
if ($result->sample > 0) {
|
||||
ProductVariant::query()
|
||||
->whereKey($result->product_variant_id)
|
||||
->increment('stock', $result->sample);
|
||||
}
|
||||
|
||||
if ($result->original_outside_sample > 0) {
|
||||
ProductVariant::query()
|
||||
->whereKey($result->product_variant_id)
|
||||
->increment('reject_stock', $result->original_outside_sample);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function storeResultPrices(Cutting $cutting, array $resultPrices): void
|
||||
{
|
||||
// Extract harga_modal from the first variant's prices and set as cost_per_unit
|
||||
$costPerUnit = 0;
|
||||
foreach ($resultPrices as $resultData) {
|
||||
foreach ($resultData['prices'] as $priceData) {
|
||||
if ($priceData['type'] === 'harga_modal' && (int) $priceData['price'] > 0) {
|
||||
$costPerUnit = (int) $priceData['price'];
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($costPerUnit > 0) {
|
||||
$cutting->cost_per_unit = $costPerUnit;
|
||||
} else {
|
||||
$costPerUnit = (int) ($cutting->cost_per_unit ?? 0);
|
||||
}
|
||||
|
||||
foreach ($resultPrices as $resultData) {
|
||||
foreach ($resultData['prices'] as $priceData) {
|
||||
if ((int) $priceData['price'] > 0) {
|
||||
// Store/update to cutting_result_prices
|
||||
CuttingResultPrice::query()->updateOrCreate(
|
||||
[
|
||||
'cutting_id' => $cutting->id,
|
||||
'product_variant_id' => $resultData['product_variant_id'],
|
||||
'price_type' => $priceData['type'],
|
||||
],
|
||||
[
|
||||
'price' => (int) $priceData['price'],
|
||||
'cost_per_unit' => $costPerUnit,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function applyResultPricesToProducts(Cutting $cutting): void
|
||||
{
|
||||
$resultPrices = $cutting->resultPrices()->with('productVariant')->get();
|
||||
|
||||
foreach ($resultPrices as $resultPrice) {
|
||||
if ($resultPrice->price > 0) {
|
||||
ProductPrice::query()->updateOrCreate(
|
||||
[
|
||||
'variant_id' => $resultPrice->product_variant_id,
|
||||
'type' => $resultPrice->price_type->value,
|
||||
],
|
||||
[
|
||||
'price' => $resultPrice->price,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function appendCostPreview(Cutting $cutting): void
|
||||
{
|
||||
$totalResultPieces = (int) $cutting->results->sum('cutting_result');
|
||||
$cutting->setAttribute('total_result_pieces', $totalResultPieces);
|
||||
$cutting->setAttribute('total_material_usage', (float) $cutting->materials->sum('material_usage'));
|
||||
|
||||
$totalMaterialCost = $cutting->total_material_cost ?? 0;
|
||||
$sewingCost = (int) ($cutting->sewing_cost ?? 0);
|
||||
$otherCost = (int) ($cutting->other_cost ?? 0);
|
||||
$totalProductionCost = $totalMaterialCost + $sewingCost + $otherCost;
|
||||
$costPerUnit = $cutting->cost_per_unit ?? 0;
|
||||
$materialCostPerProduct = $totalResultPieces > 0 ? (int) round($totalMaterialCost / $totalResultPieces) : 0;
|
||||
|
||||
$cutting->setAttribute('total_material_cost', $totalMaterialCost);
|
||||
$cutting->setAttribute('total_material_cost_formatted', 'Rp '.number_format($totalMaterialCost, 0, ',', '.'));
|
||||
$cutting->setAttribute('material_cost_per_product', $materialCostPerProduct);
|
||||
$cutting->setAttribute('material_cost_per_product_formatted', 'Rp '.number_format($materialCostPerProduct, 0, ',', '.'));
|
||||
$cutting->setAttribute('sewing_cost', $sewingCost);
|
||||
$cutting->setAttribute('sewing_cost_formatted', 'Rp '.number_format($sewingCost, 0, ',', '.'));
|
||||
$cutting->setAttribute('other_cost', $otherCost);
|
||||
$cutting->setAttribute('other_cost_formatted', 'Rp '.number_format($otherCost, 0, ',', '.'));
|
||||
$cutting->setAttribute('total_production_cost', $totalProductionCost);
|
||||
$cutting->setAttribute('total_production_cost_formatted', 'Rp '.number_format($totalProductionCost, 0, ',', '.'));
|
||||
$cutting->setAttribute('estimated_cost_per_unit', $costPerUnit);
|
||||
$cutting->setAttribute('estimated_cost_per_unit_formatted', 'Rp '.number_format($costPerUnit, 0, ',', '.'));
|
||||
}
|
||||
|
||||
private function appendPhotos(Cutting $cutting): void
|
||||
{
|
||||
$cutting->materials->each(function (CuttingMaterial $material): void {
|
||||
$price = $material->rawMaterialPrice;
|
||||
|
||||
if ($price) {
|
||||
$price->setAttribute('images', MediaPresenter::collection($price, 'images'));
|
||||
}
|
||||
});
|
||||
|
||||
$cutting->results->each(function (CuttingResult $result): void {
|
||||
$variant = $result->productVariant;
|
||||
|
||||
if ($variant) {
|
||||
$variant->setAttribute(
|
||||
'images',
|
||||
MediaPresenter::collection($variant, 'images'),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
class StockService {}
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
use App\Models\Cutting;
|
||||
use App\Models\CuttingMaterial;
|
||||
use App\Models\CuttingResult;
|
||||
use App\Models\CuttingResultPrice;
|
||||
use App\Models\Employee;
|
||||
use App\Models\EmployeeAdvance;
|
||||
use App\Models\Expense;
|
||||
@ -49,7 +48,6 @@ class ModelLabel
|
||||
Cutting::class => 'Cutting',
|
||||
CuttingMaterial::class => 'Bahan Cutting',
|
||||
CuttingResult::class => 'Hasil Cutting',
|
||||
CuttingResultPrice::class => 'Harga Hasil Cutting',
|
||||
Employee::class => 'Pegawai',
|
||||
EmployeeAdvance::class => 'Kasbon',
|
||||
Expense::class => 'Pengeluaran',
|
||||
|
||||
@ -27,11 +27,4 @@ public function completed(): static
|
||||
'status' => CuttingStatus::COMPLETED->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function verified(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'status' => CuttingStatus::VERIFIED->value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
use App\Models\Cutting;
|
||||
use App\Models\CuttingResult;
|
||||
use App\Models\ProductVariant;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
@ -19,7 +18,7 @@ public function definition(): array
|
||||
|
||||
return [
|
||||
'cutting_id' => Cutting::factory(),
|
||||
'product_variant_id' => ProductVariant::factory(),
|
||||
'product_name' => fake()->words(2, true),
|
||||
'cutting_result' => $cuttingResult,
|
||||
'sample' => $cuttingResult - $originalOutsideSample,
|
||||
'original_outside_sample' => $originalOutsideSample,
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private function hasForeignKey(string $table, string $column): bool
|
||||
{
|
||||
$schema = config('database.connections.mysql.database');
|
||||
$result = DB::selectOne(
|
||||
'SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ? AND REFERENCED_TABLE_NAME IS NOT NULL LIMIT 1',
|
||||
[$schema, $table, $column],
|
||||
);
|
||||
|
||||
return $result !== null;
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('cutting_results', 'product_name')) {
|
||||
Schema::table('cutting_results', function (Blueprint $table) {
|
||||
$table->string('product_name', 255)->nullable()->after('id');
|
||||
});
|
||||
|
||||
DB::statement('
|
||||
UPDATE cutting_results cr
|
||||
JOIN product_variants pv ON cr.product_variant_id = pv.id
|
||||
JOIN products p ON pv.product_id = p.id
|
||||
SET cr.product_name = p.name
|
||||
WHERE cr.product_name IS NULL
|
||||
AND cr.product_variant_id IS NOT NULL
|
||||
');
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('cutting_results', 'product_variant_id')) {
|
||||
Schema::table('cutting_results', function (Blueprint $table) {
|
||||
if ($this->hasForeignKey('cutting_results', 'product_variant_id')) {
|
||||
$table->dropForeign(['product_variant_id']);
|
||||
}
|
||||
$table->dropColumn('product_variant_id');
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('cutting_results', 'variant_name')) {
|
||||
Schema::table('cutting_results', function (Blueprint $table) {
|
||||
$table->dropColumn('variant_name');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasColumn('cutting_results', 'product_variant_id')) {
|
||||
Schema::table('cutting_results', function (Blueprint $table) {
|
||||
$table->foreignId('product_variant_id')->nullable()->after('cutting_id')->constrained()->restrictOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('cutting_results', 'product_name')) {
|
||||
Schema::table('cutting_results', function (Blueprint $table) {
|
||||
$table->dropColumn('product_name');
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('cutting_results', 'variant_name')) {
|
||||
Schema::table('cutting_results', function (Blueprint $table) {
|
||||
$table->string('variant_name', 255)->nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('cutting_result_prices');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::create('cutting_result_prices', function ($table) {
|
||||
$table->id();
|
||||
$table->foreignId('cutting_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('product_variant_id')->constrained()->restrictOnDelete();
|
||||
$table->string('price_type');
|
||||
$table->unsignedInteger('price')->default(0);
|
||||
$table->unsignedInteger('cost_per_unit')->default(0);
|
||||
$table->timestamps();
|
||||
$table->unique(['cutting_id', 'product_variant_id', 'price_type'], 'cutting_result_prices_unique');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
use App\Models\Cutting;
|
||||
use App\Models\CuttingResult;
|
||||
use App\Models\ProductVariant;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class CuttingResultSeeder extends Seeder
|
||||
@ -12,9 +11,8 @@ class CuttingResultSeeder extends Seeder
|
||||
public function run(): void
|
||||
{
|
||||
$cutting = Cutting::query()->first();
|
||||
$variant = ProductVariant::query()->first();
|
||||
|
||||
if ($cutting === null || $variant === null) {
|
||||
if ($cutting === null) {
|
||||
CuttingResult::factory()->count(3)->create();
|
||||
|
||||
return;
|
||||
@ -24,7 +22,6 @@ public function run(): void
|
||||
->count(2)
|
||||
->create([
|
||||
'cutting_id' => $cutting->id,
|
||||
'product_variant_id' => $variant->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ interface MenuItem {
|
||||
href: string;
|
||||
icon: any;
|
||||
permission?: string | string[];
|
||||
badgeKey?: 'pendingLeaveRequests' | 'pendingEmployeeAdvances' | 'pendingCuttings';
|
||||
badgeKey?: 'pendingLeaveRequests' | 'pendingEmployeeAdvances';
|
||||
}
|
||||
|
||||
interface MenuGroup {
|
||||
@ -57,7 +57,6 @@ const menuGroups: MenuGroup[] = [
|
||||
{ title: 'Belanja', href: admin.manage.purchases.index.url(), icon: ShoppingBag, permission: 'purchases.view' },
|
||||
{ title: 'Cutting', href: admin.manage.cuttings.index.url(), icon: Scissors, permission: 'cuttings.view' },
|
||||
{ title: 'Restock', href: admin.manage.restocks.index.url(), icon: PackagePlus, permission: 'restocks.view' },
|
||||
{ title: 'Stok Gudang', href: admin.manage.stocks.index.url(), icon: Warehouse, permission: 'stocks.view', badgeKey: 'pendingCuttings' },
|
||||
{ title: 'Stok Opname', href: admin.manage.stokOpnames.index.url(), icon: ClipboardList, permission: 'stok_opnames.view' },
|
||||
{ title: 'Pesanan', href: admin.manage.orders.index.url(), icon: ShoppingCart, permission: 'orders.view' },
|
||||
],
|
||||
|
||||
@ -13,7 +13,7 @@ interface NavItem {
|
||||
href: string;
|
||||
icon: any;
|
||||
permission?: string;
|
||||
badgeKey?: 'pendingLeaveRequests' | 'pendingCuttings';
|
||||
badgeKey?: 'pendingLeaveRequests';
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
|
||||
@ -19,10 +19,10 @@ import {
|
||||
} from '@/components/ui/field';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import { approve, reject, approve_request, reject_request } from '@/routes/admin/manage/owner_verifications';
|
||||
import { approve_request, reject_request } from '@/routes/admin/manage/owner_verifications';
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'request' | 'cutting';
|
||||
type: 'request';
|
||||
id: number;
|
||||
}>();
|
||||
|
||||
@ -35,24 +35,15 @@ const rejectForm = useForm({
|
||||
reason: '',
|
||||
});
|
||||
|
||||
const approveUrl = computed(() => (
|
||||
props.type === 'cutting'
|
||||
? approve.url(props.id)
|
||||
: approve_request.url(props.id)
|
||||
));
|
||||
|
||||
const rejectUrl = computed(() => (
|
||||
props.type === 'cutting'
|
||||
? reject.url(props.id)
|
||||
: reject_request.url(props.id)
|
||||
));
|
||||
const approveUrl = approve_request.url(props.id);
|
||||
const rejectUrl = reject_request.url(props.id);
|
||||
|
||||
const canApprove = computed(() => can('owner_verifications.verify'));
|
||||
const canReject = computed(() => can('owner_verifications.reject'));
|
||||
|
||||
function submitApprove() {
|
||||
approveForm
|
||||
.post(approveUrl.value, {
|
||||
.post(approveUrl, {
|
||||
preserveScroll: true,
|
||||
onError: (errors: Record<string, string>) => {
|
||||
const firstError = Object.values(errors)[0];
|
||||
@ -66,7 +57,7 @@ function submitApprove() {
|
||||
|
||||
function submitReject() {
|
||||
rejectForm
|
||||
.post(rejectUrl.value, {
|
||||
.post(rejectUrl, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
rejectDialogOpen.value = false;
|
||||
|
||||
@ -1,26 +1,14 @@
|
||||
import { Check, RotateCcw, Scissors, X } from '@lucide/vue';
|
||||
import type { Component } from 'vue';
|
||||
import { Scissors } from '@lucide/vue';
|
||||
import type { BadgeVariant } from '@/lib/badge-variant';
|
||||
|
||||
export const CuttingStatus = {
|
||||
IN_PROGRESS: 'in_progress',
|
||||
COMPLETED: 'completed',
|
||||
PENDING_VERIFICATION: 'pending_verification',
|
||||
VERIFIED: 'verified',
|
||||
REJECTED: 'rejected',
|
||||
} as const;
|
||||
|
||||
export type CuttingStatusValue = (typeof CuttingStatus)[keyof typeof CuttingStatus];
|
||||
|
||||
export function cuttingStatusBadgeVariant(status: string): BadgeVariant {
|
||||
if (status === CuttingStatus.VERIFIED) {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
if (status === CuttingStatus.REJECTED) {
|
||||
return 'destructive';
|
||||
}
|
||||
|
||||
if (status === CuttingStatus.COMPLETED) {
|
||||
return 'secondary';
|
||||
}
|
||||
@ -30,32 +18,12 @@ export function cuttingStatusBadgeVariant(status: string): BadgeVariant {
|
||||
|
||||
export function cuttingStatusTransitionConfirmDescription(targetStatus: string): string {
|
||||
if (targetStatus === CuttingStatus.COMPLETED) {
|
||||
return 'Cutting akan ditandai selesai. Stok bahan baku akan dipotong sesuai pemakaian. Menunggu verifikasi admin toko.';
|
||||
}
|
||||
|
||||
if (targetStatus === CuttingStatus.VERIFIED) {
|
||||
return 'Hasil cutting akan diverifikasi. Stok Sample dan hasil cutting Diluar Sample akan ditambahkan ke produk.';
|
||||
}
|
||||
|
||||
if (targetStatus === CuttingStatus.IN_PROGRESS) {
|
||||
return 'Cutting dikembalikan ke proses untuk diperbaiki.';
|
||||
return 'Cutting akan ditandai selesai. Stok bahan baku akan dipotong sesuai pemakaian.';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function cuttingStatusActionIcon(status: string): Component {
|
||||
if (status === CuttingStatus.COMPLETED) {
|
||||
return Scissors;
|
||||
}
|
||||
|
||||
if (status === CuttingStatus.VERIFIED) {
|
||||
return Check;
|
||||
}
|
||||
|
||||
if (status === CuttingStatus.IN_PROGRESS) {
|
||||
return RotateCcw;
|
||||
}
|
||||
|
||||
return X;
|
||||
export function cuttingStatusActionIcon(status: string): any {
|
||||
return Scissors;
|
||||
}
|
||||
|
||||
@ -5,20 +5,16 @@ import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import { index, store } from '@/routes/admin/manage/cuttings';
|
||||
import type {
|
||||
CuttingMaterialCartItem,
|
||||
CuttingProductCatalogItem,
|
||||
CuttingRawMaterialCatalogItem,
|
||||
CuttingResultCartItem,
|
||||
} from '@/types/cutting';
|
||||
import type { CategoryOption } from '@/types/product';
|
||||
import type { EnumOption } from '@/types/raw-material';
|
||||
import CuttingPosForm from './form/CuttingPosForm.vue';
|
||||
|
||||
defineProps<{
|
||||
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
||||
productCatalog: CuttingProductCatalogItem[];
|
||||
draftMaterials: CuttingMaterialCartItem[];
|
||||
draftResults: CuttingResultCartItem[];
|
||||
categories: CategoryOption[];
|
||||
units: EnumOption[];
|
||||
}>();
|
||||
</script>
|
||||
@ -41,10 +37,8 @@ defineProps<{
|
||||
|
||||
<CuttingPosForm
|
||||
:raw-material-catalog="rawMaterialCatalog"
|
||||
:product-catalog="productCatalog"
|
||||
:draft-materials="draftMaterials"
|
||||
:draft-results="draftResults"
|
||||
:categories="categories"
|
||||
:units="units"
|
||||
:submit-url="store.url()"
|
||||
method="post"
|
||||
|
||||
@ -6,18 +6,14 @@ import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import { index, update } from '@/routes/admin/manage/cuttings';
|
||||
import type {
|
||||
CuttingEditItem,
|
||||
CuttingProductCatalogItem,
|
||||
CuttingRawMaterialCatalogItem,
|
||||
} from '@/types/cutting';
|
||||
import type { CategoryOption } from '@/types/product';
|
||||
import type { EnumOption } from '@/types/raw-material';
|
||||
import CuttingPosForm from './form/CuttingPosForm.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
cutting: CuttingEditItem;
|
||||
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
||||
productCatalog: CuttingProductCatalogItem[];
|
||||
categories: CategoryOption[];
|
||||
units: EnumOption[];
|
||||
}>();
|
||||
|
||||
@ -39,15 +35,12 @@ const initialData = computed(() => ({
|
||||
combination_material_result: item.combination_material_result ?? null,
|
||||
})),
|
||||
results: props.cutting.results.map((item) => ({
|
||||
product_variant_id: item.product_variant_id,
|
||||
product_name: item.product_variant?.product?.name ?? '',
|
||||
variant_name: item.product_variant?.name ?? '',
|
||||
stock: item.product_variant?.stock ?? 0,
|
||||
product_name: item.product_name ?? '',
|
||||
cutting_result: String(item.cutting_result ?? 0),
|
||||
sample: String(item.sample ?? 0),
|
||||
original_outside_sample: String(item.original_outside_sample ?? 0),
|
||||
images: item.product_variant?.images ?? [],
|
||||
})),
|
||||
images: props.cutting.images ?? [],
|
||||
}));
|
||||
</script>
|
||||
|
||||
@ -64,8 +57,8 @@ const initialData = computed(() => ({
|
||||
<BackButton :href="index.url()" />
|
||||
</div>
|
||||
|
||||
<CuttingPosForm :raw-material-catalog="rawMaterialCatalog" :product-catalog="productCatalog"
|
||||
:categories="categories" :units="units" :initial-data="initialData"
|
||||
<CuttingPosForm :raw-material-catalog="rawMaterialCatalog"
|
||||
:units="units" :initial-data="initialData"
|
||||
:submit-url="update.url(props.cutting.id)" method="put" submit-label="Perbarui" />
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
@ -25,12 +25,6 @@ interface GroupedMaterials {
|
||||
isCombination?: boolean;
|
||||
}
|
||||
|
||||
interface GroupedResults {
|
||||
key: number;
|
||||
name: string;
|
||||
items: typeof props.cutting.results;
|
||||
}
|
||||
|
||||
const groupedMaterials = computed<GroupedMaterials[]>(() => {
|
||||
// First, group by combination_id
|
||||
const combinationGroups: Record<number, typeof props.cutting.materials> = {};
|
||||
@ -83,24 +77,6 @@ const groupedMaterials = computed<GroupedMaterials[]>(() => {
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const groupedResults = computed<GroupedResults[]>(() => {
|
||||
const groups: Record<number, GroupedResults> = {};
|
||||
|
||||
props.cutting.results.forEach((item) => {
|
||||
const product = item.product_variant?.product;
|
||||
const key = product?.id ?? 0;
|
||||
const name = product?.name ?? 'Produk Tidak Diketahui';
|
||||
|
||||
if (!groups[key]) {
|
||||
groups[key] = { key, name, items: [] };
|
||||
}
|
||||
|
||||
groups[key].items.push(item);
|
||||
});
|
||||
|
||||
return Object.values(groups);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -144,6 +120,13 @@ const groupedResults = computed<GroupedResults[]>(() => {
|
||||
{{ cutting.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="cutting.images?.length" class="mt-4">
|
||||
<MediaThumbnailCell
|
||||
:items="cutting.images"
|
||||
:max-visible="4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Results Section -->
|
||||
@ -153,8 +136,7 @@ const groupedResults = computed<GroupedResults[]>(() => {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Varian</TableHead>
|
||||
<TableHead>Foto</TableHead>
|
||||
<TableHead>Produk</TableHead>
|
||||
<TableHead class="text-right"
|
||||
>Hasil</TableHead
|
||||
>
|
||||
@ -167,55 +149,30 @@ const groupedResults = computed<GroupedResults[]>(() => {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template
|
||||
v-for="(group, groupIndex) in groupedResults"
|
||||
:key="group.key"
|
||||
<TableRow
|
||||
v-for="result in cutting.results"
|
||||
:key="result.id"
|
||||
>
|
||||
<TableRow
|
||||
class="bg-muted/20 hover:bg-muted/20"
|
||||
<TableCell>
|
||||
{{ result.product_name ?? 'Produk Tidak Diketahui' }}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class="text-right tabular-nums"
|
||||
>
|
||||
<TableCell
|
||||
colspan="5"
|
||||
class="font-semibold"
|
||||
>
|
||||
{{ groupIndex + 1 }}. {{ group.name }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
v-for="result in group.items"
|
||||
:key="result.id"
|
||||
{{ result.cutting_result }} pcs
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class="text-right tabular-nums"
|
||||
>
|
||||
<TableCell class="pl-6">
|
||||
{{ result.product_variant?.name }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MediaThumbnailCell
|
||||
:items="
|
||||
result.product_variant
|
||||
?.images ?? []
|
||||
"
|
||||
:max-visible="1"
|
||||
:all-stocks="(result.product_variant?.images ?? []).map(() => `${result.cutting_result} pcs`)"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class="text-right tabular-nums"
|
||||
>
|
||||
{{ result.cutting_result }} pcs
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class="text-right tabular-nums"
|
||||
>
|
||||
{{ result.sample }} pcs
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class="text-right tabular-nums"
|
||||
>
|
||||
{{ result.original_outside_sample }}
|
||||
pcs
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
{{ result.sample }} pcs
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class="text-right tabular-nums"
|
||||
>
|
||||
{{ result.original_outside_sample }}
|
||||
pcs
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@ -27,9 +27,6 @@ const emit = defineEmits<{
|
||||
'sync-material-field': [index: number];
|
||||
'remove-combination': [combinationId: number];
|
||||
'sync-combination-result': [combinationId: number, result: number | null];
|
||||
'remove-result': [index: number];
|
||||
'sync-result-totals': [item: CuttingResultCartItem];
|
||||
'sync-result-field': [index: number];
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true });
|
||||
@ -53,9 +50,7 @@ const open = defineModel<boolean>('open', { required: true });
|
||||
<div v-if="resultCart.length > 0">
|
||||
<p class="mb-2 text-xs font-medium text-muted-foreground">Hasil Produk</p>
|
||||
<CuttingPosResultSummaryItems :form="form" :result-cart="resultCart"
|
||||
:total-result-pieces="totalResultPieces" :is-create-mode="isCreateMode"
|
||||
@remove="emit('remove-result', $event)" @sync-totals="emit('sync-result-totals', $event)"
|
||||
@sync-field="emit('sync-result-field', $event)" />
|
||||
:total-result-pieces="totalResultPieces" :is-create-mode="isCreateMode" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t pt-3">
|
||||
|
||||
@ -6,24 +6,20 @@ import { toast } from 'vue-sonner';
|
||||
import { parseRupiah } from '@/lib/rupiah';
|
||||
import type {
|
||||
CuttingMaterialCartItem,
|
||||
CuttingProductCatalogItem,
|
||||
CuttingRawMaterialCatalogItem,
|
||||
CuttingResultCartItem,
|
||||
} from '@/types/cutting';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
import type { CategoryOption } from '@/types/product';
|
||||
import type { MediaItem, MediaUploadState } from '@/types/media';
|
||||
import { createMediaUploadState, appendRootPhotosToFormData } from '@/types/media';
|
||||
import type { EnumOption } from '@/types/raw-material';
|
||||
import CuttingPosCartDetailDialog from './CuttingPosCartDetailDialog.vue';
|
||||
import CuttingPosMaterialCatalogPanel from './CuttingPosMaterialCatalogPanel.vue';
|
||||
import CuttingPosResultCatalogPanel from './CuttingPosResultCatalogPanel.vue';
|
||||
import CuttingPosSummaryPanel from './CuttingPosSummaryPanel.vue';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||
import { useCuttingPosCart } from './useCuttingPosCart';
|
||||
|
||||
const props = defineProps<{
|
||||
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
||||
productCatalog: CuttingProductCatalogItem[];
|
||||
categories: CategoryOption[];
|
||||
units: EnumOption[];
|
||||
initialData?: {
|
||||
description: string;
|
||||
@ -42,16 +38,12 @@ const props = defineProps<{
|
||||
|
||||
const isCreateMode = computed(() => props.method === 'post');
|
||||
const cartDetailOpen = ref(false);
|
||||
|
||||
// No image upload state needed
|
||||
const photoState = ref<MediaUploadState>(createMediaUploadState());
|
||||
|
||||
// Make catalogs mutable so we can add new items from quick-create dialogs
|
||||
const rawMaterialCatalogState = ref<CuttingRawMaterialCatalogItem[]>([
|
||||
...props.rawMaterialCatalog,
|
||||
]);
|
||||
const productCatalogState = ref<CuttingProductCatalogItem[]>([
|
||||
...props.productCatalog,
|
||||
]);
|
||||
|
||||
watch(
|
||||
() => props.rawMaterialCatalog,
|
||||
@ -59,12 +51,6 @@ watch(
|
||||
rawMaterialCatalogState.value = [...val];
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => props.productCatalog,
|
||||
(val) => {
|
||||
productCatalogState.value = [...val];
|
||||
},
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
description: '',
|
||||
@ -75,38 +61,29 @@ const form = useForm({
|
||||
const {
|
||||
materialSearch,
|
||||
selectedRawMaterialId,
|
||||
productSearch,
|
||||
materialCart,
|
||||
resultCart,
|
||||
filteredRawMaterials,
|
||||
filteredProducts,
|
||||
totalMaterialCost,
|
||||
totalResultPieces,
|
||||
materialLineCost,
|
||||
setCarts,
|
||||
loadDraftItems,
|
||||
getMaterialCartItem,
|
||||
getResultCartItem,
|
||||
syncResultTotals,
|
||||
addMaterial,
|
||||
removeMaterial,
|
||||
decreaseMaterialQty,
|
||||
addResult,
|
||||
removeResult,
|
||||
decreaseResultQty,
|
||||
syncMaterialField,
|
||||
syncResultField,
|
||||
removeCombination,
|
||||
syncCombinationResult,
|
||||
} = useCuttingPosCart({
|
||||
rawMaterialCatalog: rawMaterialCatalogState,
|
||||
productCatalog: productCatalogState,
|
||||
isCreateMode: () => isCreateMode.value,
|
||||
});
|
||||
|
||||
// Confirmation Dialog states
|
||||
const showDeleteConfirm = ref(false);
|
||||
const deleteType = ref<'material' | 'result' | 'combination' | null>(null);
|
||||
const deleteType = ref<'material' | 'combination' | null>(null);
|
||||
const deleteIndex = ref<number | null>(null);
|
||||
const deleteCombinationId = ref<number | null>(null);
|
||||
|
||||
@ -114,20 +91,14 @@ const deleteConfirmTitle = computed(() => {
|
||||
if (deleteType.value === 'combination') {
|
||||
return 'Hapus Kombinasi?';
|
||||
}
|
||||
if (deleteType.value === 'material') {
|
||||
return 'Hapus Bahan Baku?';
|
||||
}
|
||||
return 'Hapus Hasil Produk?';
|
||||
return 'Hapus Bahan Baku?';
|
||||
});
|
||||
|
||||
const deleteConfirmDescription = computed(() => {
|
||||
if (deleteType.value === 'combination') {
|
||||
return 'Apakah Anda yakin ingin menghapus kombinasi bahan baku ini dari keranjang? Tindakan ini tidak dapat dibatalkan.';
|
||||
}
|
||||
if (deleteType.value === 'material') {
|
||||
return 'Apakah Anda yakin ingin menghapus bahan baku ini dari keranjang? Tindakan ini tidak dapat dibatalkan.';
|
||||
}
|
||||
return 'Apakah Anda yakin ingin menghapus hasil produk ini dari keranjang? Tindakan ini tidak dapat dibatalkan.';
|
||||
return 'Apakah Anda yakin ingin menghapus bahan baku ini dari keranjang? Tindakan ini tidak dapat dibatalkan.';
|
||||
});
|
||||
|
||||
function confirmRemoveMaterial(index: number) {
|
||||
@ -136,12 +107,6 @@ function confirmRemoveMaterial(index: number) {
|
||||
showDeleteConfirm.value = true;
|
||||
}
|
||||
|
||||
function confirmRemoveResult(index: number) {
|
||||
deleteType.value = 'result';
|
||||
deleteIndex.value = index;
|
||||
showDeleteConfirm.value = true;
|
||||
}
|
||||
|
||||
function confirmRemoveCombination(combinationId: number) {
|
||||
deleteType.value = 'combination';
|
||||
deleteCombinationId.value = combinationId;
|
||||
@ -151,8 +116,6 @@ function confirmRemoveCombination(combinationId: number) {
|
||||
async function handleConfirmDelete() {
|
||||
if (deleteType.value === 'material' && deleteIndex.value !== null) {
|
||||
await removeMaterial(deleteIndex.value);
|
||||
} else if (deleteType.value === 'result' && deleteIndex.value !== null) {
|
||||
await removeResult(deleteIndex.value);
|
||||
} else if (deleteType.value === 'combination' && deleteCombinationId.value !== null) {
|
||||
await removeCombination(deleteCombinationId.value);
|
||||
}
|
||||
@ -171,11 +134,21 @@ function populateForm() {
|
||||
form.description = props.initialData.description;
|
||||
form.sewing_cost = props.initialData.sewing_cost ?? '0';
|
||||
form.other_cost = props.initialData.other_cost ?? '0';
|
||||
photoState.value = createMediaUploadState(props.initialData.images ?? []);
|
||||
setCarts(props.initialData.materials, props.initialData.results);
|
||||
}
|
||||
|
||||
watch(() => props.initialData, populateForm, { immediate: true });
|
||||
loadDraftItems(props.draftMaterials ?? [], props.draftResults ?? []);
|
||||
loadDraftItems(props.draftMaterials ?? []);
|
||||
|
||||
if (isCreateMode.value && resultCart.value.length === 0) {
|
||||
resultCart.value.push({
|
||||
product_name: '',
|
||||
cutting_result: '',
|
||||
sample: '',
|
||||
original_outside_sample: '',
|
||||
});
|
||||
}
|
||||
|
||||
function buildFormData(): FormData {
|
||||
const formData = new FormData();
|
||||
@ -194,10 +167,9 @@ function buildFormData(): FormData {
|
||||
String(Number.parseInt(parseRupiah(form.other_cost), 10) || 0),
|
||||
);
|
||||
|
||||
// No images to append
|
||||
appendRootPhotosToFormData(formData, photoState.value);
|
||||
|
||||
if (props.method === 'put') {
|
||||
// Build combination results map
|
||||
const combinationResults: Record<number, number | null> = {};
|
||||
materialCart.value.forEach(item => {
|
||||
if (item.combination_id && item.combination_material_result !== undefined) {
|
||||
@ -228,7 +200,6 @@ function buildFormData(): FormData {
|
||||
String(item.combination_id),
|
||||
);
|
||||
|
||||
// Add combination_material_result
|
||||
const combinationResult = combinationResults[item.combination_id];
|
||||
|
||||
if (combinationResult !== undefined && combinationResult !== null) {
|
||||
@ -239,23 +210,27 @@ function buildFormData(): FormData {
|
||||
}
|
||||
}
|
||||
});
|
||||
resultCart.value.forEach((item, index) => {
|
||||
formData.append(
|
||||
`results[${index}][product_variant_id]`,
|
||||
String(item.product_variant_id),
|
||||
);
|
||||
formData.append(
|
||||
`results[${index}][cutting_result]`,
|
||||
String(item.cutting_result),
|
||||
);
|
||||
formData.append(`results[${index}][sample]`, String(item.sample));
|
||||
formData.append(
|
||||
`results[${index}][original_outside_sample]`,
|
||||
String(item.original_outside_sample),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
resultCart.value.forEach((item, index) => {
|
||||
formData.append(
|
||||
`results[${index}][product_name]`,
|
||||
item.product_name ?? '',
|
||||
);
|
||||
formData.append(
|
||||
`results[${index}][cutting_result]`,
|
||||
item.cutting_result === '' ? '' : String(item.cutting_result ?? 0),
|
||||
);
|
||||
formData.append(
|
||||
`results[${index}][sample]`,
|
||||
item.sample === '' ? '' : String(item.sample ?? 0),
|
||||
);
|
||||
formData.append(
|
||||
`results[${index}][original_outside_sample]`,
|
||||
item.original_outside_sample === '' ? '' : String(item.original_outside_sample ?? 0),
|
||||
);
|
||||
});
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
@ -296,32 +271,14 @@ function submit() {
|
||||
|
||||
function onCombinationCreated(items: CuttingMaterialCartItem[]) {
|
||||
for (const item of items) {
|
||||
// Combination items use raw_material_price_id + combination_id as unique key
|
||||
// Same variant can exist in multiple combinations
|
||||
materialCart.value.push({ ...item });
|
||||
}
|
||||
}
|
||||
|
||||
function onProductCreated(product: CuttingProductCatalogItem) {
|
||||
// Add to catalog
|
||||
productCatalogState.value.push(product);
|
||||
|
||||
// Auto-add the first variant to cart
|
||||
const firstVariant = product.variants[0];
|
||||
|
||||
if (firstVariant) {
|
||||
addResult(product, firstVariant);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid min-w-0 gap-4 xl:grid-cols-[1fr_400px]">
|
||||
<div class="min-w-0 space-y-4 order-last xl:order-first">
|
||||
<CuttingPosResultCatalogPanel v-model:product-search="productSearch" :filtered-products="filteredProducts"
|
||||
:get-result-cart-item="getResultCartItem" @add-result="addResult" :is-create-mode="isCreateMode"
|
||||
:categories="categories" :material-cart="materialCart" @decrease-result-qty="decreaseResultQty" @product-created="onProductCreated" />
|
||||
|
||||
<CuttingPosMaterialCatalogPanel v-model:material-search="materialSearch"
|
||||
v-model:selected-raw-material-id="selectedRawMaterialId"
|
||||
:filtered-raw-materials="filteredRawMaterials" :raw-material-catalog="rawMaterialCatalogState"
|
||||
@ -332,10 +289,9 @@ function onProductCreated(product: CuttingProductCatalogItem) {
|
||||
|
||||
<CuttingPosSummaryPanel class="order-first xl:order-last" :form="form" :material-cart="materialCart"
|
||||
:result-cart="resultCart" :total-result-pieces="totalResultPieces" :submit-label="submitLabel"
|
||||
@submit="submit" :is-create-mode="isCreateMode"
|
||||
@submit="submit" :is-create-mode="isCreateMode" v-model:photo-state="photoState"
|
||||
@open-detail="cartDetailOpen = true" @remove-material="confirmRemoveMaterial"
|
||||
@sync-material-field="syncMaterialField" @remove-result="confirmRemoveResult"
|
||||
@sync-result-totals="syncResultTotals" @sync-result-field="syncResultField"
|
||||
@sync-material-field="syncMaterialField"
|
||||
@remove-combination="confirmRemoveCombination" @sync-combination-result="syncCombinationResult" />
|
||||
</div>
|
||||
|
||||
@ -345,10 +301,7 @@ function onProductCreated(product: CuttingProductCatalogItem) {
|
||||
@remove-material="confirmRemoveMaterial"
|
||||
@sync-material-field="syncMaterialField"
|
||||
@remove-combination="confirmRemoveCombination"
|
||||
@sync-combination-result="syncCombinationResult"
|
||||
@remove-result="confirmRemoveResult"
|
||||
@sync-result-totals="syncResultTotals"
|
||||
@sync-result-field="syncResultField" />
|
||||
@sync-combination-result="syncCombinationResult" />
|
||||
|
||||
<!-- Floating Cart button on mobile -->
|
||||
<button
|
||||
|
||||
@ -1,176 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, Minus, Plus, Search } from '@lucide/vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
|
||||
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
|
||||
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { getFirstCoverImage } from '@/lib/catalog-cover';
|
||||
import type { CuttingProductCatalogItem, CuttingResultCartItem, CuttingMaterialCartItem } from '@/types/cutting';
|
||||
import type { CategoryOption } from '@/types/product';
|
||||
import QuickCreateProductModal from './QuickCreateProductModal.vue';
|
||||
import type { CuttingCatalogVariant } from './useCuttingPosCart';
|
||||
|
||||
const props = defineProps<{
|
||||
filteredProducts: CuttingProductCatalogItem[];
|
||||
getResultCartItem: (variantId: number) => CuttingResultCartItem | undefined;
|
||||
isCreateMode: boolean;
|
||||
categories: CategoryOption[];
|
||||
materialCart: CuttingMaterialCartItem[];
|
||||
}>();
|
||||
|
||||
const previewOpen = ref(false);
|
||||
const previewUrl = ref<string | null>(null);
|
||||
const previewTitle = ref<string>('');
|
||||
const previewStock = ref<string>('');
|
||||
|
||||
const allVariantImages = computed(() => {
|
||||
const list: { variantId: number; title: string; stock: string; url: string }[] = [];
|
||||
props.filteredProducts.forEach((product) => {
|
||||
product.variants.forEach((variant) => {
|
||||
if (variant.images && variant.images.length > 0) {
|
||||
list.push({
|
||||
variantId: variant.id,
|
||||
title: `${product.name} - ${variant.name}`,
|
||||
stock: `Stok: ${variant.stock} pcs`,
|
||||
url: variant.images[0].url,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return list;
|
||||
});
|
||||
|
||||
const previewUrls = computed(() => allVariantImages.value.map((item) => item.url));
|
||||
|
||||
function handleThumbClick(variantId: number) {
|
||||
const idx = allVariantImages.value.findIndex((item) => item.variantId === variantId);
|
||||
|
||||
if (idx !== -1) {
|
||||
previewUrl.value = allVariantImages.value[idx].url;
|
||||
previewTitle.value = allVariantImages.value[idx].title;
|
||||
previewStock.value = allVariantImages.value[idx].stock;
|
||||
previewOpen.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
watch(previewUrl, (newUrl) => {
|
||||
const matched = allVariantImages.value.find((item) => item.url === newUrl);
|
||||
|
||||
if (matched) {
|
||||
previewTitle.value = matched.title;
|
||||
previewStock.value = matched.stock;
|
||||
}
|
||||
});
|
||||
|
||||
const productSearch = defineModel<string>('productSearch', { required: true });
|
||||
|
||||
const emit = defineEmits<{
|
||||
'add-result': [product: CuttingProductCatalogItem, variant: CuttingCatalogVariant];
|
||||
'decrease-result-qty': [variantId: number];
|
||||
'product-created': [product: CuttingProductCatalogItem];
|
||||
}>();
|
||||
|
||||
const quickCreateOpen = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="min-w-0">
|
||||
<CardHeader class="flex flex-row items-center justify-between pb-3 space-y-0">
|
||||
<CardTitle class="text-base">Pilih Nama Produk</CardTitle>
|
||||
<Button type="button" variant="outline" size="sm" @click="quickCreateOpen = true">
|
||||
<Plus class="size-3.5 mr-1" />
|
||||
Baru
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
<div class="relative">
|
||||
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input v-model="productSearch" placeholder="Cari produk..." class="pl-9" />
|
||||
</div>
|
||||
|
||||
<div v-if="filteredProducts.length === 0" class="py-8">
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Tidak ada produk ditemukan</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Silakan lakukan pencarian untuk menemukan produk.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
|
||||
<div v-else class="columns-1 gap-4 sm:columns-2 xl:columns-3">
|
||||
<PosCatalogCard v-for="product in filteredProducts" :key="product.id" :title="product.name"
|
||||
:cover-image="getFirstCoverImage(product.variants)">
|
||||
<p v-if="!product.variants.length" class="px-3 py-4 text-sm text-muted-foreground">
|
||||
Belum ada varian
|
||||
</p>
|
||||
<div v-for="variant in product.variants" :key="variant.id"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200" :class="[
|
||||
'cursor-pointer hover:bg-muted/30',
|
||||
getResultCartItem(variant.id)
|
||||
? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/5'
|
||||
: '',
|
||||
]" @click="!getResultCartItem(variant.id) && emit('add-result', product, variant)">
|
||||
<PosCatalogVariantThumb :items="variant.images" custom-preview
|
||||
@click-thumb="handleThumbClick(variant.id)" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{{ variant.name }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<span class="tabular-nums">Stok: {{ variant.stock }} pcs</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="getResultCartItem(variant.id)" class="flex shrink-0 items-center gap-1.5">
|
||||
<template v-if="isCreateMode">
|
||||
<span class="text-primary mr-1">
|
||||
<Check class="size-4" />
|
||||
</span>
|
||||
<Button type="button" variant="outline" size="icon-sm"
|
||||
class="hover:text-destructive hover:bg-destructive/10"
|
||||
@click.stop="emit('decrease-result-qty', variant.id)">
|
||||
<Minus class="size-3.5" />
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button type="button" variant="outline" size="icon-sm"
|
||||
@click.stop="emit('decrease-result-qty', variant.id)">
|
||||
<Minus class="size-3.5" />
|
||||
</Button>
|
||||
<span class="min-w-5 text-center text-xs font-semibold tabular-nums">
|
||||
{{ getResultCartItem(variant.id)!.cutting_result }}
|
||||
</span>
|
||||
<Button type="button" variant="outline" size="icon-sm"
|
||||
@click.stop="emit('add-result', product, variant)">
|
||||
<Plus class="size-3.5" />
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
<Button v-else type="button" variant="outline" size="icon-sm" class="shrink-0"
|
||||
@click.stop="emit('add-result', product, variant)">
|
||||
<Plus class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</PosCatalogCard>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<QuickCreateProductModal v-model:open="quickCreateOpen" :categories="categories" :selected-materials="materialCart"
|
||||
@created="emit('product-created', $event)" />
|
||||
|
||||
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="previewUrls" :title="previewTitle" :stocks="previewStock ? [previewStock] : []" />
|
||||
</template>
|
||||
@ -1,17 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { Trash2 } from '@lucide/vue';
|
||||
import { NumberInput } from '@/components/form/number-input';
|
||||
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from '@/components/ui/field';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import type { FormWithErrors } from '@/lib/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { NumberInput } from '@/components/form/number-input';
|
||||
import type { CuttingResultCartItem } from '@/types/cutting';
|
||||
import type { FormWithErrors } from '@/lib/form';
|
||||
|
||||
defineProps<{
|
||||
form: FormWithErrors;
|
||||
@ -19,78 +14,41 @@ defineProps<{
|
||||
totalResultPieces: number;
|
||||
isCreateMode: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: [index: number];
|
||||
'sync-totals': [item: CuttingResultCartItem];
|
||||
'sync-field': [index: number];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-sm font-medium">Hasil Produk</p>
|
||||
<Badge v-if="!isCreateMode && totalResultPieces > 0" variant="outline" class="tabular-nums text-xs">
|
||||
<Badge v-if="totalResultPieces > 0" variant="outline" class="tabular-nums text-xs">
|
||||
{{ totalResultPieces }} pcs
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div v-if="resultCart.length === 0"
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
Belum ada produk hasil dipilih.
|
||||
Belum ada produk hasil.
|
||||
</div>
|
||||
|
||||
<div v-else class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overflow-x-hidden overscroll-y-contain">
|
||||
<div v-for="(item, index) in resultCart" :key="item.product_variant_id" class="rounded-lg border p-3">
|
||||
<div class="mb-2 flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<MediaThumbnailCell
|
||||
v-if="item.images?.length"
|
||||
:items="item.images"
|
||||
:max-visible="1"
|
||||
:all-stocks="item.images.map(() => `Stok: ${item.stock} pcs`)"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{{ item.product_name }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-muted-foreground">
|
||||
{{ item.variant_name }} · Stok {{ item.stock }} pcs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon"
|
||||
class="size-7 shrink-0 text-destructive hover:text-destructive" @click="emit('remove', index)">
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-for="(item, index) in resultCart" :key="index" class="rounded-lg border p-3">
|
||||
<Field>
|
||||
<FieldLabel class="text-xs">Nama Produk</FieldLabel>
|
||||
<Input v-model="item.product_name" type="text" placeholder="Masukkan nama produk" />
|
||||
</Field>
|
||||
|
||||
<div v-if="!isCreateMode" class="grid grid-cols-3 gap-2">
|
||||
<Field :data-invalid="formErrors(form, `results.${index}.sample`).length > 0 ? 'true' : undefined">
|
||||
<FieldLabel class="text-xs">Sample</FieldLabel>
|
||||
<NumberInput v-model="item.sample" class="h-8"
|
||||
:aria-invalid="formErrors(form, `results.${index}.sample`).length > 0"
|
||||
@change="item.cutting_result = String((Number(item.sample) || 0) + (Number(item.original_outside_sample) || 0)); emit('sync-field', index)" />
|
||||
<FieldError :errors="formErrors(form, `results.${index}.sample`)" class="text-[10px] mt-0.5 leading-tight" />
|
||||
</Field>
|
||||
<Field :data-invalid="formErrors(form, `results.${index}.original_outside_sample`).length > 0 ? 'true' : undefined">
|
||||
<FieldLabel class="text-xs">Diluar Sample</FieldLabel>
|
||||
<NumberInput v-model="item.original_outside_sample" class="h-8"
|
||||
:aria-invalid="formErrors(form, `results.${index}.original_outside_sample`).length > 0"
|
||||
@change="item.cutting_result = String((Number(item.sample) || 0) + (Number(item.original_outside_sample) || 0)); emit('sync-field', index)" />
|
||||
<FieldError :errors="formErrors(form, `results.${index}.original_outside_sample`)" class="text-[10px] mt-0.5 leading-tight" />
|
||||
</Field>
|
||||
<Field :data-invalid="formErrors(form, `results.${index}.cutting_result`).length > 0 ? 'true' : undefined">
|
||||
<FieldLabel class="text-xs">Hasil</FieldLabel>
|
||||
<NumberInput v-model="item.cutting_result" class="h-8"
|
||||
:aria-invalid="formErrors(form, `results.${index}.cutting_result`).length > 0"
|
||||
@change="item.original_outside_sample = String(Math.max((Number(item.cutting_result) || 0) - (Number(item.sample) || 0), 0)); emit('sync-field', index)" />
|
||||
<FieldError :errors="formErrors(form, `results.${index}.cutting_result`)" class="text-[10px] mt-0.5 leading-tight" />
|
||||
</Field>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 mt-2">
|
||||
<Field>
|
||||
<FieldLabel class="text-xs">Sample</FieldLabel>
|
||||
<NumberInput v-model="item.sample" class="h-8" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel class="text-xs">Diluar Sample</FieldLabel>
|
||||
<NumberInput v-model="item.original_outside_sample" class="h-8" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel class="text-xs">Hasil</FieldLabel>
|
||||
<NumberInput v-model="item.cutting_result" class="h-8" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -14,7 +14,9 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||
import type { FormWithErrors } from '@/lib/form';
|
||||
import type { MediaUploadState } from '@/types/media';
|
||||
import type {
|
||||
CuttingMaterialCartItem,
|
||||
CuttingResultCartItem,
|
||||
@ -22,13 +24,14 @@ import type {
|
||||
import CuttingPosMaterialSummaryItems from './CuttingPosMaterialSummaryItems.vue';
|
||||
import CuttingPosResultSummaryItems from './CuttingPosResultSummaryItems.vue';
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
form: FormWithErrors & { description: string; processing?: boolean };
|
||||
materialCart: CuttingMaterialCartItem[];
|
||||
resultCart: CuttingResultCartItem[];
|
||||
totalResultPieces: number;
|
||||
submitLabel: string;
|
||||
isCreateMode: boolean;
|
||||
photoState: MediaUploadState;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -36,11 +39,9 @@ const emit = defineEmits<{
|
||||
'open-detail': [];
|
||||
'remove-material': [index: number];
|
||||
'sync-material-field': [index: number];
|
||||
'remove-result': [index: number];
|
||||
'sync-result-totals': [item: CuttingResultCartItem];
|
||||
'sync-result-field': [index: number];
|
||||
'remove-combination': [combinationId: number];
|
||||
'sync-combination-result': [combinationId: number, result: number | null];
|
||||
'update:photo-state': [value: MediaUploadState];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@ -79,9 +80,10 @@ const emit = defineEmits<{
|
||||
<Separator />
|
||||
|
||||
<CuttingPosResultSummaryItems :form="form" :result-cart="resultCart"
|
||||
:total-result-pieces="totalResultPieces" :is-create-mode="isCreateMode"
|
||||
@remove="emit('remove-result', $event)" @sync-totals="emit('sync-result-totals', $event)"
|
||||
@sync-field="emit('sync-result-field', $event)" />
|
||||
:total-result-pieces="totalResultPieces" :is-create-mode="isCreateMode" />
|
||||
|
||||
<MediaDropzone id="cutting-photos" :model-value="props.photoState" label="Foto"
|
||||
:max-files="10" @update:model-value="emit('update:photo-state', $event)" />
|
||||
|
||||
<Button type="submit" class="w-full" :disabled="form.processing ||
|
||||
materialCart.length === 0 ||
|
||||
|
||||
@ -1,335 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Layers, Plus, Save } from '@lucide/vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useVariantList } from '@/composables/useVariantList';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import ProductInfoSection from '@/pages/admin/master/products/form/ProductInfoSection.vue';
|
||||
import CuttingProductVariantSection from './CuttingProductVariantSection.vue';
|
||||
import type { CuttingMaterialCartItem, CuttingProductCatalogItem } from '@/types/cutting';
|
||||
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
|
||||
import type { CategoryOption, ProductVariantFormItem } from '@/types/product';
|
||||
|
||||
const props = defineProps<{
|
||||
categories: CategoryOption[];
|
||||
selectedMaterials?: CuttingMaterialCartItem[];
|
||||
}>();
|
||||
|
||||
const uniqueMaterialVariants = computed(() => {
|
||||
if (!props.selectedMaterials) {
|
||||
return [];
|
||||
}
|
||||
// Only use single (non-combination) materials for auto-fill
|
||||
const names = props.selectedMaterials
|
||||
.filter((m) => !m.combination_id)
|
||||
.map((m) => m.variant.trim());
|
||||
return [...new Set(names)].filter(Boolean);
|
||||
});
|
||||
|
||||
const materialGroups = computed(() => {
|
||||
if (!props.selectedMaterials) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const combinationMap = new Map<number, CuttingMaterialCartItem[]>();
|
||||
const singleItems: CuttingMaterialCartItem[] = [];
|
||||
|
||||
props.selectedMaterials.forEach((item) => {
|
||||
if (item.combination_id) {
|
||||
if (!combinationMap.has(item.combination_id)) {
|
||||
combinationMap.set(item.combination_id, []);
|
||||
}
|
||||
combinationMap.get(item.combination_id)!.push(item);
|
||||
} else {
|
||||
singleItems.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
const groups: Array<{
|
||||
type: 'combination' | 'single';
|
||||
combinationId?: number;
|
||||
items: CuttingMaterialCartItem[];
|
||||
}> = [];
|
||||
|
||||
for (const [combinationId, items] of combinationMap) {
|
||||
groups.push({
|
||||
type: 'combination',
|
||||
combinationId,
|
||||
items,
|
||||
});
|
||||
}
|
||||
|
||||
if (singleItems.length > 0) {
|
||||
groups.push({
|
||||
type: 'single',
|
||||
items: singleItems,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true });
|
||||
|
||||
const emit = defineEmits<{
|
||||
'created': [product: CuttingProductCatalogItem];
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const isUploading = computed(() =>
|
||||
variants.value.some((v) => v.media.pendingUploads > 0)
|
||||
);
|
||||
|
||||
function createClientId(): string {
|
||||
return `variant-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
category_ids: [] as number[],
|
||||
errors: {} as Record<string, string>,
|
||||
});
|
||||
|
||||
const {
|
||||
items: variants,
|
||||
addItem: addVariant,
|
||||
removeItem: removeVariant,
|
||||
setField: setVariantField,
|
||||
appendToFormData,
|
||||
itemErrors: variantErrors,
|
||||
} = useVariantList<ProductVariantFormItem>(
|
||||
'variants',
|
||||
() => ({
|
||||
client_id: createClientId(),
|
||||
name: '',
|
||||
stock: '0',
|
||||
retail_stock: '0',
|
||||
prices: {
|
||||
distributor: '0',
|
||||
agent: '0',
|
||||
sub_agent: '0',
|
||||
grosir: '0',
|
||||
retail: '0',
|
||||
tiktok: '0',
|
||||
shopee: '0',
|
||||
harga_modal: '0',
|
||||
},
|
||||
media: createMediaUploadState(),
|
||||
}),
|
||||
() => [{
|
||||
client_id: createClientId(),
|
||||
name: '',
|
||||
stock: '0',
|
||||
retail_stock: '0',
|
||||
prices: {
|
||||
distributor: '0',
|
||||
agent: '0',
|
||||
sub_agent: '0',
|
||||
grosir: '0',
|
||||
retail: '0',
|
||||
tiktok: '0',
|
||||
shopee: '0',
|
||||
harga_modal: '0',
|
||||
},
|
||||
media: createMediaUploadState(),
|
||||
}],
|
||||
);
|
||||
|
||||
function toggleCategory(categoryId: number, checked: boolean) {
|
||||
if (checked) {
|
||||
if (!form.value.category_ids.includes(categoryId)) {
|
||||
form.value.category_ids = [...form.value.category_ids, categoryId];
|
||||
}
|
||||
} else {
|
||||
form.value.category_ids = form.value.category_ids.filter((id) => id !== categoryId);
|
||||
}
|
||||
}
|
||||
|
||||
const categoryError = computed(() => form.value.errors.category_ids ?? '');
|
||||
|
||||
function initFromMaterials() {
|
||||
const materialVariants = uniqueMaterialVariants.value;
|
||||
|
||||
if (materialVariants.length > 0) {
|
||||
variants.value = materialVariants.map((variantName) => ({
|
||||
client_id: createClientId(),
|
||||
name: variantName,
|
||||
stock: '0',
|
||||
retail_stock: '0',
|
||||
prices: {
|
||||
distributor: '0',
|
||||
agent: '0',
|
||||
sub_agent: '0',
|
||||
grosir: '0',
|
||||
retail: '0',
|
||||
tiktok: '0',
|
||||
shopee: '0',
|
||||
harga_modal: '0',
|
||||
},
|
||||
media: createMediaUploadState(),
|
||||
}));
|
||||
} else {
|
||||
variants.value = [{
|
||||
client_id: createClientId(),
|
||||
name: '',
|
||||
stock: '0',
|
||||
retail_stock: '0',
|
||||
prices: {
|
||||
distributor: '0',
|
||||
agent: '0',
|
||||
sub_agent: '0',
|
||||
grosir: '0',
|
||||
retail: '0',
|
||||
tiktok: '0',
|
||||
shopee: '0',
|
||||
harga_modal: '0',
|
||||
},
|
||||
media: createMediaUploadState(),
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.value = { name: '', description: '', category_ids: [], errors: {} };
|
||||
initFromMaterials();
|
||||
}
|
||||
|
||||
watch(open, (value) => {
|
||||
if (value) {
|
||||
initFromMaterials();
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
});
|
||||
|
||||
function buildFormData(): FormData {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('name', form.value.name.trim());
|
||||
formData.append('description', form.value.description.trim());
|
||||
|
||||
form.value.category_ids.forEach((categoryId) => {
|
||||
formData.append('category_ids[]', String(categoryId));
|
||||
});
|
||||
|
||||
appendToFormData(formData, (formData, index, variant) => {
|
||||
formData.append(`variants[${index}][name]`, variant.name.trim());
|
||||
formData.append(`variants[${index}][stock]`, String(Number.parseInt(String(variant.stock), 10) || 0));
|
||||
formData.append(`variants[${index}][retail_stock]`, String(Number.parseInt(String(variant.retail_stock), 10) || 0));
|
||||
|
||||
appendMediaToFormData(formData, `variants[${index}]`, variant.media);
|
||||
}, 'post');
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
loading.value = true;
|
||||
form.value.errors = {};
|
||||
|
||||
try {
|
||||
const payload = buildFormData();
|
||||
const { product } = await apiFetch<{ product: CuttingProductCatalogItem }>(
|
||||
'/admin/manage/cuttings/quick-create-product',
|
||||
{
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
|
||||
toast.success(`Produk "${product.name}" berhasil ditambahkan.`);
|
||||
emit('created', product);
|
||||
open.value = false;
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="flex max-h-[90vh] flex-col sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tambah Produk Baru</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<!-- Selected Raw Materials -->
|
||||
<div v-if="selectedMaterials && selectedMaterials.length > 0" class="space-y-2">
|
||||
<p class="text-sm font-medium">Bahan Baku Terpilih</p>
|
||||
|
||||
<div class="scrollbar-thin max-h-32 space-y-1 overflow-y-auto overscroll-y-contain">
|
||||
<template v-for="group in materialGroups" :key="group.combinationId ?? 'single'">
|
||||
<div v-if="group.type === 'combination'"
|
||||
class="rounded-md border border-dashed border-primary/30 px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Layers class="size-3.5 text-primary" />
|
||||
<span class="text-xs font-medium text-primary">Kombinasi ({{ group.items.length }} bahan)</span>
|
||||
</div>
|
||||
<div class="mt-1 space-y-0.5 pl-5">
|
||||
<p v-for="mat in group.items" :key="mat.raw_material_price_id"
|
||||
class="truncate text-xs text-muted-foreground">
|
||||
{{ mat.raw_material_name }} - {{ mat.variant }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<p v-for="mat in group.items" :key="mat.raw_material_price_id"
|
||||
class="truncate rounded-md border px-3 py-1.5 text-xs">
|
||||
{{ mat.raw_material_name }} - {{ mat.variant }}
|
||||
</p>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scrollbar-thin flex-1 overflow-y-auto pr-1">
|
||||
<form id="quick-create-product-form" @submit.prevent="submit" class="space-y-6">
|
||||
<ProductInfoSection :form="form" :categories="categories" :category-error="categoryError"
|
||||
@update:name="form.name = $event" @update:description="form.description = $event"
|
||||
@toggle-category="toggleCategory" />
|
||||
|
||||
<CuttingProductVariantSection v-for="(variant, index) in variants" :key="variant.client_id" :form="form"
|
||||
:variant="variant" :index="index" :can-remove="variants.length > 1"
|
||||
:variant-errors="(clientId, field) => variantErrors(form, clientId, field)"
|
||||
@remove="removeVariant(variant.client_id)"
|
||||
@update:name="setVariantField(variant.client_id, 'name', $event)"
|
||||
@update:stock="setVariantField(variant.client_id, 'stock', $event)"
|
||||
@update:retail-stock="setVariantField(variant.client_id, 'retail_stock', $event)"
|
||||
@update:media="setVariantField(variant.client_id, 'media', $event)" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2 border-t pt-4">
|
||||
<Button type="button" variant="outline" @click="addVariant">
|
||||
<Plus class="size-4" />
|
||||
Tambah Varian
|
||||
</Button>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button type="button" variant="outline" :disabled="loading" @click="open = false">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" form="quick-create-product-form" :disabled="loading || isUploading">
|
||||
<Save class="size-4" />
|
||||
{{ isUploading ? 'Mengunggah...' : loading ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@ -4,25 +4,20 @@ import { toast } from 'vue-sonner';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import draft_combinations from '@/routes/admin/manage/cuttings/draft_combinations';
|
||||
import draft_materials from '@/routes/admin/manage/cuttings/draft_materials';
|
||||
import draft_results from '@/routes/admin/manage/cuttings/draft_results';
|
||||
import type {
|
||||
CuttingMaterialCartItem,
|
||||
CuttingProductCatalogItem,
|
||||
CuttingRawMaterialCatalogItem,
|
||||
CuttingResultCartItem,
|
||||
} from '@/types/cutting';
|
||||
|
||||
export type CuttingCatalogPrice = CuttingRawMaterialCatalogItem['prices'][number];
|
||||
export type CuttingCatalogVariant = CuttingProductCatalogItem['variants'][number];
|
||||
|
||||
export function useCuttingPosCart(options: {
|
||||
rawMaterialCatalog: MaybeRefOrGetter<CuttingRawMaterialCatalogItem[]>;
|
||||
productCatalog: MaybeRefOrGetter<CuttingProductCatalogItem[]>;
|
||||
isCreateMode: MaybeRefOrGetter<boolean>;
|
||||
}) {
|
||||
const materialSearch = ref('');
|
||||
const selectedRawMaterialId = ref('all');
|
||||
const productSearch = ref('');
|
||||
const materialCart = ref<CuttingMaterialCartItem[]>([]);
|
||||
const resultCart = ref<CuttingResultCartItem[]>([]);
|
||||
|
||||
@ -46,7 +41,7 @@ export function useCuttingPosCart(options: {
|
||||
resultCart.value = results.map((item) => ({ ...item }));
|
||||
}
|
||||
|
||||
function loadDraftItems(materials: CuttingMaterialCartItem[], results: CuttingResultCartItem[]) {
|
||||
function loadDraftItems(materials: CuttingMaterialCartItem[]) {
|
||||
if (!toValue(options.isCreateMode)) {
|
||||
return;
|
||||
}
|
||||
@ -54,10 +49,6 @@ export function useCuttingPosCart(options: {
|
||||
if (materials.length > 0) {
|
||||
materialCart.value = materials.map((item) => ({ ...item }));
|
||||
}
|
||||
|
||||
if (results.length > 0) {
|
||||
resultCart.value = results.map((item) => ({ ...item }));
|
||||
}
|
||||
}
|
||||
|
||||
const filteredRawMaterials = computed(() => {
|
||||
@ -89,21 +80,6 @@ export function useCuttingPosCart(options: {
|
||||
.filter((rawMaterial) => rawMaterial.prices.length > 0);
|
||||
});
|
||||
|
||||
const filteredProducts = computed(() => {
|
||||
const keyword = productSearch.value.trim().toLowerCase();
|
||||
const catalog = toValue(options.productCatalog);
|
||||
|
||||
if (!keyword) {
|
||||
return catalog;
|
||||
}
|
||||
|
||||
return catalog.filter(
|
||||
(product) =>
|
||||
product.name.toLowerCase().includes(keyword)
|
||||
|| product.variants.some((variant) => variant.name.toLowerCase().includes(keyword)),
|
||||
);
|
||||
});
|
||||
|
||||
const totalMaterialCost = computed(() =>
|
||||
materialCart.value.reduce((sum, item) => sum + materialLineCost(item), 0),
|
||||
);
|
||||
@ -149,20 +125,6 @@ export function useCuttingPosCart(options: {
|
||||
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: CuttingCatalogPrice,
|
||||
@ -194,45 +156,6 @@ export function useCuttingPosCart(options: {
|
||||
upsertMaterialCartItem(item);
|
||||
}
|
||||
|
||||
async function syncDraftResult(
|
||||
product: CuttingProductCatalogItem,
|
||||
variant: CuttingCatalogVariant,
|
||||
cuttingResult: string | null,
|
||||
sample: string | null,
|
||||
originalOutsideSample: string | null,
|
||||
) {
|
||||
const { item } = await apiFetch<{ item: CuttingResultCartItem }>(draft_results.store.url(), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
product_variant_id: variant.id,
|
||||
cutting_result: cuttingResult,
|
||||
sample,
|
||||
original_outside_sample: originalOutsideSample,
|
||||
}),
|
||||
});
|
||||
|
||||
upsertResultCartItem(item);
|
||||
}
|
||||
|
||||
async function syncDraftResultById(
|
||||
variantId: number,
|
||||
cuttingResult: string | null,
|
||||
sample: string | null,
|
||||
originalOutsideSample: string | null,
|
||||
) {
|
||||
const { item } = await apiFetch<{ item: CuttingResultCartItem }>(draft_results.store.url(), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
product_variant_id: variantId,
|
||||
cutting_result: cuttingResult,
|
||||
sample,
|
||||
original_outside_sample: originalOutsideSample,
|
||||
}),
|
||||
});
|
||||
|
||||
upsertResultCartItem(item);
|
||||
}
|
||||
|
||||
async function syncDraftCombination(
|
||||
name: string | null,
|
||||
materials: Array<{ raw_material_price_id: number; material_usage: string }>,
|
||||
@ -267,7 +190,6 @@ export function useCuttingPosCart(options: {
|
||||
}
|
||||
|
||||
async function syncCombinationResult(combinationId: number, result: number | null) {
|
||||
// Update local state for all items in this combination
|
||||
materialCart.value.forEach(item => {
|
||||
if (item.combination_id === combinationId) {
|
||||
item.combination_material_result = result;
|
||||
@ -279,17 +201,6 @@ export function useCuttingPosCart(options: {
|
||||
return materialCart.value.find((item) => item.raw_material_price_id === priceId);
|
||||
}
|
||||
|
||||
function getResultCartItem(variantId: number): CuttingResultCartItem | undefined {
|
||||
return resultCart.value.find((item) => item.product_variant_id === variantId);
|
||||
}
|
||||
|
||||
function syncResultTotals(item: CuttingResultCartItem) {
|
||||
const total = Number(item.cutting_result) || 0;
|
||||
const sample = Number(item.sample) || 0;
|
||||
|
||||
item.original_outside_sample = String(Math.max(total - sample, 0));
|
||||
}
|
||||
|
||||
async function removeMaterial(index: number) {
|
||||
const item = materialCart.value[index];
|
||||
|
||||
@ -378,110 +289,6 @@ export function useCuttingPosCart(options: {
|
||||
});
|
||||
}
|
||||
|
||||
async function removeResult(index: number) {
|
||||
const item = resultCart.value[index];
|
||||
|
||||
if (toValue(options.isCreateMode)) {
|
||||
try {
|
||||
await apiFetch(draft_results.destroy.url(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);
|
||||
}
|
||||
|
||||
async function decreaseResultQty(variantId: number) {
|
||||
const item = resultCart.value.find((i) => i.product_variant_id === variantId);
|
||||
|
||||
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 nextsample = Math.max(0, (Number(item.sample) || 0) - 1);
|
||||
const nextoriginalOutsideSample = Math.max(nextQty - nextsample, 0);
|
||||
|
||||
if (toValue(options.isCreateMode)) {
|
||||
try {
|
||||
await syncDraftResultById(
|
||||
variantId,
|
||||
String(nextQty),
|
||||
String(nextsample),
|
||||
String(nextoriginalOutsideSample),
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
item.cutting_result = String(nextQty);
|
||||
item.sample = String(nextsample);
|
||||
syncResultTotals(item);
|
||||
}
|
||||
|
||||
async function addResult(product: CuttingProductCatalogItem, variant: CuttingCatalogVariant) {
|
||||
const existing = resultCart.value.find(
|
||||
(item) => item.product_variant_id === variant.id,
|
||||
);
|
||||
const nextQty = existing ? (Number(existing.cutting_result) || 0) + 1 : 1;
|
||||
const nextsample = existing ? (Number(existing.sample) || 0) + 1 : 1;
|
||||
const nextoriginalOutsideSample = Math.max(nextQty - nextsample, 0);
|
||||
|
||||
if (toValue(options.isCreateMode)) {
|
||||
try {
|
||||
await syncDraftResult(
|
||||
product,
|
||||
variant,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Gagal menyimpan item ke keranjang.');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
existing.cutting_result = String(nextQty);
|
||||
existing.sample = String(nextsample);
|
||||
syncResultTotals(existing);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
resultCart.value.push({
|
||||
product_variant_id: variant.id,
|
||||
product_name: product.name,
|
||||
variant_name: variant.name,
|
||||
stock: variant.stock,
|
||||
cutting_result: '1',
|
||||
sample: '1',
|
||||
original_outside_sample: '0',
|
||||
images: variant.images ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
async function syncMaterialField(index: number) {
|
||||
if (!toValue(options.isCreateMode)) {
|
||||
return;
|
||||
@ -496,33 +303,12 @@ export function useCuttingPosCart(options: {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncResultField(index: number) {
|
||||
if (!toValue(options.isCreateMode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = resultCart.value[index];
|
||||
|
||||
try {
|
||||
await syncDraftResultById(
|
||||
item.product_variant_id,
|
||||
item.cutting_result,
|
||||
item.sample,
|
||||
item.original_outside_sample,
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui item.');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
materialSearch,
|
||||
selectedRawMaterialId,
|
||||
productSearch,
|
||||
materialCart,
|
||||
resultCart,
|
||||
filteredRawMaterials,
|
||||
filteredProducts,
|
||||
totalMaterialCost,
|
||||
totalResultPieces,
|
||||
estimatedCostPerUnit,
|
||||
@ -530,16 +316,10 @@ export function useCuttingPosCart(options: {
|
||||
setCarts,
|
||||
loadDraftItems,
|
||||
getMaterialCartItem,
|
||||
getResultCartItem,
|
||||
syncResultTotals,
|
||||
addMaterial,
|
||||
removeMaterial,
|
||||
decreaseMaterialQty,
|
||||
addResult,
|
||||
removeResult,
|
||||
decreaseResultQty,
|
||||
syncMaterialField,
|
||||
syncResultField,
|
||||
syncDraftCombination,
|
||||
removeCombination,
|
||||
syncCombinationResult,
|
||||
|
||||
@ -3,6 +3,7 @@ import { Check } from '@lucide/vue';
|
||||
import { computed } from 'vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
@ -91,14 +92,14 @@ const totalCompleted = computed(() => props.cuttings.length);
|
||||
<div class="flex items-center gap-2">
|
||||
<Check class="size-4 shrink-0 text-green-600 dark:text-green-400" />
|
||||
<h3 class="font-semibold leading-tight">
|
||||
Cutting Selesai (Menunggu Verifikasi)
|
||||
Cutting Selesai
|
||||
</h3>
|
||||
<Badge v-if="totalCompleted > 0" variant="outline" class="border-green-600/30 text-green-600 dark:text-green-400">
|
||||
{{ totalCompleted }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Daftar cutting yang telah selesai dikerjakan. Gunakan tombol aksi untuk memverifikasi atau menolak.
|
||||
Daftar cutting yang telah selesai dikerjakan.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -121,6 +122,8 @@ const totalCompleted = computed(() => props.cuttings.length);
|
||||
</div>
|
||||
|
||||
<div class="p-4 space-y-3 flex-1 text-xs">
|
||||
<MediaThumbnailCell v-if="cutting.images?.length" :items="cutting.images" :max-visible="5" class="mb-2" />
|
||||
|
||||
<div v-if="cutting.description" class="text-muted-foreground pb-2 border-b">
|
||||
<span class="font-medium text-foreground">Catatan:</span> {{ cutting.description }}
|
||||
</div>
|
||||
@ -162,7 +165,7 @@ const totalCompleted = computed(() => props.cuttings.length);
|
||||
<span class="font-medium text-foreground">Hasil Produk:</span>
|
||||
<ul class="list-disc pl-4 space-y-0.5 text-muted-foreground">
|
||||
<li v-for="res in cutting.results" :key="res.id">
|
||||
{{ res.product_variant?.product?.name }} ({{ res.product_variant?.name }})<span v-if="res.cutting_result !== null && res.cutting_result !== undefined"> - {{ res.cutting_result }} pcs</span>
|
||||
{{ res.product_name }}<span v-if="res.cutting_result !== null && res.cutting_result !== undefined"> - {{ res.cutting_result }} pcs</span>
|
||||
</li>
|
||||
<li v-if="!cutting.results.length">Belum ada hasil produk</li>
|
||||
</ul>
|
||||
|
||||
@ -55,12 +55,6 @@ interface GroupedCuttingMaterials {
|
||||
isCombination?: boolean;
|
||||
}
|
||||
|
||||
interface GroupedCuttingResults {
|
||||
productId: number;
|
||||
productName: string;
|
||||
items: any[];
|
||||
}
|
||||
|
||||
function getGroupedMaterials(materials: any[]): GroupedCuttingMaterials[] {
|
||||
// First, group by combination_id
|
||||
const combinationGroups: Record<number, any[]> = {};
|
||||
@ -120,28 +114,6 @@ function getGroupedMaterials(materials: any[]): GroupedCuttingMaterials[] {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getGroupedResults(results: any[]): GroupedCuttingResults[] {
|
||||
const groups: Record<number, GroupedCuttingResults> = {};
|
||||
|
||||
results.forEach((item) => {
|
||||
const product = item.product_variant?.product;
|
||||
const productId = product?.id ?? 0;
|
||||
const productName = product?.name ?? 'Produk Tidak Diketahui';
|
||||
|
||||
if (!groups[productId]) {
|
||||
groups[productId] = {
|
||||
productId,
|
||||
productName,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
groups[productId].items.push(item);
|
||||
});
|
||||
|
||||
return Object.values(groups);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -183,6 +155,8 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
|
||||
{{ cutting.description }}
|
||||
</p>
|
||||
|
||||
<MediaThumbnailCell v-if="cutting.images?.length" :items="cutting.images" :max-visible="5" />
|
||||
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm">
|
||||
<span>Total Hasil Cutting
|
||||
<strong class="text-primary">{{
|
||||
@ -296,48 +270,32 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Varian</TableHead>
|
||||
<TableHead>Foto</TableHead>
|
||||
<TableHead>Hasil</TableHead>
|
||||
<TableHead>Sample</TableHead>
|
||||
<TableHead>Diluar Sample</TableHead>
|
||||
<TableHead>Produk</TableHead>
|
||||
<TableHead class="text-right">Hasil</TableHead>
|
||||
<TableHead class="text-right">Sample</TableHead>
|
||||
<TableHead class="text-right">Diluar Sample</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="!cutting.results.length" :key="`${cutting.id}-result-empty`">
|
||||
<TableCell colspan="5" class="text-muted-foreground">
|
||||
<TableCell colspan="4" class="text-muted-foreground">
|
||||
Belum ada hasil produk
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<template v-else v-for="(group, groupIndex) in getGroupedResults(
|
||||
cutting.results,
|
||||
)" :key="group.productId">
|
||||
<TableRow class="bg-muted/20 hover:bg-muted/20">
|
||||
<TableCell colspan="5" class="font-semibold text-foreground">
|
||||
{{ groupIndex + 1 }}. {{ group.productName }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-for="result in group.items" :key="result.id">
|
||||
<TableCell class="pl-6 font-medium">
|
||||
{{ result.product_variant?.name }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MediaThumbnailCell :items="result.product_variant?.images ?? []" :max-visible="1" :title="`${group.productName} - ${result.product_variant?.name}`" :all-stocks="(result.product_variant?.images ?? []).map(() => `Hasil: ${result.cutting_result} pcs`)" />
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ result.cutting_result }} pcs
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ result.sample }} pcs
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{
|
||||
result.original_outside_sample
|
||||
}}
|
||||
pcs
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
<TableRow v-for="result in cutting.results" :key="result.id">
|
||||
<TableCell>
|
||||
{{ result.product_name ?? 'Produk Tidak Diketahui' }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right tabular-nums">
|
||||
{{ result.cutting_result }} pcs
|
||||
</TableCell>
|
||||
<TableCell class="text-right tabular-nums">
|
||||
{{ result.sample }} pcs
|
||||
</TableCell>
|
||||
<TableCell class="text-right tabular-nums">
|
||||
{{ result.original_outside_sample }} pcs
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Card } from '@/components/ui/card';
|
||||
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
||||
import type { CuttingListItem } from '@/types/cutting';
|
||||
import DataTableActions from './data-table-actions.vue';
|
||||
|
||||
@ -93,6 +94,8 @@ defineProps<{
|
||||
</div>
|
||||
|
||||
<div class="p-4 space-y-3 flex-1 text-xs">
|
||||
<MediaThumbnailCell v-if="cutting.images?.length" :items="cutting.images" :max-visible="5" class="mb-2" />
|
||||
|
||||
<div v-if="cutting.description" class="text-muted-foreground pb-2 border-b">
|
||||
<span class="font-medium text-foreground">Catatan:</span> {{ cutting.description }}
|
||||
</div>
|
||||
@ -134,7 +137,7 @@ defineProps<{
|
||||
<span class="font-medium text-foreground">Hasil Produk:</span>
|
||||
<ul class="list-disc pl-4 space-y-0.5 text-muted-foreground">
|
||||
<li v-for="res in cutting.results" :key="res.id">
|
||||
{{ res.product_variant?.product?.name }} ({{ res.product_variant?.name }})<span v-if="res.cutting_result !== null && res.cutting_result !== undefined"> - {{ res.cutting_result }} pcs</span>
|
||||
{{ res.product_name }}<span v-if="res.cutting_result !== null && res.cutting_result !== undefined"> - {{ res.cutting_result }} pcs</span>
|
||||
</li>
|
||||
<li v-if="!cutting.results.length">Belum ada hasil produk</li>
|
||||
</ul>
|
||||
|
||||
@ -1,92 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSet,
|
||||
} from '@/components/ui/field';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { CuttingStatus } from '@/constants/cutting-status';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { transition_status } from '@/routes/admin/manage/cuttings';
|
||||
|
||||
const props = defineProps<{
|
||||
cuttingId: number;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true });
|
||||
|
||||
const rejectForm = useForm({
|
||||
status: CuttingStatus.REJECTED,
|
||||
reason: '',
|
||||
});
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
rejectForm.reset();
|
||||
rejectForm.clearErrors();
|
||||
}
|
||||
});
|
||||
|
||||
function submitReject() {
|
||||
rejectForm.post(transition_status.url(props.cuttingId), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
},
|
||||
onError: (errors: Record<string, string>) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tolak Cutting</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form @submit.prevent="submitReject">
|
||||
<FieldGroup>
|
||||
<FieldSet class="grid gap-4">
|
||||
<Field>
|
||||
<FieldLabel for="cutting-reject-reason" required>Alasan Penolakan</FieldLabel>
|
||||
<Textarea id="cutting-reject-reason" v-model="rejectForm.reason"
|
||||
placeholder="Masukkan alasan penolakan" rows="3"
|
||||
:maxlength="FIELD_LIMITS.reason" />
|
||||
<FieldError :errors="rejectForm.errors.reason
|
||||
? [rejectForm.errors.reason]
|
||||
: []
|
||||
" />
|
||||
</Field>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter class="mt-6">
|
||||
<Button type="button" variant="outline" :disabled="rejectForm.processing" @click="open = false">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" variant="destructive" :disabled="rejectForm.processing">
|
||||
{{ rejectForm.processing ? 'Menyimpan...' : 'Tolak' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@ -1,241 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { CuttingStatus } from '@/constants/cutting-status';
|
||||
import { parseRupiah } from '@/lib/rupiah';
|
||||
import { transition_status } from '@/routes/admin/manage/cuttings';
|
||||
import type { CuttingListItem } from '@/types/cutting';
|
||||
import {
|
||||
PRICE_TYPES,
|
||||
PRICE_TYPE_LABELS,
|
||||
} from '@/types/product';
|
||||
|
||||
const props = defineProps<{
|
||||
cutting: CuttingListItem;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true });
|
||||
|
||||
function buildEmptyPrices(): Record<string, string> {
|
||||
return Object.fromEntries(PRICE_TYPES.map((type) => [type, '']));
|
||||
}
|
||||
|
||||
const verifyForm = useForm({
|
||||
status: CuttingStatus.VERIFIED,
|
||||
verification_note: '',
|
||||
results: props.cutting.results.map(res => ({
|
||||
product_variant_id: res.product_variant?.id || 0,
|
||||
name: `${res.product_variant?.product?.name || ''} (${res.product_variant?.name || ''})`,
|
||||
cutting_result: res.cutting_result,
|
||||
sample: res.sample,
|
||||
original_outside_sample: res.original_outside_sample,
|
||||
original_sample: res.sample,
|
||||
original_original_outside_sample: res.original_outside_sample,
|
||||
prices: buildEmptyPrices(),
|
||||
})),
|
||||
result_prices: [] as Array<{
|
||||
product_variant_id: number;
|
||||
prices: Array<{ type: string; price: number }>;
|
||||
}>,
|
||||
});
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
verifyForm.verification_note = '';
|
||||
verifyForm.results = props.cutting.results.map(res => ({
|
||||
product_variant_id: res.product_variant?.id || 0,
|
||||
name: `${res.product_variant?.product?.name || ''} (${res.product_variant?.name || ''})`,
|
||||
cutting_result: res.cutting_result,
|
||||
sample: res.sample,
|
||||
original_outside_sample: res.original_outside_sample,
|
||||
original_sample: res.sample,
|
||||
original_original_outside_sample: res.original_outside_sample,
|
||||
prices: buildEmptyPrices(),
|
||||
}));
|
||||
verifyForm.result_prices = [];
|
||||
verifyForm.clearErrors();
|
||||
}
|
||||
});
|
||||
|
||||
function setResultPrice(resultIndex: number, type: string, value: string) {
|
||||
const result = verifyForm.results[resultIndex];
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
result.prices = {
|
||||
...result.prices,
|
||||
[type]: value,
|
||||
};
|
||||
}
|
||||
|
||||
function buildResultPricesPayload() {
|
||||
return verifyForm.results.map((result) => ({
|
||||
product_variant_id: result.product_variant_id,
|
||||
prices: PRICE_TYPES.map((type) => ({
|
||||
type,
|
||||
price: Number.parseInt(parseRupiah(result.prices[type] ?? ''), 10) || 0,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function submitVerify() {
|
||||
verifyForm
|
||||
.transform((data) => ({
|
||||
status: data.status,
|
||||
verification_note: data.verification_note,
|
||||
results: data.results.map(({ product_variant_id, sample, original_outside_sample }) => ({
|
||||
product_variant_id,
|
||||
sample,
|
||||
original_outside_sample,
|
||||
})),
|
||||
result_prices: buildResultPricesPayload(),
|
||||
}))
|
||||
.post(transition_status.url(props.cutting.id), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
},
|
||||
onError: (errors: Record<string, string>) => {
|
||||
if (errors.system) {
|
||||
toast.error(errors.system);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Verifikasi Hasil Cutting</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form @submit.prevent="submitVerify">
|
||||
<div class="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin px-1 py-1">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Verifikasi jumlah produk yang diterima di toko dan tentukan harga jual per varian.
|
||||
</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div v-for="(result, index) in verifyForm.results" :key="result.product_variant_id"
|
||||
class="p-3 border rounded-lg space-y-3">
|
||||
<div class="font-medium text-sm">
|
||||
{{ result.name }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 items-center text-xs">
|
||||
<div>
|
||||
<span class="text-muted-foreground block">Hasil Potong:</span>
|
||||
<span class="font-semibold">{{ result.cutting_result }} pcs</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted-foreground block mb-0.5">Data cutting:</span>
|
||||
<span class="font-medium tabular-nums">
|
||||
{{ result.original_sample }} Sample ·
|
||||
{{ result.original_original_outside_sample }} Diluar Sample
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted-foreground block">Diluar Sample:</span>
|
||||
<Badge variant="secondary" class="font-semibold">
|
||||
{{ result.original_outside_sample }} pcs
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 items-end text-xs">
|
||||
<div>
|
||||
<label :for="`sample-${index}`" class="text-muted-foreground block mb-0.5">Sample
|
||||
(diterima):</label>
|
||||
<Input :id="`sample-${index}`" type="number" v-model.number="result.sample" min="0"
|
||||
:max="result.cutting_result" class="h-8 w-full px-2 text-xs"
|
||||
@input="result.original_outside_sample = result.cutting_result - result.sample" />
|
||||
</div>
|
||||
<div>
|
||||
<label :for="`diluar-sample-${index}`"
|
||||
class="text-muted-foreground block mb-0.5">Diluar
|
||||
Sample
|
||||
(diterima):</label>
|
||||
<Input :id="`diluar-sample-${index}`" type="number"
|
||||
v-model.number="result.original_outside_sample" min="0"
|
||||
:max="result.cutting_result" class="h-8 w-full px-2 text-xs"
|
||||
@input="result.sample = result.cutting_result - result.original_outside_sample" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<Field v-for="type in PRICE_TYPES" :key="`${result.product_variant_id}-${type}`">
|
||||
<FieldLabel class="text-xs" :for="`price-${index}-${type}`" required>
|
||||
{{ PRICE_TYPE_LABELS[type] }}
|
||||
</FieldLabel>
|
||||
<RupiahInput :id="`price-${index}-${type}`" :model-value="result.prices[type]"
|
||||
@update:model-value="setResultPrice(index, type, $event)" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="cutting.materials && cutting.materials.length > 0" class="space-y-2 mt-4">
|
||||
<p class="text-sm font-medium">Bahan Baku Terpakai</p>
|
||||
<div class="grid gap-2">
|
||||
<div v-for="material in cutting.materials" :key="material.id"
|
||||
class="flex items-center gap-3 rounded-lg border p-3 bg-muted/10">
|
||||
<div class="shrink-0">
|
||||
<MediaThumbnailCell :items="material.raw_material_price?.images ?? []" :max-visible="1" :all-stocks="(material.raw_material_price?.images ?? []).map(() => `Pemakaian: ${material.material_usage_formatted}`)" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm truncate">
|
||||
{{ material.raw_material_name || material.raw_material_price?.raw_material?.name }} - {{ material.variant || material.raw_material_price?.variant }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Pemakaian: {{ material.material_usage_formatted }}
|
||||
<span v-if="material.combination_id" class="text-[10px] text-primary bg-primary/10 px-1.5 py-0.5 rounded ml-2 font-medium">Kombinasi</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FieldError :errors="verifyForm.errors.result_prices ? [verifyForm.errors.result_prices] : []" />
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="verification-note">Catatan Verifikasi</FieldLabel>
|
||||
<Textarea id="verification-note" v-model="verifyForm.verification_note"
|
||||
placeholder="Masukkan catatan verifikasi" rows="3" />
|
||||
<FieldError
|
||||
:errors="verifyForm.errors.verification_note ? [verifyForm.errors.verification_note] : []" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="mt-6">
|
||||
<Button type="button" variant="outline" :disabled="verifyForm.processing" @click="open = false">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" :disabled="verifyForm.processing">
|
||||
{{ verifyForm.processing ? 'Menyimpan...' : 'Verifikasi' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@ -4,7 +4,6 @@ import { computed, ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { RowDeleteAction, RowEditAction, RowShareAction, RowStatusAction } from '@/components/button';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||
import OwnerVerificationRowActions from '@/components/owner-verification/OwnerVerificationRowActions.vue';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import {
|
||||
cuttingStatusActionIcon,
|
||||
@ -13,8 +12,6 @@ import {
|
||||
} from '@/constants/cutting-status';
|
||||
import { edit, destroy, transition_status } from '@/routes/admin/manage/cuttings';
|
||||
import type { CuttingListItem, CuttingStatusAction } from '@/types/cutting';
|
||||
import CuttingRejectDialog from './CuttingRejectDialog.vue';
|
||||
import CuttingVerifyDialog from './CuttingVerifyDialog.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
cutting: CuttingListItem;
|
||||
@ -24,8 +21,6 @@ const { can } = useCan();
|
||||
|
||||
const statusConfirmOpen = ref(false);
|
||||
const statusProcessing = ref(false);
|
||||
const rejectDialogOpen = ref(false);
|
||||
const verifyDialogOpen = ref(false);
|
||||
const pendingAction = ref<CuttingStatusAction | null>(null);
|
||||
|
||||
const availableActions = computed(() => props.cutting.available_actions ?? []);
|
||||
@ -34,7 +29,7 @@ const canEdit = computed(
|
||||
() => props.cutting.is_editable && can('cuttings.update'),
|
||||
);
|
||||
const canDelete = computed(
|
||||
() => can('cuttings.delete') && (props.cutting.status === CuttingStatus.IN_PROGRESS || props.cutting.status === CuttingStatus.REJECTED),
|
||||
() => can('cuttings.delete') && (props.cutting.status === CuttingStatus.IN_PROGRESS),
|
||||
);
|
||||
|
||||
function canPerformAction(action: CuttingStatusAction): boolean {
|
||||
@ -46,18 +41,6 @@ function statusConfirmDescription(action: CuttingStatusAction): string {
|
||||
}
|
||||
|
||||
function openStatusConfirm(action: CuttingStatusAction) {
|
||||
if (action.status === CuttingStatus.VERIFIED) {
|
||||
verifyDialogOpen.value = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.status === CuttingStatus.REJECTED) {
|
||||
rejectDialogOpen.value = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
pendingAction.value = action;
|
||||
statusConfirmOpen.value = true;
|
||||
}
|
||||
@ -96,9 +79,6 @@ function transitionStatus() {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center justify-end gap-1">
|
||||
<OwnerVerificationRowActions v-if="cutting.status === CuttingStatus.PENDING_VERIFICATION" type="cutting"
|
||||
:id="cutting.id" />
|
||||
|
||||
<template v-for="action in availableActions" :key="action.status">
|
||||
<RowStatusAction v-if="canPerformAction(action)" :size="'sm'" :icon="cuttingStatusActionIcon(action.status)"
|
||||
:label="action.label" :destructive="action.destructive" @click="openStatusConfirm(action)" />
|
||||
@ -120,8 +100,4 @@ function transitionStatus() {
|
||||
" :description="pendingAction ? statusConfirmDescription(pendingAction) : ''
|
||||
" :confirm-label="pendingAction?.label ?? 'Konfirmasi'" cancel-label="Batal"
|
||||
:destructive="pendingAction?.destructive ?? false" :loading="statusProcessing" @confirm="transitionStatus" />
|
||||
|
||||
<CuttingRejectDialog v-model:open="rejectDialogOpen" :cutting-id="cutting.id" />
|
||||
|
||||
<CuttingVerifyDialog v-model:open="verifyDialogOpen" :cutting="cutting" />
|
||||
</template>
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import type { CuttingListItem } from '@/types/cutting';
|
||||
import StockPendingApprovalSection from './table/StockPendingApprovalSection.vue';
|
||||
import StockPendingSection from './table/StockPendingSection.vue';
|
||||
|
||||
defineProps<{
|
||||
pendingCuttings: CuttingListItem[];
|
||||
pendingApprovalCuttings: CuttingListItem[];
|
||||
}>();
|
||||
|
||||
const { can } = useCan();
|
||||
const isOwner = can('owner_verifications.verify');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Verifikasi Stok" />
|
||||
|
||||
<AdminLayout>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-2xl font-bold tracking-tight">
|
||||
Verifikasi Stok
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StockPendingApprovalSection v-if="isOwner" :cuttings="pendingApprovalCuttings" />
|
||||
<StockPendingSection v-else :cuttings="pendingCuttings" />
|
||||
</AdminLayout>
|
||||
</template>
|
||||
@ -1,246 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
||||
import OwnerVerificationRowActions from '@/components/owner-verification/OwnerVerificationRowActions.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { CuttingListItem } from '@/types/cutting';
|
||||
|
||||
interface GroupedCuttingMaterials {
|
||||
rawMaterialId: number;
|
||||
rawMaterialName: string;
|
||||
unitLabel?: string;
|
||||
items: any[];
|
||||
isCombination?: boolean;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
cuttings: CuttingListItem[];
|
||||
}>();
|
||||
|
||||
function getGroupedMaterials(materials: any[]): GroupedCuttingMaterials[] {
|
||||
const combinationGroups: Record<number, any[]> = {};
|
||||
const nonCombinationItems: any[] = [];
|
||||
|
||||
materials.forEach((item) => {
|
||||
if (item.combination_id) {
|
||||
if (!combinationGroups[item.combination_id]) {
|
||||
combinationGroups[item.combination_id] = [];
|
||||
}
|
||||
|
||||
combinationGroups[item.combination_id].push(item);
|
||||
} else {
|
||||
nonCombinationItems.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
const result: GroupedCuttingMaterials[] = [];
|
||||
|
||||
Object.entries(combinationGroups).forEach(([combinationId, items]) => {
|
||||
const firstItem = items[0];
|
||||
const rawMaterialName = firstItem.raw_material_name || 'Bahan Baku Tidak Diketahui';
|
||||
const unitLabel = firstItem.raw_material_unit_label;
|
||||
|
||||
result.push({
|
||||
rawMaterialId: Number(combinationId) * -1,
|
||||
rawMaterialName: `Kombinasi`,
|
||||
unitLabel,
|
||||
items,
|
||||
isCombination: true,
|
||||
});
|
||||
});
|
||||
|
||||
const groups: Record<number, GroupedCuttingMaterials> = {};
|
||||
|
||||
nonCombinationItems.forEach((item) => {
|
||||
const rawMaterialId = item.raw_material_id ?? 0;
|
||||
const rawMaterialName = item.raw_material_name || 'Bahan Baku Tidak Diketahui';
|
||||
const unitLabel = item.raw_material_unit_label;
|
||||
|
||||
if (!groups[rawMaterialId]) {
|
||||
groups[rawMaterialId] = {
|
||||
rawMaterialId,
|
||||
rawMaterialName,
|
||||
unitLabel,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
groups[rawMaterialId].items.push(item);
|
||||
});
|
||||
|
||||
result.push(...Object.values(groups));
|
||||
|
||||
return result;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="cuttings.length" class="space-y-4">
|
||||
<div v-for="cutting in cuttings" :key="cutting.id" class="overflow-hidden rounded-md border">
|
||||
<div
|
||||
class="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="font-medium leading-tight">
|
||||
Cutting #{{ cutting.id }}
|
||||
</h3>
|
||||
<Badge variant="secondary">Menunggu Persetujuan</Badge>
|
||||
</div>
|
||||
<div class="text-muted-foreground text-sm">
|
||||
<p>{{ cutting.created_at_formatted }}</p>
|
||||
<p v-if="cutting.submitted_by">
|
||||
Diajukan oleh {{ cutting.submitted_by.profile?.full_name ?? cutting.submitted_by.username }}
|
||||
</p>
|
||||
<p v-if="cutting.rejection?.reason">
|
||||
Catatan: {{ cutting.rejection.reason }}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5">
|
||||
<OwnerVerificationRowActions type="cutting" :id="cutting.id" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 space-y-4">
|
||||
<!-- Bahan Baku Section -->
|
||||
<div v-if="cutting.materials && cutting.materials.length" class="space-y-2">
|
||||
<h4 class="text-sm font-semibold tracking-tight text-foreground">Bahan Baku</h4>
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Varian</TableHead>
|
||||
<TableHead>Foto</TableHead>
|
||||
<TableHead>Hasil</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-for="group in getGroupedMaterials(cutting.materials)"
|
||||
:key="group.rawMaterialId">
|
||||
<TableRow class="bg-muted/20 hover:bg-muted/20">
|
||||
<TableCell colspan="3" class="font-semibold text-foreground">
|
||||
<template v-if="group.isCombination">
|
||||
<span class="text-primary">{{ group.rawMaterialName }}</span>
|
||||
<Badge variant="default" class="ml-2 font-normal">
|
||||
{{ group.items.length }} bahan
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="group.items[0]?.combination_material_result !== null && group.items[0]?.combination_material_result !== undefined"
|
||||
variant="secondary" class="ml-2 font-normal">
|
||||
Hasil: {{ group.items[0].combination_material_result }} pcs
|
||||
</Badge>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ group.rawMaterialName }}
|
||||
<Badge v-if="group.unitLabel" variant="secondary"
|
||||
class="ml-2 font-normal">
|
||||
{{ group.unitLabel }}
|
||||
</Badge>
|
||||
</template>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-for="material in group.items" :key="material.id">
|
||||
<TableCell class="pl-6 font-medium">
|
||||
{{ material.variant }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MediaThumbnailCell :items="material.images ?? []" :max-visible="1" />
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
<template
|
||||
v-if="!group.isCombination && material.material_result !== null && material.material_result !== undefined">
|
||||
{{ material.material_result }} pcs
|
||||
</template>
|
||||
<template v-else>
|
||||
-
|
||||
</template>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-semibold tracking-tight text-foreground">Hasil Verifikasi</h4>
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Varian</TableHead>
|
||||
<TableHead>Foto</TableHead>
|
||||
<TableHead>Hasil</TableHead>
|
||||
<TableHead>Bagus</TableHead>
|
||||
<TableHead>Reject</TableHead>
|
||||
<TableHead>Harga</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="!cutting.results.length" :key="`${cutting.id}-result-empty`">
|
||||
<TableCell colspan="6" class="text-muted-foreground">
|
||||
Belum ada hasil verifikasi
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-for="result in cutting.results" :key="result.id">
|
||||
<TableCell class="font-medium">
|
||||
{{ result.product_variant?.product?.name }} ({{ result.product_variant?.name }})
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MediaThumbnailCell :items="result.product_variant?.images ?? []"
|
||||
:max-visible="1" :all-stocks="(result.product_variant?.images ?? []).map(() => `${result.cutting_result} pcs`)" />
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ result.cutting_result }} pcs
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ result.sample }} pcs
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ result.original_outside_sample }} pcs
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div v-if="cutting.result_prices?.filter(p => p.product_variant_id === result.product_variant?.id).length"
|
||||
class="space-y-0.5 text-xs">
|
||||
<div v-for="price in cutting.result_prices.filter(p => p.product_variant_id === result.product_variant?.id)"
|
||||
:key="price.id"
|
||||
class="flex items-center justify-between gap-3">
|
||||
<span class="text-muted-foreground">
|
||||
{{ price.type_label }}
|
||||
</span>
|
||||
<span class="font-medium tabular-nums">
|
||||
{{ price.price_formatted }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-xs text-muted-foreground">-</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div class="rounded-md border px-6 py-10 text-center">
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Tidak ada verifikasi yang menunggu persetujuan.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,220 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { CuttingListItem } from '@/types/cutting';
|
||||
import StockDataTableActions from './data-table-actions.vue';
|
||||
|
||||
interface GroupedCuttingMaterials {
|
||||
rawMaterialId: number;
|
||||
rawMaterialName: string;
|
||||
unitLabel?: string;
|
||||
items: any[];
|
||||
isCombination?: boolean;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
cuttings: CuttingListItem[];
|
||||
}>();
|
||||
|
||||
function getGroupedMaterials(materials: any[]): GroupedCuttingMaterials[] {
|
||||
const combinationGroups: Record<number, any[]> = {};
|
||||
const nonCombinationItems: any[] = [];
|
||||
|
||||
materials.forEach((item) => {
|
||||
if (item.combination_id) {
|
||||
if (!combinationGroups[item.combination_id]) {
|
||||
combinationGroups[item.combination_id] = [];
|
||||
}
|
||||
|
||||
combinationGroups[item.combination_id].push(item);
|
||||
} else {
|
||||
nonCombinationItems.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
const result: GroupedCuttingMaterials[] = [];
|
||||
|
||||
Object.entries(combinationGroups).forEach(([combinationId, items]) => {
|
||||
const firstItem = items[0];
|
||||
const rawMaterialName = firstItem.raw_material_name || 'Bahan Baku Tidak Diketahui';
|
||||
const unitLabel = firstItem.raw_material_unit_label;
|
||||
|
||||
result.push({
|
||||
rawMaterialId: Number(combinationId) * -1,
|
||||
rawMaterialName: `Kombinasi`,
|
||||
unitLabel,
|
||||
items,
|
||||
isCombination: true,
|
||||
});
|
||||
});
|
||||
|
||||
const groups: Record<number, GroupedCuttingMaterials> = {};
|
||||
|
||||
nonCombinationItems.forEach((item) => {
|
||||
const rawMaterialId = item.raw_material_id ?? 0;
|
||||
const rawMaterialName = item.raw_material_name || 'Bahan Baku Tidak Diketahui';
|
||||
const unitLabel = item.raw_material_unit_label;
|
||||
|
||||
if (!groups[rawMaterialId]) {
|
||||
groups[rawMaterialId] = {
|
||||
rawMaterialId,
|
||||
rawMaterialName,
|
||||
unitLabel,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
groups[rawMaterialId].items.push(item);
|
||||
});
|
||||
|
||||
result.push(...Object.values(groups));
|
||||
|
||||
return result;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="cuttings.length" class="space-y-4">
|
||||
<div v-for="cutting in cuttings" :key="cutting.id" class="overflow-hidden rounded-md border">
|
||||
<div
|
||||
class="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="font-medium leading-tight">
|
||||
Cutting #{{ cutting.id }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="text-muted-foreground text-sm">
|
||||
<p>{{ cutting.created_at_formatted }}</p>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5">
|
||||
<StockDataTableActions :cutting="cutting" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-semibold tracking-tight text-foreground">Hasil Produk</h4>
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Varian</TableHead>
|
||||
<TableHead>Foto</TableHead>
|
||||
<TableHead>Hasil</TableHead>
|
||||
<TableHead>Sample</TableHead>
|
||||
<TableHead>Diluar Sample</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="!cutting.results.length" :key="`${cutting.id}-result-empty`">
|
||||
<TableCell colspan="5" class="text-muted-foreground">
|
||||
Belum ada hasil produk
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-for="result in cutting.results" :key="result.id">
|
||||
<TableCell class="font-medium">
|
||||
{{ result.product_variant?.product?.name }} ({{ result.product_variant?.name }})
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MediaThumbnailCell :items="result.product_variant?.images ?? []"
|
||||
:max-visible="1" :all-stocks="(result.product_variant?.images ?? []).map(() => `${result.cutting_result} pcs`)" />
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ result.cutting_result }} pcs
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ result.sample }} pcs
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ result.original_outside_sample }} pcs
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="cutting.materials && cutting.materials.length" class="space-y-2">
|
||||
<h4 class="text-sm font-semibold tracking-tight text-foreground">Bahan Baku</h4>
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Varian</TableHead>
|
||||
<TableHead>Foto</TableHead>
|
||||
<TableHead>Hasil</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-for="group in getGroupedMaterials(cutting.materials)"
|
||||
:key="group.rawMaterialId">
|
||||
<TableRow class="bg-muted/20 hover:bg-muted/20">
|
||||
<TableCell colspan="3" class="font-semibold text-foreground">
|
||||
<template v-if="group.isCombination">
|
||||
<span class="text-primary">{{ group.rawMaterialName }}</span>
|
||||
<Badge variant="default" class="ml-2 font-normal">
|
||||
{{ group.items.length }} bahan
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="group.items[0]?.combination_material_result !== null && group.items[0]?.combination_material_result !== undefined"
|
||||
variant="secondary" class="ml-2 font-normal">
|
||||
Hasil: {{ group.items[0].combination_material_result }} pcs
|
||||
</Badge>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ group.rawMaterialName }}
|
||||
<Badge v-if="group.unitLabel" variant="secondary"
|
||||
class="ml-2 font-normal">
|
||||
{{ group.unitLabel }}
|
||||
</Badge>
|
||||
</template>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-for="material in group.items" :key="material.id">
|
||||
<TableCell class="pl-6 font-medium">
|
||||
{{ material.variant }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MediaThumbnailCell :items="material.images ?? []" :max-visible="1" :all-stocks="(material.images ?? []).map(() => `${material.material_result ?? '-'} pcs`)" />
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
<template
|
||||
v-if="!group.isCombination && material.material_result !== null && material.material_result !== undefined">
|
||||
{{ material.material_result }} pcs
|
||||
</template>
|
||||
<template v-else>
|
||||
-
|
||||
</template>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div class="rounded-md border px-6 py-10 text-center">
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Tidak ada cutting yang menunggu verifikasi.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,159 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { CuttingListItem } from '@/types/cutting';
|
||||
|
||||
const props = defineProps<{
|
||||
cuttings: CuttingListItem[];
|
||||
}>();
|
||||
|
||||
interface GroupedCuttingResults {
|
||||
productId: number;
|
||||
productName: string;
|
||||
items: any[];
|
||||
}
|
||||
|
||||
function getGroupedResults(results: any[]): GroupedCuttingResults[] {
|
||||
const groups: Record<number, GroupedCuttingResults> = {};
|
||||
|
||||
results.forEach((item) => {
|
||||
const product = item.product_variant?.product;
|
||||
const productId = product?.id ?? 0;
|
||||
const productName = product?.name ?? 'Produk Tidak Diketahui';
|
||||
|
||||
if (!groups[productId]) {
|
||||
groups[productId] = {
|
||||
productId,
|
||||
productName,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
groups[productId].items.push(item);
|
||||
});
|
||||
|
||||
return Object.values(groups);
|
||||
}
|
||||
|
||||
const showingCount = computed(() => props.cuttings.length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-lg font-semibold">
|
||||
Riwayat Verifikasi
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="cuttings.length" class="space-y-4">
|
||||
<div v-for="cutting in cuttings" :key="cutting.id" class="overflow-hidden rounded-md border">
|
||||
<div
|
||||
class="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="font-medium leading-tight">
|
||||
Cutting #{{ cutting.id }}
|
||||
</h3>
|
||||
<Badge variant="default">
|
||||
Terverifikasi
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="text-muted-foreground space-y-1 text-sm">
|
||||
<p>{{ cutting.created_at_formatted }}</p>
|
||||
<p>Oleh {{ cutting.created_by?.profile?.full_name ?? cutting.created_by?.username }}</p>
|
||||
</div>
|
||||
<p v-if="cutting.description" class="text-muted-foreground text-sm">
|
||||
{{ cutting.description }}
|
||||
</p>
|
||||
<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>Biaya Produksi <strong class="text-primary">{{
|
||||
cutting.total_production_cost_formatted ?? 'Rp 0' }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-semibold tracking-tight text-foreground">Hasil Produk</h4>
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Varian</TableHead>
|
||||
<TableHead>Foto</TableHead>
|
||||
<TableHead>Hasil</TableHead>
|
||||
<TableHead>Sample</TableHead>
|
||||
<TableHead>Diluar Sample</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="!cutting.results.length" :key="`${cutting.id}-result-empty`">
|
||||
<TableCell colspan="5" class="text-muted-foreground">
|
||||
Belum ada hasil produk
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<template v-else v-for="group in getGroupedResults(cutting.results)"
|
||||
:key="group.productId">
|
||||
<TableRow class="bg-muted/20 hover:bg-muted/20">
|
||||
<TableCell colspan="5" class="font-semibold text-foreground">
|
||||
{{ group.productName }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-for="result in group.items" :key="result.id">
|
||||
<TableCell class="pl-6 font-medium">
|
||||
{{ result.product_variant?.name }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MediaThumbnailCell :items="result.product_variant?.images ?? []" :max-visible="1" :all-stocks="(result.product_variant?.images ?? []).map(() => `${result.cutting_result} pcs`)" />
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ result.cutting_result }} pcs
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ result.sample }} pcs
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ result.original_outside_sample }} pcs
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-md border px-6 py-10">
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Belum ada riwayat</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Belum ada cutting yang telah diverifikasi.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,347 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { computed, watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { NumberInput } from '@/components/form/number-input';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from '@/components/ui/field';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { parseRupiah } from '@/lib/rupiah';
|
||||
import { verify } from '@/routes/admin/manage/stocks';
|
||||
import type { CuttingListItem } from '@/types/cutting';
|
||||
import {
|
||||
PRICE_TYPES,
|
||||
PRICE_TYPE_LABELS,
|
||||
} from '@/types/product';
|
||||
|
||||
const props = defineProps<{
|
||||
cutting: CuttingListItem;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true });
|
||||
|
||||
type MaterialGroup = {
|
||||
type: 'combination' | 'single';
|
||||
combinationId?: number;
|
||||
items: CuttingListItem['materials'];
|
||||
};
|
||||
|
||||
const materialGroups = computed<MaterialGroup[]>(() => {
|
||||
const materials = props.cutting.materials ?? [];
|
||||
const combinationMap = new Map<number, CuttingListItem['materials']>();
|
||||
const singleItems: CuttingListItem['materials'] = [];
|
||||
|
||||
materials.forEach((item) => {
|
||||
if (item.combination_id) {
|
||||
if (!combinationMap.has(item.combination_id)) {
|
||||
combinationMap.set(item.combination_id, []);
|
||||
}
|
||||
combinationMap.get(item.combination_id)!.push(item);
|
||||
} else {
|
||||
singleItems.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
const groups: MaterialGroup[] = [];
|
||||
|
||||
for (const [combinationId, items] of combinationMap) {
|
||||
groups.push({
|
||||
type: 'combination',
|
||||
combinationId,
|
||||
items,
|
||||
});
|
||||
}
|
||||
|
||||
if (singleItems.length > 0) {
|
||||
groups.push({
|
||||
type: 'single',
|
||||
items: singleItems,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
function buildEmptyPrices(): Record<string, string> {
|
||||
return Object.fromEntries(PRICE_TYPES.map((type) => [type, '']));
|
||||
}
|
||||
|
||||
interface VariantPriceRow {
|
||||
product_variant_id: number;
|
||||
name: string;
|
||||
prices: Record<string, string>;
|
||||
}
|
||||
|
||||
function onGoodChange(result: any, value: string | number) {
|
||||
const val = Number(value) || 0;
|
||||
result.good = val;
|
||||
}
|
||||
|
||||
function onRejectChange(result: any, value: string | number) {
|
||||
const val = Number(value) || 0;
|
||||
result.reject = val;
|
||||
}
|
||||
|
||||
const verifyForm = useForm({
|
||||
verification_note: '',
|
||||
results: props.cutting.results.map(res => ({
|
||||
product_variant_id: res.product_variant?.id || 0,
|
||||
name: `${res.product_variant?.product?.name || ''} (${res.product_variant?.name || ''})`,
|
||||
cutting_result: res.cutting_result,
|
||||
original_sample: res.sample,
|
||||
original_outside_sample: res.original_outside_sample,
|
||||
good: res.cutting_result,
|
||||
reject: 0,
|
||||
})),
|
||||
variant_prices: props.cutting.results.map(res => ({
|
||||
product_variant_id: res.product_variant?.id || 0,
|
||||
name: `${res.product_variant?.product?.name || ''} (${res.product_variant?.name || ''})`,
|
||||
prices: buildEmptyPrices(),
|
||||
})),
|
||||
shared_prices: buildEmptyPrices(),
|
||||
});
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
verifyForm.verification_note = '';
|
||||
verifyForm.results = props.cutting.results.map(res => ({
|
||||
product_variant_id: res.product_variant?.id || 0,
|
||||
name: `${res.product_variant?.product?.name || ''} (${res.product_variant?.name || ''})`,
|
||||
cutting_result: res.cutting_result,
|
||||
original_sample: res.sample,
|
||||
original_outside_sample: res.original_outside_sample,
|
||||
good: res.cutting_result,
|
||||
reject: 0,
|
||||
}));
|
||||
verifyForm.variant_prices = props.cutting.results.map(res => ({
|
||||
product_variant_id: res.product_variant?.id || 0,
|
||||
name: `${res.product_variant?.product?.name || ''} (${res.product_variant?.name || ''})`,
|
||||
prices: buildEmptyPrices(),
|
||||
}));
|
||||
verifyForm.shared_prices = buildEmptyPrices();
|
||||
verifyForm.clearErrors();
|
||||
}
|
||||
});
|
||||
|
||||
function setSharedPrice(type: string, value: string) {
|
||||
verifyForm.shared_prices[type] = value;
|
||||
verifyForm.variant_prices = verifyForm.variant_prices.map((row) => ({
|
||||
...row,
|
||||
prices: { ...row.prices, [type]: value },
|
||||
}));
|
||||
}
|
||||
|
||||
const resultPricesErrors = computed(() => {
|
||||
return Object.entries(verifyForm.errors)
|
||||
.filter(([key]) => key === 'result_prices' || key.startsWith('result_prices.'))
|
||||
.map(([_, message]) => message) as string[];
|
||||
});
|
||||
|
||||
function buildResultPricesPayload(variantPrices: VariantPriceRow[]) {
|
||||
return variantPrices.map((row) => ({
|
||||
product_variant_id: row.product_variant_id,
|
||||
prices: PRICE_TYPES.map((type) => ({
|
||||
type,
|
||||
price: Number.parseInt(parseRupiah(row.prices[type] ?? ''), 10) || 0,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function submitVerify() {
|
||||
verifyForm
|
||||
.transform((data) => ({
|
||||
verification_note: data.verification_note,
|
||||
results: data.results.map(({ product_variant_id, good, reject }) => ({
|
||||
product_variant_id,
|
||||
good,
|
||||
reject,
|
||||
})),
|
||||
result_prices: buildResultPricesPayload(data.variant_prices),
|
||||
}))
|
||||
.post(verify.url(props.cutting.id), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
},
|
||||
onError: (errors: Record<string, string>) => {
|
||||
const shown = new Set<string>();
|
||||
|
||||
for (const [key, message] of Object.entries(errors)) {
|
||||
if (
|
||||
key === 'result_prices' ||
|
||||
key.startsWith('result_prices.') ||
|
||||
key.startsWith('results.') ||
|
||||
key === 'verification_note'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!shown.has(message)) {
|
||||
shown.add(message);
|
||||
toast.error(message);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="flex max-h-[90vh] flex-col sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Verifikasi Hasil Cutting</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form @submit.prevent="submitVerify" class="flex flex-1 flex-col min-h-0">
|
||||
<div class="scrollbar-thin flex-1 space-y-4 overflow-y-auto pr-1">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Verifikasi jumlah produk yang diterima dan tentukan harga jual. Stok akan ditambahkan setelah
|
||||
verifikasi.
|
||||
</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div v-for="(result, index) in verifyForm.results" :key="result.product_variant_id"
|
||||
class="rounded-lg border p-3">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="shrink-0">
|
||||
<MediaThumbnailCell :items="cutting.results[index]?.product_variant?.images ?? []" :max-visible="1" :all-stocks="(cutting.results[index]?.product_variant?.images ?? []).map(() => `${result.cutting_result} pcs`)" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm">{{ result.name }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ result.cutting_result }} pcs
|
||||
<span class="text-[10px] block sm:inline">({{ result.original_sample }} Sample, {{
|
||||
result.original_outside_sample }} Diluar Sample)</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 self-end sm:self-center shrink-0">
|
||||
<div class="text-center">
|
||||
<label :for="`stock-good-${index}`"
|
||||
class="text-[10px] text-muted-foreground block mb-0.5">Bagus</label>
|
||||
<NumberInput :id="`stock-good-${index}`" :model-value="result.good"
|
||||
class="h-7 w-20 px-1.5 text-xs text-center"
|
||||
@update:model-value="val => onGoodChange(result, val)" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<label :for="`stock-reject-${index}`"
|
||||
class="text-[10px] text-muted-foreground block mb-0.5">Reject</label>
|
||||
<NumberInput :id="`stock-reject-${index}`" :model-value="result.reject"
|
||||
class="h-7 w-20 px-1.5 text-xs text-center"
|
||||
@update:model-value="val => onRejectChange(result, val)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FieldError :errors="[
|
||||
verifyForm.errors[`results.${index}.good`],
|
||||
verifyForm.errors[`results.${index}.reject`],
|
||||
verifyForm.errors[`results.${index}.product_variant_id`]
|
||||
].filter(Boolean) as string[]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="cutting.materials && cutting.materials.length > 0" class="space-y-2">
|
||||
<p class="text-sm font-medium">Bahan Baku Terpakai</p>
|
||||
<div class="scrollbar-thin max-h-48 space-y-3 overflow-y-auto overscroll-y-contain">
|
||||
<template v-for="group in materialGroups" :key="group.combinationId ?? 'single'">
|
||||
<div v-if="group.type === 'combination'"
|
||||
class="rounded-lg border-2 border-dashed border-primary/30 p-3">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-primary">Kombinasi</span>
|
||||
<Badge variant="secondary" class="text-xs">
|
||||
{{ group.items.length }} bahan
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="material in group.items" :key="material.id"
|
||||
class="flex items-center gap-3 rounded-md border bg-background p-2.5">
|
||||
<div class="shrink-0">
|
||||
<MediaThumbnailCell :items="material.images ?? material.raw_material_price?.images ?? []" :max-visible="1" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{{ material.raw_material_name || material.raw_material_price?.raw_material?.name }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-muted-foreground">
|
||||
{{ material.variant || material.raw_material_price?.variant }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-for="material in group.items" :key="material.id"
|
||||
class="flex items-center gap-3 rounded-lg border p-3">
|
||||
<div class="shrink-0">
|
||||
<MediaThumbnailCell :items="material.images ?? material.raw_material_price?.images ?? []" :max-visible="1" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{{ material.raw_material_name || material.raw_material_price?.raw_material?.name }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-muted-foreground">
|
||||
{{ material.variant || material.raw_material_price?.variant }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium">Harga</p>
|
||||
<div class="grid gap-2 grid-cols-2 sm:grid-cols-4">
|
||||
<Field v-for="type in PRICE_TYPES" :key="`shared-${type}`">
|
||||
<FieldLabel class="text-xs" :for="`shared-price-${type}`" required>
|
||||
{{ PRICE_TYPE_LABELS[type] }}
|
||||
</FieldLabel>
|
||||
<RupiahInput :id="`shared-price-${type}`" :model-value="verifyForm.shared_prices[type]"
|
||||
@update:model-value="setSharedPrice(type, $event)" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FieldError :errors="resultPricesErrors" />
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="stock-verification-note">Catatan Verifikasi</FieldLabel>
|
||||
<Textarea id="stock-verification-note" v-model="verifyForm.verification_note"
|
||||
placeholder="Masukkan catatan verifikasi" rows="3" />
|
||||
<FieldError
|
||||
:errors="verifyForm.errors.verification_note ? [verifyForm.errors.verification_note] : []" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="flex-col gap-2 border-t pt-4 sm:flex-row">
|
||||
<Button type="button" variant="outline" class="w-full sm:w-auto" :disabled="verifyForm.processing" @click="open = false">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" class="w-full sm:w-auto" :disabled="verifyForm.processing">
|
||||
{{ verifyForm.processing ? 'Menyimpan...' : 'Verifikasi & Tambah Stok' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@ -1,22 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { RowApproveAction } from '@/components/button';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import type { CuttingListItem } from '@/types/cutting';
|
||||
import StockVerifyDialog from './StockVerifyDialog.vue';
|
||||
|
||||
defineProps<{
|
||||
cutting: CuttingListItem;
|
||||
}>();
|
||||
|
||||
const { can } = useCan();
|
||||
|
||||
const verifyDialogOpen = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RowApproveAction v-if="can('cuttings.verify')" size="sm" tooltip="Verifikasi & Tambah Stok"
|
||||
@click="verifyDialogOpen = true" />
|
||||
|
||||
<StockVerifyDialog v-model:open="verifyDialogOpen" :cutting="cutting" />
|
||||
</template>
|
||||
@ -36,39 +36,12 @@ export type CuttingMaterialListItem = {
|
||||
};
|
||||
};
|
||||
|
||||
export type CuttingResultPriceItem = {
|
||||
id: number;
|
||||
product_variant_id: number;
|
||||
price_type: string;
|
||||
price: number;
|
||||
price_formatted: string;
|
||||
cost_per_unit: number;
|
||||
cost_per_unit_formatted: string;
|
||||
type_label: string;
|
||||
product_variant?: {
|
||||
id: number;
|
||||
name: string;
|
||||
product?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type CuttingResultListItem = {
|
||||
id: number;
|
||||
cutting_result: number;
|
||||
sample: number;
|
||||
original_outside_sample: number;
|
||||
product_variant?: {
|
||||
id: number;
|
||||
name: string;
|
||||
images?: MediaItem[];
|
||||
product?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
product_name?: string;
|
||||
};
|
||||
|
||||
export type CuttingListItem = {
|
||||
@ -117,7 +90,6 @@ export type CuttingListItem = {
|
||||
} | null;
|
||||
materials: CuttingMaterialListItem[];
|
||||
results: CuttingResultListItem[];
|
||||
result_prices?: CuttingResultPriceItem[];
|
||||
};
|
||||
|
||||
export type CuttingMaterialCartItem = {
|
||||
@ -135,14 +107,10 @@ export type CuttingMaterialCartItem = {
|
||||
};
|
||||
|
||||
export type CuttingResultCartItem = {
|
||||
product_variant_id: number;
|
||||
product_name: string;
|
||||
variant_name: string;
|
||||
stock: number;
|
||||
cutting_result: string | null;
|
||||
sample: string | null;
|
||||
original_outside_sample: string | null;
|
||||
images?: MediaItem[];
|
||||
};
|
||||
|
||||
export type CuttingEditItem = {
|
||||
@ -176,18 +144,10 @@ export type CuttingEditItem = {
|
||||
};
|
||||
}>;
|
||||
results: Array<{
|
||||
product_variant_id: number;
|
||||
product_name?: string;
|
||||
cutting_result: number;
|
||||
sample: number;
|
||||
original_outside_sample: number;
|
||||
product_variant?: {
|
||||
name: string;
|
||||
stock: number;
|
||||
images?: MediaItem[];
|
||||
product?: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
|
||||
@ -24,7 +24,6 @@
|
||||
use App\Http\Controllers\Admin\Manage\Restock\RestockController;
|
||||
use App\Http\Controllers\Admin\Manage\Restock\RestockDraftItemController;
|
||||
use App\Http\Controllers\Admin\Manage\Stock\RetailStockController;
|
||||
use App\Http\Controllers\Admin\Manage\Stock\StockController;
|
||||
use App\Http\Controllers\Admin\Manage\StokOpnameController;
|
||||
use App\Http\Controllers\Admin\Master\CategoryController;
|
||||
use App\Http\Controllers\Admin\Master\CustomerController;
|
||||
@ -378,7 +377,7 @@
|
||||
->name('destroy');
|
||||
|
||||
Route::post('{cutting}/status', [CuttingController::class, 'transitionStatus'])
|
||||
->middleware('permission:'.Permission::CUTTINGS_COMPLETE->value.'|'.Permission::CUTTINGS_VERIFY->value.'|'.Permission::CUTTINGS_REJECT->value)
|
||||
->middleware('permission:'.Permission::CUTTINGS_COMPLETE->value)
|
||||
->name('transition_status');
|
||||
|
||||
Route::post('draft-materials', [CuttingDraftItemController::class, 'storeMaterial'])
|
||||
@ -393,7 +392,7 @@
|
||||
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
|
||||
->name('draft_results.store');
|
||||
|
||||
Route::delete('draft-results/{productVariant}', [CuttingDraftItemController::class, 'destroyResult'])
|
||||
Route::delete('draft-results/{cuttingResult}', [CuttingDraftItemController::class, 'destroyResult'])
|
||||
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
|
||||
->name('draft_results.destroy');
|
||||
|
||||
@ -410,16 +409,6 @@
|
||||
->name('quick_create_product');
|
||||
});
|
||||
|
||||
Route::prefix('stocks')->name('stocks.')
|
||||
->middleware('permission:'.Permission::STOCKS_VIEW->value)
|
||||
->group(function () {
|
||||
Route::get('/', [StockController::class, 'index'])->name('index');
|
||||
|
||||
Route::post('{cutting}/verify', [StockController::class, 'verify'])
|
||||
->middleware('permission:'.Permission::CUTTINGS_VERIFY->value)
|
||||
->name('verify');
|
||||
});
|
||||
|
||||
Route::prefix('retail-stock')->name('retail-stock.')
|
||||
->middleware('permission:'.Permission::STOCKS_VIEW->value)
|
||||
->group(function () {
|
||||
@ -472,14 +461,6 @@
|
||||
Route::get('requests/{ownerVerificationRequest}', [OwnerVerificationController::class, 'show'])
|
||||
->name('show');
|
||||
|
||||
Route::post('cuttings/{cutting}/approve', [OwnerVerificationController::class, 'approveCutting'])
|
||||
->middleware('permission:'.Permission::OWNER_VERIFICATIONS_VERIFY->value)
|
||||
->name('approve');
|
||||
|
||||
Route::post('cuttings/{cutting}/reject', [OwnerVerificationController::class, 'rejectCutting'])
|
||||
->middleware('permission:'.Permission::OWNER_VERIFICATIONS_REJECT->value)
|
||||
->name('reject');
|
||||
|
||||
Route::post('requests/{ownerVerificationRequest}/approve', [OwnerVerificationController::class, 'approveRequest'])
|
||||
->middleware('permission:'.Permission::OWNER_VERIFICATIONS_VERIFY->value)
|
||||
->name('approve_request');
|
||||
|
||||
@ -8,7 +8,6 @@
|
||||
use App\Models\CuttingMaterialCombination;
|
||||
use App\Models\CuttingResult;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\RawMaterial;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Models\User;
|
||||
@ -51,17 +50,14 @@ function createCuttingWithMaterialsAndResults(?User $user = null): Cutting
|
||||
$rawMaterial = RawMaterial::factory()->create();
|
||||
$price = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
|
||||
|
||||
$product = Product::factory()->create();
|
||||
$variant = ProductVariant::factory()->create(['product_id' => $product->id]);
|
||||
|
||||
CuttingMaterial::factory()->create([
|
||||
CuttingResult::factory()->create([
|
||||
'cutting_id' => $cutting->id,
|
||||
'raw_material_price_id' => $price->id,
|
||||
'product_name' => 'Test Product',
|
||||
]);
|
||||
|
||||
CuttingResult::factory()->create([
|
||||
'cutting_id' => $cutting->id,
|
||||
'product_variant_id' => $variant->id,
|
||||
'product_name' => 'Second Product',
|
||||
]);
|
||||
|
||||
return $cutting;
|
||||
@ -72,10 +68,17 @@ function setupDraftItems(User $user): array
|
||||
$rawMaterial = RawMaterial::factory()->create();
|
||||
$price = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
|
||||
|
||||
$product = Product::factory()->create();
|
||||
$variant = ProductVariant::factory()->create(['product_id' => $product->id]);
|
||||
return ['price' => $price];
|
||||
}
|
||||
|
||||
return ['price' => $price, 'variant' => $variant];
|
||||
function makeResultData(): array
|
||||
{
|
||||
return [
|
||||
'product_name' => 'Test Product',
|
||||
'cutting_result' => 10,
|
||||
'sample' => 8,
|
||||
'original_outside_sample' => 2,
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Index ────────────────────────────────────────────────
|
||||
@ -168,22 +171,13 @@ function setupDraftItems(User $user): array
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
// Create draft result
|
||||
$this->actingAs($user)
|
||||
->postJson(route('admin.manage.cuttings.draft_results.store'), [
|
||||
'product_variant_id' => $draft['variant']->id,
|
||||
'cutting_result' => 10,
|
||||
'sample' => 8,
|
||||
'original_outside_sample' => 2,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
// Store cutting
|
||||
// Store cutting with results directly
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.manage.cuttings.store'), [
|
||||
'description' => 'Cutting test',
|
||||
'sewing_cost' => 50000,
|
||||
'other_cost' => 10000,
|
||||
'results' => [makeResultData()],
|
||||
])
|
||||
->assertRedirect(route('admin.manage.cuttings.index'));
|
||||
|
||||
@ -228,20 +222,11 @@ function setupDraftItems(User $user): array
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
// Create draft result
|
||||
$this->actingAs($user)
|
||||
->postJson(route('admin.manage.cuttings.draft_results.store'), [
|
||||
'product_variant_id' => $draft['variant']->id,
|
||||
'cutting_result' => 10,
|
||||
'sample' => 8,
|
||||
'original_outside_sample' => 2,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
// Store cutting
|
||||
// Store cutting with results directly
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.manage.cuttings.store'), [
|
||||
'description' => 'Cutting with result',
|
||||
'results' => [makeResultData()],
|
||||
])
|
||||
->assertRedirect(route('admin.manage.cuttings.index'));
|
||||
|
||||
@ -268,31 +253,21 @@ function setupDraftItems(User $user): array
|
||||
test('store fails without draft materials', function () {
|
||||
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE);
|
||||
|
||||
$draft = setupDraftItems($user);
|
||||
|
||||
// Only create draft result, no material
|
||||
$this->actingAs($user)
|
||||
->postJson(route('admin.manage.cuttings.draft_results.store'), [
|
||||
'product_variant_id' => $draft['variant']->id,
|
||||
'cutting_result' => 10,
|
||||
'sample' => 8,
|
||||
'original_outside_sample' => 2,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
// Send results but no draft material
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.manage.cuttings.store'), [
|
||||
'description' => 'Test',
|
||||
'results' => [makeResultData()],
|
||||
])
|
||||
->assertSessionHasErrors('materials');
|
||||
});
|
||||
|
||||
test('store fails without draft results', function () {
|
||||
test('store fails without results', function () {
|
||||
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE);
|
||||
|
||||
$draft = setupDraftItems($user);
|
||||
|
||||
// Only create draft material, no result
|
||||
// Create draft material but don't send results
|
||||
$this->actingAs($user)
|
||||
->postJson(route('admin.manage.cuttings.draft_materials.store'), [
|
||||
'raw_material_price_id' => $draft['price']->id,
|
||||
@ -360,9 +335,6 @@ function setupDraftItems(User $user): array
|
||||
$rawMaterial = RawMaterial::factory()->create();
|
||||
$price = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
|
||||
|
||||
$product = Product::factory()->create();
|
||||
$variant = ProductVariant::factory()->create(['product_id' => $product->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->put(route('admin.manage.cuttings.update', $cutting), [
|
||||
'description' => 'Updated description',
|
||||
@ -373,7 +345,7 @@ function setupDraftItems(User $user): array
|
||||
],
|
||||
'results' => [
|
||||
[
|
||||
'product_variant_id' => $variant->id,
|
||||
'product_name' => 'Test Product',
|
||||
'cutting_result' => 20,
|
||||
'sample' => 15,
|
||||
'original_outside_sample' => 5,
|
||||
@ -393,9 +365,6 @@ function setupDraftItems(User $user): array
|
||||
$rawMaterial = RawMaterial::factory()->create();
|
||||
$price = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
|
||||
|
||||
$product = Product::factory()->create();
|
||||
$variant = ProductVariant::factory()->create(['product_id' => $product->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->put(route('admin.manage.cuttings.update', $cutting), [
|
||||
'description' => 'With material result',
|
||||
@ -406,7 +375,7 @@ function setupDraftItems(User $user): array
|
||||
],
|
||||
'results' => [
|
||||
[
|
||||
'product_variant_id' => $variant->id,
|
||||
'product_name' => 'Test Product',
|
||||
'cutting_result' => 20,
|
||||
'sample' => 15,
|
||||
'original_outside_sample' => 5,
|
||||
@ -425,7 +394,7 @@ function setupDraftItems(User $user): array
|
||||
$this->put(route('admin.manage.cuttings.update', $cutting), [
|
||||
'description' => 'Test',
|
||||
'materials' => [['raw_material_price_id' => 1, 'material_usage' => 1]],
|
||||
'results' => [['product_variant_id' => 1, 'cutting_result' => 1, 'sample' => 1, 'original_outside_sample' => 0]],
|
||||
'results' => [['product_name' => 'P', 'cutting_result' => 1, 'sample' => 1, 'original_outside_sample' => 0]],
|
||||
])->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
@ -438,7 +407,7 @@ function setupDraftItems(User $user): array
|
||||
->put(route('admin.manage.cuttings.update', $cutting), [
|
||||
'description' => 'Test',
|
||||
'materials' => [['raw_material_price_id' => 1, 'material_usage' => 1]],
|
||||
'results' => [['product_variant_id' => 1, 'cutting_result' => 1, 'sample' => 1, 'original_outside_sample' => 0]],
|
||||
'results' => [['product_name' => 'P', 'cutting_result' => 1, 'sample' => 1, 'original_outside_sample' => 0]],
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
@ -530,27 +499,11 @@ function setupDraftItems(User $user): array
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('invalid status transition is rejected', function () {
|
||||
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_COMPLETE, PermissionEnum::CUTTINGS_VERIFY);
|
||||
test('completed cutting cannot transition to another status', function () {
|
||||
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_COMPLETE, PermissionEnum::CUTTINGS_UPDATE);
|
||||
|
||||
$cutting = createCuttingWithMaterialsAndResults($user);
|
||||
|
||||
// IN_PROGRESS cannot go directly to VERIFIED
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.manage.cuttings.transition_status', $cutting), [
|
||||
'status' => CuttingStatus::VERIFIED->value,
|
||||
])
|
||||
->assertSessionHasErrors('status');
|
||||
});
|
||||
|
||||
test('completed cutting can be verified by owner', function () {
|
||||
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_COMPLETE, PermissionEnum::CUTTINGS_VERIFY);
|
||||
$user->assignRole('owner');
|
||||
$user->forgetCachedPermissions();
|
||||
|
||||
$cutting = createCuttingWithMaterialsAndResults($user);
|
||||
|
||||
// First complete
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.manage.cuttings.transition_status', $cutting), [
|
||||
'status' => CuttingStatus::COMPLETED->value,
|
||||
@ -558,30 +511,12 @@ function setupDraftItems(User $user): array
|
||||
|
||||
expect($cutting->fresh()->status)->toBe(CuttingStatus::COMPLETED);
|
||||
|
||||
// Then verify with result_prices
|
||||
$result = $cutting->results->first();
|
||||
|
||||
// COMPLETED is final, cannot transition to another status
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.manage.cuttings.transition_status', $cutting), [
|
||||
'status' => CuttingStatus::VERIFIED->value,
|
||||
'results' => [
|
||||
[
|
||||
'product_variant_id' => $result->product_variant_id,
|
||||
'sample' => $result->sample ?? 0,
|
||||
'original_outside_sample' => $result->original_outside_sample ?? 0,
|
||||
],
|
||||
],
|
||||
'result_prices' => [
|
||||
[
|
||||
'product_variant_id' => $result->product_variant_id,
|
||||
'prices' => [
|
||||
['type' => 'retail', 'price' => 150000],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
expect($cutting->fresh()->status)->toBe(CuttingStatus::VERIFIED);
|
||||
'status' => CuttingStatus::IN_PROGRESS->value,
|
||||
])
|
||||
->assertSessionHasErrors('status');
|
||||
});
|
||||
});
|
||||
|
||||
@ -812,9 +747,6 @@ function setupDraftItems(User $user): array
|
||||
$price1 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
|
||||
$price2 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
|
||||
|
||||
$product = Product::factory()->create();
|
||||
$variant = ProductVariant::factory()->create(['product_id' => $product->id]);
|
||||
|
||||
// Create draft combination
|
||||
$this->actingAs($user)
|
||||
->postJson(route('admin.manage.cuttings.draft_combinations.store'), [
|
||||
@ -825,20 +757,11 @@ function setupDraftItems(User $user): array
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
// Create draft result
|
||||
$this->actingAs($user)
|
||||
->postJson(route('admin.manage.cuttings.draft_results.store'), [
|
||||
'product_variant_id' => $variant->id,
|
||||
'cutting_result' => 10,
|
||||
'sample' => 8,
|
||||
'original_outside_sample' => 2,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
// Store cutting
|
||||
// Store cutting with results directly
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.manage.cuttings.store'), [
|
||||
'description' => 'Cutting with combination',
|
||||
'results' => [makeResultData()],
|
||||
])
|
||||
->assertRedirect(route('admin.manage.cuttings.index'));
|
||||
|
||||
@ -862,9 +785,6 @@ function setupDraftItems(User $user): array
|
||||
$price1 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
|
||||
$price2 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
|
||||
|
||||
$product = Product::factory()->create();
|
||||
$variant = ProductVariant::factory()->create(['product_id' => $product->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->put(route('admin.manage.cuttings.update', $cutting), [
|
||||
'description' => 'Updated with combination',
|
||||
@ -876,7 +796,7 @@ function setupDraftItems(User $user): array
|
||||
],
|
||||
'results' => [
|
||||
[
|
||||
'product_variant_id' => $variant->id,
|
||||
'product_name' => 'Test Product',
|
||||
'cutting_result' => 10,
|
||||
'sample' => 8,
|
||||
'original_outside_sample' => 2,
|
||||
@ -902,9 +822,6 @@ function setupDraftItems(User $user): array
|
||||
$price1 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
|
||||
$price2 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]);
|
||||
|
||||
$product = Product::factory()->create();
|
||||
$variant = ProductVariant::factory()->create(['product_id' => $product->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->put(route('admin.manage.cuttings.update', $cutting), [
|
||||
'description' => 'Combination with result',
|
||||
@ -916,7 +833,7 @@ function setupDraftItems(User $user): array
|
||||
],
|
||||
'results' => [
|
||||
[
|
||||
'product_variant_id' => $variant->id,
|
||||
'product_name' => 'Test Product',
|
||||
'cutting_result' => 10,
|
||||
'sample' => 8,
|
||||
'original_outside_sample' => 2,
|
||||
@ -977,12 +894,10 @@ function setupDraftItems(User $user): array
|
||||
expect($cutting->createdBy->id)->toBe($user->id);
|
||||
});
|
||||
|
||||
test('cutting status is editable for in_progress and rejected', function () {
|
||||
test('cutting status is editable for in_progress', function () {
|
||||
$inProgress = Cutting::factory()->create(['status' => CuttingStatus::IN_PROGRESS->value]);
|
||||
$rejected = Cutting::factory()->create(['status' => CuttingStatus::REJECTED->value]);
|
||||
|
||||
expect($inProgress->status->isEditable())->toBeTrue();
|
||||
expect($rejected->status->isEditable())->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1,364 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\CuttingStatus;
|
||||
use App\Enums\Permission as PermissionEnum;
|
||||
use App\Models\Cutting;
|
||||
use App\Models\CuttingMaterial;
|
||||
use App\Models\CuttingResult;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\RawMaterial;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
});
|
||||
|
||||
// ─── Helper ───────────────────────────────────────────────
|
||||
|
||||
function createStockUserWithPermission(PermissionEnum ...$permissions): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user->givePermissionTo(
|
||||
array_merge(
|
||||
[PermissionEnum::DASHBOARD_VIEW->value],
|
||||
array_map(fn (PermissionEnum $p) => $p->value, $permissions)
|
||||
)
|
||||
);
|
||||
|
||||
$user->forgetCachedPermissions();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function createCompletedCuttingSetup(): array
|
||||
{
|
||||
$product = Product::factory()->create();
|
||||
$variant = ProductVariant::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'stock' => 10,
|
||||
'reject_stock' => 5,
|
||||
]);
|
||||
|
||||
$rawMaterial = RawMaterial::factory()->create();
|
||||
$rawMaterialPrice = RawMaterialPrice::factory()->create([
|
||||
'raw_material_id' => $rawMaterial->id,
|
||||
]);
|
||||
|
||||
$cutting = Cutting::factory()->create([
|
||||
'status' => CuttingStatus::COMPLETED,
|
||||
]);
|
||||
|
||||
CuttingMaterial::factory()->create([
|
||||
'cutting_id' => $cutting->id,
|
||||
'raw_material_price_id' => $rawMaterialPrice->id,
|
||||
]);
|
||||
|
||||
CuttingResult::factory()->create([
|
||||
'cutting_id' => $cutting->id,
|
||||
'product_variant_id' => $variant->id,
|
||||
'cutting_result' => 7,
|
||||
'sample' => 5, // Good stock result
|
||||
'original_outside_sample' => 2, // Reject stock result
|
||||
]);
|
||||
|
||||
return [
|
||||
'cutting' => $cutting,
|
||||
'variant' => $variant,
|
||||
'raw_material_price' => $rawMaterialPrice,
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────
|
||||
|
||||
describe('Stock Warehouse Management', function () {
|
||||
test('authenticated user with permission can view stocks index', function () {
|
||||
$user = createStockUserWithPermission(PermissionEnum::STOCKS_VIEW);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.manage.stocks.index'))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('user with permission can submit verification for completed cutting', function () {
|
||||
$user = createStockUserWithPermission(
|
||||
PermissionEnum::STOCKS_VIEW,
|
||||
PermissionEnum::CUTTINGS_VERIFY
|
||||
);
|
||||
|
||||
$setup = createCompletedCuttingSetup();
|
||||
$cutting = $setup['cutting'];
|
||||
|
||||
$payload = [
|
||||
'verification_note' => 'Verification notes',
|
||||
'results' => [
|
||||
[
|
||||
'product_variant_id' => $setup['variant']->id,
|
||||
'good' => 6, // changed from 5 to 6
|
||||
'reject' => 1, // changed from 2 to 1
|
||||
],
|
||||
],
|
||||
'result_prices' => [
|
||||
[
|
||||
'product_variant_id' => $setup['variant']->id,
|
||||
'prices' => [
|
||||
[
|
||||
'type' => 'harga_modal',
|
||||
'price' => 50000,
|
||||
],
|
||||
[
|
||||
'type' => 'retail',
|
||||
'price' => 75000,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.manage.stocks.verify', $cutting), $payload)
|
||||
->assertRedirect(route('admin.manage.stocks.index'));
|
||||
|
||||
$cutting->refresh();
|
||||
$this->assertEquals(CuttingStatus::PENDING_VERIFICATION, $cutting->status);
|
||||
|
||||
// Verify cutting results are updated
|
||||
$this->assertDatabaseHas('cutting_results', [
|
||||
'cutting_id' => $cutting->id,
|
||||
'product_variant_id' => $setup['variant']->id,
|
||||
'sample' => 6,
|
||||
'original_outside_sample' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
test('user with permission can approve verification and increase stock', function () {
|
||||
$user = createStockUserWithPermission(
|
||||
PermissionEnum::STOCKS_VIEW,
|
||||
PermissionEnum::OWNER_VERIFICATIONS_VERIFY
|
||||
);
|
||||
|
||||
$setup = createCompletedCuttingSetup();
|
||||
$cutting = $setup['cutting'];
|
||||
$cutting->status = CuttingStatus::PENDING_VERIFICATION;
|
||||
$cutting->save();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.manage.owner_verifications.approve', $cutting), [
|
||||
'approval_note' => 'Approved by owner',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$cutting->refresh();
|
||||
$setup['variant']->refresh();
|
||||
|
||||
$this->assertEquals(CuttingStatus::VERIFIED, $cutting->status);
|
||||
|
||||
// Stock (good) should increment by 5 (10 + 5 = 15)
|
||||
$this->assertEquals(15, $setup['variant']->stock);
|
||||
|
||||
// Reject stock should increment by 2 (5 + 2 = 7)
|
||||
$this->assertEquals(7, $setup['variant']->reject_stock);
|
||||
});
|
||||
|
||||
test('user with permission can reject verification', function () {
|
||||
$user = createStockUserWithPermission(
|
||||
PermissionEnum::STOCKS_VIEW,
|
||||
PermissionEnum::OWNER_VERIFICATIONS_REJECT
|
||||
);
|
||||
|
||||
$setup = createCompletedCuttingSetup();
|
||||
$cutting = $setup['cutting'];
|
||||
$cutting->status = CuttingStatus::PENDING_VERIFICATION;
|
||||
$cutting->save();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.manage.owner_verifications.reject', $cutting), [
|
||||
'reason' => 'Invalid results',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$cutting->refresh();
|
||||
|
||||
// Status should revert to COMPLETED
|
||||
$this->assertEquals(CuttingStatus::COMPLETED, $cutting->status);
|
||||
|
||||
$this->assertDatabaseHas('rejections', [
|
||||
'rejectable_id' => $cutting->id,
|
||||
'rejectable_type' => Cutting::class,
|
||||
'reason' => 'Invalid results',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Retail Stock Transfer ──────────────────────────────────
|
||||
|
||||
describe('Retail Stock Transfer', function () {
|
||||
test('user with permission can transfer stock from good to retail', function () {
|
||||
$user = createStockUserWithPermission(PermissionEnum::STOCKS_VIEW);
|
||||
|
||||
$product = Product::factory()->create();
|
||||
$variant = ProductVariant::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'stock' => 10,
|
||||
'retail_stock' => 5,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson(route('admin.manage.retail-stock.transfer'), [
|
||||
'product_variant_id' => $variant->id,
|
||||
'quantity' => 3,
|
||||
'notes' => 'Transfer test',
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$variant->refresh();
|
||||
|
||||
$this->assertEquals(7, $variant->stock);
|
||||
$this->assertEquals(8, $variant->retail_stock);
|
||||
|
||||
$this->assertDatabaseHas('retail_stock_histories', [
|
||||
'product_variant_id' => $variant->id,
|
||||
'user_id' => $user->id,
|
||||
'quantity' => 3,
|
||||
'stock_before' => 10,
|
||||
'retail_stock_before' => 5,
|
||||
'stock_after' => 7,
|
||||
'retail_stock_after' => 8,
|
||||
'notes' => 'Transfer test',
|
||||
]);
|
||||
});
|
||||
|
||||
test('retail stock transfer does not require owner verification', function () {
|
||||
$user = createStockUserWithPermission(PermissionEnum::STOCKS_VIEW);
|
||||
|
||||
$product = Product::factory()->create();
|
||||
$variant = ProductVariant::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'stock' => 10,
|
||||
'retail_stock' => 5,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson(route('admin.manage.retail-stock.transfer'), [
|
||||
'product_variant_id' => $variant->id,
|
||||
'quantity' => 3,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$this->assertDatabaseCount('owner_verification_requests', 0);
|
||||
|
||||
$variant->refresh();
|
||||
$this->assertEquals(7, $variant->stock);
|
||||
$this->assertEquals(8, $variant->retail_stock);
|
||||
});
|
||||
|
||||
test('retail stock transfer fails when quantity exceeds stock', function () {
|
||||
$user = createStockUserWithPermission(PermissionEnum::STOCKS_VIEW);
|
||||
|
||||
$product = Product::factory()->create();
|
||||
$variant = ProductVariant::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'stock' => 5,
|
||||
'retail_stock' => 0,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson(route('admin.manage.retail-stock.transfer'), [
|
||||
'product_variant_id' => $variant->id,
|
||||
'quantity' => 10,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('quantity');
|
||||
});
|
||||
|
||||
test('retail stock transfer fails when quantity is zero', function () {
|
||||
$user = createStockUserWithPermission(PermissionEnum::STOCKS_VIEW);
|
||||
|
||||
$product = Product::factory()->create();
|
||||
$variant = ProductVariant::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'stock' => 10,
|
||||
'retail_stock' => 5,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson(route('admin.manage.retail-stock.transfer'), [
|
||||
'product_variant_id' => $variant->id,
|
||||
'quantity' => 0,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('quantity');
|
||||
});
|
||||
|
||||
test('user without permission cannot transfer stock', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$product = Product::factory()->create();
|
||||
$variant = ProductVariant::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'stock' => 10,
|
||||
'retail_stock' => 5,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson(route('admin.manage.retail-stock.transfer'), [
|
||||
'product_variant_id' => $variant->id,
|
||||
'quantity' => 3,
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('owner can verify stock directly without pending verification', function () {
|
||||
$user = createStockUserWithPermission(
|
||||
PermissionEnum::STOCKS_VIEW,
|
||||
PermissionEnum::CUTTINGS_VERIFY,
|
||||
PermissionEnum::OWNER_VERIFICATIONS_VERIFY
|
||||
);
|
||||
|
||||
$setup = createCompletedCuttingSetup();
|
||||
$cutting = $setup['cutting'];
|
||||
|
||||
$payload = [
|
||||
'verification_note' => 'Verified directly by owner',
|
||||
'results' => [
|
||||
[
|
||||
'product_variant_id' => $setup['variant']->id,
|
||||
'good' => 6,
|
||||
'reject' => 1,
|
||||
],
|
||||
],
|
||||
'result_prices' => [
|
||||
[
|
||||
'product_variant_id' => $setup['variant']->id,
|
||||
'prices' => [
|
||||
[
|
||||
'type' => 'harga_modal',
|
||||
'price' => 50000,
|
||||
],
|
||||
[
|
||||
'type' => 'retail',
|
||||
'price' => 75000,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.manage.stocks.verify', $cutting), $payload)
|
||||
->assertRedirect(route('admin.manage.stocks.index'));
|
||||
|
||||
$cutting->refresh();
|
||||
$this->assertEquals(CuttingStatus::VERIFIED, $cutting->status);
|
||||
|
||||
$setup['variant']->refresh();
|
||||
// Base stock was 10. Added good stock is 6.
|
||||
$this->assertEquals(16, $setup['variant']->stock);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user