feat: add cutting management functionality with CRUD operations
- Implemented CuttingIndex component for listing and managing cuttings. - Added routes for cutting management in web.php. - Created CuttingTest for testing cutting-related features including authorization, validation, and stock management. - Updated roles create and edit pages to include necessary imports. - Refactored settings and profile pages to streamline imports. - Enhanced permissions checks for cutting management actions.
This commit is contained in:
parent
00a848747f
commit
e7aa582572
73
app/Http/Controllers/Admin/Manage/CuttingController.php
Normal file
73
app/Http/Controllers/Admin/Manage/CuttingController.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Manage;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Manage\CuttingRequest;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Models\Cutting;
|
||||
use App\Services\Admin\Manage\CuttingService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class CuttingController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private CuttingService $service,
|
||||
) {}
|
||||
|
||||
public function index(PaginatedRequest $request): Response
|
||||
{
|
||||
return Inertia::render('admin/manage/cutting/index', [
|
||||
'cuttings' => $this->service->paginated(
|
||||
...$request->validatedWithDefaults(),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): Response
|
||||
{
|
||||
return Inertia::render('admin/manage/cutting/create', [
|
||||
'data' => $this->service->getForCreate(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(CuttingRequest $request): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn () => $this->service->create($request->validated()),
|
||||
'Cutting berhasil ditambahkan.',
|
||||
'admin.manage.cuttings.index',
|
||||
'admin.manage.cuttings.create'
|
||||
);
|
||||
}
|
||||
|
||||
public function edit(Cutting $cutting): Response
|
||||
{
|
||||
return Inertia::render('admin/manage/cutting/edit', [
|
||||
'cutting' => $this->service->getForEdit($cutting),
|
||||
'data' => $this->service->getForCreate(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(CuttingRequest $request, Cutting $cutting): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn () => $this->service->update($cutting, $request->validated()),
|
||||
'Cutting berhasil diperbarui.',
|
||||
'admin.manage.cuttings.index',
|
||||
'admin.manage.cuttings.edit',
|
||||
['cutting' => $cutting]
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(Cutting $cutting): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn () => $this->service->delete($cutting),
|
||||
'Cutting berhasil dihapus.',
|
||||
'admin.manage.cuttings.index'
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -39,7 +39,9 @@ public function share(Request $request): array
|
||||
...parent::share($request),
|
||||
'name' => config('app.name'),
|
||||
'auth' => [
|
||||
'user' => $request->user()?->load('userProfile'),
|
||||
'user' => $request->user()
|
||||
? $request->user()->load('userProfile', 'roles', 'permissions')
|
||||
: null,
|
||||
],
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
'seo' => [
|
||||
|
||||
51
app/Http/Requests/Admin/Manage/CuttingRequest.php
Normal file
51
app/Http/Requests/Admin/Manage/CuttingRequest.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CuttingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'description' => ['nullable', 'string', 'max:100'],
|
||||
'product_name' => ['required', 'string', 'max:255'],
|
||||
'sample' => ['required', 'integer', 'min:0'],
|
||||
'original_outside_sample' => ['required', 'integer', 'min:0'],
|
||||
'cutting_result' => ['required', 'integer', 'min:0'],
|
||||
'materials' => ['required', 'array', 'min:1'],
|
||||
'materials.*.raw_material_price_id' => ['required', 'integer', 'exists:raw_material_prices,id'],
|
||||
'materials.*.material_usage' => ['required', 'integer', 'min:0'],
|
||||
'materials.*.material_result' => ['required', 'integer', 'min:0'],
|
||||
'materials.*.combination_index' => ['nullable', 'integer', 'min:0'],
|
||||
'combinations' => ['nullable', 'array'],
|
||||
'combinations.*.material_result' => ['nullable', 'integer', 'min:0'],
|
||||
'photo_key' => ['nullable', 'string', 'max:500'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'description' => 'Keterangan',
|
||||
'product_name' => 'Nama Produk',
|
||||
'sample' => 'Sample',
|
||||
'original_outside_sample' => 'Diluar Sample',
|
||||
'cutting_result' => 'Hasil',
|
||||
'materials' => 'Bahan Baku',
|
||||
'materials.*.raw_material_price_id' => 'Varian Bahan Baku',
|
||||
'materials.*.material_usage' => 'Pemakaian',
|
||||
'materials.*.material_result' => 'Hasil Material',
|
||||
'materials.*.combination_index' => 'Indeks Kombinasi',
|
||||
'combinations' => 'Kombinasi',
|
||||
'combinations.*.material_result' => 'Hasil Kombinasi',
|
||||
'photo_key' => 'Foto',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -4,48 +4,28 @@
|
||||
|
||||
use App\Enums\CuttingStatus;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
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;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
class Cutting extends Model
|
||||
class Cutting extends Model implements HasMedia
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use HasFactory, InteractsWithMedia, SoftDeletes;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => CuttingStatus::class,
|
||||
'total_material_cost' => 'integer',
|
||||
'sewing_cost' => 'integer',
|
||||
'other_cost' => 'integer',
|
||||
'cost_per_unit' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function cancelled(Builder $query): void
|
||||
{
|
||||
$query->where('status', CuttingStatus::CANCELLED);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function completed(Builder $query): void
|
||||
{
|
||||
$query->where('status', CuttingStatus::COMPLETED);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function inProgress(Builder $query): void
|
||||
{
|
||||
$query->where('status', CuttingStatus::IN_PROGRESS);
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_id');
|
||||
@ -65,9 +45,4 @@ public function cuttingResults(): HasMany
|
||||
{
|
||||
return $this->hasMany(CuttingResult::class);
|
||||
}
|
||||
|
||||
public function submittedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'submitted_by_id');
|
||||
}
|
||||
}
|
||||
|
||||
395
app/Services/Admin/Manage/CuttingService.php
Normal file
395
app/Services/Admin/Manage/CuttingService.php
Normal file
@ -0,0 +1,395 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Manage;
|
||||
|
||||
use App\Models\Cutting;
|
||||
use App\Models\CuttingMaterial;
|
||||
use App\Models\CuttingMaterialCombination;
|
||||
use App\Models\CuttingResult;
|
||||
use App\Models\RawMaterial;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Services\Concerns\RegistersMedia;
|
||||
use App\Services\S3PresignedService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class CuttingService
|
||||
{
|
||||
use RegistersMedia;
|
||||
|
||||
public function __construct(
|
||||
private S3PresignedService $s3Service = new S3PresignedService,
|
||||
) {}
|
||||
|
||||
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
|
||||
{
|
||||
$paginator = Cutting::query()
|
||||
->select('id', 'created_by_id', 'status', 'description', 'total_material_cost', 'cost_per_unit', 'created_at')
|
||||
->with([
|
||||
'createdBy:id',
|
||||
'createdBy.userProfile:id,user_id,full_name',
|
||||
'cuttingResults:id,cutting_id,product_name,cutting_result,sample,original_outside_sample',
|
||||
'cuttingMaterials:id,cutting_id,raw_material_price_id,material_usage,material_result,combination_id',
|
||||
'cuttingMaterials.rawMaterialPrice:id,raw_material_id,variant,price,stock',
|
||||
'cuttingMaterials.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||
'cuttingMaterialCombinations:id,cutting_id,material_result',
|
||||
])
|
||||
->when($search, function ($q) use ($search) {
|
||||
$q->whereHas('cuttingResults', fn ($rq) => $rq->where('product_name', 'like', "%{$search}%"))
|
||||
->orWhere('description', 'like', "%{$search}%");
|
||||
})
|
||||
->orderBy($sort, $direction)
|
||||
->paginate($perPage);
|
||||
|
||||
$paginator->getCollection()->each(function (Cutting $cutting) {
|
||||
$cuttingMedia = $cutting->getFirstMedia('photos');
|
||||
$cutting->photo_url = $cuttingMedia
|
||||
? $this->s3Service->getTemporaryUrl($cuttingMedia->file_name)
|
||||
: null;
|
||||
|
||||
$cutting->cuttingMaterials->each(function (CuttingMaterial $material) {
|
||||
$media = $material->rawMaterialPrice?->getFirstMedia('photos');
|
||||
if ($material->rawMaterialPrice) {
|
||||
$material->rawMaterialPrice->photo_url = $media
|
||||
? $this->s3Service->getTemporaryUrl($media->file_name)
|
||||
: null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
public function getForCreate(): array
|
||||
{
|
||||
return [
|
||||
'rawMaterials' => RawMaterial::query()
|
||||
->select('id', 'name', 'unit', 'is_active')
|
||||
->with([
|
||||
'rawMaterialPrices:id,raw_material_id,variant,price,stock',
|
||||
])
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->each(function (RawMaterial $rawMaterial) {
|
||||
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
|
||||
$media = $price->getFirstMedia('photos');
|
||||
$price->photo_url = $media
|
||||
? $this->s3Service->getTemporaryUrl($media->file_name)
|
||||
: null;
|
||||
});
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
public function getForEdit(Cutting $cutting): array
|
||||
{
|
||||
$cutting->load([
|
||||
'cuttingResults',
|
||||
'cuttingMaterials.rawMaterialPrice.rawMaterial',
|
||||
'cuttingMaterialCombinations',
|
||||
]);
|
||||
|
||||
$result = $cutting->cuttingResults->first();
|
||||
|
||||
$materials = $cutting->cuttingMaterials->map(function (CuttingMaterial $material) {
|
||||
$media = $material->rawMaterialPrice?->getFirstMedia('photos');
|
||||
|
||||
return [
|
||||
'id' => $material->id,
|
||||
'raw_material_price_id' => $material->raw_material_price_id,
|
||||
'material_usage' => $material->material_usage,
|
||||
'material_result' => $material->material_result,
|
||||
'combination_id' => $material->combination_id,
|
||||
'variant' => $material->rawMaterialPrice?->variant,
|
||||
'photo_url' => $media
|
||||
? $this->s3Service->getTemporaryUrl($media->file_name)
|
||||
: null,
|
||||
];
|
||||
});
|
||||
|
||||
$combinations = $cutting->cuttingMaterialCombinations->map(function (CuttingMaterialCombination $combination) use ($materials) {
|
||||
$materialIndices = $materials
|
||||
->filter(fn ($m) => $m['combination_id'] === $combination->id)
|
||||
->keys()
|
||||
->values();
|
||||
|
||||
return [
|
||||
'id' => $combination->id,
|
||||
'material_result' => $combination->material_result,
|
||||
'material_indices' => $materialIndices,
|
||||
];
|
||||
});
|
||||
|
||||
$cuttingMedia = $cutting->getFirstMedia('photos');
|
||||
$photoKey = $cuttingMedia?->file_name;
|
||||
$photoUrl = $cuttingMedia
|
||||
? $this->s3Service->getTemporaryUrl($cuttingMedia->file_name)
|
||||
: null;
|
||||
|
||||
return [
|
||||
'id' => $cutting->id,
|
||||
'status' => $cutting->status->value,
|
||||
'description' => $cutting->description,
|
||||
'product_name' => $result?->product_name ?? '',
|
||||
'sample' => $result?->sample ?? 0,
|
||||
'original_outside_sample' => $result?->original_outside_sample ?? 0,
|
||||
'cutting_result' => $result?->cutting_result ?? 0,
|
||||
'materials' => $materials,
|
||||
'combinations' => $combinations,
|
||||
'photo_key' => $photoKey,
|
||||
'photo_url' => $photoUrl,
|
||||
];
|
||||
}
|
||||
|
||||
public function create(array $data): Cutting
|
||||
{
|
||||
foreach ($data['materials'] as $materialData) {
|
||||
$usage = (int) ($materialData['material_usage'] ?? 0);
|
||||
if ($usage <= 0) {
|
||||
continue;
|
||||
}
|
||||
$price = RawMaterialPrice::find($materialData['raw_material_price_id']);
|
||||
if (! $price) {
|
||||
continue;
|
||||
}
|
||||
if ($usage > $price->stock) {
|
||||
throw ValidationException::withMessages([
|
||||
'materials' => "Stok {$price->variant} tidak mencukupi. Tersedia: {$price->stock}, dibutuhkan: {$usage}.",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($data) {
|
||||
$cutting = Cutting::create([
|
||||
'created_by_id' => auth()->id(),
|
||||
'status' => 'in_progress',
|
||||
'description' => $data['description'] ?? null,
|
||||
]);
|
||||
|
||||
$totalMaterialCost = 0;
|
||||
$now = now();
|
||||
|
||||
$combinations = $data['combinations'] ?? [];
|
||||
$combinationMap = [];
|
||||
|
||||
foreach ($combinations as $index => $combo) {
|
||||
$combination = CuttingMaterialCombination::create([
|
||||
'cutting_id' => $cutting->id,
|
||||
'user_id' => auth()->id(),
|
||||
'material_result' => $combo['material_result'] ?? null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$combinationMap[$index] = $combination->id;
|
||||
}
|
||||
|
||||
foreach ($data['materials'] as $materialData) {
|
||||
$price = RawMaterialPrice::find($materialData['raw_material_price_id']);
|
||||
$materialCost = $price ? $price->price * ($materialData['material_usage'] ?? 0) : 0;
|
||||
$totalMaterialCost += $materialCost;
|
||||
|
||||
$combinationId = null;
|
||||
if (isset($materialData['combination_index']) && isset($combinationMap[$materialData['combination_index']])) {
|
||||
$combinationId = $combinationMap[$materialData['combination_index']];
|
||||
}
|
||||
|
||||
CuttingMaterial::create([
|
||||
'cutting_id' => $cutting->id,
|
||||
'user_id' => auth()->id(),
|
||||
'raw_material_price_id' => $materialData['raw_material_price_id'],
|
||||
'material_usage' => $materialData['material_usage'] ?? 0,
|
||||
'material_result' => $materialData['material_result'] ?? null,
|
||||
'combination_id' => $combinationId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if ($price && ($materialData['material_usage'] ?? 0) > 0) {
|
||||
$price->decrement('stock', (int) $materialData['material_usage']);
|
||||
}
|
||||
}
|
||||
|
||||
$costPerUnit = 0;
|
||||
if (($data['cutting_result'] ?? 0) > 0) {
|
||||
$costPerUnit = (int) ($totalMaterialCost / $data['cutting_result']);
|
||||
}
|
||||
|
||||
$cutting->update([
|
||||
'total_material_cost' => $totalMaterialCost,
|
||||
'cost_per_unit' => $costPerUnit,
|
||||
]);
|
||||
|
||||
CuttingResult::create([
|
||||
'cutting_id' => $cutting->id,
|
||||
'user_id' => auth()->id(),
|
||||
'product_name' => $data['product_name'] ?? null,
|
||||
'sample' => $data['sample'] ?? null,
|
||||
'original_outside_sample' => $data['original_outside_sample'] ?? null,
|
||||
'cutting_result' => $data['cutting_result'] ?? null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if (! empty($data['photo_key'])) {
|
||||
$this->registerMedia(
|
||||
model: $cutting,
|
||||
s3Key: $data['photo_key'],
|
||||
collectionName: 'photos',
|
||||
orderColumn: 1,
|
||||
);
|
||||
}
|
||||
|
||||
return $cutting;
|
||||
});
|
||||
}
|
||||
|
||||
public function update(Cutting $cutting, array $data): Cutting
|
||||
{
|
||||
$cutting->load(['cuttingMaterials.rawMaterialPrice']);
|
||||
|
||||
foreach ($cutting->cuttingMaterials as $oldMaterial) {
|
||||
if ($oldMaterial->material_usage > 0 && $oldMaterial->rawMaterialPrice) {
|
||||
$oldMaterial->rawMaterialPrice->increment('stock', (int) $oldMaterial->material_usage);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($data['materials'] as $materialData) {
|
||||
$usage = (int) ($materialData['material_usage'] ?? 0);
|
||||
if ($usage <= 0) {
|
||||
continue;
|
||||
}
|
||||
$price = RawMaterialPrice::find($materialData['raw_material_price_id']);
|
||||
if (! $price) {
|
||||
continue;
|
||||
}
|
||||
if ($usage > $price->stock) {
|
||||
throw ValidationException::withMessages([
|
||||
'materials' => "Stok {$price->variant} tidak mencukupi. Tersedia: {$price->stock}, dibutuhkan: {$usage}.",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($cutting, $data) {
|
||||
$cutting->load(['cuttingMaterials', 'cuttingMaterialCombinations', 'cuttingResults']);
|
||||
|
||||
$cutting->cuttingResults()->delete();
|
||||
$cutting->cuttingMaterials()->delete();
|
||||
$cutting->cuttingMaterialCombinations()->delete();
|
||||
|
||||
$totalMaterialCost = 0;
|
||||
$now = now();
|
||||
|
||||
$combinations = $data['combinations'] ?? [];
|
||||
$combinationMap = [];
|
||||
|
||||
foreach ($combinations as $index => $combo) {
|
||||
$combination = CuttingMaterialCombination::create([
|
||||
'cutting_id' => $cutting->id,
|
||||
'user_id' => auth()->id(),
|
||||
'material_result' => $combo['material_result'] ?? null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$combinationMap[$index] = $combination->id;
|
||||
}
|
||||
|
||||
foreach ($data['materials'] as $materialData) {
|
||||
$price = RawMaterialPrice::find($materialData['raw_material_price_id']);
|
||||
$materialCost = $price ? $price->price * ($materialData['material_usage'] ?? 0) : 0;
|
||||
$totalMaterialCost += $materialCost;
|
||||
|
||||
$combinationId = null;
|
||||
if (isset($materialData['combination_index']) && isset($combinationMap[$materialData['combination_index']])) {
|
||||
$combinationId = $combinationMap[$materialData['combination_index']];
|
||||
}
|
||||
|
||||
CuttingMaterial::create([
|
||||
'cutting_id' => $cutting->id,
|
||||
'user_id' => auth()->id(),
|
||||
'raw_material_price_id' => $materialData['raw_material_price_id'],
|
||||
'material_usage' => $materialData['material_usage'] ?? 0,
|
||||
'material_result' => $materialData['material_result'] ?? null,
|
||||
'combination_id' => $combinationId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if ($price && ($materialData['material_usage'] ?? 0) > 0) {
|
||||
$price->decrement('stock', (int) $materialData['material_usage']);
|
||||
}
|
||||
}
|
||||
|
||||
$costPerUnit = 0;
|
||||
if (($data['cutting_result'] ?? 0) > 0) {
|
||||
$costPerUnit = (int) ($totalMaterialCost / $data['cutting_result']);
|
||||
}
|
||||
|
||||
$cutting->update([
|
||||
'description' => $data['description'] ?? null,
|
||||
'total_material_cost' => $totalMaterialCost,
|
||||
'cost_per_unit' => $costPerUnit,
|
||||
]);
|
||||
|
||||
CuttingResult::create([
|
||||
'cutting_id' => $cutting->id,
|
||||
'user_id' => auth()->id(),
|
||||
'product_name' => $data['product_name'] ?? null,
|
||||
'sample' => $data['sample'] ?? null,
|
||||
'original_outside_sample' => $data['original_outside_sample'] ?? null,
|
||||
'cutting_result' => $data['cutting_result'] ?? null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$this->syncCuttingPhoto($cutting, $data);
|
||||
|
||||
return $cutting;
|
||||
});
|
||||
}
|
||||
|
||||
private function syncCuttingPhoto(Cutting $cutting, array $data): void
|
||||
{
|
||||
if (! array_key_exists('photo_key', $data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currentKey = $cutting->getFirstMedia('photos')?->file_name;
|
||||
|
||||
if ($data['photo_key'] === $currentKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cutting->clearMediaCollection('photos');
|
||||
|
||||
if (! empty($data['photo_key'])) {
|
||||
$this->registerMedia(
|
||||
model: $cutting,
|
||||
s3Key: $data['photo_key'],
|
||||
collectionName: 'photos',
|
||||
orderColumn: 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(Cutting $cutting): bool
|
||||
{
|
||||
return DB::transaction(function () use ($cutting) {
|
||||
$cutting->load('cuttingMaterials.rawMaterialPrice');
|
||||
|
||||
foreach ($cutting->cuttingMaterials as $material) {
|
||||
if ($material->material_usage > 0 && $material->rawMaterialPrice) {
|
||||
$material->rawMaterialPrice->increment('stock', (int) $material->material_usage);
|
||||
}
|
||||
}
|
||||
|
||||
$cutting->clearMediaCollection('photos');
|
||||
$cutting->cuttingResults()->delete();
|
||||
$cutting->cuttingMaterials()->delete();
|
||||
$cutting->cuttingMaterialCombinations()->delete();
|
||||
$cutting->delete();
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -10,16 +10,12 @@ class CuttingFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
$materialCost = fake()->numberBetween(50000, 5000000);
|
||||
$sewingCost = fake()->numberBetween(10000, 500000);
|
||||
$otherCost = fake()->numberBetween(0, 200000);
|
||||
|
||||
return [
|
||||
'created_by_id' => User::factory(),
|
||||
'status' => fake()->randomElement(['in_progress', 'completed', 'cancelled']),
|
||||
'description' => fake()->sentence(),
|
||||
'total_material_cost' => $materialCost,
|
||||
'sewing_cost' => $sewingCost,
|
||||
'other_cost' => $otherCost,
|
||||
'cost_per_unit' => fake()->numberBetween(1000, 50000),
|
||||
];
|
||||
}
|
||||
|
||||
@ -28,7 +28,10 @@ public function run(): void
|
||||
'attendance' => ['view', 'check-in', 'check-out', 'by-date'],
|
||||
'settings' => ['view', 'update-system', 'update-homepage', 'update-social-media', 'update-marketplace', 'update-hr'],
|
||||
'purchase' => ['view', 'create', 'update', 'delete'],
|
||||
'cutting' => ['view', 'create', 'update', 'delete'],
|
||||
'restock' => ['view', 'create', 'update', 'delete'],
|
||||
'dashboard' => ['attendance', 'revenue', 'expense', 'orders_channel', 'orders_payment', 'orders_marketing', 'orders_status'],
|
||||
'analysis' => ['attendance', 'cash', 'raw_materials', 'product_stock', 'revenue', 'expense', 'profit_gross', 'profit_hpp', 'profit_orders', 'marketing_sales', 'top_suppliers', 'top_products', 'top_customers', 'busy_hours'],
|
||||
];
|
||||
|
||||
foreach ($permissions as $module => $actions) {
|
||||
@ -56,6 +59,7 @@ public function run(): void
|
||||
'Admin Bahan Baku' => array_filter($allPermissions, function ($p) {
|
||||
return str_starts_with($p, 'supplier.')
|
||||
|| str_starts_with($p, 'purchase.')
|
||||
|| str_starts_with($p, 'cutting.')
|
||||
|| str_starts_with($p, 'restock.')
|
||||
|| $p === 'category.view'
|
||||
|| $p === 'customer.view'
|
||||
|
||||
@ -1,33 +1,3 @@
|
||||
import React from 'react';
|
||||
import AppLogo from '@/components/app-logo';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { useCurrentUrl } from '@/hooks/use-current-url';
|
||||
import { dashboard } from '@/routes';
|
||||
import { index as cashAccountsIndex } from '@/routes/admin/finance/cash-accounts';
|
||||
import { index as employeeAdvancesIndex } from '@/routes/admin/finance/employee-advances';
|
||||
import { index as expensesIndex } from '@/routes/admin/finance/expenses';
|
||||
import { current as payrollCurrent } from '@/routes/admin/finance/payroll-periods';
|
||||
import { index as attendancesIndex } from '@/routes/admin/hr/attendances';
|
||||
import { index as employeesIndex } from '@/routes/admin/hr/employees';
|
||||
import { index as leaveRequestsIndex } from '@/routes/admin/hr/leave-requests';
|
||||
import { index as categoriesIndex } from '@/routes/admin/master/categories';
|
||||
import { index as customersIndex } from '@/routes/admin/master/customers';
|
||||
import { index as productsIndex } from '@/routes/admin/master/products';
|
||||
import { index as rawMaterialsIndex } from '@/routes/admin/master/raw-materials';
|
||||
import { index as purchasesIndex } from '@/routes/admin/manage/purchases';
|
||||
import { index as restocksIndex } from '@/routes/admin/manage/restocks';
|
||||
import { index as suppliersIndex } from '@/routes/admin/master/suppliers';
|
||||
import { index as rolesIndex } from '@/routes/admin/settings/roles';
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
@ -53,6 +23,38 @@ import {
|
||||
Users,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import AppLogo from '@/components/app-logo';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { useCurrentUrl } from '@/hooks/use-current-url';
|
||||
import { dashboard } from '@/routes';
|
||||
import admin from '@/routes/admin';
|
||||
import { index as cashAccountsIndex } from '@/routes/admin/finance/cash-accounts';
|
||||
import { index as employeeAdvancesIndex } from '@/routes/admin/finance/employee-advances';
|
||||
import { index as expensesIndex } from '@/routes/admin/finance/expenses';
|
||||
import { current as payrollCurrent } from '@/routes/admin/finance/payroll-periods';
|
||||
import { index as attendancesIndex } from '@/routes/admin/hr/attendances';
|
||||
import { index as employeesIndex } from '@/routes/admin/hr/employees';
|
||||
import { index as leaveRequestsIndex } from '@/routes/admin/hr/leave-requests';
|
||||
import { index as cuttingsIndex } from '@/routes/admin/manage/cuttings';
|
||||
import { index as purchasesIndex } from '@/routes/admin/manage/purchases';
|
||||
import { index as restocksIndex } from '@/routes/admin/manage/restocks';
|
||||
import { index as categoriesIndex } from '@/routes/admin/master/categories';
|
||||
import { index as customersIndex } from '@/routes/admin/master/customers';
|
||||
import { index as productsIndex } from '@/routes/admin/master/products';
|
||||
import { index as rawMaterialsIndex } from '@/routes/admin/master/raw-materials';
|
||||
import { index as suppliersIndex } from '@/routes/admin/master/suppliers';
|
||||
import { index as rolesIndex } from '@/routes/admin/settings/roles';
|
||||
|
||||
type NavMenuItem = { title: string; href: string; icon: LucideIcon };
|
||||
|
||||
@ -78,7 +80,7 @@ const masterItems: NavMenuItem[] = [
|
||||
|
||||
const kelolaItems: NavMenuItem[] = [
|
||||
{ title: 'Belanja', href: purchasesIndex.url(), icon: ShoppingCart },
|
||||
{ title: 'Cutting', href: '#', icon: Scissors },
|
||||
{ title: 'Cutting', href: cuttingsIndex.url(), icon: Scissors },
|
||||
{ title: 'Restock', href: restocksIndex.url(), icon: RefreshCw },
|
||||
{ title: 'Stok Opname', href: '#', icon: ClipboardCheck },
|
||||
];
|
||||
@ -133,7 +135,9 @@ export function AppSidebar() {
|
||||
const { isMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
if (!isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanup = router.on('finish', () => {
|
||||
setOpenMobile(false);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Camera, RotateCcw, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Camera, RotateCcw, X } from 'lucide-react';
|
||||
|
||||
interface CameraCaptureProps {
|
||||
onCapture: (dataUrl: string) => void;
|
||||
@ -20,9 +20,11 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
|
||||
video: { facingMode: 'user', width: 640, height: 480 },
|
||||
});
|
||||
setStream(mediaStream);
|
||||
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = mediaStream;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch {
|
||||
setError(
|
||||
@ -33,13 +35,16 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
|
||||
|
||||
useEffect(() => {
|
||||
startCamera();
|
||||
|
||||
return () => {
|
||||
stream?.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const capture = () => {
|
||||
if (!videoRef.current || !canvasRef.current) return;
|
||||
if (!videoRef.current || !canvasRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const video = videoRef.current;
|
||||
@ -47,7 +52,10 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
|
||||
canvas.height = video.videoHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.translate(canvas.width, 0);
|
||||
ctx.scale(-1, 1);
|
||||
|
||||
@ -63,6 +63,7 @@ function useDebounce(callback: (value: string) => void, delay: number) {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
callback(value);
|
||||
}, delay);
|
||||
@ -102,13 +103,17 @@ export function CardTable<TData>({
|
||||
|
||||
function handleSearchChange(value: string) {
|
||||
setLocalSearch(value);
|
||||
|
||||
if (isServerMode) {
|
||||
handleSearchDebounced(value);
|
||||
}
|
||||
}
|
||||
|
||||
function isItemExpanded(key: number | string): boolean {
|
||||
if (expandedKeys === 'all') return true;
|
||||
if (expandedKeys === 'all') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return expandedKeys.has(key);
|
||||
}
|
||||
|
||||
|
||||
98
resources/js/components/card/attendance-card.tsx
Normal file
98
resources/js/components/card/attendance-card.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import { Clock, LogIn, LogOut } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type TodayAttendance = {
|
||||
id: number;
|
||||
attendance_date: string;
|
||||
check_in_at: string | null;
|
||||
check_out_at: string | null;
|
||||
check_in_photo: string | null;
|
||||
check_out_photo: string | null;
|
||||
check_in_latitude: number;
|
||||
check_in_longitude: number;
|
||||
check_out_latitude: number | null;
|
||||
check_out_longitude: number | null;
|
||||
work_duration_minutes: number | null;
|
||||
} | null;
|
||||
|
||||
type AttendanceCardProps = {
|
||||
todayAttendance: TodayAttendance;
|
||||
isOnLeave: boolean;
|
||||
canCheckIn: boolean;
|
||||
onCheckIn?: () => void;
|
||||
onCheckOut?: () => void;
|
||||
};
|
||||
|
||||
export function AttendanceCard({ todayAttendance, isOnLeave, canCheckIn, onCheckIn, onCheckOut }: AttendanceCardProps) {
|
||||
const hasCheckedIn = !!todayAttendance?.check_in_at;
|
||||
const hasCheckedOut = !!todayAttendance?.check_out_at;
|
||||
|
||||
function formatTime(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<Clock className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle>Presensi Hari Ini</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isOnLeave ? (
|
||||
<div className="rounded-md bg-yellow-50 p-3 text-sm text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400">
|
||||
Anda sedang cuti hari ini.
|
||||
</div>
|
||||
) : !canCheckIn ? (
|
||||
<div className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
|
||||
Anda tidak memiliki akses presensi.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-md bg-muted p-3">
|
||||
<p className="text-xs text-muted-foreground">Masuk</p>
|
||||
<p className="text-lg font-semibold">{formatTime(todayAttendance?.check_in_at ?? null)}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted p-3">
|
||||
<p className="text-xs text-muted-foreground">Pulang</p>
|
||||
<p className="text-lg font-semibold">{formatTime(todayAttendance?.check_out_at ?? null)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{todayAttendance?.work_duration_minutes != null && (
|
||||
<div className="rounded-md bg-muted p-3">
|
||||
<p className="text-xs text-muted-foreground">Durasi Kerja</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{Math.floor(todayAttendance.work_duration_minutes / 60)}j {todayAttendance.work_duration_minutes % 60}m
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{!hasCheckedIn ? (
|
||||
<Button onClick={onCheckIn} className="flex-1">
|
||||
<LogIn className="mr-2 size-4" />
|
||||
Presensi Masuk
|
||||
</Button>
|
||||
) : !hasCheckedOut ? (
|
||||
<Button onClick={onCheckOut} className="flex-1" variant="destructive">
|
||||
<LogOut className="mr-2 size-4" />
|
||||
Presensi Pulang
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex-1 rounded-md bg-green-50 p-3 text-center text-sm text-green-700 dark:bg-green-900/20 dark:text-green-400">
|
||||
Presensi selesai untuk hari ini.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { id } from 'date-fns/locale';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import {
|
||||
@ -11,6 +10,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DatePickerProps {
|
||||
value?: Date | string | null;
|
||||
@ -38,13 +38,22 @@ function DatePicker({
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const date = React.useMemo(() => {
|
||||
if (!value) return undefined;
|
||||
if (value instanceof Date) return value;
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Date(value);
|
||||
}, [value]);
|
||||
|
||||
const formattedDate = React.useMemo(() => {
|
||||
if (!date) return '';
|
||||
if (!date) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return format(date, 'dd MMM yyyy', { locale: id });
|
||||
}, [date]);
|
||||
|
||||
@ -74,8 +83,14 @@ function DatePicker({
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={(date) => {
|
||||
if (min && date < min) return true;
|
||||
if (max && date > max) return true;
|
||||
if (min && date < min) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (max && date > max) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}}
|
||||
initialFocus
|
||||
|
||||
@ -62,6 +62,7 @@ function formatMaxSize(bytes: number): string {
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(0)}KB`;
|
||||
}
|
||||
|
||||
return `${(bytes / (1024 * 1024)).toFixed(0)}MB`;
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Filter, X } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@ -5,7 +6,6 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Filter, X } from 'lucide-react';
|
||||
|
||||
type FilterPopoverProps = {
|
||||
open: boolean;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
interface LocationMapProps {
|
||||
@ -19,7 +19,9 @@ export function LocationMap({
|
||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || mapInstanceRef.current) return;
|
||||
if (!mapRef.current || mapInstanceRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const map = L.map(mapRef.current, {
|
||||
center: [latitude, longitude],
|
||||
|
||||
@ -194,6 +194,7 @@ export function NotificationBell() {
|
||||
className="min-w-0 flex-1 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (notification.url) {
|
||||
window.location.href =
|
||||
notification.url;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
type PhoneNumberInputProps = {
|
||||
name?: string;
|
||||
@ -12,9 +12,11 @@ type PhoneNumberInputProps = {
|
||||
function formatPhone(value: string | null | undefined): string {
|
||||
const digits = (value ?? '').replace(/[^0-9]/g, '');
|
||||
const groups: string[] = [];
|
||||
|
||||
for (let i = 0; i < digits.length; i += 4) {
|
||||
groups.push(digits.slice(i, i + 4));
|
||||
}
|
||||
|
||||
return groups.join(' ');
|
||||
}
|
||||
|
||||
|
||||
@ -47,6 +47,7 @@ export function RupiahInput({
|
||||
|
||||
if (isControlled) {
|
||||
const formatted = formatRupiah(value);
|
||||
|
||||
if (formatted !== displayValue) {
|
||||
setDisplayValue(formatted);
|
||||
lastValidRef.current = value;
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { LogOut, Settings } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
@ -9,8 +11,6 @@ import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
|
||||
import { logout } from '@/routes';
|
||||
import { edit } from '@/routes/profile';
|
||||
import type { User } from '@/types';
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { LogOut, Settings } from 'lucide-react';
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
|
||||
48
resources/js/hooks/use-can.ts
Normal file
48
resources/js/hooks/use-can.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { usePage } from '@inertiajs/react';
|
||||
|
||||
type RoleOrPermission = { name: string } | string;
|
||||
|
||||
type User = {
|
||||
id: number;
|
||||
username?: string;
|
||||
roles?: RoleOrPermission[];
|
||||
permissions?: RoleOrPermission[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type PageProps = {
|
||||
auth: {
|
||||
user?: User;
|
||||
};
|
||||
};
|
||||
|
||||
function extractNames(items?: RoleOrPermission[]): string[] {
|
||||
if (!items) return [];
|
||||
return items.map((item) => (typeof item === 'string' ? item : item.name));
|
||||
}
|
||||
|
||||
export function useCan() {
|
||||
const { auth } = usePage().props as PageProps;
|
||||
const user = auth.user;
|
||||
|
||||
const roleNames = extractNames(user?.roles);
|
||||
const permissionNames = extractNames(user?.permissions);
|
||||
|
||||
function can(permission: string): boolean {
|
||||
if (!user) return false;
|
||||
if (roleNames.includes('developer') || roleNames.includes('owner')) return true;
|
||||
return permissionNames.includes(permission);
|
||||
}
|
||||
|
||||
function hasRole(role: string): boolean {
|
||||
if (!user) return false;
|
||||
return roleNames.includes(role);
|
||||
}
|
||||
|
||||
function hasAnyRole(roles: string[]): boolean {
|
||||
if (!user) return false;
|
||||
return roles.some((role) => roleNames.includes(role));
|
||||
}
|
||||
|
||||
return { can, hasRole, hasAnyRole };
|
||||
}
|
||||
20
resources/js/hooks/use-cutting-draft.ts
Normal file
20
resources/js/hooks/use-cutting-draft.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { useDraftSave } from '@/hooks/use-draft-save';
|
||||
import { clearCuttingDraft, saveCuttingDraft } from '@/lib/cutting-draft';
|
||||
import type { CuttingDraftData } from '@/lib/cutting-draft';
|
||||
|
||||
type DraftType = 'create' | 'edit';
|
||||
|
||||
export function useCuttingDraftSave(
|
||||
type: DraftType,
|
||||
data: CuttingDraftData,
|
||||
userId?: number,
|
||||
delay = 500,
|
||||
) {
|
||||
return useDraftSave({
|
||||
type,
|
||||
data,
|
||||
userId,
|
||||
delay,
|
||||
store: { save: saveCuttingDraft, clear: clearCuttingDraft },
|
||||
});
|
||||
}
|
||||
@ -27,7 +27,9 @@ export function useInfiniteScroll<T>({
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loading || currentPage >= lastPage) return;
|
||||
if (loading || currentPage >= lastPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
@ -42,7 +44,7 @@ export function useInfiniteScroll<T>({
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
only: ['mutations'],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
onSuccess: (page: any) => {
|
||||
const newMutations = (page.props as Record<string, unknown>)
|
||||
.mutations as PaginatedData<T>;
|
||||
@ -61,7 +63,10 @@ export function useInfiniteScroll<T>({
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
|
||||
if (!sentinel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
|
||||
48
resources/js/lib/cutting-draft.ts
Normal file
48
resources/js/lib/cutting-draft.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { createDraftStore } from '@/lib/draft-store';
|
||||
|
||||
export type CuttingDraftData = {
|
||||
productName: string;
|
||||
sample: number;
|
||||
originalOutsideSample: number;
|
||||
notes: string;
|
||||
materials: Array<{
|
||||
raw_material_price_id: number;
|
||||
material_usage: number;
|
||||
material_result: number | null;
|
||||
combination_index: number | null;
|
||||
variant: string;
|
||||
material_name: string;
|
||||
unit: string;
|
||||
photo_url: string | null;
|
||||
}>;
|
||||
combinations: Array<{
|
||||
material_result: number | null;
|
||||
}>;
|
||||
selectedMaterialName?: string;
|
||||
photo?: string;
|
||||
};
|
||||
|
||||
export const cuttingDraftStore =
|
||||
createDraftStore<CuttingDraftData>('cutting-draft');
|
||||
|
||||
export function saveCuttingDraft(
|
||||
type: 'create' | 'edit',
|
||||
data: CuttingDraftData,
|
||||
userId?: number,
|
||||
): boolean {
|
||||
return cuttingDraftStore.save(type, data, userId);
|
||||
}
|
||||
|
||||
export function loadCuttingDraft(
|
||||
type: 'create' | 'edit',
|
||||
userId?: number,
|
||||
): CuttingDraftData | null {
|
||||
return cuttingDraftStore.load(type, userId);
|
||||
}
|
||||
|
||||
export function clearCuttingDraft(
|
||||
type: 'create' | 'edit',
|
||||
userId?: number,
|
||||
): void {
|
||||
cuttingDraftStore.clear(type, userId);
|
||||
}
|
||||
20
resources/js/lib/rupiah.ts
Normal file
20
resources/js/lib/rupiah.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export function formatRupiah(value: number): string {
|
||||
return value.toLocaleString('id-ID');
|
||||
}
|
||||
|
||||
export function formatRupiahShort(value: number): string {
|
||||
if (value >= 1_000_000_000) {
|
||||
return (value / 1_000_000_000).toFixed(1).replace('.0', '') + 'jt';
|
||||
}
|
||||
if (value >= 1_000_000) {
|
||||
return (value / 1_000_000).toFixed(1).replace('.0', '') + 'jt';
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return (value / 1_000).toFixed(0) + 'rb';
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
export function formatRupiahDisplay(value: number): string {
|
||||
return 'Rp' + formatRupiah(value);
|
||||
}
|
||||
@ -1,19 +1,3 @@
|
||||
import { CameraCapture } from '@/components/camera-capture';
|
||||
import { LocationMap } from '@/components/location-map';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
index as attendanceIndex,
|
||||
store,
|
||||
update,
|
||||
} from '@/routes/admin/hr/attendances';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { addMonths, format, subMonths } from 'date-fns';
|
||||
import { id } from 'date-fns/locale';
|
||||
@ -31,6 +15,22 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { CameraCapture } from '@/components/camera-capture';
|
||||
import { LocationMap } from '@/components/location-map';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
index as attendanceIndex,
|
||||
store,
|
||||
update,
|
||||
} from '@/routes/admin/hr/attendances';
|
||||
|
||||
type Attendance = {
|
||||
id: number;
|
||||
@ -85,6 +85,7 @@ function isSameDay(d1: Date, d2: Date): boolean {
|
||||
|
||||
function checkIsToday(date: Date): boolean {
|
||||
const t = new Date();
|
||||
|
||||
return (
|
||||
date.getDate() === t.getDate() &&
|
||||
date.getMonth() === t.getMonth() &&
|
||||
@ -94,6 +95,7 @@ function checkIsToday(date: Date): boolean {
|
||||
|
||||
function isWeekend(date: Date): boolean {
|
||||
const day = date.getDay();
|
||||
|
||||
return day === 0 || day === 6;
|
||||
}
|
||||
|
||||
@ -102,10 +104,14 @@ function isLate(
|
||||
officeHour: number,
|
||||
officeMinute: number,
|
||||
): boolean {
|
||||
if (!checkInAt) return false;
|
||||
if (!checkInAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const d = new Date(checkInAt);
|
||||
const h = d.getHours();
|
||||
const m = d.getMinutes();
|
||||
|
||||
return h > officeHour || (h === officeHour && m > officeMinute);
|
||||
}
|
||||
|
||||
@ -114,18 +120,29 @@ function getLateMinutes(
|
||||
officeHour: number,
|
||||
officeMinute: number,
|
||||
): number {
|
||||
if (!checkInAt || !isLate(checkInAt, officeHour, officeMinute)) return 0;
|
||||
if (!checkInAt || !isLate(checkInAt, officeHour, officeMinute)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const d = new Date(checkInAt);
|
||||
const officeStart = new Date(d);
|
||||
officeStart.setHours(officeHour, officeMinute, 0, 0);
|
||||
|
||||
return Math.ceil((d.getTime() - officeStart.getTime()) / 60000);
|
||||
}
|
||||
|
||||
function formatMinutes(minutes: number | null): string {
|
||||
if (!minutes) return '-';
|
||||
if (!minutes) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = Math.floor(minutes % 60);
|
||||
if (mins === 0) return `${hours} jam`;
|
||||
|
||||
if (mins === 0) {
|
||||
return `${hours} jam`;
|
||||
}
|
||||
|
||||
return `${hours} jam ${mins} menit`;
|
||||
}
|
||||
|
||||
@ -161,11 +178,13 @@ export default function AttendanceIndex({
|
||||
attendances.forEach((att) => {
|
||||
dates.set(att.attendance_date, att);
|
||||
});
|
||||
|
||||
return dates;
|
||||
}, [attendances]);
|
||||
|
||||
const selectedAttendance = useMemo(() => {
|
||||
const dateStr = format(selectedDate, 'yyyy-MM-dd');
|
||||
|
||||
return attendanceDates.get(dateStr) ?? null;
|
||||
}, [selectedDate, attendanceDates]);
|
||||
|
||||
@ -199,6 +218,7 @@ export default function AttendanceIndex({
|
||||
}
|
||||
|
||||
const remaining = 42 - days.length;
|
||||
|
||||
for (let i = 1; i <= remaining; i++) {
|
||||
const m = viewMonth === 12 ? 1 : viewMonth + 1;
|
||||
const y = viewMonth === 12 ? viewYear + 1 : viewYear;
|
||||
@ -222,6 +242,7 @@ export default function AttendanceIndex({
|
||||
if (!navigator.geolocation) {
|
||||
setLocationLoading(false);
|
||||
toast.error('Geolocation tidak didukung di browser ini.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -268,7 +289,10 @@ export default function AttendanceIndex({
|
||||
};
|
||||
|
||||
function formatTime(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
if (!dateStr) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return format(new Date(dateStr), 'HH:mm');
|
||||
}
|
||||
|
||||
@ -555,8 +579,10 @@ export default function AttendanceIndex({
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
setSelectedDate(cell.date);
|
||||
if (attendance)
|
||||
setDetailAttendance(attendance);
|
||||
|
||||
if (attendance) {
|
||||
setDetailAttendance(attendance);
|
||||
}
|
||||
}}
|
||||
className={`group relative flex min-h-[100px] flex-col p-2 text-left transition-colors hover:bg-muted/50 ${
|
||||
!cell.isCurrentMonth
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { AlertCircle, ArrowLeft } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { DatePicker } from '@/components/date-picker';
|
||||
import InputError from '@/components/input-error';
|
||||
import { PhoneNumberInput } from '@/components/phone-number-input';
|
||||
@ -17,9 +20,6 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { index as employeeIndex, store } from '@/routes/admin/hr/employees';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { AlertCircle, ArrowLeft } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function EmployeeCreate() {
|
||||
const [joinDate, setJoinDate] = useState<Date | undefined>(undefined);
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { DatePicker } from '@/components/date-picker';
|
||||
import InputError from '@/components/input-error';
|
||||
import { PhoneNumberInput } from '@/components/phone-number-input';
|
||||
@ -16,9 +19,6 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { index as employeeIndex, update } from '@/routes/admin/hr/employees';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
type EmployeeData = {
|
||||
id: number;
|
||||
|
||||
104
resources/js/pages/admin/manage/cutting/columns.tsx
Normal file
104
resources/js/pages/admin/manage/cutting/columns.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
export type CuttingMaterial = {
|
||||
id: number;
|
||||
raw_material_price_id: number;
|
||||
material_usage: number;
|
||||
material_result: number | null;
|
||||
combination_id: number | null;
|
||||
variant_name: string;
|
||||
};
|
||||
|
||||
export type CuttingResult = {
|
||||
id: number;
|
||||
product_name: string | null;
|
||||
cutting_result: number | null;
|
||||
sample: number | null;
|
||||
original_outside_sample: number | null;
|
||||
};
|
||||
|
||||
export type CuttingCombination = {
|
||||
id: number;
|
||||
material_result: number | null;
|
||||
};
|
||||
|
||||
export type Cutting = {
|
||||
id: number;
|
||||
created_by_id: number;
|
||||
status: string;
|
||||
description: string | null;
|
||||
total_material_cost: number | null;
|
||||
cost_per_unit: number | null;
|
||||
photo_url: string | null;
|
||||
created_at: string;
|
||||
created_by: {
|
||||
id: number;
|
||||
user_profile: {
|
||||
full_name: string;
|
||||
};
|
||||
};
|
||||
cutting_results: CuttingResult[];
|
||||
cutting_materials: {
|
||||
id: number;
|
||||
raw_material_price_id: number;
|
||||
material_usage: number;
|
||||
material_result: number | null;
|
||||
combination_id: number | null;
|
||||
raw_material_price: {
|
||||
id: number;
|
||||
variant: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
photo_url: string | null;
|
||||
raw_material: {
|
||||
id: number;
|
||||
name: string;
|
||||
unit: string;
|
||||
};
|
||||
};
|
||||
}[];
|
||||
cutting_material_combinations: {
|
||||
id: number;
|
||||
material_result: number | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type CuttingForEdit = {
|
||||
id: number;
|
||||
status: string;
|
||||
description: string | null;
|
||||
product_name: string;
|
||||
sample: number;
|
||||
original_outside_sample: number;
|
||||
cutting_result: number;
|
||||
materials: {
|
||||
id: number;
|
||||
raw_material_price_id: number;
|
||||
material_usage: number;
|
||||
material_result: number | null;
|
||||
combination_id: number | null;
|
||||
variant: string | null;
|
||||
photo_url: string | null;
|
||||
}[];
|
||||
combinations: {
|
||||
id: number;
|
||||
material_result: number | null;
|
||||
material_indices: number[];
|
||||
}[];
|
||||
photo_key: string | null;
|
||||
photo_url: string | null;
|
||||
};
|
||||
|
||||
export type CuttingCreateData = {
|
||||
rawMaterials: {
|
||||
id: number;
|
||||
name: string;
|
||||
unit: string;
|
||||
is_active: boolean;
|
||||
raw_material_prices: {
|
||||
id: number;
|
||||
variant: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
photo_url: string | null;
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
700
resources/js/pages/admin/manage/cutting/create.tsx
Normal file
700
resources/js/pages/admin/manage/cutting/create.tsx
Normal file
@ -0,0 +1,700 @@
|
||||
'use no memo';
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { FileUpload } from '@/components/file-upload';
|
||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||
import InputError from '@/components/input-error';
|
||||
import { NumberInput } from '@/components/number-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from '@/components/ui/combobox';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCuttingDraftSave } from '@/hooks/use-cutting-draft';
|
||||
import { loadCuttingDraft } from '@/lib/cutting-draft';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
import { getTemporaryUrl } from '@/lib/upload';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { index as cuttingIndex, store } from '@/routes/admin/manage/cuttings';
|
||||
import { Form, Head, usePage } from '@inertiajs/react';
|
||||
import { ArrowLeft, Check, Layers, Plus, ShoppingCart, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import type { CuttingCreateData } from './columns';
|
||||
|
||||
type MaterialState = {
|
||||
raw_material_price_id: number;
|
||||
material_usage: number;
|
||||
material_result: number;
|
||||
combination_id: number | null;
|
||||
variant: string;
|
||||
material_name: string;
|
||||
unit: string;
|
||||
photo_url: string | null;
|
||||
};
|
||||
|
||||
type CombinationState = {
|
||||
material_result: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
data: CuttingCreateData;
|
||||
};
|
||||
|
||||
export default function CuttingCreate({ data }: Props) {
|
||||
const { rawMaterials } = data;
|
||||
const { auth } = usePage().props as { auth: { user?: { id?: number } } };
|
||||
const userId = auth.user?.id;
|
||||
|
||||
const draft = loadCuttingDraft('create', userId);
|
||||
|
||||
const [materials, setMaterials] = useState<MaterialState[]>(() => {
|
||||
if (draft?.materials && draft.materials.length > 0) {
|
||||
return draft.materials.map((m) => ({
|
||||
raw_material_price_id: m.raw_material_price_id,
|
||||
material_usage: m.material_usage,
|
||||
material_result: m.material_result ?? 0,
|
||||
combination_id: m.combination_index ?? null,
|
||||
variant: m.variant,
|
||||
material_name: m.material_name,
|
||||
unit: m.unit,
|
||||
photo_url: m.photo_url,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const [combinations, setCombinations] = useState<CombinationState[]>(() => {
|
||||
if (draft?.combinations && draft.combinations.length > 0) {
|
||||
return draft.combinations.map((c) => ({
|
||||
material_result: c.material_result ?? 0,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const [selectedMaterialName, setSelectedMaterialName] = useState(draft?.selectedMaterialName ?? '');
|
||||
|
||||
const [productName, setProductName] = useState(draft?.productName ?? '');
|
||||
const [sample, setSample] = useState(draft?.sample ?? 0);
|
||||
const [originalOutsideSample, setOriginalOutsideSample] = useState(draft?.originalOutsideSample ?? 0);
|
||||
const cuttingResult = sample + originalOutsideSample;
|
||||
const [notes, setNotes] = useState(draft?.notes ?? '');
|
||||
const [photo, setPhoto] = useState<string | null>(draft?.photo ?? null);
|
||||
const [photoUrl, setPhotoUrl] = useState<string | null>(
|
||||
draft?.photo ? getTemporaryUrl(draft.photo) : null,
|
||||
);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const submittingRef = useRef(false);
|
||||
|
||||
const [cartOpen, setCartOpen] = useState(false);
|
||||
const [previewKey, setPreviewKey] = useState<string | null>(null);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [deleteMaterialIndex, setDeleteMaterialIndex] = useState<number | null>(null);
|
||||
|
||||
const [cartDeleteConfirmOpen, setCartDeleteConfirmOpen] = useState(false);
|
||||
const [cartDeleteIndex, setCartDeleteIndex] = useState<number | null>(null);
|
||||
const [comboDeleteConfirmOpen, setComboDeleteConfirmOpen] = useState(false);
|
||||
const [comboDeleteIndex, setComboDeleteIndex] = useState<number | null>(null);
|
||||
|
||||
const [comboDialogOpen, setComboDialogOpen] = useState(false);
|
||||
const [comboMaterialName, setComboMaterialName] = useState('');
|
||||
const [comboSelectedPriceIds, setComboSelectedPriceIds] = useState<number[]>([]);
|
||||
const [comboResult, setComboResult] = useState(0);
|
||||
|
||||
const comboMaterial = useMemo(
|
||||
() => rawMaterials.find((m) => m.name === comboMaterialName) ?? null,
|
||||
[rawMaterials, comboMaterialName],
|
||||
);
|
||||
|
||||
const draftData = useMemo(
|
||||
() => ({
|
||||
productName,
|
||||
sample,
|
||||
originalOutsideSample,
|
||||
notes,
|
||||
materials: materials.map((m) => ({
|
||||
raw_material_price_id: m.raw_material_price_id,
|
||||
material_usage: m.material_usage,
|
||||
material_result: m.material_result,
|
||||
combination_index: m.combination_id,
|
||||
variant: m.variant,
|
||||
material_name: m.material_name,
|
||||
unit: m.unit,
|
||||
photo_url: m.photo_url,
|
||||
})),
|
||||
combinations: combinations.map((c) => ({
|
||||
material_result: c.material_result,
|
||||
})),
|
||||
selectedMaterialName,
|
||||
photo: photo ?? undefined,
|
||||
}),
|
||||
[productName, sample, originalOutsideSample, notes, materials, combinations, selectedMaterialName, photo],
|
||||
);
|
||||
|
||||
useCuttingDraftSave('create', draftData, userId);
|
||||
|
||||
const materialsRef = useRef(materials);
|
||||
materialsRef.current = materials;
|
||||
|
||||
const priceMap = useMemo(
|
||||
() => new Map(rawMaterials.flatMap((m) => m.raw_material_prices.map((p) => [p.id, p]))),
|
||||
[rawMaterials],
|
||||
);
|
||||
|
||||
const selectedMaterial = useMemo(
|
||||
() => rawMaterials.find((m) => m.name === selectedMaterialName) ?? null,
|
||||
[rawMaterials, selectedMaterialName],
|
||||
);
|
||||
|
||||
const addVariant = useCallback(
|
||||
(priceId: number) => {
|
||||
if (!selectedMaterial) return;
|
||||
const price = selectedMaterial.raw_material_prices.find((p) => p.id === priceId);
|
||||
if (!price) return;
|
||||
|
||||
setMaterials((prev) => {
|
||||
if (prev.some((m) => m.raw_material_price_id === priceId && m.combination_id === null)) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
raw_material_price_id: price.id,
|
||||
material_usage: 0,
|
||||
material_result: 0,
|
||||
combination_id: null,
|
||||
variant: price.variant,
|
||||
material_name: selectedMaterial.name,
|
||||
unit: selectedMaterial.unit,
|
||||
photo_url: price.photo_url,
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
[selectedMaterial],
|
||||
);
|
||||
|
||||
const openComboDialog = useCallback((materialName: string, preSelectPriceId?: number) => {
|
||||
setComboMaterialName(materialName);
|
||||
setComboSelectedPriceIds(preSelectPriceId ? [preSelectPriceId] : []);
|
||||
setComboResult(0);
|
||||
setComboDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const toggleComboPrice = useCallback((priceId: number) => {
|
||||
setComboSelectedPriceIds((prev) =>
|
||||
prev.includes(priceId) ? prev.filter((id) => id !== priceId) : [...prev, priceId],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const confirmCombo = useCallback(() => {
|
||||
if (comboSelectedPriceIds.length < 2) return;
|
||||
|
||||
const comboIndex = combinations.length;
|
||||
|
||||
const newMaterials: MaterialState[] = comboSelectedPriceIds.map((priceId) => {
|
||||
let foundMaterial: typeof rawMaterials[number] | undefined;
|
||||
let foundPrice: typeof rawMaterials[number]['raw_material_prices'][number] | undefined;
|
||||
for (const rm of rawMaterials) {
|
||||
const p = rm.raw_material_prices.find((pp) => pp.id === priceId);
|
||||
if (p) {
|
||||
foundMaterial = rm;
|
||||
foundPrice = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
raw_material_price_id: priceId,
|
||||
material_usage: 0,
|
||||
material_result: 0,
|
||||
combination_id: comboIndex,
|
||||
variant: foundPrice?.variant ?? '',
|
||||
material_name: foundMaterial?.name ?? '',
|
||||
unit: foundMaterial?.unit ?? '',
|
||||
photo_url: foundPrice?.photo_url ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
setMaterials((prev) => [...prev, ...newMaterials]);
|
||||
setCombinations((prev) => [
|
||||
...prev,
|
||||
{
|
||||
material_result: comboResult,
|
||||
},
|
||||
]);
|
||||
|
||||
setComboDialogOpen(false);
|
||||
setComboMaterialName('');
|
||||
setComboSelectedPriceIds([]);
|
||||
setComboResult(0);
|
||||
}, [comboSelectedPriceIds, comboResult, rawMaterials, combinations.length]);
|
||||
|
||||
const removeMaterial = useCallback((index: number) => {
|
||||
setDeleteMaterialIndex(index);
|
||||
setDeleteConfirmOpen(true);
|
||||
}, []);
|
||||
|
||||
const updateMaterial = useCallback(
|
||||
(index: number, field: keyof MaterialState, value: unknown) => {
|
||||
setMaterials((prev) => {
|
||||
const updated = [...prev];
|
||||
(updated[index] as Record<string, unknown>)[field] = value;
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateCombinationResult = useCallback((comboIndex: number, value: number) => {
|
||||
setCombinations((prev) => prev.map((c, i) => (i === comboIndex ? { ...c, material_result: value } : c)));
|
||||
}, []);
|
||||
|
||||
const totalMaterialCost = useMemo(() => {
|
||||
return materials.reduce((sum, m) => {
|
||||
const price = priceMap.get(m.raw_material_price_id);
|
||||
return sum + (price ? price.price * m.material_usage : 0);
|
||||
}, 0);
|
||||
}, [materials, priceMap]);
|
||||
|
||||
const totalCost = totalMaterialCost;
|
||||
const costPerUnit = cuttingResult > 0 ? Math.floor(totalCost / cuttingResult) : 0;
|
||||
|
||||
function formatQuantity(value: number): string {
|
||||
return formatNumber(value, { maximumFractionDigits: 4 });
|
||||
}
|
||||
|
||||
function getPayload() {
|
||||
return {
|
||||
description: notes || null,
|
||||
product_name: productName || null,
|
||||
sample: sample || null,
|
||||
original_outside_sample: originalOutsideSample || null,
|
||||
cutting_result: cuttingResult || null,
|
||||
materials: materialsRef.current.map((m) => ({
|
||||
raw_material_price_id: m.raw_material_price_id,
|
||||
material_usage: m.material_usage,
|
||||
material_result: m.material_result,
|
||||
combination_index: m.combination_id,
|
||||
})),
|
||||
combinations: combinations.map((c) => ({
|
||||
material_result: c.material_result,
|
||||
})),
|
||||
photo_key: photo,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Tambah Cutting" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Tambah Cutting</h2>
|
||||
<Button asChild variant="outline">
|
||||
<a href={cuttingIndex.url()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Kembali
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form action={store()} transform={(data) => ({ ...data, ...getPayload() })} onSubmit={() => { submittingRef.current = true; }}>
|
||||
{({ errors, processing }) => (
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<div className="space-y-6 md:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Pilih Bahan Baku</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Nama Bahan Baku <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Combobox
|
||||
items={rawMaterials}
|
||||
itemToStringLabel={(m) => m.name}
|
||||
value={selectedMaterial}
|
||||
onValueChange={(value) => setSelectedMaterialName(value?.name ?? '')}
|
||||
>
|
||||
<ComboboxInput placeholder="Cari bahan baku..." className="w-full" />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>Tidak ada bahan baku ditemukan.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(m) => (
|
||||
<ComboboxItem key={m.id} value={m}>
|
||||
{m.name} ({m.unit})
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
{selectedMaterial && selectedMaterial.raw_material_prices.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Pilih Varian</Label>
|
||||
<div className="space-y-2">
|
||||
{selectedMaterial.raw_material_prices.map((price) => {
|
||||
const isAdded = materials.some((m) => m.raw_material_price_id === price.id);
|
||||
const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length;
|
||||
return (
|
||||
<div key={price.id} className={isAdded ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{price.photo_url ? (
|
||||
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
|
||||
) : (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{price.variant}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stok: {formatNumber(Number(price.stock))} {selectedMaterial.unit} · {formatCurrency(price.price)}
|
||||
{addedCount > 0 && ` · ×${addedCount}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => addVariant(price.id)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => openComboDialog(selectedMaterial.name, price.id)}>
|
||||
<Layers className="h-4 w-4" />
|
||||
Kombinasi
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 md:col-span-1">
|
||||
<Card className="sticky top-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Ringkasan</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product_name">
|
||||
Nama Produk <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input id="product_name" name="product_name" value={productName} onChange={(e) => setProductName(e.target.value)} placeholder="Masukkan nama produk" />
|
||||
<InputError message={errors.product_name} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sample">
|
||||
Sample <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<NumberInput id="sample" value={sample} onValueChange={setSample} />
|
||||
<InputError message={errors.sample} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="original_outside_sample">
|
||||
Diluar Sample <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<NumberInput id="original_outside_sample" value={originalOutsideSample} onValueChange={setOriginalOutsideSample} />
|
||||
<InputError message={errors.original_outside_sample} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cutting_result">Hasil</Label>
|
||||
<NumberInput id="cutting_result" value={cuttingResult} disabled />
|
||||
<InputError message={errors.cutting_result} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="border-t pt-2">
|
||||
<div className="flex items-center justify-between text-sm font-semibold">
|
||||
<span>Total</span>
|
||||
<span>{formatCurrency(totalCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{cuttingResult > 0 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Biaya Per Unit</span>
|
||||
<span className="font-medium">{formatCurrency(costPerUnit)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="notes">Keterangan</Label>
|
||||
<Textarea id="notes" value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Masukkan keterangan" maxLength={100} />
|
||||
<InputError message={errors.description} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Foto</Label>
|
||||
<FileUpload value={photo} onChange={(key) => { setPhoto(key); setPhotoUrl(key ? getTemporaryUrl(key) : null); }} folder="cutting" existingUrl={photoUrl} onUploadingChange={setUploading} />
|
||||
<InputError message={errors.photo_key} />
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={processing || submittingRef.current || uploading || materials.length === 0 || !productName || !sample}>
|
||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<Button type="button" onClick={() => setCartOpen(true)} className="fixed top-1/2 right-4 z-50 h-14 w-14 -translate-y-1/2 rounded-full shadow-lg" size="icon" aria-label="Buka keranjang cutting">
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
{materials.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-xs font-semibold text-white">
|
||||
{materials.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Sheet open={cartOpen} onOpenChange={setCartOpen}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Keranjang Cutting</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 space-y-3 overflow-y-auto px-6 pb-6">
|
||||
{materials.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keranjang kosong.</p>
|
||||
) : (
|
||||
(() => {
|
||||
const groups: { comboIndex: number | null; items: { m: MaterialState; index: number }[] }[] = [];
|
||||
const comboMap = new Map<number, { m: MaterialState; index: number }[]>();
|
||||
const singleItems: { m: MaterialState; index: number }[] = [];
|
||||
|
||||
materials.forEach((m, i) => {
|
||||
if (m.combination_id !== null) {
|
||||
if (!comboMap.has(m.combination_id)) comboMap.set(m.combination_id, []);
|
||||
comboMap.get(m.combination_id)!.push({ m, index: i });
|
||||
} else {
|
||||
singleItems.push({ m, index: i });
|
||||
}
|
||||
});
|
||||
|
||||
comboMap.forEach((items, comboIdx) => groups.push({ comboIndex: comboIdx, items }));
|
||||
singleItems.forEach((item) => groups.push({ comboIndex: null, items: [item] }));
|
||||
|
||||
return groups.map((group, gi) => (
|
||||
<div key={gi} className="space-y-2 rounded-lg border p-3">
|
||||
{group.comboIndex !== null && (
|
||||
<div className="flex items-center justify-between border-b pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground">Kombinasi {group.comboIndex + 1}</span>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<Label className="text-xs whitespace-nowrap">Hasil <span className="text-destructive">*</span></Label>
|
||||
<NumberInput
|
||||
className="w-20"
|
||||
value={combinations[group.comboIndex]?.material_result ?? 0}
|
||||
onValueChange={(val) => updateCombinationResult(group.comboIndex!, val)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => {
|
||||
setComboDeleteIndex(group.comboIndex);
|
||||
setComboDeleteConfirmOpen(true);
|
||||
}}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{group.items.map(({ m, index }) => {
|
||||
const price = priceMap.get(m.raw_material_price_id);
|
||||
const cartKey = `material-${index}`;
|
||||
return (
|
||||
<div key={cartKey} className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{m.photo_url ? (
|
||||
<button type="button" onClick={() => setPreviewKey(cartKey)} className="block h-8 w-8 shrink-0 overflow-hidden rounded-md border transition-opacity hover:opacity-80">
|
||||
<img src={m.photo_url} alt={m.variant} className="h-full w-full object-cover" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium">{m.material_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{m.variant}{price ? ` · ${formatNumber(Number(price.stock))} ${m.unit}` : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
{group.comboIndex === null && (
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => {
|
||||
setCartDeleteIndex(index);
|
||||
setCartDeleteConfirmOpen(true);
|
||||
}}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">Pemakaian <span className="text-destructive">*</span></span>
|
||||
<NumberInput
|
||||
className="flex-1"
|
||||
value={m.material_usage}
|
||||
onValueChange={(val) => updateMaterial(index, 'material_usage', val)}
|
||||
/>
|
||||
</div>
|
||||
{group.comboIndex === null && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">Hasil <span className="text-destructive">*</span></span>
|
||||
<NumberInput
|
||||
className="flex-1"
|
||||
value={m.material_result}
|
||||
onValueChange={(val) => updateMaterial(index, 'material_result', val)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
));
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<div className="flex items-center justify-between border-t pt-4">
|
||||
<span className="text-sm">Total Pemakaian</span>
|
||||
<span className="text-sm font-semibold">{formatQuantity(materials.reduce((sum, m) => sum + m.material_usage, 0))}</span>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<ImagePreviewModal
|
||||
open={previewKey !== null}
|
||||
onOpenChange={(open) => { if (!open) setPreviewKey(null); }}
|
||||
src={previewKey && previewKey.startsWith('material-') ? (materials[Number(previewKey.replace('material-', ''))]?.photo_url ?? null) : null}
|
||||
title={previewKey && previewKey.startsWith('material-') ? `${materials[Number(previewKey.replace('material-', ''))]?.material_name} — ${materials[Number(previewKey.replace('material-', ''))]?.variant}` : undefined}
|
||||
sources={materials.filter((m) => m.photo_url).map((m) => m.photo_url!)}
|
||||
/>
|
||||
|
||||
{comboDialogOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => setComboDialogOpen(false)} />
|
||||
)}
|
||||
|
||||
<Dialog open={comboDialogOpen} onOpenChange={setComboDialogOpen} modal={false}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tambah Kombinasi</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Nama Bahan Baku <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Combobox
|
||||
items={rawMaterials}
|
||||
itemToStringLabel={(m) => m.name}
|
||||
value={comboMaterial}
|
||||
onValueChange={(value) => setComboMaterialName(value?.name ?? '')}
|
||||
>
|
||||
<ComboboxInput placeholder="Cari bahan baku..." className="w-full" />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>Tidak ada bahan baku ditemukan.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(m) => (
|
||||
<ComboboxItem key={m.id} value={m}>
|
||||
{m.name} ({m.unit})
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
{comboSelectedPriceIds.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Varian Dipilih</Label>
|
||||
<div className="space-y-1">
|
||||
{comboSelectedPriceIds.map((priceId) => {
|
||||
let variantName = '';
|
||||
let materialName = '';
|
||||
let photoUrl: string | null = null;
|
||||
for (const rm of rawMaterials) {
|
||||
const p = rm.raw_material_prices.find((pp) => pp.id === priceId);
|
||||
if (p) {
|
||||
variantName = p.variant;
|
||||
materialName = rm.name;
|
||||
photoUrl = p.photo_url;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div key={priceId} className="flex items-center justify-between gap-2 rounded-md border border-primary bg-primary/5 px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{photoUrl ? (
|
||||
<img src={photoUrl} alt={variantName} className="h-8 w-8 shrink-0 rounded-md object-cover" />
|
||||
) : (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{variantName}</p>
|
||||
<p className="text-xs text-muted-foreground">{materialName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => toggleComboPrice(priceId)}>
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{comboMaterial && comboMaterial.raw_material_prices.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Pilih Varian</Label>
|
||||
<div className="space-y-2">
|
||||
{comboMaterial.raw_material_prices.map((price) => {
|
||||
const isSelected = comboSelectedPriceIds.includes(price.id);
|
||||
return (
|
||||
<div key={price.id} className={`flex items-center justify-between gap-3 rounded-lg border p-3 ${isSelected ? 'border-primary' : ''}`}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{price.photo_url ? (
|
||||
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
|
||||
) : (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{price.variant}</p>
|
||||
<p className="text-xs text-muted-foreground">Stok: {formatNumber(Number(price.stock))} {comboMaterial.unit}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant={isSelected ? 'default' : 'outline'} size="sm" onClick={() => toggleComboPrice(price.id)}>
|
||||
{isSelected ? <><Check className="h-4 w-4" /> Dipilih</> : 'Pilih'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setComboDialogOpen(false)}>Batal</Button>
|
||||
<Button type="button" onClick={confirmCombo} disabled={comboSelectedPriceIds.length < 2}>
|
||||
Konfirmasi
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={deleteConfirmOpen} onOpenChange={(open) => { if (!open) { setDeleteConfirmOpen(false); setDeleteMaterialIndex(null); } }} title="Hapus Bahan Baku" description="Apakah Anda yakin ingin menghapus bahan baku ini?" confirmLabel="Hapus" onConfirm={() => { if (deleteMaterialIndex !== null) setMaterials((prev) => prev.filter((_, i) => i !== deleteMaterialIndex)); setDeleteConfirmOpen(false); setDeleteMaterialIndex(null); }} />
|
||||
|
||||
<ConfirmDialog open={cartDeleteConfirmOpen} onOpenChange={(open) => { if (!open) { setCartDeleteConfirmOpen(false); setCartDeleteIndex(null); } }} title="Hapus dari Keranjang" description="Apakah Anda yakin ingin menghapus item ini dari keranjang?" confirmLabel="Hapus" variant="destructive" onConfirm={() => { if (cartDeleteIndex !== null) setMaterials((prev) => prev.filter((_, i) => i !== cartDeleteIndex)); setCartDeleteConfirmOpen(false); setCartDeleteIndex(null); }} />
|
||||
|
||||
<ConfirmDialog open={comboDeleteConfirmOpen} onOpenChange={(open) => { if (!open) { setComboDeleteConfirmOpen(false); setComboDeleteIndex(null); } }} title="Hapus Kombinasi" description="Apakah Anda yakin ingin menghapus kombinasi ini beserta semua bahannya?" confirmLabel="Hapus" variant="destructive" onConfirm={() => { if (comboDeleteIndex !== null) { setCombinations((prev) => prev.filter((_, i) => i !== comboDeleteIndex)); setMaterials((prev) => prev.filter((m) => m.combination_id !== comboDeleteIndex)); } setComboDeleteConfirmOpen(false); setComboDeleteIndex(null); }} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
146
resources/js/pages/admin/manage/cutting/cutting-card.tsx
Normal file
146
resources/js/pages/admin/manage/cutting/cutting-card.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
||||
import { ImagePreviewButton } from '@/components/image-preview-button';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { formatDateTime, formatNumber } from '@/lib/format';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import type { Cutting } from './columns';
|
||||
|
||||
export type CuttingCardRowParams = {
|
||||
cutting: Cutting;
|
||||
index: number;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onEdit: (cutting: Cutting) => void;
|
||||
onDelete: (cutting: Cutting) => void;
|
||||
};
|
||||
|
||||
export function CuttingCardRow({
|
||||
cutting,
|
||||
index,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: CuttingCardRowParams) {
|
||||
const items = cutting.cutting_materials ?? [];
|
||||
const result = cutting.cutting_results?.[0];
|
||||
const materialCount = items.length;
|
||||
const productName = result?.product_name ?? '-';
|
||||
const totalUsage = items.reduce(
|
||||
(sum, item) => sum + Number(item.material_usage),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-0.5 h-6 w-6 shrink-0"
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{index}.
|
||||
</span>
|
||||
<h3 className="truncate font-medium">
|
||||
{productName}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{materialCount > 0 && (
|
||||
<span>
|
||||
{materialCount} bahan baku
|
||||
</span>
|
||||
)}
|
||||
{cutting.description && (
|
||||
<span className="ml-2">
|
||||
· {cutting.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-foreground">
|
||||
{formatDateTime(cutting.created_at)}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-foreground">
|
||||
{cutting.status === 'completed'
|
||||
? 'Selesai'
|
||||
: cutting.status === 'cancelled'
|
||||
? 'Dibatalkan'
|
||||
: 'Dikerjakan'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
|
||||
<span>
|
||||
<span className="text-muted-foreground">
|
||||
Pemakaian:{' '}
|
||||
</span>
|
||||
{formatNumber(totalUsage)}
|
||||
</span>
|
||||
{result?.cutting_result && (
|
||||
<span>
|
||||
<span className="text-muted-foreground">
|
||||
Hasil:{' '}
|
||||
</span>
|
||||
{formatNumber(result.cutting_result)}
|
||||
</span>
|
||||
)}
|
||||
{cutting.cost_per_unit && (
|
||||
<span className="font-semibold">
|
||||
<span className="font-normal text-muted-foreground">
|
||||
Per Unit:{' '}
|
||||
</span>
|
||||
{formatCurrency(cutting.cost_per_unit)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cutting.photo_url && (
|
||||
<div className="mt-2">
|
||||
<ImagePreviewButton
|
||||
srcs={[cutting.photo_url]}
|
||||
title="Foto Cutting"
|
||||
className="h-16 w-16"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
onClick: () => onEdit(cutting),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () => onDelete(cutting),
|
||||
},
|
||||
]}
|
||||
wrapperClassName="flex shrink-0 items-center gap-1"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
131
resources/js/pages/admin/manage/cutting/cutting-sub-row.tsx
Normal file
131
resources/js/pages/admin/manage/cutting/cutting-sub-row.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import { ImagePreviewButton } from '@/components/image-preview-button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
import type { Cutting } from './columns';
|
||||
|
||||
export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) {
|
||||
const items = cutting.cutting_materials ?? [];
|
||||
const combinations = cutting.cutting_material_combinations ?? [];
|
||||
|
||||
const groupedMaterials = items.reduce(
|
||||
(acc, item) => {
|
||||
const key = item.combination_id ?? `single-${item.id}`;
|
||||
|
||||
if (!acc[key]) {
|
||||
acc[key] = {
|
||||
combination: item.combination_id
|
||||
? (combinations.find((c) => c.id === item.combination_id) ?? null)
|
||||
: null,
|
||||
materials: [],
|
||||
};
|
||||
}
|
||||
|
||||
acc[key].materials.push(item);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<
|
||||
string,
|
||||
{
|
||||
combination: { id: number; material_result: number | null } | null;
|
||||
materials: typeof items;
|
||||
}
|
||||
>,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 overflow-x-auto">
|
||||
{Object.entries(groupedMaterials).map(([key, group]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
{group.combination && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-foreground">
|
||||
Kombinasi
|
||||
</span>
|
||||
{group.combination.material_result !== null && (
|
||||
<span>
|
||||
Hasil: {formatNumber(group.combination.material_result)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">
|
||||
No
|
||||
</TableHead>
|
||||
<TableHead className="w-[60px]">Foto</TableHead>
|
||||
<TableHead>Bahan Baku</TableHead>
|
||||
<TableHead>Varian</TableHead>
|
||||
<TableHead className="text-right">Pemakaian</TableHead>
|
||||
<TableHead className="text-right">Hasil</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.materials.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
Tidak ada item.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
group.materials.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.raw_material_price?.photo_url ? (
|
||||
<ImagePreviewButton
|
||||
srcs={[
|
||||
item.raw_material_price
|
||||
.photo_url,
|
||||
]}
|
||||
title={
|
||||
item.raw_material_price.variant
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
|
||||
N/A
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.raw_material_price?.raw_material?.name ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.raw_material_price?.variant ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(item.material_usage)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{group.combination
|
||||
? '-'
|
||||
: (item.material_result !== null
|
||||
? formatNumber(item.material_result)
|
||||
: '-')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
675
resources/js/pages/admin/manage/cutting/edit.tsx
Normal file
675
resources/js/pages/admin/manage/cutting/edit.tsx
Normal file
@ -0,0 +1,675 @@
|
||||
'use no memo';
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { FileUpload } from '@/components/file-upload';
|
||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||
import InputError from '@/components/input-error';
|
||||
import { NumberInput } from '@/components/number-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from '@/components/ui/combobox';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
import { getTemporaryUrl } from '@/lib/upload';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { index as cuttingIndex, update } from '@/routes/admin/manage/cuttings';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { ArrowLeft, Check, Layers, Plus, ShoppingCart, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import type { CuttingCreateData, CuttingForEdit } from './columns';
|
||||
|
||||
type MaterialState = {
|
||||
id?: number;
|
||||
raw_material_price_id: number;
|
||||
material_usage: number;
|
||||
material_result: number;
|
||||
combination_id: number | null;
|
||||
variant: string;
|
||||
material_name: string;
|
||||
unit: string;
|
||||
photo_url: string | null;
|
||||
};
|
||||
|
||||
type CombinationState = {
|
||||
id?: number;
|
||||
material_result: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
cutting: CuttingForEdit;
|
||||
data: CuttingCreateData;
|
||||
};
|
||||
|
||||
export default function CuttingEdit({ cutting, data }: Props) {
|
||||
const { rawMaterials } = data;
|
||||
|
||||
const [materials, setMaterials] = useState<MaterialState[]>(() => {
|
||||
const comboIdToIndex = new Map<number, number>();
|
||||
cutting.combinations.forEach((c, i) => comboIdToIndex.set(c.id, i));
|
||||
|
||||
return cutting.materials.map((m) => {
|
||||
const rawMaterial = rawMaterials.find((rm) =>
|
||||
rm.raw_material_prices.some((p) => p.id === m.raw_material_price_id),
|
||||
);
|
||||
const price = rawMaterial?.raw_material_prices.find(
|
||||
(p) => p.id === m.raw_material_price_id,
|
||||
);
|
||||
|
||||
return {
|
||||
id: m.id,
|
||||
raw_material_price_id: m.raw_material_price_id,
|
||||
material_usage: m.material_usage,
|
||||
material_result: m.material_result ?? 0,
|
||||
combination_id:
|
||||
m.combination_id !== null
|
||||
? (comboIdToIndex.get(m.combination_id) ?? null)
|
||||
: null,
|
||||
variant: price?.variant ?? m.variant ?? '',
|
||||
material_name: rawMaterial?.name ?? '',
|
||||
unit: rawMaterial?.unit ?? '',
|
||||
photo_url: price?.photo_url ?? m.photo_url ?? null,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const [combinations, setCombinations] = useState<CombinationState[]>(
|
||||
() =>
|
||||
cutting.combinations.map((c) => ({
|
||||
id: c.id,
|
||||
material_result: c.material_result ?? 0,
|
||||
})),
|
||||
);
|
||||
|
||||
const [selectedMaterialName, setSelectedMaterialName] = useState('');
|
||||
|
||||
const [productName, setProductName] = useState(cutting.product_name);
|
||||
const [sample, setSample] = useState(cutting.sample);
|
||||
const [originalOutsideSample, setOriginalOutsideSample] = useState(cutting.original_outside_sample);
|
||||
const cuttingResult = sample + originalOutsideSample;
|
||||
const [notes, setNotes] = useState(cutting.description ?? '');
|
||||
const [photo, setPhoto] = useState<string | null>(cutting.photo_key);
|
||||
const [photoUrl, setPhotoUrl] = useState<string | null>(cutting.photo_url);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const submittingRef = useRef(false);
|
||||
|
||||
const [cartOpen, setCartOpen] = useState(false);
|
||||
const [previewKey, setPreviewKey] = useState<string | null>(null);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [deleteMaterialIndex, setDeleteMaterialIndex] = useState<number | null>(null);
|
||||
|
||||
const [cartDeleteConfirmOpen, setCartDeleteConfirmOpen] = useState(false);
|
||||
const [cartDeleteIndex, setCartDeleteIndex] = useState<number | null>(null);
|
||||
const [comboDeleteConfirmOpen, setComboDeleteConfirmOpen] = useState(false);
|
||||
const [comboDeleteIndex, setComboDeleteIndex] = useState<number | null>(null);
|
||||
|
||||
const [comboDialogOpen, setComboDialogOpen] = useState(false);
|
||||
const [comboMaterialName, setComboMaterialName] = useState('');
|
||||
const [comboSelectedPriceIds, setComboSelectedPriceIds] = useState<number[]>([]);
|
||||
const [comboResult, setComboResult] = useState(0);
|
||||
|
||||
const comboMaterial = useMemo(
|
||||
() => rawMaterials.find((m) => m.name === comboMaterialName) ?? null,
|
||||
[rawMaterials, comboMaterialName],
|
||||
);
|
||||
|
||||
const materialsRef = useRef(materials);
|
||||
materialsRef.current = materials;
|
||||
|
||||
const priceMap = useMemo(
|
||||
() => new Map(rawMaterials.flatMap((m) => m.raw_material_prices.map((p) => [p.id, p]))),
|
||||
[rawMaterials],
|
||||
);
|
||||
|
||||
const selectedMaterial = useMemo(
|
||||
() => rawMaterials.find((m) => m.name === selectedMaterialName) ?? null,
|
||||
[rawMaterials, selectedMaterialName],
|
||||
);
|
||||
|
||||
const addVariant = useCallback(
|
||||
(priceId: number) => {
|
||||
if (!selectedMaterial) return;
|
||||
const price = selectedMaterial.raw_material_prices.find((p) => p.id === priceId);
|
||||
if (!price) return;
|
||||
|
||||
setMaterials((prev) => {
|
||||
if (prev.some((m) => m.raw_material_price_id === priceId && m.combination_id === null)) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
raw_material_price_id: price.id,
|
||||
material_usage: 0,
|
||||
material_result: 0,
|
||||
combination_id: null,
|
||||
variant: price.variant,
|
||||
material_name: selectedMaterial.name,
|
||||
unit: selectedMaterial.unit,
|
||||
photo_url: price.photo_url,
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
[selectedMaterial],
|
||||
);
|
||||
|
||||
const openComboDialog = useCallback((materialName: string, preSelectPriceId?: number) => {
|
||||
setComboMaterialName(materialName);
|
||||
setComboSelectedPriceIds(preSelectPriceId ? [preSelectPriceId] : []);
|
||||
setComboResult(0);
|
||||
setComboDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const toggleComboPrice = useCallback((priceId: number) => {
|
||||
setComboSelectedPriceIds((prev) =>
|
||||
prev.includes(priceId) ? prev.filter((id) => id !== priceId) : [...prev, priceId],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const confirmCombo = useCallback(() => {
|
||||
if (comboSelectedPriceIds.length < 2) return;
|
||||
|
||||
const comboIndex = combinations.length;
|
||||
|
||||
const newMaterials: MaterialState[] = comboSelectedPriceIds.map((priceId) => {
|
||||
let foundMaterial: (typeof rawMaterials)[number] | undefined;
|
||||
let foundPrice: (typeof rawMaterials)[number]['raw_material_prices'][number] | undefined;
|
||||
for (const rm of rawMaterials) {
|
||||
const p = rm.raw_material_prices.find((pp) => pp.id === priceId);
|
||||
if (p) {
|
||||
foundMaterial = rm;
|
||||
foundPrice = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
raw_material_price_id: priceId,
|
||||
material_usage: 0,
|
||||
material_result: 0,
|
||||
combination_id: comboIndex,
|
||||
variant: foundPrice?.variant ?? '',
|
||||
material_name: foundMaterial?.name ?? '',
|
||||
unit: foundMaterial?.unit ?? '',
|
||||
photo_url: foundPrice?.photo_url ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
setMaterials((prev) => [...prev, ...newMaterials]);
|
||||
setCombinations((prev) => [
|
||||
...prev,
|
||||
{
|
||||
material_result: comboResult,
|
||||
},
|
||||
]);
|
||||
|
||||
setComboDialogOpen(false);
|
||||
setComboMaterialName('');
|
||||
setComboSelectedPriceIds([]);
|
||||
setComboResult(0);
|
||||
}, [comboSelectedPriceIds, comboResult, rawMaterials, combinations.length]);
|
||||
|
||||
const updateMaterial = useCallback(
|
||||
(index: number, field: keyof MaterialState, value: unknown) => {
|
||||
setMaterials((prev) => {
|
||||
const updated = [...prev];
|
||||
(updated[index] as Record<string, unknown>)[field] = value;
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateCombinationResult = useCallback((comboIndex: number, value: number) => {
|
||||
setCombinations((prev) => prev.map((c, i) => (i === comboIndex ? { ...c, material_result: value } : c)));
|
||||
}, []);
|
||||
|
||||
const totalMaterialCost = useMemo(() => {
|
||||
return materials.reduce((sum, m) => {
|
||||
const price = priceMap.get(m.raw_material_price_id);
|
||||
return sum + (price ? price.price * m.material_usage : 0);
|
||||
}, 0);
|
||||
}, [materials, priceMap]);
|
||||
|
||||
const totalCost = totalMaterialCost;
|
||||
const costPerUnit = cuttingResult > 0 ? Math.floor(totalCost / cuttingResult) : 0;
|
||||
|
||||
function formatQuantity(value: number): string {
|
||||
return formatNumber(value, { maximumFractionDigits: 4 });
|
||||
}
|
||||
|
||||
function getPayload() {
|
||||
return {
|
||||
description: notes || null,
|
||||
product_name: productName || null,
|
||||
sample: sample || null,
|
||||
original_outside_sample: originalOutsideSample || null,
|
||||
cutting_result: cuttingResult || null,
|
||||
materials: materialsRef.current.map((m) => ({
|
||||
raw_material_price_id: m.raw_material_price_id,
|
||||
material_usage: m.material_usage,
|
||||
material_result: m.material_result,
|
||||
combination_index: m.combination_id,
|
||||
})),
|
||||
combinations: combinations.map((c) => ({
|
||||
material_result: c.material_result,
|
||||
})),
|
||||
photo_key: photo,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Edit Cutting" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Edit Cutting</h2>
|
||||
<Button asChild variant="outline">
|
||||
<a href={cuttingIndex.url()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Kembali
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form action={update(cutting.id)} transform={(data) => ({ ...data, ...getPayload() })} onSubmit={() => { submittingRef.current = true; }}>
|
||||
{({ errors, processing }) => (
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<div className="space-y-6 md:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Pilih Bahan Baku</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Nama Bahan Baku <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Combobox
|
||||
items={rawMaterials}
|
||||
itemToStringLabel={(m) => m.name}
|
||||
value={selectedMaterial}
|
||||
onValueChange={(value) => setSelectedMaterialName(value?.name ?? '')}
|
||||
>
|
||||
<ComboboxInput placeholder="Cari bahan baku..." className="w-full" />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>Tidak ada bahan baku ditemukan.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(m) => (
|
||||
<ComboboxItem key={m.id} value={m}>
|
||||
{m.name} ({m.unit})
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
{selectedMaterial && selectedMaterial.raw_material_prices.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Pilih Varian</Label>
|
||||
<div className="space-y-2">
|
||||
{selectedMaterial.raw_material_prices.map((price) => {
|
||||
const isAdded = materials.some((m) => m.raw_material_price_id === price.id);
|
||||
const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length;
|
||||
return (
|
||||
<div key={price.id} className={isAdded ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{price.photo_url ? (
|
||||
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
|
||||
) : (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{price.variant}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stok: {formatNumber(Number(price.stock))} {selectedMaterial.unit} · {formatCurrency(price.price)}
|
||||
{addedCount > 0 && ` · ×${addedCount}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => addVariant(price.id)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => openComboDialog(selectedMaterial.name, price.id)}>
|
||||
<Layers className="h-4 w-4" />
|
||||
Kombinasi
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 md:col-span-1">
|
||||
<Card className="sticky top-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Ringkasan</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product_name">
|
||||
Nama Produk <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input id="product_name" name="product_name" value={productName} onChange={(e) => setProductName(e.target.value)} placeholder="Masukkan nama produk" />
|
||||
<InputError message={errors.product_name} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sample">
|
||||
Sample <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<NumberInput id="sample" value={sample} onValueChange={setSample} />
|
||||
<InputError message={errors.sample} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="original_outside_sample">
|
||||
Diluar Sample <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<NumberInput id="original_outside_sample" value={originalOutsideSample} onValueChange={setOriginalOutsideSample} />
|
||||
<InputError message={errors.original_outside_sample} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cutting_result">Hasil</Label>
|
||||
<NumberInput id="cutting_result" value={cuttingResult} disabled />
|
||||
<InputError message={errors.cutting_result} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="border-t pt-2">
|
||||
<div className="flex items-center justify-between text-sm font-semibold">
|
||||
<span>Total</span>
|
||||
<span>{formatCurrency(totalCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{cuttingResult > 0 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Biaya Per Unit</span>
|
||||
<span className="font-medium">{formatCurrency(costPerUnit)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="notes">Keterangan</Label>
|
||||
<Textarea id="notes" value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Masukkan keterangan" maxLength={100} />
|
||||
<InputError message={errors.description} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Foto</Label>
|
||||
<FileUpload value={photo} onChange={(key) => { setPhoto(key); setPhotoUrl(key ? getTemporaryUrl(key) : null); }} folder="cutting" existingUrl={photoUrl} onUploadingChange={setUploading} />
|
||||
<InputError message={errors.photo_key} />
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={processing || submittingRef.current || uploading || materials.length === 0 || !productName || !sample}>
|
||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<Button type="button" onClick={() => setCartOpen(true)} className="fixed top-1/2 right-4 z-50 h-14 w-14 -translate-y-1/2 rounded-full shadow-lg" size="icon" aria-label="Buka keranjang cutting">
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
{materials.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-xs font-semibold text-white">
|
||||
{materials.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Sheet open={cartOpen} onOpenChange={setCartOpen}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Keranjang Cutting</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 space-y-3 overflow-y-auto px-6 pb-6">
|
||||
{materials.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keranjang kosong.</p>
|
||||
) : (
|
||||
(() => {
|
||||
const groups: { comboIndex: number | null; items: { m: MaterialState; index: number }[] }[] = [];
|
||||
const comboMap = new Map<number, { m: MaterialState; index: number }[]>();
|
||||
const singleItems: { m: MaterialState; index: number }[] = [];
|
||||
|
||||
materials.forEach((m, i) => {
|
||||
if (m.combination_id !== null) {
|
||||
if (!comboMap.has(m.combination_id)) comboMap.set(m.combination_id, []);
|
||||
comboMap.get(m.combination_id)!.push({ m, index: i });
|
||||
} else {
|
||||
singleItems.push({ m, index: i });
|
||||
}
|
||||
});
|
||||
|
||||
comboMap.forEach((items, comboIdx) => groups.push({ comboIndex: comboIdx, items }));
|
||||
singleItems.forEach((item) => groups.push({ comboIndex: null, items: [item] }));
|
||||
|
||||
return groups.map((group, gi) => (
|
||||
<div key={gi} className="space-y-2 rounded-lg border p-3">
|
||||
{group.comboIndex !== null && (
|
||||
<div className="flex items-center justify-between border-b pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground">Kombinasi {group.comboIndex + 1}</span>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<Label className="text-xs whitespace-nowrap">Hasil <span className="text-destructive">*</span></Label>
|
||||
<NumberInput
|
||||
className="w-20"
|
||||
value={combinations[group.comboIndex]?.material_result ?? 0}
|
||||
onValueChange={(val) => updateCombinationResult(group.comboIndex!, val)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => {
|
||||
setComboDeleteIndex(group.comboIndex);
|
||||
setComboDeleteConfirmOpen(true);
|
||||
}}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{group.items.map(({ m, index }) => {
|
||||
const price = priceMap.get(m.raw_material_price_id);
|
||||
const cartKey = `material-${index}`;
|
||||
return (
|
||||
<div key={cartKey} className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{m.photo_url ? (
|
||||
<button type="button" onClick={() => setPreviewKey(cartKey)} className="block h-8 w-8 shrink-0 overflow-hidden rounded-md border transition-opacity hover:opacity-80">
|
||||
<img src={m.photo_url} alt={m.variant} className="h-full w-full object-cover" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium">{m.material_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{m.variant}{price ? ` · ${formatNumber(Number(price.stock))} ${m.unit}` : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
{group.comboIndex === null && (
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => {
|
||||
setCartDeleteIndex(index);
|
||||
setCartDeleteConfirmOpen(true);
|
||||
}}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">Pemakaian <span className="text-destructive">*</span></span>
|
||||
<NumberInput
|
||||
className="flex-1"
|
||||
value={m.material_usage}
|
||||
onValueChange={(val) => updateMaterial(index, 'material_usage', val)}
|
||||
/>
|
||||
</div>
|
||||
{group.comboIndex === null && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">Hasil <span className="text-destructive">*</span></span>
|
||||
<NumberInput
|
||||
className="flex-1"
|
||||
value={m.material_result}
|
||||
onValueChange={(val) => updateMaterial(index, 'material_result', val)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
));
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<div className="flex items-center justify-between border-t pt-4">
|
||||
<span className="text-sm">Total Pemakaian</span>
|
||||
<span className="text-sm font-semibold">{formatQuantity(materials.reduce((sum, m) => sum + m.material_usage, 0))}</span>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<ImagePreviewModal
|
||||
open={previewKey !== null}
|
||||
onOpenChange={(open) => { if (!open) setPreviewKey(null); }}
|
||||
src={previewKey && previewKey.startsWith('material-') ? (materials[Number(previewKey.replace('material-', ''))]?.photo_url ?? null) : null}
|
||||
title={previewKey && previewKey.startsWith('material-') ? `${materials[Number(previewKey.replace('material-', ''))]?.material_name} — ${materials[Number(previewKey.replace('material-', ''))]?.variant}` : undefined}
|
||||
sources={materials.filter((m) => m.photo_url).map((m) => m.photo_url!)}
|
||||
/>
|
||||
|
||||
{comboDialogOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => setComboDialogOpen(false)} />
|
||||
)}
|
||||
|
||||
<Dialog open={comboDialogOpen} onOpenChange={setComboDialogOpen} modal={false}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tambah Kombinasi</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Nama Bahan Baku <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Combobox
|
||||
items={rawMaterials}
|
||||
itemToStringLabel={(m) => m.name}
|
||||
value={comboMaterial}
|
||||
onValueChange={(value) => setComboMaterialName(value?.name ?? '')}
|
||||
>
|
||||
<ComboboxInput placeholder="Cari bahan baku..." className="w-full" />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>Tidak ada bahan baku ditemukan.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(m) => (
|
||||
<ComboboxItem key={m.id} value={m}>
|
||||
{m.name} ({m.unit})
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
{comboSelectedPriceIds.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Varian Dipilih</Label>
|
||||
<div className="space-y-1">
|
||||
{comboSelectedPriceIds.map((priceId) => {
|
||||
let variantName = '';
|
||||
let materialName = '';
|
||||
let photoUrl: string | null = null;
|
||||
for (const rm of rawMaterials) {
|
||||
const p = rm.raw_material_prices.find((pp) => pp.id === priceId);
|
||||
if (p) {
|
||||
variantName = p.variant;
|
||||
materialName = rm.name;
|
||||
photoUrl = p.photo_url;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div key={priceId} className="flex items-center justify-between gap-2 rounded-md border border-primary bg-primary/5 px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{photoUrl ? (
|
||||
<img src={photoUrl} alt={variantName} className="h-8 w-8 shrink-0 rounded-md object-cover" />
|
||||
) : (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{variantName}</p>
|
||||
<p className="text-xs text-muted-foreground">{materialName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => toggleComboPrice(priceId)}>
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{comboMaterial && comboMaterial.raw_material_prices.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Pilih Varian</Label>
|
||||
<div className="space-y-2">
|
||||
{comboMaterial.raw_material_prices.map((price) => {
|
||||
const isSelected = comboSelectedPriceIds.includes(price.id);
|
||||
return (
|
||||
<div key={price.id} className={`flex items-center justify-between gap-3 rounded-lg border p-3 ${isSelected ? 'border-primary' : ''}`}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{price.photo_url ? (
|
||||
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
|
||||
) : (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{price.variant}</p>
|
||||
<p className="text-xs text-muted-foreground">Stok: {formatNumber(Number(price.stock))} {comboMaterial.unit}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant={isSelected ? 'default' : 'outline'} size="sm" onClick={() => toggleComboPrice(price.id)}>
|
||||
{isSelected ? <><Check className="h-4 w-4" /> Dipilih</> : 'Pilih'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setComboDialogOpen(false)}>Batal</Button>
|
||||
<Button type="button" onClick={confirmCombo} disabled={comboSelectedPriceIds.length < 2}>
|
||||
Konfirmasi
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={deleteConfirmOpen} onOpenChange={(open) => { if (!open) { setDeleteConfirmOpen(false); setDeleteMaterialIndex(null); } }} title="Hapus Bahan Baku" description="Apakah Anda yakin ingin menghapus bahan baku ini?" confirmLabel="Hapus" onConfirm={() => { if (deleteMaterialIndex !== null) setMaterials((prev) => prev.filter((_, i) => i !== deleteMaterialIndex)); setDeleteConfirmOpen(false); setDeleteMaterialIndex(null); }} />
|
||||
|
||||
<ConfirmDialog open={cartDeleteConfirmOpen} onOpenChange={(open) => { if (!open) { setCartDeleteConfirmOpen(false); setCartDeleteIndex(null); } }} title="Hapus dari Keranjang" description="Apakah Anda yakin ingin menghapus item ini dari keranjang?" confirmLabel="Hapus" variant="destructive" onConfirm={() => { if (cartDeleteIndex !== null) setMaterials((prev) => prev.filter((_, i) => i !== cartDeleteIndex)); setCartDeleteConfirmOpen(false); setCartDeleteIndex(null); }} />
|
||||
|
||||
<ConfirmDialog open={comboDeleteConfirmOpen} onOpenChange={(open) => { if (!open) { setComboDeleteConfirmOpen(false); setComboDeleteIndex(null); } }} title="Hapus Kombinasi" description="Apakah Anda yakin ingin menghapus kombinasi ini beserta semua bahannya?" confirmLabel="Hapus" variant="destructive" onConfirm={() => { if (comboDeleteIndex !== null) { setCombinations((prev) => prev.filter((_, i) => i !== comboDeleteIndex)); setMaterials((prev) => prev.filter((m) => m.combination_id !== comboDeleteIndex)); } setComboDeleteConfirmOpen(false); setComboDeleteIndex(null); }} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
132
resources/js/pages/admin/manage/cutting/index.tsx
Normal file
132
resources/js/pages/admin/manage/cutting/index.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { CardTable } from '@/components/card-table';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useServerTable } from '@/hooks/use-server-table';
|
||||
import {
|
||||
destroy,
|
||||
create as cuttingCreate,
|
||||
index as cuttingIndex,
|
||||
edit as cuttingEdit,
|
||||
} from '@/routes/admin/manage/cuttings';
|
||||
import type { Cutting } from './columns';
|
||||
import { CuttingCardRow } from './cutting-card';
|
||||
import { CuttingItemSubRow } from './cutting-sub-row';
|
||||
|
||||
type Props = {
|
||||
cuttings: {
|
||||
data: Cutting[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
|
||||
export default function CuttingIndex({ cuttings }: Props) {
|
||||
const [deleting, setDeleting] = useState<Cutting | null>(null);
|
||||
const expand = useCardTableExpand(true);
|
||||
|
||||
const pagination = {
|
||||
current_page: cuttings.current_page,
|
||||
last_page: cuttings.last_page,
|
||||
per_page: cuttings.per_page,
|
||||
total: cuttings.total,
|
||||
};
|
||||
|
||||
const {
|
||||
search,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
} = useServerTable({
|
||||
route: () => cuttingIndex.url(),
|
||||
pagination,
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.delete(destroy.url(deleting.id), {
|
||||
onSuccess: () => setDeleting(null),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Cutting" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="Cutting"
|
||||
actions={
|
||||
<Button asChild>
|
||||
<a href={cuttingCreate.url()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<CardTable
|
||||
data={cuttings.data}
|
||||
getItemKey={(c) => c.id}
|
||||
expandedKeys={expand.expandedKeys}
|
||||
onToggleExpand={expand.toggleExpand}
|
||||
searchValue={search}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="Cari berdasarkan nama produk..."
|
||||
pagination={pagination}
|
||||
onPageChange={handlePageChange}
|
||||
onPerPageChange={handlePerPageChange}
|
||||
renderCard={({
|
||||
item,
|
||||
index,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
}) => (
|
||||
<CuttingCardRow
|
||||
cutting={item}
|
||||
index={
|
||||
(pagination.current_page - 1) *
|
||||
pagination.per_page +
|
||||
index +
|
||||
1
|
||||
}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onEdit={(c) => {
|
||||
window.location.href = cuttingEdit.url(c.id);
|
||||
}}
|
||||
onDelete={(c) => setDeleting(c)}
|
||||
/>
|
||||
)}
|
||||
renderSubContent={(cutting) => (
|
||||
<CuttingItemSubRow cutting={cutting} />
|
||||
)}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
target={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Cutting"
|
||||
description={(cutting) =>
|
||||
`Apakah Anda yakin ingin menghapus cutting "${cutting.cutting_results?.[0]?.product_name ?? '-'}"? Tindakan ini tidak dapat dibatalkan.`
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@ -5,8 +7,6 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { index as rolesIndex, store } from '@/routes/admin/settings/roles';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
type PermissionsByModule = Record<string, string[]>;
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@ -5,8 +7,6 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { index as rolesIndex, update } from '@/routes/admin/settings/roles';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
type PermissionsByModule = Record<string, string[]>;
|
||||
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { FileUpload } from '@/components/file-upload';
|
||||
import InputError from '@/components/input-error';
|
||||
import { PhoneNumberInput } from '@/components/phone-number-input';
|
||||
@ -18,9 +21,6 @@ import {
|
||||
updateSocialMedia,
|
||||
updateSystem,
|
||||
} from '@/routes/admin/settings';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
type MarketplaceFeeRule = {
|
||||
base: string;
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import InputError from '@/components/input-error';
|
||||
import PasswordInput from '@/components/password-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -6,7 +7,6 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { store } from '@/routes/login';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
|
||||
export default function Login() {
|
||||
return (
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import AppearanceTabs from '@/components/appearance-tabs';
|
||||
import { edit as editAppearance } from '@/routes/appearance';
|
||||
import { Head } from '@inertiajs/react';
|
||||
|
||||
export default function Appearance() {
|
||||
return (
|
||||
|
||||
@ -146,7 +146,7 @@ export default function Permissions() {
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- permission check on mount is safe
|
||||
checkCameraPermission();
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- permission check on mount is safe
|
||||
|
||||
checkLocationPermission();
|
||||
}, [checkCameraPermission, checkLocationPermission]);
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||
import { DatePicker } from '@/components/date-picker';
|
||||
import InputError from '@/components/input-error';
|
||||
@ -9,8 +11,6 @@ import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { edit } from '@/routes/profile';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
type UserData = {
|
||||
id: number;
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { useRef } from 'react';
|
||||
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
|
||||
import InputError from '@/components/input-error';
|
||||
import PasswordInput from '@/components/password-input';
|
||||
@ -5,8 +7,6 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { edit } from '@/routes/security';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { useRef } from 'react';
|
||||
|
||||
type Props = {
|
||||
passwordRules: string;
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Admin\AdminSettingsController;
|
||||
use App\Http\Controllers\Admin\AnalysisController;
|
||||
use App\Http\Controllers\Admin\DashboardController;
|
||||
use App\Http\Controllers\Admin\Finance\CashAccountController;
|
||||
use App\Http\Controllers\Admin\Finance\EmployeeAdvanceController;
|
||||
use App\Http\Controllers\Admin\Finance\ExpenseController;
|
||||
@ -10,6 +12,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\PurchaseController;
|
||||
use App\Http\Controllers\Admin\Manage\RestockController;
|
||||
use App\Http\Controllers\Admin\Master\CategoryController;
|
||||
@ -23,7 +26,7 @@
|
||||
use App\Http\Controllers\Admin\RoleController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', fn () => inertia('welcome', [
|
||||
Route::get('/', fn() => inertia('welcome', [
|
||||
'seo' => [
|
||||
'title' => 'DST Collection - DST Punya Gaya',
|
||||
'description' => 'DST Collection - DST Punya Gaya. Toko fashion terpercaya dengan koleksi terlengkap.',
|
||||
@ -113,8 +116,9 @@
|
||||
|
||||
Route::prefix('admin/manage')->name('admin.manage.')->group(function () {
|
||||
Route::resource('purchases', PurchaseController::class)->except(['show'])->middleware('permission:purchase.view|purchase.create|purchase.update|purchase.delete');
|
||||
Route::resource('cuttings', CuttingController::class)->except(['show'])->middleware('permission:cutting.view|cutting.create|cutting.update|cutting.delete');
|
||||
Route::resource('restocks', RestockController::class)->except(['show'])->middleware('permission:restock.view|restock.create|restock.update|restock.delete');
|
||||
});
|
||||
});
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
require __DIR__ . '/settings.php';
|
||||
|
||||
747
tests/Feature/Admin/Manage/CuttingTest.php
Normal file
747
tests/Feature/Admin/Manage/CuttingTest.php
Normal file
@ -0,0 +1,747 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Cutting;
|
||||
use App\Models\CuttingMaterial;
|
||||
use App\Models\CuttingMaterialCombination;
|
||||
use App\Models\CuttingResult;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HELPERS
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function giveCuttingPermissions(User $user): void
|
||||
{
|
||||
$seeder = new RolePermissionSeeder;
|
||||
$seeder->run();
|
||||
|
||||
$user->givePermissionTo([
|
||||
'cutting.view',
|
||||
'cutting.create',
|
||||
'cutting.update',
|
||||
'cutting.delete',
|
||||
]);
|
||||
}
|
||||
|
||||
function makeValidCuttingPayload(array $overrides = []): array
|
||||
{
|
||||
$price = RawMaterialPrice::factory()->create(['stock' => 100]);
|
||||
|
||||
return array_merge([
|
||||
'product_name' => 'Produk Test',
|
||||
'sample' => 5,
|
||||
'original_outside_sample' => 10,
|
||||
'cutting_result' => 15,
|
||||
'description' => 'Keterangan test',
|
||||
'materials' => [
|
||||
[
|
||||
'raw_material_price_id' => $price->id,
|
||||
'material_usage' => 20,
|
||||
'material_result' => 15,
|
||||
'combination_index' => null,
|
||||
],
|
||||
],
|
||||
'combinations' => [],
|
||||
'photo_key' => null,
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| AUTHENTICATION
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('guests are redirected to the login page', function () {
|
||||
$response = $this->get(route('admin.manage.cuttings.index'));
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('guests are redirected when visiting create page', function () {
|
||||
$response = $this->get(route('admin.manage.cuttings.create'));
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('guests are redirected when visiting edit page', function () {
|
||||
$cutting = Cutting::factory()->create();
|
||||
$response = $this->get(route('admin.manage.cuttings.edit', $cutting));
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('guest cannot create cutting', function () {
|
||||
$response = $this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload());
|
||||
$response->assertRedirect(route('login'));
|
||||
$this->assertDatabaseCount('cuttings', 0);
|
||||
});
|
||||
|
||||
test('guest cannot update cutting', function () {
|
||||
$cutting = Cutting::factory()->create();
|
||||
$response = $this->put(route('admin.manage.cuttings.update', $cutting), makeValidCuttingPayload());
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('guest cannot delete cutting', function () {
|
||||
$cutting = Cutting::factory()->create();
|
||||
$response = $this->delete(route('admin.manage.cuttings.destroy', $cutting));
|
||||
$response->assertRedirect(route('login'));
|
||||
$this->assertDatabaseHas('cuttings', ['id' => $cutting->id, 'deleted_at' => null]);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| AUTHORIZATION
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('user without permission cannot view cuttings', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->get(route('admin.manage.cuttings.index'))->assertForbidden();
|
||||
});
|
||||
|
||||
test('user without permission cannot visit create page', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->get(route('admin.manage.cuttings.create'))->assertForbidden();
|
||||
});
|
||||
|
||||
test('user without permission cannot store cutting', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload())->assertForbidden();
|
||||
$this->assertDatabaseCount('cuttings', 0);
|
||||
});
|
||||
|
||||
test('user without permission cannot visit edit page', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$cutting = Cutting::factory()->create();
|
||||
$this->get(route('admin.manage.cuttings.edit', $cutting))->assertForbidden();
|
||||
});
|
||||
|
||||
test('user without permission cannot update cutting', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$cutting = Cutting::factory()->create();
|
||||
$this->put(route('admin.manage.cuttings.update', $cutting), makeValidCuttingPayload())->assertForbidden();
|
||||
});
|
||||
|
||||
test('user without permission cannot delete cutting', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$cutting = Cutting::factory()->create();
|
||||
$this->delete(route('admin.manage.cuttings.destroy', $cutting))->assertForbidden();
|
||||
$this->assertDatabaseHas('cuttings', ['id' => $cutting->id, 'deleted_at' => null]);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| INDEX
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('user with permission can view cuttings', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
Cutting::factory()->count(3)->create(['created_by_id' => $user->id]);
|
||||
|
||||
$this->get(route('admin.manage.cuttings.index'))->assertOk();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| CREATE
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('user with permission can visit create page', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->get(route('admin.manage.cuttings.create'))->assertOk();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| STORE - VALIDATION
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('store requires product_name', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$payload = makeValidCuttingPayload();
|
||||
unset($payload['product_name']);
|
||||
|
||||
$this->post(route('admin.manage.cuttings.store'), $payload)
|
||||
->assertSessionHasErrors('product_name');
|
||||
});
|
||||
|
||||
test('store requires sample', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$payload = makeValidCuttingPayload();
|
||||
unset($payload['sample']);
|
||||
|
||||
$this->post(route('admin.manage.cuttings.store'), $payload)
|
||||
->assertSessionHasErrors('sample');
|
||||
});
|
||||
|
||||
test('store requires original_outside_sample', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$payload = makeValidCuttingPayload();
|
||||
unset($payload['original_outside_sample']);
|
||||
|
||||
$this->post(route('admin.manage.cuttings.store'), $payload)
|
||||
->assertSessionHasErrors('original_outside_sample');
|
||||
});
|
||||
|
||||
test('store requires cutting_result', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$payload = makeValidCuttingPayload();
|
||||
unset($payload['cutting_result']);
|
||||
|
||||
$this->post(route('admin.manage.cuttings.store'), $payload)
|
||||
->assertSessionHasErrors('cutting_result');
|
||||
});
|
||||
|
||||
test('store requires at least one material', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$payload = makeValidCuttingPayload(['materials' => []]);
|
||||
|
||||
$this->post(route('admin.manage.cuttings.store'), $payload)
|
||||
->assertSessionHasErrors('materials');
|
||||
});
|
||||
|
||||
test('store requires materials.raw_material_price_id', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$payload = makeValidCuttingPayload();
|
||||
unset($payload['materials'][0]['raw_material_price_id']);
|
||||
|
||||
$this->post(route('admin.manage.cuttings.store'), $payload)
|
||||
->assertSessionHasErrors('materials.0.raw_material_price_id');
|
||||
});
|
||||
|
||||
test('store requires materials.material_usage', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$payload = makeValidCuttingPayload();
|
||||
unset($payload['materials'][0]['material_usage']);
|
||||
|
||||
$this->post(route('admin.manage.cuttings.store'), $payload)
|
||||
->assertSessionHasErrors('materials.0.material_usage');
|
||||
});
|
||||
|
||||
test('store requires materials.material_result', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$payload = makeValidCuttingPayload();
|
||||
unset($payload['materials'][0]['material_result']);
|
||||
|
||||
$this->post(route('admin.manage.cuttings.store'), $payload)
|
||||
->assertSessionHasErrors('materials.0.material_result');
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| STORE - SUCCESS
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('user with permission can store cutting', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload())
|
||||
->assertRedirect(route('admin.manage.cuttings.index'));
|
||||
|
||||
$this->assertDatabaseHas('cuttings', ['created_by_id' => $user->id]);
|
||||
$this->assertDatabaseHas('cutting_results', ['product_name' => 'Produk Test']);
|
||||
});
|
||||
|
||||
test('store creates cutting material', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$price = RawMaterialPrice::factory()->create(['stock' => 100]);
|
||||
|
||||
$this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload([
|
||||
'materials' => [
|
||||
[
|
||||
'raw_material_price_id' => $price->id,
|
||||
'material_usage' => 20,
|
||||
'material_result' => 15,
|
||||
'combination_index' => null,
|
||||
],
|
||||
],
|
||||
]))->assertRedirect();
|
||||
|
||||
$cutting = Cutting::latest()->first();
|
||||
$this->assertDatabaseHas('cutting_materials', [
|
||||
'cutting_id' => $cutting->id,
|
||||
'raw_material_price_id' => $price->id,
|
||||
'material_usage' => 20,
|
||||
'material_result' => 15,
|
||||
]);
|
||||
});
|
||||
|
||||
test('store deducts stock from raw_material_price', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$price = RawMaterialPrice::factory()->create(['stock' => 100]);
|
||||
|
||||
$this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload([
|
||||
'materials' => [
|
||||
[
|
||||
'raw_material_price_id' => $price->id,
|
||||
'material_usage' => 30,
|
||||
'material_result' => 25,
|
||||
'combination_index' => null,
|
||||
],
|
||||
],
|
||||
]))->assertRedirect();
|
||||
|
||||
$price->refresh();
|
||||
$this->assertEquals(70, $price->stock);
|
||||
});
|
||||
|
||||
test('store with combination creates combination record', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$price1 = RawMaterialPrice::factory()->create(['stock' => 100]);
|
||||
$price2 = RawMaterialPrice::factory()->create(['stock' => 50]);
|
||||
|
||||
$this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload([
|
||||
'materials' => [
|
||||
[
|
||||
'raw_material_price_id' => $price1->id,
|
||||
'material_usage' => 15,
|
||||
'material_result' => 12,
|
||||
'combination_index' => 0,
|
||||
],
|
||||
[
|
||||
'raw_material_price_id' => $price2->id,
|
||||
'material_usage' => 10,
|
||||
'material_result' => 8,
|
||||
'combination_index' => 0,
|
||||
],
|
||||
],
|
||||
'combinations' => [
|
||||
['material_result' => 20],
|
||||
],
|
||||
]))->assertRedirect();
|
||||
|
||||
$cutting = Cutting::latest()->first();
|
||||
$this->assertDatabaseHas('cutting_material_combinations', [
|
||||
'cutting_id' => $cutting->id,
|
||||
'material_result' => 20,
|
||||
]);
|
||||
|
||||
$combination = CuttingMaterialCombination::where('cutting_id', $cutting->id)->first();
|
||||
$this->assertDatabaseHas('cutting_materials', [
|
||||
'cutting_id' => $cutting->id,
|
||||
'raw_material_price_id' => $price1->id,
|
||||
'combination_id' => $combination->id,
|
||||
]);
|
||||
$this->assertDatabaseHas('cutting_materials', [
|
||||
'cutting_id' => $cutting->id,
|
||||
'raw_material_price_id' => $price2->id,
|
||||
'combination_id' => $combination->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('store deducts stock for multiple materials', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$price1 = RawMaterialPrice::factory()->create(['stock' => 100]);
|
||||
$price2 = RawMaterialPrice::factory()->create(['stock' => 50]);
|
||||
|
||||
$this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload([
|
||||
'materials' => [
|
||||
[
|
||||
'raw_material_price_id' => $price1->id,
|
||||
'material_usage' => 25,
|
||||
'material_result' => 20,
|
||||
'combination_index' => null,
|
||||
],
|
||||
[
|
||||
'raw_material_price_id' => $price2->id,
|
||||
'material_usage' => 15,
|
||||
'material_result' => 12,
|
||||
'combination_index' => 0,
|
||||
],
|
||||
],
|
||||
'combinations' => [
|
||||
['material_result' => 18],
|
||||
],
|
||||
]))->assertRedirect();
|
||||
|
||||
$price1->refresh();
|
||||
$price2->refresh();
|
||||
$this->assertEquals(75, $price1->stock);
|
||||
$this->assertEquals(35, $price2->stock);
|
||||
});
|
||||
|
||||
test('store with photo_key registers media', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload([
|
||||
'photo_key' => 'cuttings/test-photo.jpg',
|
||||
]))->assertRedirect();
|
||||
|
||||
$cutting = Cutting::latest()->first();
|
||||
$this->assertNotNull($cutting->getFirstMedia('photos'));
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| STORE - STOCK VALIDATION
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('store fails when stock is insufficient', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
|
||||
|
||||
$response = $this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload([
|
||||
'materials' => [
|
||||
[
|
||||
'raw_material_price_id' => $price->id,
|
||||
'material_usage' => 20,
|
||||
'material_result' => 15,
|
||||
'combination_index' => null,
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseCount('cuttings', 0);
|
||||
|
||||
$price->refresh();
|
||||
$this->assertEquals(10, $price->stock);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| EDIT
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('user with permission can visit edit page', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
|
||||
|
||||
$this->get(route('admin.manage.cuttings.edit', $cutting))->assertOk();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| UPDATE - VALIDATION
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('update requires product_name', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
|
||||
$payload = makeValidCuttingPayload();
|
||||
unset($payload['product_name']);
|
||||
|
||||
$this->put(route('admin.manage.cuttings.update', $cutting), $payload)
|
||||
->assertSessionHasErrors('product_name');
|
||||
});
|
||||
|
||||
test('update requires sample', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
|
||||
$payload = makeValidCuttingPayload();
|
||||
unset($payload['sample']);
|
||||
|
||||
$this->put(route('admin.manage.cuttings.update', $cutting), $payload)
|
||||
->assertSessionHasErrors('sample');
|
||||
});
|
||||
|
||||
test('update requires original_outside_sample', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
|
||||
$payload = makeValidCuttingPayload();
|
||||
unset($payload['original_outside_sample']);
|
||||
|
||||
$this->put(route('admin.manage.cuttings.update', $cutting), $payload)
|
||||
->assertSessionHasErrors('original_outside_sample');
|
||||
});
|
||||
|
||||
test('update requires cutting_result', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
|
||||
$payload = makeValidCuttingPayload();
|
||||
unset($payload['cutting_result']);
|
||||
|
||||
$this->put(route('admin.manage.cuttings.update', $cutting), $payload)
|
||||
->assertSessionHasErrors('cutting_result');
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| UPDATE - SUCCESS
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('user with permission can update cutting', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$price = RawMaterialPrice::factory()->create(['stock' => 100]);
|
||||
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
|
||||
|
||||
$this->put(route('admin.manage.cuttings.update', $cutting), makeValidCuttingPayload([
|
||||
'product_name' => 'Updated Product',
|
||||
'materials' => [
|
||||
[
|
||||
'raw_material_price_id' => $price->id,
|
||||
'material_usage' => 25,
|
||||
'material_result' => 20,
|
||||
'combination_index' => null,
|
||||
],
|
||||
],
|
||||
]))->assertRedirect(route('admin.manage.cuttings.index'));
|
||||
|
||||
$this->assertDatabaseHas('cutting_results', [
|
||||
'cutting_id' => $cutting->id,
|
||||
'product_name' => 'Updated Product',
|
||||
]);
|
||||
});
|
||||
|
||||
test('update adjusts stock correctly', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$price = RawMaterialPrice::factory()->create(['stock' => 100]);
|
||||
|
||||
// First store with usage 20
|
||||
$this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload([
|
||||
'materials' => [
|
||||
[
|
||||
'raw_material_price_id' => $price->id,
|
||||
'material_usage' => 20,
|
||||
'material_result' => 15,
|
||||
'combination_index' => null,
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
$price->refresh();
|
||||
$this->assertEquals(80, $price->stock);
|
||||
|
||||
$cutting = Cutting::latest()->first();
|
||||
|
||||
// Update: restore old (20), then deduct new (30) -> 80 + 20 - 30 = 70
|
||||
$this->put(route('admin.manage.cuttings.update', $cutting), makeValidCuttingPayload([
|
||||
'materials' => [
|
||||
[
|
||||
'raw_material_price_id' => $price->id,
|
||||
'material_usage' => 30,
|
||||
'material_result' => 25,
|
||||
'combination_index' => null,
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
$price->refresh();
|
||||
$this->assertEquals(70, $price->stock);
|
||||
});
|
||||
|
||||
test('update fails when stock is insufficient after restore', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$price = RawMaterialPrice::factory()->create(['stock' => 5]);
|
||||
|
||||
// Store with usage 3
|
||||
$this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload([
|
||||
'materials' => [
|
||||
[
|
||||
'raw_material_price_id' => $price->id,
|
||||
'material_usage' => 3,
|
||||
'material_result' => 2,
|
||||
'combination_index' => null,
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
$price->refresh();
|
||||
$this->assertEquals(2, $price->stock);
|
||||
|
||||
$cutting = Cutting::latest()->first();
|
||||
|
||||
// Update: restore old (3) -> 2 + 3 = 5, then try to deduct 10 -> fail
|
||||
// Stock remains at 5 because restore happens before validation
|
||||
$this->put(route('admin.manage.cuttings.update', $cutting), makeValidCuttingPayload([
|
||||
'materials' => [
|
||||
[
|
||||
'raw_material_price_id' => $price->id,
|
||||
'material_usage' => 10,
|
||||
'material_result' => 8,
|
||||
'combination_index' => null,
|
||||
],
|
||||
],
|
||||
]))->assertRedirect();
|
||||
|
||||
$price->refresh();
|
||||
$this->assertEquals(5, $price->stock);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| DESTROY - SUCCESS
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('user with permission can delete cutting', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
|
||||
|
||||
$this->delete(route('admin.manage.cuttings.destroy', $cutting))
|
||||
->assertRedirect(route('admin.manage.cuttings.index'));
|
||||
|
||||
$this->assertSoftDeleted('cuttings', ['id' => $cutting->id]);
|
||||
});
|
||||
|
||||
test('delete restores stock from raw_material_prices', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$price = RawMaterialPrice::factory()->create(['stock' => 100]);
|
||||
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
|
||||
|
||||
CuttingMaterial::create([
|
||||
'cutting_id' => $cutting->id,
|
||||
'raw_material_price_id' => $price->id,
|
||||
'material_usage' => 30,
|
||||
'material_result' => 25,
|
||||
]);
|
||||
|
||||
$this->delete(route('admin.manage.cuttings.destroy', $cutting))
|
||||
->assertRedirect(route('admin.manage.cuttings.index'));
|
||||
|
||||
$price->refresh();
|
||||
$this->assertEquals(130, $price->stock);
|
||||
});
|
||||
|
||||
test('delete restores stock for multiple materials', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$price1 = RawMaterialPrice::factory()->create(['stock' => 50]);
|
||||
$price2 = RawMaterialPrice::factory()->create(['stock' => 30]);
|
||||
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
|
||||
|
||||
CuttingMaterial::create([
|
||||
'cutting_id' => $cutting->id,
|
||||
'raw_material_price_id' => $price1->id,
|
||||
'material_usage' => 20,
|
||||
'material_result' => 15,
|
||||
]);
|
||||
CuttingMaterial::create([
|
||||
'cutting_id' => $cutting->id,
|
||||
'raw_material_price_id' => $price2->id,
|
||||
'material_usage' => 10,
|
||||
'material_result' => 8,
|
||||
]);
|
||||
|
||||
$this->delete(route('admin.manage.cuttings.destroy', $cutting))
|
||||
->assertRedirect(route('admin.manage.cuttings.index'));
|
||||
|
||||
$price1->refresh();
|
||||
$price2->refresh();
|
||||
$this->assertEquals(70, $price1->stock);
|
||||
$this->assertEquals(40, $price2->stock);
|
||||
});
|
||||
|
||||
test('delete removes cutting_material_combinations', function () {
|
||||
$user = User::factory()->create();
|
||||
giveCuttingPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
|
||||
$combination = CuttingMaterialCombination::create([
|
||||
'cutting_id' => $cutting->id,
|
||||
'material_result' => 15,
|
||||
]);
|
||||
|
||||
$this->delete(route('admin.manage.cuttings.destroy', $cutting))
|
||||
->assertRedirect(route('admin.manage.cuttings.index'));
|
||||
|
||||
$this->assertSoftDeleted('cutting_material_combinations', ['id' => $combination->id]);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user