Add cutting management features including Cutting, CuttingMaterial, and CuttingResult models. Implement CuttingController for CRUD operations and request validation. Introduce CuttingStatus enum for status management and enhance sidebar navigation for cuttings. Integrate UI components for creating, editing, and listing cuttings, along with permissions in the Permission and Role enums.
This commit is contained in:
parent
7a0ac4f4d1
commit
ec8b49be05
96
app/Enums/CuttingStatus.php
Normal file
96
app/Enums/CuttingStatus.php
Normal file
@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Traits\ProvidesEnumOptions;
|
||||
use InvalidArgumentException;
|
||||
|
||||
enum CuttingStatus: string
|
||||
{
|
||||
use ProvidesEnumOptions;
|
||||
|
||||
case IN_PROGRESS = 'in_progress';
|
||||
case COMPLETED = 'completed';
|
||||
case VERIFIED = 'verified';
|
||||
case REJECTED = 'rejected';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::IN_PROGRESS => 'Proses',
|
||||
self::COMPLETED => 'Selesai',
|
||||
self::VERIFIED => 'Terverifikasi',
|
||||
self::REJECTED => 'Ditolak',
|
||||
};
|
||||
}
|
||||
|
||||
public function isEditable(): bool
|
||||
{
|
||||
return in_array($this, [self::IN_PROGRESS, self::REJECTED], true);
|
||||
}
|
||||
|
||||
public function canTransitionTo(self $status): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::IN_PROGRESS => $status === self::COMPLETED,
|
||||
self::COMPLETED => in_array($status, [self::VERIFIED, self::REJECTED], true),
|
||||
self::REJECTED => $status === self::IN_PROGRESS,
|
||||
self::VERIFIED => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function transitionPermission(): Permission
|
||||
{
|
||||
return match ($this) {
|
||||
self::COMPLETED => Permission::CUTTINGS_COMPLETE,
|
||||
self::VERIFIED => Permission::CUTTINGS_VERIFY,
|
||||
self::REJECTED => Permission::CUTTINGS_REJECT,
|
||||
self::IN_PROGRESS => Permission::CUTTINGS_UPDATE,
|
||||
default => throw new InvalidArgumentException('Status tidak mendukung transisi.'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{status: string, label: string, destructive: bool, permission: string, icon_only: bool}>
|
||||
*/
|
||||
public function availableActions(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::IN_PROGRESS => [
|
||||
[
|
||||
'status' => self::COMPLETED->value,
|
||||
'label' => 'Selesai',
|
||||
'destructive' => false,
|
||||
'permission' => Permission::CUTTINGS_COMPLETE->value,
|
||||
'icon_only' => false,
|
||||
],
|
||||
],
|
||||
self::COMPLETED => [
|
||||
[
|
||||
'status' => self::VERIFIED->value,
|
||||
'label' => 'Verifikasi',
|
||||
'destructive' => false,
|
||||
'permission' => Permission::CUTTINGS_VERIFY->value,
|
||||
'icon_only' => false,
|
||||
],
|
||||
[
|
||||
'status' => self::REJECTED->value,
|
||||
'label' => 'Tolak',
|
||||
'destructive' => true,
|
||||
'permission' => Permission::CUTTINGS_REJECT->value,
|
||||
'icon_only' => false,
|
||||
],
|
||||
],
|
||||
self::REJECTED => [
|
||||
[
|
||||
'status' => self::IN_PROGRESS->value,
|
||||
'label' => 'Perbaiki',
|
||||
'destructive' => false,
|
||||
'permission' => Permission::CUTTINGS_UPDATE->value,
|
||||
'icon_only' => false,
|
||||
],
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -68,6 +68,14 @@ enum Permission: string
|
||||
case ORDERS_COMPLETE = 'orders.complete';
|
||||
case ORDERS_CANCEL = 'orders.cancel';
|
||||
|
||||
case CUTTINGS_VIEW = 'cuttings.view';
|
||||
case CUTTINGS_CREATE = 'cuttings.create';
|
||||
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 CASH_VIEW = 'cash.view';
|
||||
case CASH_DEPOSIT = 'cash.deposit';
|
||||
case CASH_UPDATE = 'cash.update';
|
||||
@ -156,6 +164,14 @@ public function label(): string
|
||||
self::ORDERS_COMPLETE => 'Selesaikan Pesanan',
|
||||
self::ORDERS_CANCEL => 'Batalkan Pesanan',
|
||||
|
||||
self::CUTTINGS_VIEW => 'Lihat Cutting',
|
||||
self::CUTTINGS_CREATE => 'Tambah Cutting',
|
||||
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::CASH_VIEW => 'Lihat Kas',
|
||||
self::CASH_DEPOSIT => 'Setor Kas',
|
||||
self::CASH_UPDATE => 'Ubah Setor Kas',
|
||||
@ -208,6 +224,9 @@ public function group(): string
|
||||
self::ORDERS_VIEW, self::ORDERS_CREATE, self::ORDERS_UPDATE,
|
||||
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::CASH_VIEW, self::CASH_DEPOSIT, self::CASH_UPDATE, self::CASH_DELETE => 'Kas',
|
||||
self::EXPENSES_VIEW, self::EXPENSES_CREATE, self::EXPENSES_UPDATE,
|
||||
self::EXPENSES_DELETE => 'Pengeluaran',
|
||||
|
||||
@ -85,6 +85,13 @@ public function permissions(): array
|
||||
Permission::ORDERS_SEND,
|
||||
Permission::ORDERS_COMPLETE,
|
||||
Permission::ORDERS_CANCEL,
|
||||
Permission::CUTTINGS_VIEW,
|
||||
Permission::CUTTINGS_CREATE,
|
||||
Permission::CUTTINGS_UPDATE,
|
||||
Permission::CUTTINGS_DELETE,
|
||||
Permission::CUTTINGS_COMPLETE,
|
||||
Permission::CUTTINGS_VERIFY,
|
||||
Permission::CUTTINGS_REJECT,
|
||||
Permission::CASH_VIEW,
|
||||
Permission::CASH_DEPOSIT,
|
||||
Permission::CASH_UPDATE,
|
||||
@ -142,6 +149,9 @@ public function permissions(): array
|
||||
Permission::ORDERS_SEND,
|
||||
Permission::ORDERS_COMPLETE,
|
||||
Permission::ORDERS_CANCEL,
|
||||
Permission::CUTTINGS_VIEW,
|
||||
Permission::CUTTINGS_VERIFY,
|
||||
Permission::CUTTINGS_REJECT,
|
||||
Permission::CASH_VIEW,
|
||||
Permission::CASH_DEPOSIT,
|
||||
Permission::CASH_UPDATE,
|
||||
@ -188,6 +198,11 @@ public function permissions(): array
|
||||
Permission::PURCHASES_CREATE,
|
||||
Permission::PURCHASES_UPDATE,
|
||||
Permission::PURCHASES_DELETE,
|
||||
Permission::CUTTINGS_VIEW,
|
||||
Permission::CUTTINGS_CREATE,
|
||||
Permission::CUTTINGS_UPDATE,
|
||||
Permission::CUTTINGS_DELETE,
|
||||
Permission::CUTTINGS_COMPLETE,
|
||||
],
|
||||
self::MARKETING => [
|
||||
Permission::DASHBOARD_VIEW,
|
||||
|
||||
107
app/Http/Controllers/Admin/Manage/CuttingController.php
Normal file
107
app/Http/Controllers/Admin/Manage/CuttingController.php
Normal file
@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Manage;
|
||||
|
||||
use App\Enums\CuttingStatus;
|
||||
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Manage\CuttingRequest;
|
||||
use App\Http\Requests\Admin\Manage\CuttingStatusTransitionRequest;
|
||||
use App\Models\Cutting;
|
||||
use App\Services\Manage\CuttingService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class CuttingController extends Controller
|
||||
{
|
||||
use ParsesDataTableQuery;
|
||||
|
||||
public function __construct(
|
||||
private readonly CuttingService $cuttingService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$tableQuery = $this->parseDataTableQuery($request);
|
||||
|
||||
return Inertia::render('admin/manage/cuttings/Index', [
|
||||
'cuttings' => $this->cuttingService->paginateForIndex($tableQuery, $request->user()),
|
||||
'filters' => $this->dataTableFilters($tableQuery),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): Response
|
||||
{
|
||||
return Inertia::render('admin/manage/cuttings/Create', [
|
||||
'rawMaterialCatalog' => $this->cuttingService->rawMaterialCatalog(),
|
||||
'productCatalog' => $this->cuttingService->productCatalog(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(CuttingRequest $request): RedirectResponse
|
||||
{
|
||||
$this->cuttingService->create($request->validated(), $request->user());
|
||||
|
||||
Inertia::flash('success', 'Cutting berhasil ditambahkan.');
|
||||
|
||||
return redirect()->route('admin.manage.cuttings.index');
|
||||
}
|
||||
|
||||
public function edit(Cutting $cutting): Response|RedirectResponse
|
||||
{
|
||||
if (! $cutting->status->isEditable()) {
|
||||
Inertia::flash('error', 'Cutting tidak dapat diubah.');
|
||||
|
||||
return redirect()->route('admin.manage.cuttings.index');
|
||||
}
|
||||
|
||||
return Inertia::render('admin/manage/cuttings/Edit', [
|
||||
'cutting' => $this->cuttingService->findForEdit($cutting),
|
||||
'rawMaterialCatalog' => $this->cuttingService->rawMaterialCatalog($cutting),
|
||||
'productCatalog' => $this->cuttingService->productCatalog($cutting),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(CuttingRequest $request, Cutting $cutting): RedirectResponse
|
||||
{
|
||||
$this->cuttingService->update($cutting, $request->validated());
|
||||
|
||||
Inertia::flash('success', 'Cutting berhasil diperbarui.');
|
||||
|
||||
return redirect()->route('admin.manage.cuttings.index');
|
||||
}
|
||||
|
||||
public function destroy(Cutting $cutting): RedirectResponse
|
||||
{
|
||||
$this->cuttingService->delete($cutting);
|
||||
|
||||
Inertia::flash('success', 'Cutting berhasil dihapus.');
|
||||
|
||||
return redirect()->route('admin.manage.cuttings.index');
|
||||
}
|
||||
|
||||
public function transitionStatus(CuttingStatusTransitionRequest $request, Cutting $cutting): RedirectResponse
|
||||
{
|
||||
$status = CuttingStatus::from($request->validated('status'));
|
||||
|
||||
$this->cuttingService->transitionStatus(
|
||||
$cutting,
|
||||
$status,
|
||||
$request->validated('reason'),
|
||||
);
|
||||
|
||||
$message = match ($status) {
|
||||
CuttingStatus::COMPLETED => 'Cutting berhasil diselesaikan. Menunggu verifikasi admin toko.',
|
||||
CuttingStatus::VERIFIED => 'Cutting berhasil diverifikasi. Stok produk telah diperbarui.',
|
||||
CuttingStatus::REJECTED => 'Cutting ditolak.',
|
||||
CuttingStatus::IN_PROGRESS => 'Cutting dikembalikan ke proses.',
|
||||
default => 'Status cutting berhasil diperbarui.',
|
||||
};
|
||||
|
||||
Inertia::flash('success', $message);
|
||||
|
||||
return redirect()->route('admin.manage.cuttings.index');
|
||||
}
|
||||
}
|
||||
48
app/Http/Requests/Admin/Manage/CuttingRequest.php
Normal file
48
app/Http/Requests/Admin/Manage/CuttingRequest.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CuttingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$permission = $this->isMethod('POST')
|
||||
? Permission::CUTTINGS_CREATE
|
||||
: Permission::CUTTINGS_UPDATE;
|
||||
|
||||
return $this->user()?->can($permission->value) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'materials' => ['required', 'array', 'min:1'],
|
||||
'materials.*.raw_material_price_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
'distinct',
|
||||
Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'),
|
||||
],
|
||||
'materials.*.material_usage' => ['required', 'numeric', 'gt:0'],
|
||||
'materials.*.remaining_material' => ['required', 'numeric', 'gte:0'],
|
||||
'results' => ['required', 'array', 'min:1'],
|
||||
'results.*.product_variant_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
'distinct',
|
||||
Rule::exists('product_variants', 'id')->whereNull('deleted_at'),
|
||||
],
|
||||
'results.*.cutting_result' => ['required', 'integer', 'min:1'],
|
||||
'results.*.warehouse_stock' => ['required', 'integer', 'min:0'],
|
||||
'results.*.cutting_reject' => ['nullable', 'integer', 'min:0'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage;
|
||||
|
||||
use App\Enums\CuttingStatus;
|
||||
use App\Models\Cutting;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class CuttingStatusTransitionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$status = CuttingStatus::tryFrom((string) $this->input('status'));
|
||||
|
||||
if ($status === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->user()?->can($status->transitionPermission()->value) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['required', 'string', Rule::in(array_column(CuttingStatus::cases(), 'value'))],
|
||||
'reason' => ['nullable', 'string', 'max:500'],
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
/** @var Cutting $cutting */
|
||||
$cutting = $this->route('cutting');
|
||||
$status = CuttingStatus::tryFrom((string) $this->input('status'));
|
||||
|
||||
if ($status === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $cutting->status->canTransitionTo($status)) {
|
||||
$validator->errors()->add('status', 'Status cutting tidak dapat diubah.');
|
||||
}
|
||||
|
||||
if ($status === CuttingStatus::REJECTED && trim((string) $this->input('reason')) === '') {
|
||||
$validator->errors()->add('reason', 'Alasan penolakan wajib diisi.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
60
app/Models/Cutting.php
Normal file
60
app/Models/Cutting.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\CuttingStatus;
|
||||
use App\Models\Concerns\HasRejection;
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends([
|
||||
'status_label',
|
||||
'created_at_formatted',
|
||||
])]
|
||||
class Cutting extends Model
|
||||
{
|
||||
use HasRejection;
|
||||
use SoftDeletes;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => CuttingStatus::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function statusLabel(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->status->label(),
|
||||
);
|
||||
}
|
||||
|
||||
public function createdAtFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'),
|
||||
);
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_id');
|
||||
}
|
||||
|
||||
public function materials(): HasMany
|
||||
{
|
||||
return $this->hasMany(CuttingMaterial::class);
|
||||
}
|
||||
|
||||
public function results(): HasMany
|
||||
{
|
||||
return $this->hasMany(CuttingResult::class);
|
||||
}
|
||||
}
|
||||
85
app/Models/CuttingMaterial.php
Normal file
85
app/Models/CuttingMaterial.php
Normal file
@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends([
|
||||
'material_usage_formatted',
|
||||
'material_usage_input',
|
||||
'remaining_material_formatted',
|
||||
'remaining_material_input',
|
||||
'unit_abbreviation',
|
||||
])]
|
||||
class CuttingMaterial extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'material_usage' => 'decimal:4',
|
||||
'remaining_material' => 'decimal:4',
|
||||
];
|
||||
}
|
||||
|
||||
public function materialUsageFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->formatQuantity($this->material_usage),
|
||||
);
|
||||
}
|
||||
|
||||
public function materialUsageInput(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->formatQuantityInput($this->material_usage),
|
||||
);
|
||||
}
|
||||
|
||||
public function remainingMaterialFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->formatQuantity($this->remaining_material),
|
||||
);
|
||||
}
|
||||
|
||||
public function remainingMaterialInput(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->formatQuantityInput($this->remaining_material),
|
||||
);
|
||||
}
|
||||
|
||||
public function unitAbbreviation(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->rawMaterialPrice?->rawMaterial?->unit?->abbreviation(),
|
||||
);
|
||||
}
|
||||
|
||||
public function cutting(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cutting::class);
|
||||
}
|
||||
|
||||
public function rawMaterialPrice(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(RawMaterialPrice::class);
|
||||
}
|
||||
|
||||
private function formatQuantity(float|string|null $value): string
|
||||
{
|
||||
$formatted = rtrim(rtrim(number_format((float) $value, 4, ',', '.'), '0'), ',');
|
||||
|
||||
return "{$formatted} {$this->unitAbbreviation}";
|
||||
}
|
||||
|
||||
private function formatQuantityInput(float|string|null $value): string
|
||||
{
|
||||
return rtrim(rtrim(number_format((float) $value, 4, '.', ''), '0'), '.');
|
||||
}
|
||||
}
|
||||
30
app/Models/CuttingResult.php
Normal file
30
app/Models/CuttingResult.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
class CuttingResult extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'cutting_result' => 'integer',
|
||||
'warehouse_stock' => 'integer',
|
||||
'cutting_reject' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function cutting(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cutting::class);
|
||||
}
|
||||
|
||||
public function productVariant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductVariant::class);
|
||||
}
|
||||
}
|
||||
@ -47,4 +47,9 @@ public function orderItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(OrderItem::class);
|
||||
}
|
||||
|
||||
public function cuttingResults(): HasMany
|
||||
{
|
||||
return $this->hasMany(CuttingResult::class);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
|
||||
@ -72,4 +73,9 @@ public function rawMaterial(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(RawMaterial::class);
|
||||
}
|
||||
|
||||
public function cuttingMaterials(): HasMany
|
||||
{
|
||||
return $this->hasMany(CuttingMaterial::class);
|
||||
}
|
||||
}
|
||||
|
||||
458
app/Services/Manage/CuttingService.php
Normal file
458
app/Services/Manage/CuttingService.php
Normal file
@ -0,0 +1,458 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Manage;
|
||||
|
||||
use App\Enums\CuttingStatus;
|
||||
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 App\Support\Media\MediaPresenter;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class CuttingService
|
||||
{
|
||||
/**
|
||||
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
|
||||
*/
|
||||
public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator
|
||||
{
|
||||
$query = Cutting::query()
|
||||
->with([
|
||||
'createdBy.profile',
|
||||
'rejection.rejectedBy.profile',
|
||||
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||
'results.productVariant.product:id,name',
|
||||
'results.productVariant:id,product_id,name',
|
||||
])
|
||||
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
|
||||
$search = $tableQuery['search'];
|
||||
$query->where(function (Builder $query) use ($search): void {
|
||||
$query->where('description', 'like', "%{$search}%")
|
||||
->orWhereHas('materials.rawMaterialPrice', function (Builder $query) use ($search): void {
|
||||
$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}%"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
|
||||
|
||||
return $query
|
||||
->paginate(10)
|
||||
->withQueryString()
|
||||
->through(function (Cutting $cutting) use ($user) {
|
||||
$actions = collect($cutting->status->availableActions())
|
||||
->filter(fn (array $action) => $user->can($action['permission']))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$cutting->setAttribute('available_actions', $actions);
|
||||
$cutting->setAttribute('is_editable', $cutting->status->isEditable());
|
||||
|
||||
return $cutting;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, RawMaterial>
|
||||
*/
|
||||
public function rawMaterialCatalog(?Cutting $cutting = null): Collection
|
||||
{
|
||||
$selectedPriceIds = $cutting
|
||||
? $cutting->materials()->pluck('raw_material_price_id')->all()
|
||||
: [];
|
||||
|
||||
return RawMaterial::query()
|
||||
->with([
|
||||
'prices' => fn ($query) => $query
|
||||
->orderBy('created_at')
|
||||
->with(['rawMaterial:id,unit', 'media']),
|
||||
])
|
||||
->where(function (Builder $query) use ($selectedPriceIds): void {
|
||||
$query->active();
|
||||
|
||||
if ($selectedPriceIds !== []) {
|
||||
$query->orWhereHas(
|
||||
'prices',
|
||||
fn (Builder $query) => $query->whereIn('id', $selectedPriceIds),
|
||||
);
|
||||
}
|
||||
})
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->each(function (RawMaterial $rawMaterial): void {
|
||||
$rawMaterial->prices->each(function (RawMaterialPrice $price): void {
|
||||
$price->setAttribute(
|
||||
'images',
|
||||
MediaPresenter::collection($price, 'images'),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Product>
|
||||
*/
|
||||
public function productCatalog(?Cutting $cutting = null): Collection
|
||||
{
|
||||
$selectedVariantIds = $cutting
|
||||
? $cutting->results()->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->where('is_active', true);
|
||||
|
||||
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([
|
||||
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||
'materials.rawMaterialPrice.media',
|
||||
'results.productVariant.product:id,name',
|
||||
'results.productVariant.media',
|
||||
'rejection.rejectedBy.profile',
|
||||
]);
|
||||
|
||||
$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'),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return $cutting;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
public function create(array $validated, User $user): Cutting
|
||||
{
|
||||
return DB::transaction(function () use ($validated, $user): Cutting {
|
||||
$materials = $this->buildMaterials($validated['materials']);
|
||||
$results = $this->buildResults($validated['results']);
|
||||
|
||||
$cutting = Cutting::create([
|
||||
'status' => CuttingStatus::IN_PROGRESS,
|
||||
'description' => $validated['description'] ?? null,
|
||||
'created_by_id' => $user->id,
|
||||
]);
|
||||
|
||||
foreach ($materials as $materialData) {
|
||||
$cutting->materials()->create($materialData);
|
||||
}
|
||||
|
||||
foreach ($results as $resultData) {
|
||||
$cutting->results()->create($resultData);
|
||||
}
|
||||
|
||||
return $cutting;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
public function update(Cutting $cutting, array $validated): void
|
||||
{
|
||||
if (! $cutting->status->isEditable()) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => 'Cutting tidak dapat diubah.',
|
||||
]);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($cutting, $validated): void {
|
||||
$cutting->materials()->delete();
|
||||
$cutting->results()->delete();
|
||||
|
||||
$materials = $this->buildMaterials($validated['materials']);
|
||||
$results = $this->buildResults($validated['results']);
|
||||
|
||||
$cutting->description = $validated['description'] ?? null;
|
||||
$cutting->save();
|
||||
|
||||
foreach ($materials as $materialData) {
|
||||
$cutting->materials()->create($materialData);
|
||||
}
|
||||
|
||||
foreach ($results as $resultData) {
|
||||
$cutting->results()->create($resultData);
|
||||
}
|
||||
|
||||
if ($cutting->status === CuttingStatus::REJECTED) {
|
||||
$cutting->rejection()?->delete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(Cutting $cutting): void
|
||||
{
|
||||
if ($cutting->status !== CuttingStatus::IN_PROGRESS) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => 'Cutting hanya dapat dihapus saat masih proses.',
|
||||
]);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($cutting): void {
|
||||
$cutting->materials()->delete();
|
||||
$cutting->results()->delete();
|
||||
$cutting->delete();
|
||||
});
|
||||
}
|
||||
|
||||
public function transitionStatus(Cutting $cutting, CuttingStatus $status, ?string $reason = null): void
|
||||
{
|
||||
if (! $cutting->status->canTransitionTo($status)) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => 'Status cutting tidak dapat diubah.',
|
||||
]);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($cutting, $status, $reason): void {
|
||||
$cutting->load(['materials', 'results']);
|
||||
|
||||
if ($status === CuttingStatus::COMPLETED) {
|
||||
$this->applyMaterialStockOnComplete($cutting);
|
||||
}
|
||||
|
||||
if ($status === CuttingStatus::VERIFIED) {
|
||||
$this->applyProductStockOnVerify($cutting);
|
||||
}
|
||||
|
||||
if ($status === CuttingStatus::REJECTED) {
|
||||
$this->reverseMaterialStock($cutting);
|
||||
$this->storeRejection($cutting, $reason);
|
||||
}
|
||||
|
||||
if ($status === CuttingStatus::IN_PROGRESS && $cutting->status === CuttingStatus::REJECTED) {
|
||||
$cutting->rejection()?->delete();
|
||||
}
|
||||
|
||||
$cutting->status = $status;
|
||||
$cutting->save();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{raw_material_price_id: int, material_usage: float|int|string, remaining_material: float|int|string}> $materials
|
||||
* @return list<array{raw_material_price_id: int, material_usage: float, remaining_material: float}>
|
||||
*/
|
||||
private function buildMaterials(array $materials): array
|
||||
{
|
||||
return collect($materials)
|
||||
->map(function (array $itemData, int $index) {
|
||||
$price = RawMaterialPrice::query()->find($itemData['raw_material_price_id']);
|
||||
|
||||
if ($price === null) {
|
||||
throw ValidationException::withMessages([
|
||||
"materials.{$index}.raw_material_price_id" => 'Bahan baku tidak ditemukan.',
|
||||
]);
|
||||
}
|
||||
|
||||
$materialUsage = (float) $itemData['material_usage'];
|
||||
$remainingMaterial = (float) $itemData['remaining_material'];
|
||||
|
||||
if ($materialUsage <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
"materials.{$index}.material_usage" => 'Pemakaian bahan harus lebih dari 0.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($remainingMaterial < 0) {
|
||||
throw ValidationException::withMessages([
|
||||
"materials.{$index}.remaining_material" => 'Sisa bahan tidak boleh negatif.',
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'raw_material_price_id' => $price->id,
|
||||
'material_usage' => $materialUsage,
|
||||
'remaining_material' => $remainingMaterial,
|
||||
];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{product_variant_id: int, cutting_result: int|string, warehouse_stock: int|string, cutting_reject?: int|string}> $results
|
||||
* @return list<array{product_variant_id: int, cutting_result: int, warehouse_stock: int, cutting_reject: int}>
|
||||
*/
|
||||
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.',
|
||||
]);
|
||||
}
|
||||
|
||||
$cuttingResult = (int) $itemData['cutting_result'];
|
||||
$warehouseStock = (int) $itemData['warehouse_stock'];
|
||||
$cuttingReject = (int) ($itemData['cutting_reject'] ?? 0);
|
||||
|
||||
if ($cuttingResult < 1) {
|
||||
throw ValidationException::withMessages([
|
||||
"results.{$index}.cutting_result" => 'Hasil cutting minimal 1 pcs.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($warehouseStock < 0) {
|
||||
throw ValidationException::withMessages([
|
||||
"results.{$index}.warehouse_stock" => 'Stok gudang tidak boleh negatif.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($cuttingReject < 0) {
|
||||
throw ValidationException::withMessages([
|
||||
"results.{$index}.cutting_reject" => 'Reject tidak boleh negatif.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (($warehouseStock + $cuttingReject) !== $cuttingResult) {
|
||||
throw ValidationException::withMessages([
|
||||
"results.{$index}.cutting_result" => 'Hasil cutting harus sama dengan stok gudang ditambah reject.',
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'product_variant_id' => $variant->id,
|
||||
'cutting_result' => $cuttingResult,
|
||||
'warehouse_stock' => $warehouseStock,
|
||||
'cutting_reject' => $cuttingReject,
|
||||
];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
private function applyMaterialStockOnComplete(Cutting $cutting): void
|
||||
{
|
||||
foreach ($cutting->materials as $material) {
|
||||
$totalTaken = (float) $material->material_usage + (float) $material->remaining_material;
|
||||
$price = RawMaterialPrice::query()->lockForUpdate()->find($material->raw_material_price_id);
|
||||
|
||||
if ($price === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'materials' => 'Bahan baku tidak ditemukan.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ((float) $price->stock < $totalTaken) {
|
||||
throw ValidationException::withMessages([
|
||||
'materials' => "Stok {$price->rawMaterial?->name} ({$price->variant}) tidak mencukupi.",
|
||||
]);
|
||||
}
|
||||
|
||||
$price->decrement('stock', $totalTaken);
|
||||
|
||||
if ((float) $material->remaining_material > 0) {
|
||||
$price->increment('stock', $material->remaining_material);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function reverseMaterialStock(Cutting $cutting): void
|
||||
{
|
||||
foreach ($cutting->materials as $material) {
|
||||
RawMaterialPrice::query()
|
||||
->whereKey($material->raw_material_price_id)
|
||||
->increment('stock', $material->material_usage);
|
||||
}
|
||||
}
|
||||
|
||||
private function applyProductStockOnVerify(Cutting $cutting): void
|
||||
{
|
||||
foreach ($cutting->results as $result) {
|
||||
if ($result->warehouse_stock < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ProductVariant::query()
|
||||
->whereKey($result->product_variant_id)
|
||||
->increment('stock', $result->warehouse_stock);
|
||||
}
|
||||
}
|
||||
|
||||
private function storeRejection(Cutting $cutting, ?string $reason): void
|
||||
{
|
||||
if ($reason === null || trim($reason) === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'reason' => 'Alasan penolakan wajib diisi.',
|
||||
]);
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
$cutting->rejection()?->delete();
|
||||
|
||||
$cutting->rejection()->create([
|
||||
'reason' => trim($reason),
|
||||
'rejected_by_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
private function applySorting(Builder $query, string $sort, string $direction): void
|
||||
{
|
||||
if (in_array($sort, ['created_at', 'status'], true)) {
|
||||
$query->orderBy($sort, $direction);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->latest();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cuttings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('status')->default('in_progress');
|
||||
$table->text('description')->nullable();
|
||||
$table->foreignId('created_by_id')->constrained('users')->restrictOnDelete();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index('status');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cuttings');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cutting_materials', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('cutting_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('raw_material_price_id')->constrained()->restrictOnDelete();
|
||||
$table->decimal('material_usage', 18, 4);
|
||||
$table->decimal('remaining_material', 18, 4);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['cutting_id', 'raw_material_price_id']);
|
||||
$table->index('cutting_id');
|
||||
$table->index('raw_material_price_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cutting_materials');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cutting_results', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('cutting_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('product_variant_id')->constrained()->restrictOnDelete();
|
||||
$table->unsignedInteger('cutting_result');
|
||||
$table->unsignedInteger('warehouse_stock');
|
||||
$table->unsignedInteger('cutting_reject')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['cutting_id', 'product_variant_id']);
|
||||
$table->index('cutting_id');
|
||||
$table->index('product_variant_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cutting_results');
|
||||
}
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import { Banknote, CalendarDays, Clock, FolderTree, Layers, LayoutDashboard, Package, Receipt, Settings2, ShoppingBag, ShoppingCart, User, UserCheck, Users, Wallet, WalletCards } from '@lucide/vue';
|
||||
import { Banknote, CalendarDays, Clock, FolderTree, Layers, LayoutDashboard, Package, Receipt, Scissors, Settings2, ShoppingBag, ShoppingCart, User, UserCheck, Users, Wallet, WalletCards } from '@lucide/vue';
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
Sidebar,
|
||||
@ -31,7 +31,8 @@ const isSuppliersActive = computed(() => page.url.startsWith('/admin/master/supp
|
||||
const isCustomersActive = computed(() => page.url.startsWith('/admin/master/customers'));
|
||||
const isPurchasesActive = computed(() => page.url.startsWith('/admin/manage/purchases'));
|
||||
const isOrdersActive = computed(() => page.url.startsWith('/admin/manage/orders'));
|
||||
const showManageMenu = computed(() => can('purchases.view') || can('orders.view'));
|
||||
const isCuttingsActive = computed(() => page.url.startsWith('/admin/manage/cuttings'));
|
||||
const showManageMenu = computed(() => can('purchases.view') || can('orders.view') || can('cuttings.view'));
|
||||
const isCashActive = computed(() => page.url.startsWith('/admin/finance/cash'));
|
||||
const isExpensesActive = computed(() => page.url.startsWith('/admin/finance/expenses'));
|
||||
const isEmployeeAdvancesActive = computed(() => page.url.startsWith('/admin/finance/employee-advances'));
|
||||
@ -145,6 +146,14 @@ const showSystemMenu = computed(() => can('settings.view'));
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem v-if="can('cuttings.view')">
|
||||
<SidebarMenuButton as-child tooltip="Cutting" :is-active="isCuttingsActive">
|
||||
<Link href="/admin/manage/cuttings">
|
||||
<Scissors />
|
||||
<span>Cutting</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
@ -0,0 +1,227 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import DataTableActions from '@/components/admin/manage/cuttings/data-table-actions.vue';
|
||||
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type {
|
||||
DataTablePagination,
|
||||
DataTablePaginationLink,
|
||||
} from '@/types/data-table';
|
||||
import type { CuttingListItem } from '@/types/cutting';
|
||||
|
||||
const props = defineProps<{
|
||||
cuttings: CuttingListItem[];
|
||||
firstItem?: number;
|
||||
pagination?: DataTablePagination;
|
||||
paginationLinks?: DataTablePaginationLink[];
|
||||
}>();
|
||||
|
||||
const search = defineModel<string>('search', { default: '' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
'filters-reset': [];
|
||||
}>();
|
||||
|
||||
const showingCount = computed(() => props.cuttings.length);
|
||||
|
||||
const paginationSummary = computed(() => {
|
||||
if (!props.pagination) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { total } = props.pagination;
|
||||
|
||||
if (total === 0) {
|
||||
return 'Menampilkan 0 cutting';
|
||||
}
|
||||
|
||||
return `Menampilkan ${showingCount.value} cutting dari ${total}`;
|
||||
});
|
||||
|
||||
function rowNumber(index: number): number {
|
||||
return (props.firstItem ?? 1) + index;
|
||||
}
|
||||
|
||||
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
if (status === 'verified') {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
if (status === 'rejected') {
|
||||
return 'destructive';
|
||||
}
|
||||
|
||||
if (status === 'completed') {
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
return 'outline';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<DataTableToolbar v-model:search="search" @filters-reset="emit('filters-reset')" />
|
||||
|
||||
<div v-if="cuttings.length" class="space-y-4">
|
||||
<div v-for="(cutting, index) 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">
|
||||
<span class="text-muted-foreground w-8 shrink-0 pt-0.5 text-center text-sm tabular-nums">
|
||||
{{ rowNumber(index) }}
|
||||
</span>
|
||||
<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="statusVariant(cutting.status)">
|
||||
{{ cutting.status_label }}
|
||||
</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>
|
||||
<p v-if="cutting.rejection?.reason" class="text-destructive text-sm">
|
||||
Alasan ditolak: {{ cutting.rejection.reason }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5">
|
||||
<DataTableActions :cutting="cutting" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-0 lg:grid-cols-2">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead colspan="4" class="bg-muted/20">
|
||||
Bahan Baku
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>Bahan</TableHead>
|
||||
<TableHead>Varian</TableHead>
|
||||
<TableHead>Pemakaian</TableHead>
|
||||
<TableHead>Sisa</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="!cutting.materials.length" :key="`${cutting.id}-material-empty`">
|
||||
<TableCell colspan="4" class="text-muted-foreground">
|
||||
Belum ada bahan baku
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-for="material in cutting.materials" :key="material.id">
|
||||
<TableCell class="font-medium">
|
||||
{{ material.raw_material_price?.raw_material?.name }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{{ material.raw_material_price?.variant }}
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ material.material_usage_formatted }}
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ material.remaining_material_formatted }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead colspan="5" class="bg-muted/20">
|
||||
Hasil Produk
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>Produk</TableHead>
|
||||
<TableHead>Varian</TableHead>
|
||||
<TableHead>Hasil</TableHead>
|
||||
<TableHead>Gudang</TableHead>
|
||||
<TableHead>Reject</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 }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{{ result.product_variant?.name }}
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ result.cutting_result }} pcs
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ result.warehouse_stock }} pcs
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ result.cutting_reject }} pcs
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-md border px-6 py-10">
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Data tidak ditemukan</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Silakan lakukan pencarian untuk menemukan data yang Anda cari.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
|
||||
<div v-if="pagination" class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ paginationSummary }}
|
||||
</p>
|
||||
|
||||
<div v-if="paginationLinks?.length && pagination.lastPage > 1"
|
||||
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end">
|
||||
<Button v-for="link in paginationLinks" :key="`${link.label}-${link.url}`" variant="outline" size="sm"
|
||||
:disabled="!link.url || link.active" as-child>
|
||||
<Link v-if="link.url" :href="link.url" preserve-scroll>
|
||||
<span v-html="link.label" />
|
||||
</Link>
|
||||
<span v-else v-html="link.label" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
506
resources/js/components/admin/manage/cuttings/CuttingPosForm.vue
Normal file
506
resources/js/components/admin/manage/cuttings/CuttingPosForm.vue
Normal file
@ -0,0 +1,506 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { Plus, Save, Scissors, Search, Trash2 } from '@lucide/vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
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 {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSet,
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type {
|
||||
CuttingMaterialCartItem,
|
||||
CuttingProductCatalogItem,
|
||||
CuttingRawMaterialCatalogItem,
|
||||
CuttingResultCartItem,
|
||||
} from '@/types/cutting';
|
||||
|
||||
const props = defineProps<{
|
||||
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
||||
productCatalog: CuttingProductCatalogItem[];
|
||||
initialData?: {
|
||||
description: string;
|
||||
materials: CuttingMaterialCartItem[];
|
||||
results: CuttingResultCartItem[];
|
||||
};
|
||||
submitUrl: string;
|
||||
method: 'post' | 'put';
|
||||
submitLabel: string;
|
||||
}>();
|
||||
|
||||
const materialSearch = ref('');
|
||||
const productSearch = ref('');
|
||||
const materialCart = ref<CuttingMaterialCartItem[]>([]);
|
||||
const resultCart = ref<CuttingResultCartItem[]>([]);
|
||||
|
||||
const form = useForm({
|
||||
description: '',
|
||||
});
|
||||
|
||||
function populateForm() {
|
||||
if (!props.initialData) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.description = props.initialData.description;
|
||||
materialCart.value = props.initialData.materials.map((item) => ({ ...item }));
|
||||
resultCart.value = props.initialData.results.map((item) => ({ ...item }));
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initialData,
|
||||
() => {
|
||||
populateForm();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
type CatalogPrice = CuttingRawMaterialCatalogItem['prices'][number];
|
||||
|
||||
const filteredRawMaterials = computed(() => {
|
||||
const keyword = materialSearch.value.trim().toLowerCase();
|
||||
|
||||
if (!keyword) {
|
||||
return props.rawMaterialCatalog;
|
||||
}
|
||||
|
||||
return props.rawMaterialCatalog.filter((rawMaterial) =>
|
||||
rawMaterial.name.toLowerCase().includes(keyword)
|
||||
|| rawMaterial.prices.some((price) => price.variant.toLowerCase().includes(keyword)),
|
||||
);
|
||||
});
|
||||
|
||||
const filteredProducts = computed(() => {
|
||||
const keyword = productSearch.value.trim().toLowerCase();
|
||||
|
||||
if (!keyword) {
|
||||
return props.productCatalog;
|
||||
}
|
||||
|
||||
return props.productCatalog.filter((product) =>
|
||||
product.name.toLowerCase().includes(keyword)
|
||||
|| product.variants.some((variant) => variant.name.toLowerCase().includes(keyword)),
|
||||
);
|
||||
});
|
||||
|
||||
function addMaterial(rawMaterial: CuttingRawMaterialCatalogItem, price: CatalogPrice) {
|
||||
const existing = materialCart.value.find(
|
||||
(item) => item.raw_material_price_id === price.id,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
toast.error('Bahan baku ini sudah ditambahkan.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
materialCart.value.push({
|
||||
raw_material_price_id: price.id,
|
||||
raw_material_name: rawMaterial.name,
|
||||
variant: price.variant,
|
||||
unit_abbreviation: rawMaterial.unit_abbreviation,
|
||||
stock_input: price.stock_input,
|
||||
material_usage: '1',
|
||||
remaining_material: '0',
|
||||
images: price.images ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
function removeMaterial(index: number) {
|
||||
materialCart.value.splice(index, 1);
|
||||
}
|
||||
|
||||
function addResult(product: CuttingProductCatalogItem, variant: CuttingProductCatalogItem['variants'][number]) {
|
||||
const existing = resultCart.value.find(
|
||||
(item) => item.product_variant_id === variant.id,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
toast.error('Varian produk ini sudah ditambahkan.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
resultCart.value.push({
|
||||
product_variant_id: variant.id,
|
||||
product_name: product.name,
|
||||
variant_name: variant.name,
|
||||
stock: variant.stock,
|
||||
cutting_result: '1',
|
||||
warehouse_stock: '1',
|
||||
cutting_reject: '0',
|
||||
images: variant.images ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
function removeResult(index: number) {
|
||||
resultCart.value.splice(index, 1);
|
||||
}
|
||||
|
||||
function syncResultTotals(item: CuttingResultCartItem) {
|
||||
const total = Number(item.cutting_result) || 0;
|
||||
const warehouse = Number(item.warehouse_stock) || 0;
|
||||
const reject = Math.max(total - warehouse, 0);
|
||||
|
||||
item.cutting_reject = String(reject);
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (materialCart.value.length === 0) {
|
||||
toast.error('Tambahkan minimal satu bahan baku.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (resultCart.value.length === 0) {
|
||||
toast.error('Tambahkan minimal satu hasil produk.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of resultCart.value) {
|
||||
const total = Number(item.cutting_result) || 0;
|
||||
const warehouse = Number(item.warehouse_stock) || 0;
|
||||
const reject = Number(item.cutting_reject) || 0;
|
||||
|
||||
if (warehouse + reject !== total) {
|
||||
toast.error(`Hasil cutting ${item.product_name} - ${item.variant_name} harus sama dengan stok gudang + reject.`);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
description: form.description,
|
||||
materials: materialCart.value.map((item) => ({
|
||||
raw_material_price_id: item.raw_material_price_id,
|
||||
material_usage: item.material_usage,
|
||||
remaining_material: item.remaining_material,
|
||||
})),
|
||||
results: resultCart.value.map((item) => ({
|
||||
product_variant_id: item.product_variant_id,
|
||||
cutting_result: item.cutting_result,
|
||||
warehouse_stock: item.warehouse_stock,
|
||||
cutting_reject: item.cutting_reject,
|
||||
})),
|
||||
};
|
||||
|
||||
if (props.method === 'put') {
|
||||
form.transform(() => payload).put(props.submitUrl, {
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
form.transform(() => payload).post(props.submitUrl, {
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-4 xl:grid-cols-[1fr_400px]">
|
||||
<div class="space-y-4">
|
||||
<Card class="min-w-0">
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="text-base">Pilih Bahan Baku</CardTitle>
|
||||
</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="materialSearch" placeholder="Cari bahan baku..." class="pl-9" />
|
||||
</div>
|
||||
|
||||
<div v-if="filteredRawMaterials.length === 0" class="py-8">
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Tidak ada bahan baku ditemukan</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Silakan lakukan pencarian untuk menemukan bahan baku.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="rawMaterial in filteredRawMaterials" :key="rawMaterial.id"
|
||||
class="overflow-hidden rounded-md border">
|
||||
<div class="border-b bg-muted/30 px-4 py-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="font-medium leading-tight">
|
||||
{{ rawMaterial.name }}
|
||||
</h3>
|
||||
<Badge variant="secondary">
|
||||
{{ rawMaterial.unit_label }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Varian</TableHead>
|
||||
<TableHead>Stok</TableHead>
|
||||
<TableHead>Foto</TableHead>
|
||||
<TableHead class="w-24 text-right" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="price in rawMaterial.prices" :key="price.id"
|
||||
class="cursor-pointer hover:bg-muted/30"
|
||||
@click="addMaterial(rawMaterial, price)">
|
||||
<TableCell class="font-medium">
|
||||
{{ price.variant }}
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ price.stock_formatted }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MediaThumbnailCell :items="price.images ?? []" />
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<Button type="button" variant="outline" size="sm"
|
||||
@click.stop="addMaterial(rawMaterial, price)">
|
||||
<Plus class="size-3.5" />
|
||||
Tambah
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="min-w-0">
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="text-base">Pilih Produk Hasil</CardTitle>
|
||||
</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="space-y-4">
|
||||
<div v-for="product in filteredProducts" :key="product.id"
|
||||
class="overflow-hidden rounded-md border">
|
||||
<div class="border-b bg-muted/30 px-4 py-3">
|
||||
<h3 class="font-medium leading-tight">
|
||||
{{ product.name }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Varian</TableHead>
|
||||
<TableHead>Stok</TableHead>
|
||||
<TableHead>Foto</TableHead>
|
||||
<TableHead class="w-24 text-right" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="variant in product.variants" :key="variant.id"
|
||||
class="cursor-pointer hover:bg-muted/30"
|
||||
@click="addResult(product, variant)">
|
||||
<TableCell class="font-medium">
|
||||
{{ variant.name }}
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ variant.stock }} pcs
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MediaThumbnailCell :items="variant.images ?? []" />
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<Button type="button" variant="outline" size="sm"
|
||||
@click.stop="addResult(product, variant)">
|
||||
<Plus class="size-3.5" />
|
||||
Tambah
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card class="h-fit xl:sticky xl:top-4">
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Scissors class="size-4" />
|
||||
Ringkasan Cutting
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form @submit.prevent="submit">
|
||||
<FieldGroup>
|
||||
<FieldSet class="grid gap-4">
|
||||
<Field>
|
||||
<FieldLabel for="description">Keterangan</FieldLabel>
|
||||
<Textarea id="description" v-model="form.description"
|
||||
placeholder="Contoh: Cutting batch pagi" rows="2" />
|
||||
<FieldError :errors="form.errors.description ? [form.errors.description] : []" />
|
||||
</Field>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium">
|
||||
Bahan Baku
|
||||
</p>
|
||||
|
||||
<div v-if="materialCart.length === 0"
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
Belum ada bahan baku dipilih.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="(item, index) in materialCart" :key="item.raw_material_price_id"
|
||||
class="rounded-lg border p-3">
|
||||
<div class="mb-2 flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{{ item.raw_material_name }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-muted-foreground">
|
||||
{{ item.variant }} · Stok {{ item.stock_input }} {{ item.unit_abbreviation }}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon"
|
||||
class="text-destructive hover:text-destructive size-7 shrink-0"
|
||||
@click="removeMaterial(index)">
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Field>
|
||||
<FieldLabel class="text-xs">
|
||||
Pemakaian ({{ item.unit_abbreviation }})
|
||||
</FieldLabel>
|
||||
<Input v-model="item.material_usage" type="number" min="0.0001"
|
||||
step="any" class="h-8" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel class="text-xs">
|
||||
Sisa ({{ item.unit_abbreviation }})
|
||||
</FieldLabel>
|
||||
<Input v-model="item.remaining_material" type="number" min="0"
|
||||
step="any" class="h-8" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium">
|
||||
Hasil Produk
|
||||
</p>
|
||||
|
||||
<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.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<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">
|
||||
<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>
|
||||
<Button type="button" variant="ghost" size="icon"
|
||||
class="text-destructive hover:text-destructive size-7 shrink-0"
|
||||
@click="removeResult(index)">
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<Field>
|
||||
<FieldLabel class="text-xs">Hasil</FieldLabel>
|
||||
<Input v-model="item.cutting_result" type="number" min="1"
|
||||
class="h-8" @change="syncResultTotals(item)" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel class="text-xs">Gudang</FieldLabel>
|
||||
<Input v-model="item.warehouse_stock" type="number" min="0"
|
||||
class="h-8" @change="syncResultTotals(item)" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel class="text-xs">Reject</FieldLabel>
|
||||
<Input v-model="item.cutting_reject" type="number" min="0"
|
||||
class="h-8" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" class="w-full"
|
||||
:disabled="form.processing || materialCart.length === 0 || resultCart.length === 0">
|
||||
<Save class="size-4" />
|
||||
{{ form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
</Button>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,264 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, router, useForm } from '@inertiajs/vue3';
|
||||
import { Check, Pencil, RotateCcw, Scissors, Trash2, X } from '@lucide/vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||
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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import type { CuttingListItem, CuttingStatusAction } from '@/types/cutting';
|
||||
|
||||
const props = defineProps<{
|
||||
cutting: CuttingListItem;
|
||||
}>();
|
||||
|
||||
const { can } = useCan();
|
||||
|
||||
const deleteConfirmOpen = ref(false);
|
||||
const deleteProcessing = ref(false);
|
||||
const statusConfirmOpen = ref(false);
|
||||
const statusProcessing = ref(false);
|
||||
const rejectDialogOpen = ref(false);
|
||||
const pendingAction = ref<CuttingStatusAction | null>(null);
|
||||
|
||||
const rejectForm = useForm({
|
||||
status: 'rejected',
|
||||
reason: '',
|
||||
});
|
||||
|
||||
const availableActions = computed(() => props.cutting.available_actions ?? []);
|
||||
|
||||
const canEdit = computed(() => props.cutting.is_editable && can('cuttings.update'));
|
||||
const canDelete = computed(() => can('cuttings.delete') && props.cutting.status === 'in_progress');
|
||||
|
||||
function canPerformAction(action: CuttingStatusAction): boolean {
|
||||
return can(action.permission);
|
||||
}
|
||||
|
||||
function statusConfirmDescription(action: CuttingStatusAction): string {
|
||||
if (action.status === 'completed') {
|
||||
return 'Cutting akan ditandai selesai. Stok bahan baku akan dipotong dan sisa dikembalikan. Menunggu verifikasi admin toko.';
|
||||
}
|
||||
|
||||
if (action.status === 'verified') {
|
||||
return 'Hasil cutting akan diverifikasi dan stok produk gudang akan ditambahkan.';
|
||||
}
|
||||
|
||||
if (action.status === 'in_progress') {
|
||||
return 'Cutting dikembalikan ke proses untuk diperbaiki.';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function openStatusConfirm(action: CuttingStatusAction) {
|
||||
if (action.status === 'rejected') {
|
||||
rejectForm.reset();
|
||||
rejectForm.clearErrors();
|
||||
rejectDialogOpen.value = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
pendingAction.value = action;
|
||||
statusConfirmOpen.value = true;
|
||||
}
|
||||
|
||||
function transitionStatus() {
|
||||
if (!pendingAction.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
statusProcessing.value = true;
|
||||
|
||||
router.post(`/admin/manage/cuttings/${props.cutting.id}/status`, {
|
||||
status: pendingAction.value.status,
|
||||
}, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
statusConfirmOpen.value = false;
|
||||
pendingAction.value = null;
|
||||
},
|
||||
onError: (errors) => {
|
||||
const message = Object.values(errors)[0];
|
||||
|
||||
toast.error(typeof message === 'string' ? message : 'Gagal memperbarui status cutting.');
|
||||
},
|
||||
onFinish: () => {
|
||||
statusProcessing.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function submitReject() {
|
||||
rejectForm.post(`/admin/manage/cuttings/${props.cutting.id}/status`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
rejectDialogOpen.value = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
const message = Object.values(errors)[0];
|
||||
|
||||
toast.error(typeof message === 'string' ? message : 'Gagal menolak cutting.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
watch(rejectDialogOpen, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
rejectForm.reset();
|
||||
rejectForm.clearErrors();
|
||||
}
|
||||
});
|
||||
|
||||
function destroyCutting() {
|
||||
deleteProcessing.value = true;
|
||||
|
||||
router.delete(`/admin/manage/cuttings/${props.cutting.id}`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
deleteConfirmOpen.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal menghapus cutting.');
|
||||
},
|
||||
onFinish: () => {
|
||||
deleteProcessing.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function actionIcon(status: string) {
|
||||
if (status === 'completed') {
|
||||
return Scissors;
|
||||
}
|
||||
|
||||
if (status === 'verified') {
|
||||
return Check;
|
||||
}
|
||||
|
||||
if (status === 'in_progress') {
|
||||
return RotateCcw;
|
||||
}
|
||||
|
||||
return X;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center justify-end gap-1">
|
||||
<template v-for="action in availableActions" :key="action.status">
|
||||
<Button
|
||||
v-if="canPerformAction(action)"
|
||||
size="sm"
|
||||
:variant="action.destructive ? 'outline' : 'default'"
|
||||
:class="action.destructive ? 'text-destructive hover:text-destructive' : ''"
|
||||
@click="openStatusConfirm(action)"
|
||||
>
|
||||
<component :is="actionIcon(action.status)" class="size-3.5" />
|
||||
{{ action.label }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<Tooltip v-if="canEdit">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="size-8" as-child>
|
||||
<Link :href="`/admin/manage/cuttings/${cutting.id}/edit`">
|
||||
<Pencil class="size-4" />
|
||||
<span class="sr-only">Ubah</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Ubah</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip v-if="canDelete">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-destructive hover:text-destructive size-8"
|
||||
@click="deleteConfirmOpen = true"
|
||||
>
|
||||
<Trash2 class="size-4" />
|
||||
<span class="sr-only">Hapus</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Hapus</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="statusConfirmOpen"
|
||||
:title="pendingAction ? `${pendingAction.label} cutting?` : 'Ubah status cutting?'"
|
||||
:description="pendingAction ? statusConfirmDescription(pendingAction) : ''"
|
||||
:confirm-label="pendingAction?.label ?? 'Konfirmasi'"
|
||||
cancel-label="Batal"
|
||||
:destructive="pendingAction?.destructive ?? false"
|
||||
:loading="statusProcessing"
|
||||
@confirm="transitionStatus"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
v-if="canDelete"
|
||||
v-model:open="deleteConfirmOpen"
|
||||
title="Hapus cutting?"
|
||||
description="Data cutting ini akan dihapus permanen."
|
||||
confirm-label="Hapus"
|
||||
cancel-label="Batal"
|
||||
destructive
|
||||
:loading="deleteProcessing"
|
||||
@confirm="destroyCutting"
|
||||
/>
|
||||
|
||||
<Dialog v-model:open="rejectDialogOpen">
|
||||
<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="Contoh: Jumlah barang yang diterima tidak sesuai"
|
||||
rows="3"
|
||||
autofocus
|
||||
/>
|
||||
<FieldError :errors="rejectForm.errors.reason ? [rejectForm.errors.reason] : []" />
|
||||
</Field>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter class="mt-6">
|
||||
<Button type="button" variant="outline" :disabled="rejectForm.processing" @click="rejectDialogOpen = false">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" variant="destructive" :disabled="rejectForm.processing">
|
||||
{{ rejectForm.processing ? 'Menyimpan...' : 'Tolak' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
42
resources/js/pages/admin/manage/cuttings/Create.vue
Normal file
42
resources/js/pages/admin/manage/cuttings/Create.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import { ArrowLeft } from '@lucide/vue';
|
||||
import CuttingPosForm from '@/components/admin/manage/cuttings/CuttingPosForm.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import type { CuttingProductCatalogItem, CuttingRawMaterialCatalogItem } from '@/types/cutting';
|
||||
|
||||
defineProps<{
|
||||
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
||||
productCatalog: CuttingProductCatalogItem[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Tambah Cutting" />
|
||||
|
||||
<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">
|
||||
Tambah Cutting
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" as-child class="shrink-0 self-start sm:self-center">
|
||||
<Link href="/admin/manage/cuttings">
|
||||
<ArrowLeft class="size-4" />
|
||||
Kembali
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CuttingPosForm
|
||||
:raw-material-catalog="rawMaterialCatalog"
|
||||
:product-catalog="productCatalog"
|
||||
submit-url="/admin/manage/cuttings"
|
||||
method="post"
|
||||
submit-label="Simpan"
|
||||
/>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
73
resources/js/pages/admin/manage/cuttings/Edit.vue
Normal file
73
resources/js/pages/admin/manage/cuttings/Edit.vue
Normal file
@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import { ArrowLeft } from '@lucide/vue';
|
||||
import { computed } from 'vue';
|
||||
import CuttingPosForm from '@/components/admin/manage/cuttings/CuttingPosForm.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import type {
|
||||
CuttingEditItem,
|
||||
CuttingProductCatalogItem,
|
||||
CuttingRawMaterialCatalogItem,
|
||||
} from '@/types/cutting';
|
||||
|
||||
const props = defineProps<{
|
||||
cutting: CuttingEditItem;
|
||||
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
||||
productCatalog: CuttingProductCatalogItem[];
|
||||
}>();
|
||||
|
||||
const initialData = computed(() => ({
|
||||
description: props.cutting.description ?? '',
|
||||
materials: props.cutting.materials.map((item) => ({
|
||||
raw_material_price_id: item.raw_material_price_id,
|
||||
raw_material_name: item.raw_material_price?.raw_material?.name ?? '',
|
||||
variant: item.raw_material_price?.variant ?? '',
|
||||
unit_abbreviation: item.raw_material_price?.raw_material?.unit_abbreviation ?? '',
|
||||
stock_input: item.raw_material_price?.stock_input ?? '',
|
||||
material_usage: item.material_usage_input,
|
||||
remaining_material: item.remaining_material_input,
|
||||
images: item.raw_material_price?.images ?? [],
|
||||
})),
|
||||
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,
|
||||
cutting_result: String(item.cutting_result),
|
||||
warehouse_stock: String(item.warehouse_stock),
|
||||
cutting_reject: String(item.cutting_reject),
|
||||
images: item.product_variant?.images ?? [],
|
||||
})),
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Ubah Cutting" />
|
||||
|
||||
<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">
|
||||
Ubah Cutting
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" as-child class="shrink-0 self-start sm:self-center">
|
||||
<Link href="/admin/manage/cuttings">
|
||||
<ArrowLeft class="size-4" />
|
||||
Kembali
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CuttingPosForm
|
||||
:raw-material-catalog="rawMaterialCatalog"
|
||||
:product-catalog="productCatalog"
|
||||
:initial-data="initialData"
|
||||
:submit-url="`/admin/manage/cuttings/${cutting.id}`"
|
||||
method="put"
|
||||
submit-label="Perbarui"
|
||||
/>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
89
resources/js/pages/admin/manage/cuttings/Index.vue
Normal file
89
resources/js/pages/admin/manage/cuttings/Index.vue
Normal file
@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import { Plus } from '@lucide/vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import CuttingGroupedTable from '@/components/admin/manage/cuttings/CuttingGroupedTable.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import type { PaginatedCuttings } from '@/types/cutting';
|
||||
|
||||
const props = defineProps<{
|
||||
cuttings: PaginatedCuttings;
|
||||
filters: {
|
||||
search: string;
|
||||
sort?: string;
|
||||
direction?: 'asc' | 'desc';
|
||||
};
|
||||
}>();
|
||||
|
||||
const { can } = useCan();
|
||||
const search = ref(props.filters.search ?? '');
|
||||
|
||||
const { setSearch, resetFilters, syncFromServer } = useDataTableQuery({
|
||||
url: '/admin/manage/cuttings',
|
||||
initial: { ...props.filters },
|
||||
});
|
||||
|
||||
useDataTableQuerySync(() => props.filters, syncFromServer);
|
||||
|
||||
const tablePagination = computed(() => ({
|
||||
currentPage: props.cuttings.current_page,
|
||||
perPage: props.cuttings.per_page,
|
||||
lastPage: props.cuttings.last_page,
|
||||
total: props.cuttings.total,
|
||||
}));
|
||||
|
||||
const firstItem = computed(() => (
|
||||
props.cuttings.data.length > 0
|
||||
? (props.cuttings.current_page - 1) * props.cuttings.per_page + 1
|
||||
: 0
|
||||
));
|
||||
|
||||
watch(search, (value) => {
|
||||
setSearch(value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.filters.search,
|
||||
(value) => {
|
||||
search.value = value ?? '';
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Cutting" />
|
||||
|
||||
<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">
|
||||
Cutting
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Button v-if="can('cuttings.create')" as-child class="shrink-0 self-start sm:self-center">
|
||||
<Link href="/admin/manage/cuttings/create">
|
||||
<Plus class="size-4" />
|
||||
Tambah
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card class="min-w-0">
|
||||
<CardContent class="min-w-0">
|
||||
<CuttingGroupedTable
|
||||
v-model:search="search"
|
||||
:cuttings="cuttings.data"
|
||||
:first-item="firstItem"
|
||||
:pagination="tablePagination"
|
||||
:pagination-links="cuttings.links"
|
||||
@filters-reset="resetFilters"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
139
resources/js/types/cutting.ts
Normal file
139
resources/js/types/cutting.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import type { MediaItem } from '@/types/media';
|
||||
import type { ProductListItem } from '@/types/product';
|
||||
import type { RawMaterialListItem } from '@/types/raw-material';
|
||||
|
||||
export type CuttingStatusAction = {
|
||||
status: string;
|
||||
label: string;
|
||||
destructive: boolean;
|
||||
permission: string;
|
||||
icon_only: boolean;
|
||||
};
|
||||
|
||||
export type CuttingMaterialListItem = {
|
||||
id: number;
|
||||
material_usage_formatted: string;
|
||||
remaining_material_formatted: string;
|
||||
raw_material_price?: {
|
||||
id: number;
|
||||
variant: string;
|
||||
raw_material?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type CuttingResultListItem = {
|
||||
id: number;
|
||||
cutting_result: number;
|
||||
warehouse_stock: number;
|
||||
cutting_reject: number;
|
||||
product_variant?: {
|
||||
id: number;
|
||||
name: string;
|
||||
product?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type CuttingListItem = {
|
||||
id: number;
|
||||
status: string;
|
||||
status_label: string;
|
||||
description: string | null;
|
||||
is_editable: boolean;
|
||||
available_actions: CuttingStatusAction[];
|
||||
created_at_formatted: string;
|
||||
created_by?: {
|
||||
username: string;
|
||||
profile?: {
|
||||
full_name: string;
|
||||
};
|
||||
};
|
||||
rejection?: {
|
||||
reason: string;
|
||||
rejected_by?: {
|
||||
profile?: {
|
||||
full_name: string;
|
||||
};
|
||||
};
|
||||
} | null;
|
||||
materials: CuttingMaterialListItem[];
|
||||
results: CuttingResultListItem[];
|
||||
};
|
||||
|
||||
export type CuttingMaterialCartItem = {
|
||||
raw_material_price_id: number;
|
||||
raw_material_name: string;
|
||||
variant: string;
|
||||
unit_abbreviation: string;
|
||||
stock_input: string;
|
||||
material_usage: string;
|
||||
remaining_material: string;
|
||||
images?: MediaItem[];
|
||||
};
|
||||
|
||||
export type CuttingResultCartItem = {
|
||||
product_variant_id: number;
|
||||
product_name: string;
|
||||
variant_name: string;
|
||||
stock: number;
|
||||
cutting_result: string;
|
||||
warehouse_stock: string;
|
||||
cutting_reject: string;
|
||||
images?: MediaItem[];
|
||||
};
|
||||
|
||||
export type CuttingEditItem = {
|
||||
id: number;
|
||||
status: string;
|
||||
description: string | null;
|
||||
materials: Array<{
|
||||
raw_material_price_id: number;
|
||||
material_usage_input: string;
|
||||
remaining_material_input: string;
|
||||
raw_material_price?: {
|
||||
variant: string;
|
||||
images?: MediaItem[];
|
||||
raw_material?: {
|
||||
name: string;
|
||||
unit?: {
|
||||
abbreviation?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
results: Array<{
|
||||
product_variant_id: number;
|
||||
cutting_result: number;
|
||||
warehouse_stock: number;
|
||||
cutting_reject: number;
|
||||
product_variant?: {
|
||||
name: string;
|
||||
stock: number;
|
||||
images?: MediaItem[];
|
||||
product?: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export type PaginatedCuttings = {
|
||||
data: CuttingListItem[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
links: Array<{
|
||||
url: string | null;
|
||||
label: string;
|
||||
active: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type CuttingRawMaterialCatalogItem = RawMaterialListItem;
|
||||
export type CuttingProductCatalogItem = ProductListItem;
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Http\Controllers\Admin\Hr\AttendanceController;
|
||||
use App\Http\Controllers\Admin\Hr\EmployeeController;
|
||||
use App\Http\Controllers\Admin\Hr\LeaveRequestController;
|
||||
use App\Http\Controllers\Admin\Manage\CuttingController;
|
||||
use App\Http\Controllers\Admin\Manage\OrderController;
|
||||
use App\Http\Controllers\Admin\Manage\PurchaseController;
|
||||
use App\Http\Controllers\Admin\Master\CategoryController;
|
||||
@ -211,6 +212,35 @@
|
||||
->middleware('permission:'.Permission::ORDERS_DELETE->value)
|
||||
->name('destroy');
|
||||
});
|
||||
|
||||
Route::prefix('cuttings')->name('cuttings.')
|
||||
->middleware('permission:'.Permission::CUTTINGS_VIEW->value)
|
||||
->group(function () {
|
||||
Route::get('/', [CuttingController::class, 'index'])->name('index');
|
||||
|
||||
Route::get('create', [CuttingController::class, 'create'])
|
||||
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
|
||||
->name('create');
|
||||
|
||||
Route::post('/', [CuttingController::class, 'store'])
|
||||
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
|
||||
->name('store');
|
||||
|
||||
Route::get('{cutting}/edit', [CuttingController::class, 'edit'])
|
||||
->middleware('permission:'.Permission::CUTTINGS_UPDATE->value)
|
||||
->name('edit');
|
||||
|
||||
Route::put('{cutting}', [CuttingController::class, 'update'])
|
||||
->middleware('permission:'.Permission::CUTTINGS_UPDATE->value)
|
||||
->name('update');
|
||||
|
||||
Route::post('{cutting}/status', [CuttingController::class, 'transitionStatus'])
|
||||
->name('transition-status');
|
||||
|
||||
Route::delete('{cutting}', [CuttingController::class, 'destroy'])
|
||||
->middleware('permission:'.Permission::CUTTINGS_DELETE->value)
|
||||
->name('destroy');
|
||||
});
|
||||
});
|
||||
|
||||
Route::prefix('system')->name('system.')->group(function () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user