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),
|
...parent::share($request),
|
||||||
'name' => config('app.name'),
|
'name' => config('app.name'),
|
||||||
'auth' => [
|
'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',
|
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||||
'seo' => [
|
'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 App\Enums\CuttingStatus;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
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\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Spatie\MediaLibrary\HasMedia;
|
||||||
|
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||||
|
|
||||||
#[Guarded(['id'])]
|
#[Guarded(['id'])]
|
||||||
class Cutting extends Model
|
class Cutting extends Model implements HasMedia
|
||||||
{
|
{
|
||||||
use HasFactory, SoftDeletes;
|
use HasFactory, InteractsWithMedia, SoftDeletes;
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'status' => CuttingStatus::class,
|
'status' => CuttingStatus::class,
|
||||||
'total_material_cost' => 'integer',
|
'total_material_cost' => 'integer',
|
||||||
'sewing_cost' => 'integer',
|
|
||||||
'other_cost' => 'integer',
|
|
||||||
'cost_per_unit' => '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
|
public function createdBy(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'created_by_id');
|
return $this->belongsTo(User::class, 'created_by_id');
|
||||||
@ -65,9 +45,4 @@ public function cuttingResults(): HasMany
|
|||||||
{
|
{
|
||||||
return $this->hasMany(CuttingResult::class);
|
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
|
public function definition(): array
|
||||||
{
|
{
|
||||||
$materialCost = fake()->numberBetween(50000, 5000000);
|
$materialCost = fake()->numberBetween(50000, 5000000);
|
||||||
$sewingCost = fake()->numberBetween(10000, 500000);
|
|
||||||
$otherCost = fake()->numberBetween(0, 200000);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'created_by_id' => User::factory(),
|
'created_by_id' => User::factory(),
|
||||||
'status' => fake()->randomElement(['in_progress', 'completed', 'cancelled']),
|
'status' => fake()->randomElement(['in_progress', 'completed', 'cancelled']),
|
||||||
'description' => fake()->sentence(),
|
'description' => fake()->sentence(),
|
||||||
'total_material_cost' => $materialCost,
|
'total_material_cost' => $materialCost,
|
||||||
'sewing_cost' => $sewingCost,
|
|
||||||
'other_cost' => $otherCost,
|
|
||||||
'cost_per_unit' => fake()->numberBetween(1000, 50000),
|
'cost_per_unit' => fake()->numberBetween(1000, 50000),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,10 @@ public function run(): void
|
|||||||
'attendance' => ['view', 'check-in', 'check-out', 'by-date'],
|
'attendance' => ['view', 'check-in', 'check-out', 'by-date'],
|
||||||
'settings' => ['view', 'update-system', 'update-homepage', 'update-social-media', 'update-marketplace', 'update-hr'],
|
'settings' => ['view', 'update-system', 'update-homepage', 'update-social-media', 'update-marketplace', 'update-hr'],
|
||||||
'purchase' => ['view', 'create', 'update', 'delete'],
|
'purchase' => ['view', 'create', 'update', 'delete'],
|
||||||
|
'cutting' => ['view', 'create', 'update', 'delete'],
|
||||||
'restock' => ['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) {
|
foreach ($permissions as $module => $actions) {
|
||||||
@ -56,6 +59,7 @@ public function run(): void
|
|||||||
'Admin Bahan Baku' => array_filter($allPermissions, function ($p) {
|
'Admin Bahan Baku' => array_filter($allPermissions, function ($p) {
|
||||||
return str_starts_with($p, 'supplier.')
|
return str_starts_with($p, 'supplier.')
|
||||||
|| str_starts_with($p, 'purchase.')
|
|| str_starts_with($p, 'purchase.')
|
||||||
|
|| str_starts_with($p, 'cutting.')
|
||||||
|| str_starts_with($p, 'restock.')
|
|| str_starts_with($p, 'restock.')
|
||||||
|| $p === 'category.view'
|
|| $p === 'category.view'
|
||||||
|| $p === 'customer.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 { Link, router } from '@inertiajs/react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@ -53,6 +23,38 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Wallet,
|
Wallet,
|
||||||
} from 'lucide-react';
|
} 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 };
|
type NavMenuItem = { title: string; href: string; icon: LucideIcon };
|
||||||
|
|
||||||
@ -78,7 +80,7 @@ const masterItems: NavMenuItem[] = [
|
|||||||
|
|
||||||
const kelolaItems: NavMenuItem[] = [
|
const kelolaItems: NavMenuItem[] = [
|
||||||
{ title: 'Belanja', href: purchasesIndex.url(), icon: ShoppingCart },
|
{ 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: 'Restock', href: restocksIndex.url(), icon: RefreshCw },
|
||||||
{ title: 'Stok Opname', href: '#', icon: ClipboardCheck },
|
{ title: 'Stok Opname', href: '#', icon: ClipboardCheck },
|
||||||
];
|
];
|
||||||
@ -133,7 +135,9 @@ export function AppSidebar() {
|
|||||||
const { isMobile, setOpenMobile } = useSidebar();
|
const { isMobile, setOpenMobile } = useSidebar();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isMobile) return;
|
if (!isMobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const cleanup = router.on('finish', () => {
|
const cleanup = router.on('finish', () => {
|
||||||
setOpenMobile(false);
|
setOpenMobile(false);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
|
import { Camera, RotateCcw, X } from 'lucide-react';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Camera, RotateCcw, X } from 'lucide-react';
|
|
||||||
|
|
||||||
interface CameraCaptureProps {
|
interface CameraCaptureProps {
|
||||||
onCapture: (dataUrl: string) => void;
|
onCapture: (dataUrl: string) => void;
|
||||||
@ -20,9 +20,11 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
|
|||||||
video: { facingMode: 'user', width: 640, height: 480 },
|
video: { facingMode: 'user', width: 640, height: 480 },
|
||||||
});
|
});
|
||||||
setStream(mediaStream);
|
setStream(mediaStream);
|
||||||
|
|
||||||
if (videoRef.current) {
|
if (videoRef.current) {
|
||||||
videoRef.current.srcObject = mediaStream;
|
videoRef.current.srcObject = mediaStream;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch {
|
} catch {
|
||||||
setError(
|
setError(
|
||||||
@ -33,13 +35,16 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
startCamera();
|
startCamera();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
stream?.getTracks().forEach((track) => track.stop());
|
stream?.getTracks().forEach((track) => track.stop());
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const capture = () => {
|
const capture = () => {
|
||||||
if (!videoRef.current || !canvasRef.current) return;
|
if (!videoRef.current || !canvasRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
@ -47,7 +52,10 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
|
|||||||
canvas.height = video.videoHeight;
|
canvas.height = video.videoHeight;
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
|
||||||
|
if (!ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.translate(canvas.width, 0);
|
ctx.translate(canvas.width, 0);
|
||||||
ctx.scale(-1, 1);
|
ctx.scale(-1, 1);
|
||||||
|
|||||||
@ -63,6 +63,7 @@ function useDebounce(callback: (value: string) => void, delay: number) {
|
|||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
clearTimeout(timeoutRef.current);
|
clearTimeout(timeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
timeoutRef.current = setTimeout(() => {
|
timeoutRef.current = setTimeout(() => {
|
||||||
callback(value);
|
callback(value);
|
||||||
}, delay);
|
}, delay);
|
||||||
@ -102,13 +103,17 @@ export function CardTable<TData>({
|
|||||||
|
|
||||||
function handleSearchChange(value: string) {
|
function handleSearchChange(value: string) {
|
||||||
setLocalSearch(value);
|
setLocalSearch(value);
|
||||||
|
|
||||||
if (isServerMode) {
|
if (isServerMode) {
|
||||||
handleSearchDebounced(value);
|
handleSearchDebounced(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isItemExpanded(key: number | string): boolean {
|
function isItemExpanded(key: number | string): boolean {
|
||||||
if (expandedKeys === 'all') return true;
|
if (expandedKeys === 'all') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return expandedKeys.has(key);
|
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 { format } from 'date-fns';
|
||||||
import { id } from 'date-fns/locale';
|
import { id } from 'date-fns/locale';
|
||||||
import { CalendarIcon } from 'lucide-react';
|
import { CalendarIcon } from 'lucide-react';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Calendar } from '@/components/ui/calendar';
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
import {
|
import {
|
||||||
@ -11,6 +10,7 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/popover';
|
} from '@/components/ui/popover';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface DatePickerProps {
|
interface DatePickerProps {
|
||||||
value?: Date | string | null;
|
value?: Date | string | null;
|
||||||
@ -38,13 +38,22 @@ function DatePicker({
|
|||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
const date = React.useMemo(() => {
|
const date = React.useMemo(() => {
|
||||||
if (!value) return undefined;
|
if (!value) {
|
||||||
if (value instanceof Date) return value;
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
return new Date(value);
|
return new Date(value);
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
const formattedDate = React.useMemo(() => {
|
const formattedDate = React.useMemo(() => {
|
||||||
if (!date) return '';
|
if (!date) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
return format(date, 'dd MMM yyyy', { locale: id });
|
return format(date, 'dd MMM yyyy', { locale: id });
|
||||||
}, [date]);
|
}, [date]);
|
||||||
|
|
||||||
@ -74,8 +83,14 @@ function DatePicker({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
disabled={(date) => {
|
disabled={(date) => {
|
||||||
if (min && date < min) return true;
|
if (min && date < min) {
|
||||||
if (max && date > max) return true;
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (max && date > max) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
initialFocus
|
initialFocus
|
||||||
|
|||||||
@ -62,6 +62,7 @@ function formatMaxSize(bytes: number): string {
|
|||||||
if (bytes < 1024 * 1024) {
|
if (bytes < 1024 * 1024) {
|
||||||
return `${(bytes / 1024).toFixed(0)}KB`;
|
return `${(bytes / 1024).toFixed(0)}KB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(0)}MB`;
|
return `${(bytes / (1024 * 1024)).toFixed(0)}MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { Filter, X } from 'lucide-react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@ -5,7 +6,6 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/popover';
|
} from '@/components/ui/popover';
|
||||||
import { Filter, X } from 'lucide-react';
|
|
||||||
|
|
||||||
type FilterPopoverProps = {
|
type FilterPopoverProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
interface LocationMapProps {
|
interface LocationMapProps {
|
||||||
@ -19,7 +19,9 @@ export function LocationMap({
|
|||||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapRef.current || mapInstanceRef.current) return;
|
if (!mapRef.current || mapInstanceRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const map = L.map(mapRef.current, {
|
const map = L.map(mapRef.current, {
|
||||||
center: [latitude, longitude],
|
center: [latitude, longitude],
|
||||||
|
|||||||
@ -194,6 +194,7 @@ export function NotificationBell() {
|
|||||||
className="min-w-0 flex-1 cursor-pointer"
|
className="min-w-0 flex-1 cursor-pointer"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (notification.url) {
|
if (notification.url) {
|
||||||
window.location.href =
|
window.location.href =
|
||||||
notification.url;
|
notification.url;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
type PhoneNumberInputProps = {
|
type PhoneNumberInputProps = {
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -12,9 +12,11 @@ type PhoneNumberInputProps = {
|
|||||||
function formatPhone(value: string | null | undefined): string {
|
function formatPhone(value: string | null | undefined): string {
|
||||||
const digits = (value ?? '').replace(/[^0-9]/g, '');
|
const digits = (value ?? '').replace(/[^0-9]/g, '');
|
||||||
const groups: string[] = [];
|
const groups: string[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < digits.length; i += 4) {
|
for (let i = 0; i < digits.length; i += 4) {
|
||||||
groups.push(digits.slice(i, i + 4));
|
groups.push(digits.slice(i, i + 4));
|
||||||
}
|
}
|
||||||
|
|
||||||
return groups.join(' ');
|
return groups.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -47,6 +47,7 @@ export function RupiahInput({
|
|||||||
|
|
||||||
if (isControlled) {
|
if (isControlled) {
|
||||||
const formatted = formatRupiah(value);
|
const formatted = formatRupiah(value);
|
||||||
|
|
||||||
if (formatted !== displayValue) {
|
if (formatted !== displayValue) {
|
||||||
setDisplayValue(formatted);
|
setDisplayValue(formatted);
|
||||||
lastValidRef.current = value;
|
lastValidRef.current = value;
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { Link, router } from '@inertiajs/react';
|
||||||
|
import { LogOut, Settings } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
DropdownMenuGroup,
|
DropdownMenuGroup,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
@ -9,8 +11,6 @@ import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
|
|||||||
import { logout } from '@/routes';
|
import { logout } from '@/routes';
|
||||||
import { edit } from '@/routes/profile';
|
import { edit } from '@/routes/profile';
|
||||||
import type { User } from '@/types';
|
import type { User } from '@/types';
|
||||||
import { Link, router } from '@inertiajs/react';
|
|
||||||
import { LogOut, Settings } from 'lucide-react';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
user: User;
|
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 sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (loading || currentPage >= lastPage) return;
|
if (loading || currentPage >= lastPage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
@ -42,7 +44,7 @@ export function useInfiniteScroll<T>({
|
|||||||
preserveState: true,
|
preserveState: true,
|
||||||
replace: true,
|
replace: true,
|
||||||
only: ['mutations'],
|
only: ['mutations'],
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
onSuccess: (page: any) => {
|
onSuccess: (page: any) => {
|
||||||
const newMutations = (page.props as Record<string, unknown>)
|
const newMutations = (page.props as Record<string, unknown>)
|
||||||
.mutations as PaginatedData<T>;
|
.mutations as PaginatedData<T>;
|
||||||
@ -61,7 +63,10 @@ export function useInfiniteScroll<T>({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sentinel = sentinelRef.current;
|
const sentinel = sentinelRef.current;
|
||||||
if (!sentinel) return;
|
|
||||||
|
if (!sentinel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(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 { Head, router } from '@inertiajs/react';
|
||||||
import { addMonths, format, subMonths } from 'date-fns';
|
import { addMonths, format, subMonths } from 'date-fns';
|
||||||
import { id } from 'date-fns/locale';
|
import { id } from 'date-fns/locale';
|
||||||
@ -31,6 +15,22 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
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 = {
|
type Attendance = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -85,6 +85,7 @@ function isSameDay(d1: Date, d2: Date): boolean {
|
|||||||
|
|
||||||
function checkIsToday(date: Date): boolean {
|
function checkIsToday(date: Date): boolean {
|
||||||
const t = new Date();
|
const t = new Date();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
date.getDate() === t.getDate() &&
|
date.getDate() === t.getDate() &&
|
||||||
date.getMonth() === t.getMonth() &&
|
date.getMonth() === t.getMonth() &&
|
||||||
@ -94,6 +95,7 @@ function checkIsToday(date: Date): boolean {
|
|||||||
|
|
||||||
function isWeekend(date: Date): boolean {
|
function isWeekend(date: Date): boolean {
|
||||||
const day = date.getDay();
|
const day = date.getDay();
|
||||||
|
|
||||||
return day === 0 || day === 6;
|
return day === 0 || day === 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,10 +104,14 @@ function isLate(
|
|||||||
officeHour: number,
|
officeHour: number,
|
||||||
officeMinute: number,
|
officeMinute: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!checkInAt) return false;
|
if (!checkInAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const d = new Date(checkInAt);
|
const d = new Date(checkInAt);
|
||||||
const h = d.getHours();
|
const h = d.getHours();
|
||||||
const m = d.getMinutes();
|
const m = d.getMinutes();
|
||||||
|
|
||||||
return h > officeHour || (h === officeHour && m > officeMinute);
|
return h > officeHour || (h === officeHour && m > officeMinute);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,18 +120,29 @@ function getLateMinutes(
|
|||||||
officeHour: number,
|
officeHour: number,
|
||||||
officeMinute: number,
|
officeMinute: number,
|
||||||
): number {
|
): number {
|
||||||
if (!checkInAt || !isLate(checkInAt, officeHour, officeMinute)) return 0;
|
if (!checkInAt || !isLate(checkInAt, officeHour, officeMinute)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
const d = new Date(checkInAt);
|
const d = new Date(checkInAt);
|
||||||
const officeStart = new Date(d);
|
const officeStart = new Date(d);
|
||||||
officeStart.setHours(officeHour, officeMinute, 0, 0);
|
officeStart.setHours(officeHour, officeMinute, 0, 0);
|
||||||
|
|
||||||
return Math.ceil((d.getTime() - officeStart.getTime()) / 60000);
|
return Math.ceil((d.getTime() - officeStart.getTime()) / 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMinutes(minutes: number | null): string {
|
function formatMinutes(minutes: number | null): string {
|
||||||
if (!minutes) return '-';
|
if (!minutes) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
const mins = 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`;
|
return `${hours} jam ${mins} menit`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,11 +178,13 @@ export default function AttendanceIndex({
|
|||||||
attendances.forEach((att) => {
|
attendances.forEach((att) => {
|
||||||
dates.set(att.attendance_date, att);
|
dates.set(att.attendance_date, att);
|
||||||
});
|
});
|
||||||
|
|
||||||
return dates;
|
return dates;
|
||||||
}, [attendances]);
|
}, [attendances]);
|
||||||
|
|
||||||
const selectedAttendance = useMemo(() => {
|
const selectedAttendance = useMemo(() => {
|
||||||
const dateStr = format(selectedDate, 'yyyy-MM-dd');
|
const dateStr = format(selectedDate, 'yyyy-MM-dd');
|
||||||
|
|
||||||
return attendanceDates.get(dateStr) ?? null;
|
return attendanceDates.get(dateStr) ?? null;
|
||||||
}, [selectedDate, attendanceDates]);
|
}, [selectedDate, attendanceDates]);
|
||||||
|
|
||||||
@ -199,6 +218,7 @@ export default function AttendanceIndex({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const remaining = 42 - days.length;
|
const remaining = 42 - days.length;
|
||||||
|
|
||||||
for (let i = 1; i <= remaining; i++) {
|
for (let i = 1; i <= remaining; i++) {
|
||||||
const m = viewMonth === 12 ? 1 : viewMonth + 1;
|
const m = viewMonth === 12 ? 1 : viewMonth + 1;
|
||||||
const y = viewMonth === 12 ? viewYear + 1 : viewYear;
|
const y = viewMonth === 12 ? viewYear + 1 : viewYear;
|
||||||
@ -222,6 +242,7 @@ export default function AttendanceIndex({
|
|||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
setLocationLoading(false);
|
setLocationLoading(false);
|
||||||
toast.error('Geolocation tidak didukung di browser ini.');
|
toast.error('Geolocation tidak didukung di browser ini.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -268,7 +289,10 @@ export default function AttendanceIndex({
|
|||||||
};
|
};
|
||||||
|
|
||||||
function formatTime(dateStr: string | null): string {
|
function formatTime(dateStr: string | null): string {
|
||||||
if (!dateStr) return '-';
|
if (!dateStr) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
return format(new Date(dateStr), 'HH:mm');
|
return format(new Date(dateStr), 'HH:mm');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -555,8 +579,10 @@ export default function AttendanceIndex({
|
|||||||
key={idx}
|
key={idx}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedDate(cell.date);
|
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 ${
|
className={`group relative flex min-h-[100px] flex-col p-2 text-left transition-colors hover:bg-muted/50 ${
|
||||||
!cell.isCurrentMonth
|
!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 { DatePicker } from '@/components/date-picker';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
import { PhoneNumberInput } from '@/components/phone-number-input';
|
import { PhoneNumberInput } from '@/components/phone-number-input';
|
||||||
@ -17,9 +20,6 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { index as employeeIndex, store } from '@/routes/admin/hr/employees';
|
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() {
|
export default function EmployeeCreate() {
|
||||||
const [joinDate, setJoinDate] = useState<Date | undefined>(undefined);
|
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 { DatePicker } from '@/components/date-picker';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
import { PhoneNumberInput } from '@/components/phone-number-input';
|
import { PhoneNumberInput } from '@/components/phone-number-input';
|
||||||
@ -16,9 +19,6 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { index as employeeIndex, update } from '@/routes/admin/hr/employees';
|
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 = {
|
type EmployeeData = {
|
||||||
id: number;
|
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 InputError from '@/components/input-error';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { index as rolesIndex, store } from '@/routes/admin/settings/roles';
|
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[]>;
|
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 InputError from '@/components/input-error';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { index as rolesIndex, update } from '@/routes/admin/settings/roles';
|
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[]>;
|
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 { FileUpload } from '@/components/file-upload';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
import { PhoneNumberInput } from '@/components/phone-number-input';
|
import { PhoneNumberInput } from '@/components/phone-number-input';
|
||||||
@ -18,9 +21,6 @@ import {
|
|||||||
updateSocialMedia,
|
updateSocialMedia,
|
||||||
updateSystem,
|
updateSystem,
|
||||||
} from '@/routes/admin/settings';
|
} from '@/routes/admin/settings';
|
||||||
import { Form, Head } from '@inertiajs/react';
|
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
type MarketplaceFeeRule = {
|
type MarketplaceFeeRule = {
|
||||||
base: string;
|
base: string;
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { Form, Head } from '@inertiajs/react';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
import PasswordInput from '@/components/password-input';
|
import PasswordInput from '@/components/password-input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -6,7 +7,6 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { store } from '@/routes/login';
|
import { store } from '@/routes/login';
|
||||||
import { Form, Head } from '@inertiajs/react';
|
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
|
import { Head } from '@inertiajs/react';
|
||||||
import AppearanceTabs from '@/components/appearance-tabs';
|
import AppearanceTabs from '@/components/appearance-tabs';
|
||||||
import { edit as editAppearance } from '@/routes/appearance';
|
import { edit as editAppearance } from '@/routes/appearance';
|
||||||
import { Head } from '@inertiajs/react';
|
|
||||||
|
|
||||||
export default function Appearance() {
|
export default function Appearance() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -146,7 +146,7 @@ export default function Permissions() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- permission check on mount is safe
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- permission check on mount is safe
|
||||||
checkCameraPermission();
|
checkCameraPermission();
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- permission check on mount is safe
|
|
||||||
checkLocationPermission();
|
checkLocationPermission();
|
||||||
}, [checkCameraPermission, 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 ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||||
import { DatePicker } from '@/components/date-picker';
|
import { DatePicker } from '@/components/date-picker';
|
||||||
import InputError from '@/components/input-error';
|
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 { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { edit } from '@/routes/profile';
|
import { edit } from '@/routes/profile';
|
||||||
import { Form, Head } from '@inertiajs/react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
type UserData = {
|
type UserData = {
|
||||||
id: number;
|
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 SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
import PasswordInput from '@/components/password-input';
|
import PasswordInput from '@/components/password-input';
|
||||||
@ -5,8 +7,6 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { edit } from '@/routes/security';
|
import { edit } from '@/routes/security';
|
||||||
import { Form, Head } from '@inertiajs/react';
|
|
||||||
import { useRef } from 'react';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
passwordRules: string;
|
passwordRules: string;
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Admin\AdminSettingsController;
|
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\CashAccountController;
|
||||||
use App\Http\Controllers\Admin\Finance\EmployeeAdvanceController;
|
use App\Http\Controllers\Admin\Finance\EmployeeAdvanceController;
|
||||||
use App\Http\Controllers\Admin\Finance\ExpenseController;
|
use App\Http\Controllers\Admin\Finance\ExpenseController;
|
||||||
@ -10,6 +12,7 @@
|
|||||||
use App\Http\Controllers\Admin\HR\AttendanceController;
|
use App\Http\Controllers\Admin\HR\AttendanceController;
|
||||||
use App\Http\Controllers\Admin\HR\EmployeeController;
|
use App\Http\Controllers\Admin\HR\EmployeeController;
|
||||||
use App\Http\Controllers\Admin\HR\LeaveRequestController;
|
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\PurchaseController;
|
||||||
use App\Http\Controllers\Admin\Manage\RestockController;
|
use App\Http\Controllers\Admin\Manage\RestockController;
|
||||||
use App\Http\Controllers\Admin\Master\CategoryController;
|
use App\Http\Controllers\Admin\Master\CategoryController;
|
||||||
@ -23,7 +26,7 @@
|
|||||||
use App\Http\Controllers\Admin\RoleController;
|
use App\Http\Controllers\Admin\RoleController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', fn () => inertia('welcome', [
|
Route::get('/', fn() => inertia('welcome', [
|
||||||
'seo' => [
|
'seo' => [
|
||||||
'title' => 'DST Collection - DST Punya Gaya',
|
'title' => 'DST Collection - DST Punya Gaya',
|
||||||
'description' => 'DST Collection - DST Punya Gaya. Toko fashion terpercaya dengan koleksi terlengkap.',
|
'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::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('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');
|
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