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:
Yoga Pangestu 2026-07-19 17:29:19 +07:00
parent ebba4b3463
commit 9539ff7042
56 changed files with 357 additions and 4431 deletions

View File

@ -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 => [],
};
}

View File

@ -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,

View File

@ -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,

View File

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

View File

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

View File

@ -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,

View File

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

View File

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

View File

@ -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'],

View File

@ -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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.
*/

View File

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

View File

@ -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 {}

View File

@ -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',

View File

@ -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,
]);
}
}

View File

@ -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,

View File

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

View File

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

View File

@ -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,
]);
}
}

View File

@ -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' },
],

View File

@ -13,7 +13,7 @@ interface NavItem {
href: string;
icon: any;
permission?: string;
badgeKey?: 'pendingLeaveRequests' | 'pendingCuttings';
badgeKey?: 'pendingLeaveRequests';
}
const navItems: NavItem[] = [

View File

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

View File

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

View File

@ -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"

View File

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

View File

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

View File

@ -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">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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