From e7aa582572bfcb80b4d57d9caf28f6b086f13a45 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Tue, 4 Aug 2026 02:24:11 +0700 Subject: [PATCH] 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. --- .../Admin/Manage/CuttingController.php | 73 ++ app/Http/Middleware/HandleInertiaRequests.php | 4 +- .../Requests/Admin/Manage/CuttingRequest.php | 51 ++ app/Models/Cutting.php | 33 +- app/Services/Admin/Manage/CuttingService.php | 395 +++++++++ database/factories/CuttingFactory.php | 4 - database/seeders/RolePermissionSeeder.php | 4 + resources/js/components/app-sidebar.tsx | 68 +- resources/js/components/camera-capture.tsx | 14 +- resources/js/components/card-table.tsx | 7 +- .../js/components/card/attendance-card.tsx | 98 +++ resources/js/components/date-picker.tsx | 29 +- resources/js/components/file-upload.tsx | 1 + resources/js/components/filter-popover.tsx | 2 +- resources/js/components/location-map.tsx | 6 +- resources/js/components/notification-bell.tsx | 1 + .../js/components/phone-number-input.tsx | 4 +- resources/js/components/rupiah-input.tsx | 1 + resources/js/components/user-menu-content.tsx | 4 +- resources/js/hooks/use-can.ts | 48 ++ resources/js/hooks/use-cutting-draft.ts | 20 + resources/js/hooks/use-infinite-scroll.ts | 11 +- resources/js/lib/cutting-draft.ts | 48 ++ resources/js/lib/rupiah.ts | 20 + .../js/pages/admin/hr/attendance/index.tsx | 72 +- .../js/pages/admin/hr/employee/create.tsx | 6 +- resources/js/pages/admin/hr/employee/edit.tsx | 6 +- .../js/pages/admin/manage/cutting/columns.tsx | 104 +++ .../js/pages/admin/manage/cutting/create.tsx | 700 ++++++++++++++++ .../admin/manage/cutting/cutting-card.tsx | 146 ++++ .../admin/manage/cutting/cutting-sub-row.tsx | 131 +++ .../js/pages/admin/manage/cutting/edit.tsx | 675 ++++++++++++++++ .../js/pages/admin/manage/cutting/index.tsx | 132 ++++ resources/js/pages/admin/roles/create.tsx | 4 +- resources/js/pages/admin/roles/edit.tsx | 4 +- resources/js/pages/admin/settings/index.tsx | 6 +- resources/js/pages/auth/login.tsx | 2 +- resources/js/pages/settings/appearance.tsx | 2 +- resources/js/pages/settings/permissions.tsx | 2 +- resources/js/pages/settings/profile.tsx | 4 +- resources/js/pages/settings/security.tsx | 4 +- routes/web.php | 8 +- tests/Feature/Admin/Manage/CuttingTest.php | 747 ++++++++++++++++++ 43 files changed, 3570 insertions(+), 131 deletions(-) create mode 100644 app/Http/Controllers/Admin/Manage/CuttingController.php create mode 100644 app/Http/Requests/Admin/Manage/CuttingRequest.php create mode 100644 app/Services/Admin/Manage/CuttingService.php create mode 100644 resources/js/components/card/attendance-card.tsx create mode 100644 resources/js/hooks/use-can.ts create mode 100644 resources/js/hooks/use-cutting-draft.ts create mode 100644 resources/js/lib/cutting-draft.ts create mode 100644 resources/js/lib/rupiah.ts create mode 100644 resources/js/pages/admin/manage/cutting/columns.tsx create mode 100644 resources/js/pages/admin/manage/cutting/create.tsx create mode 100644 resources/js/pages/admin/manage/cutting/cutting-card.tsx create mode 100644 resources/js/pages/admin/manage/cutting/cutting-sub-row.tsx create mode 100644 resources/js/pages/admin/manage/cutting/edit.tsx create mode 100644 resources/js/pages/admin/manage/cutting/index.tsx create mode 100644 tests/Feature/Admin/Manage/CuttingTest.php diff --git a/app/Http/Controllers/Admin/Manage/CuttingController.php b/app/Http/Controllers/Admin/Manage/CuttingController.php new file mode 100644 index 0000000..1ea9f12 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/CuttingController.php @@ -0,0 +1,73 @@ + $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' + ); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 97276ac..6f5a55b 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -39,7 +39,9 @@ public function share(Request $request): array ...parent::share($request), 'name' => config('app.name'), 'auth' => [ - 'user' => $request->user()?->load('userProfile'), + 'user' => $request->user() + ? $request->user()->load('userProfile', 'roles', 'permissions') + : null, ], 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'seo' => [ diff --git a/app/Http/Requests/Admin/Manage/CuttingRequest.php b/app/Http/Requests/Admin/Manage/CuttingRequest.php new file mode 100644 index 0000000..5cafaca --- /dev/null +++ b/app/Http/Requests/Admin/Manage/CuttingRequest.php @@ -0,0 +1,51 @@ + ['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', + ]; + } +} diff --git a/app/Models/Cutting.php b/app/Models/Cutting.php index 273c32b..3dc2d94 100644 --- a/app/Models/Cutting.php +++ b/app/Models/Cutting.php @@ -4,48 +4,28 @@ use App\Enums\CuttingStatus; use Illuminate\Database\Eloquent\Attributes\Guarded; -use Illuminate\Database\Eloquent\Attributes\Scope; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; #[Guarded(['id'])] -class Cutting extends Model +class Cutting extends Model implements HasMedia { - use HasFactory, SoftDeletes; + use HasFactory, InteractsWithMedia, SoftDeletes; protected function casts(): array { return [ 'status' => CuttingStatus::class, 'total_material_cost' => 'integer', - 'sewing_cost' => 'integer', - 'other_cost' => 'integer', 'cost_per_unit' => 'integer', ]; } - #[Scope] - protected function cancelled(Builder $query): void - { - $query->where('status', CuttingStatus::CANCELLED); - } - - #[Scope] - protected function completed(Builder $query): void - { - $query->where('status', CuttingStatus::COMPLETED); - } - - #[Scope] - protected function inProgress(Builder $query): void - { - $query->where('status', CuttingStatus::IN_PROGRESS); - } - public function createdBy(): BelongsTo { return $this->belongsTo(User::class, 'created_by_id'); @@ -65,9 +45,4 @@ public function cuttingResults(): HasMany { return $this->hasMany(CuttingResult::class); } - - public function submittedBy(): BelongsTo - { - return $this->belongsTo(User::class, 'submitted_by_id'); - } } diff --git a/app/Services/Admin/Manage/CuttingService.php b/app/Services/Admin/Manage/CuttingService.php new file mode 100644 index 0000000..ef90e21 --- /dev/null +++ b/app/Services/Admin/Manage/CuttingService.php @@ -0,0 +1,395 @@ +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; + }); + } +} diff --git a/database/factories/CuttingFactory.php b/database/factories/CuttingFactory.php index ea61f40..e35792a 100644 --- a/database/factories/CuttingFactory.php +++ b/database/factories/CuttingFactory.php @@ -10,16 +10,12 @@ class CuttingFactory extends Factory public function definition(): array { $materialCost = fake()->numberBetween(50000, 5000000); - $sewingCost = fake()->numberBetween(10000, 500000); - $otherCost = fake()->numberBetween(0, 200000); return [ 'created_by_id' => User::factory(), 'status' => fake()->randomElement(['in_progress', 'completed', 'cancelled']), 'description' => fake()->sentence(), 'total_material_cost' => $materialCost, - 'sewing_cost' => $sewingCost, - 'other_cost' => $otherCost, 'cost_per_unit' => fake()->numberBetween(1000, 50000), ]; } diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index 01b3b29..54cf36d 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -28,7 +28,10 @@ public function run(): void 'attendance' => ['view', 'check-in', 'check-out', 'by-date'], 'settings' => ['view', 'update-system', 'update-homepage', 'update-social-media', 'update-marketplace', 'update-hr'], 'purchase' => ['view', 'create', 'update', 'delete'], + 'cutting' => ['view', 'create', 'update', 'delete'], 'restock' => ['view', 'create', 'update', 'delete'], + 'dashboard' => ['attendance', 'revenue', 'expense', 'orders_channel', 'orders_payment', 'orders_marketing', 'orders_status'], + 'analysis' => ['attendance', 'cash', 'raw_materials', 'product_stock', 'revenue', 'expense', 'profit_gross', 'profit_hpp', 'profit_orders', 'marketing_sales', 'top_suppliers', 'top_products', 'top_customers', 'busy_hours'], ]; foreach ($permissions as $module => $actions) { @@ -56,6 +59,7 @@ public function run(): void 'Admin Bahan Baku' => array_filter($allPermissions, function ($p) { return str_starts_with($p, 'supplier.') || str_starts_with($p, 'purchase.') + || str_starts_with($p, 'cutting.') || str_starts_with($p, 'restock.') || $p === 'category.view' || $p === 'customer.view' diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index a1b1c76..aad42e4 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -1,33 +1,3 @@ -import React from 'react'; -import AppLogo from '@/components/app-logo'; -import { - Sidebar, - SidebarContent, - SidebarGroup, - SidebarGroupLabel, - SidebarHeader, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from '@/components/ui/sidebar'; -import { useCurrentUrl } from '@/hooks/use-current-url'; -import { dashboard } from '@/routes'; -import { index as cashAccountsIndex } from '@/routes/admin/finance/cash-accounts'; -import { index as employeeAdvancesIndex } from '@/routes/admin/finance/employee-advances'; -import { index as expensesIndex } from '@/routes/admin/finance/expenses'; -import { current as payrollCurrent } from '@/routes/admin/finance/payroll-periods'; -import { index as attendancesIndex } from '@/routes/admin/hr/attendances'; -import { index as employeesIndex } from '@/routes/admin/hr/employees'; -import { index as leaveRequestsIndex } from '@/routes/admin/hr/leave-requests'; -import { index as categoriesIndex } from '@/routes/admin/master/categories'; -import { index as customersIndex } from '@/routes/admin/master/customers'; -import { index as productsIndex } from '@/routes/admin/master/products'; -import { index as rawMaterialsIndex } from '@/routes/admin/master/raw-materials'; -import { index as purchasesIndex } from '@/routes/admin/manage/purchases'; -import { index as restocksIndex } from '@/routes/admin/manage/restocks'; -import { index as suppliersIndex } from '@/routes/admin/master/suppliers'; -import { index as rolesIndex } from '@/routes/admin/settings/roles'; import { Link, router } from '@inertiajs/react'; import type { LucideIcon } from 'lucide-react'; import { @@ -53,6 +23,38 @@ import { Users, Wallet, } from 'lucide-react'; +import React from 'react'; +import AppLogo from '@/components/app-logo'; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from '@/components/ui/sidebar'; +import { useCurrentUrl } from '@/hooks/use-current-url'; +import { dashboard } from '@/routes'; +import admin from '@/routes/admin'; +import { index as cashAccountsIndex } from '@/routes/admin/finance/cash-accounts'; +import { index as employeeAdvancesIndex } from '@/routes/admin/finance/employee-advances'; +import { index as expensesIndex } from '@/routes/admin/finance/expenses'; +import { current as payrollCurrent } from '@/routes/admin/finance/payroll-periods'; +import { index as attendancesIndex } from '@/routes/admin/hr/attendances'; +import { index as employeesIndex } from '@/routes/admin/hr/employees'; +import { index as leaveRequestsIndex } from '@/routes/admin/hr/leave-requests'; +import { index as cuttingsIndex } from '@/routes/admin/manage/cuttings'; +import { index as purchasesIndex } from '@/routes/admin/manage/purchases'; +import { index as restocksIndex } from '@/routes/admin/manage/restocks'; +import { index as categoriesIndex } from '@/routes/admin/master/categories'; +import { index as customersIndex } from '@/routes/admin/master/customers'; +import { index as productsIndex } from '@/routes/admin/master/products'; +import { index as rawMaterialsIndex } from '@/routes/admin/master/raw-materials'; +import { index as suppliersIndex } from '@/routes/admin/master/suppliers'; +import { index as rolesIndex } from '@/routes/admin/settings/roles'; type NavMenuItem = { title: string; href: string; icon: LucideIcon }; @@ -78,7 +80,7 @@ const masterItems: NavMenuItem[] = [ const kelolaItems: NavMenuItem[] = [ { title: 'Belanja', href: purchasesIndex.url(), icon: ShoppingCart }, - { title: 'Cutting', href: '#', icon: Scissors }, + { title: 'Cutting', href: cuttingsIndex.url(), icon: Scissors }, { title: 'Restock', href: restocksIndex.url(), icon: RefreshCw }, { title: 'Stok Opname', href: '#', icon: ClipboardCheck }, ]; @@ -133,7 +135,9 @@ export function AppSidebar() { const { isMobile, setOpenMobile } = useSidebar(); React.useEffect(() => { - if (!isMobile) return; + if (!isMobile) { +return; +} const cleanup = router.on('finish', () => { setOpenMobile(false); diff --git a/resources/js/components/camera-capture.tsx b/resources/js/components/camera-capture.tsx index 5857df5..80855ba 100644 --- a/resources/js/components/camera-capture.tsx +++ b/resources/js/components/camera-capture.tsx @@ -1,6 +1,6 @@ +import { Camera, RotateCcw, X } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; -import { Camera, RotateCcw, X } from 'lucide-react'; interface CameraCaptureProps { onCapture: (dataUrl: string) => void; @@ -20,9 +20,11 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) { video: { facingMode: 'user', width: 640, height: 480 }, }); setStream(mediaStream); + if (videoRef.current) { videoRef.current.srcObject = mediaStream; } + setError(null); } catch { setError( @@ -33,13 +35,16 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) { useEffect(() => { startCamera(); + return () => { stream?.getTracks().forEach((track) => track.stop()); }; }, []); const capture = () => { - if (!videoRef.current || !canvasRef.current) return; + if (!videoRef.current || !canvasRef.current) { +return; +} const canvas = canvasRef.current; const video = videoRef.current; @@ -47,7 +52,10 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) { canvas.height = video.videoHeight; const ctx = canvas.getContext('2d'); - if (!ctx) return; + + if (!ctx) { +return; +} ctx.translate(canvas.width, 0); ctx.scale(-1, 1); diff --git a/resources/js/components/card-table.tsx b/resources/js/components/card-table.tsx index 201578b..42c67f4 100644 --- a/resources/js/components/card-table.tsx +++ b/resources/js/components/card-table.tsx @@ -63,6 +63,7 @@ function useDebounce(callback: (value: string) => void, delay: number) { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } + timeoutRef.current = setTimeout(() => { callback(value); }, delay); @@ -102,13 +103,17 @@ export function CardTable({ function handleSearchChange(value: string) { setLocalSearch(value); + if (isServerMode) { handleSearchDebounced(value); } } function isItemExpanded(key: number | string): boolean { - if (expandedKeys === 'all') return true; + if (expandedKeys === 'all') { +return true; +} + return expandedKeys.has(key); } diff --git a/resources/js/components/card/attendance-card.tsx b/resources/js/components/card/attendance-card.tsx new file mode 100644 index 0000000..6ea4ff5 --- /dev/null +++ b/resources/js/components/card/attendance-card.tsx @@ -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 ( + + +
+
+ +
+ Presensi Hari Ini +
+
+ + {isOnLeave ? ( +
+ Anda sedang cuti hari ini. +
+ ) : !canCheckIn ? ( +
+ Anda tidak memiliki akses presensi. +
+ ) : ( +
+
+
+

Masuk

+

{formatTime(todayAttendance?.check_in_at ?? null)}

+
+
+

Pulang

+

{formatTime(todayAttendance?.check_out_at ?? null)}

+
+
+ {todayAttendance?.work_duration_minutes != null && ( +
+

Durasi Kerja

+

+ {Math.floor(todayAttendance.work_duration_minutes / 60)}j {todayAttendance.work_duration_minutes % 60}m +

+
+ )} +
+ {!hasCheckedIn ? ( + + ) : !hasCheckedOut ? ( + + ) : ( +
+ Presensi selesai untuk hari ini. +
+ )} +
+
+ )} +
+
+ ); +} diff --git a/resources/js/components/date-picker.tsx b/resources/js/components/date-picker.tsx index b4a4446..c310257 100644 --- a/resources/js/components/date-picker.tsx +++ b/resources/js/components/date-picker.tsx @@ -1,9 +1,8 @@ -import * as React from 'react'; import { format } from 'date-fns'; import { id } from 'date-fns/locale'; import { CalendarIcon } from 'lucide-react'; +import * as React from 'react'; -import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Calendar } from '@/components/ui/calendar'; import { @@ -11,6 +10,7 @@ import { PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; interface DatePickerProps { value?: Date | string | null; @@ -38,13 +38,22 @@ function DatePicker({ const [open, setOpen] = React.useState(false); const date = React.useMemo(() => { - if (!value) return undefined; - if (value instanceof Date) return value; + if (!value) { +return undefined; +} + + if (value instanceof Date) { +return value; +} + return new Date(value); }, [value]); const formattedDate = React.useMemo(() => { - if (!date) return ''; + if (!date) { +return ''; +} + return format(date, 'dd MMM yyyy', { locale: id }); }, [date]); @@ -74,8 +83,14 @@ function DatePicker({ setOpen(false); }} disabled={(date) => { - if (min && date < min) return true; - if (max && date > max) return true; + if (min && date < min) { +return true; +} + + if (max && date > max) { +return true; +} + return false; }} initialFocus diff --git a/resources/js/components/file-upload.tsx b/resources/js/components/file-upload.tsx index 313e080..27cd5f5 100644 --- a/resources/js/components/file-upload.tsx +++ b/resources/js/components/file-upload.tsx @@ -62,6 +62,7 @@ function formatMaxSize(bytes: number): string { if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(0)}KB`; } + return `${(bytes / (1024 * 1024)).toFixed(0)}MB`; } diff --git a/resources/js/components/filter-popover.tsx b/resources/js/components/filter-popover.tsx index be49f71..a2670c0 100644 --- a/resources/js/components/filter-popover.tsx +++ b/resources/js/components/filter-popover.tsx @@ -1,3 +1,4 @@ +import { Filter, X } from 'lucide-react'; import type { ReactNode } from 'react'; import { Button } from '@/components/ui/button'; import { @@ -5,7 +6,6 @@ import { PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; -import { Filter, X } from 'lucide-react'; type FilterPopoverProps = { open: boolean; diff --git a/resources/js/components/location-map.tsx b/resources/js/components/location-map.tsx index a5fa14e..3896953 100644 --- a/resources/js/components/location-map.tsx +++ b/resources/js/components/location-map.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef } from 'react'; import L from 'leaflet'; +import { useEffect, useRef } from 'react'; import 'leaflet/dist/leaflet.css'; interface LocationMapProps { @@ -19,7 +19,9 @@ export function LocationMap({ const mapInstanceRef = useRef(null); useEffect(() => { - if (!mapRef.current || mapInstanceRef.current) return; + if (!mapRef.current || mapInstanceRef.current) { +return; +} const map = L.map(mapRef.current, { center: [latitude, longitude], diff --git a/resources/js/components/notification-bell.tsx b/resources/js/components/notification-bell.tsx index 405dc45..205a461 100644 --- a/resources/js/components/notification-bell.tsx +++ b/resources/js/components/notification-bell.tsx @@ -194,6 +194,7 @@ export function NotificationBell() { className="min-w-0 flex-1 cursor-pointer" onClick={(e) => { e.preventDefault(); + if (notification.url) { window.location.href = notification.url; diff --git a/resources/js/components/phone-number-input.tsx b/resources/js/components/phone-number-input.tsx index bec6f15..58f04d2 100644 --- a/resources/js/components/phone-number-input.tsx +++ b/resources/js/components/phone-number-input.tsx @@ -1,5 +1,5 @@ -import { Input } from '@/components/ui/input'; import { useCallback, useRef, useState } from 'react'; +import { Input } from '@/components/ui/input'; type PhoneNumberInputProps = { name?: string; @@ -12,9 +12,11 @@ type PhoneNumberInputProps = { function formatPhone(value: string | null | undefined): string { const digits = (value ?? '').replace(/[^0-9]/g, ''); const groups: string[] = []; + for (let i = 0; i < digits.length; i += 4) { groups.push(digits.slice(i, i + 4)); } + return groups.join(' '); } diff --git a/resources/js/components/rupiah-input.tsx b/resources/js/components/rupiah-input.tsx index 158c5fb..6cdb151 100644 --- a/resources/js/components/rupiah-input.tsx +++ b/resources/js/components/rupiah-input.tsx @@ -47,6 +47,7 @@ export function RupiahInput({ if (isControlled) { const formatted = formatRupiah(value); + if (formatted !== displayValue) { setDisplayValue(formatted); lastValidRef.current = value; diff --git a/resources/js/components/user-menu-content.tsx b/resources/js/components/user-menu-content.tsx index bed9ee1..8334403 100644 --- a/resources/js/components/user-menu-content.tsx +++ b/resources/js/components/user-menu-content.tsx @@ -1,3 +1,5 @@ +import { Link, router } from '@inertiajs/react'; +import { LogOut, Settings } from 'lucide-react'; import { DropdownMenuGroup, DropdownMenuItem, @@ -9,8 +11,6 @@ import { useMobileNavigation } from '@/hooks/use-mobile-navigation'; import { logout } from '@/routes'; import { edit } from '@/routes/profile'; import type { User } from '@/types'; -import { Link, router } from '@inertiajs/react'; -import { LogOut, Settings } from 'lucide-react'; type Props = { user: User; diff --git a/resources/js/hooks/use-can.ts b/resources/js/hooks/use-can.ts new file mode 100644 index 0000000..707c613 --- /dev/null +++ b/resources/js/hooks/use-can.ts @@ -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 }; +} diff --git a/resources/js/hooks/use-cutting-draft.ts b/resources/js/hooks/use-cutting-draft.ts new file mode 100644 index 0000000..6615c66 --- /dev/null +++ b/resources/js/hooks/use-cutting-draft.ts @@ -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 }, + }); +} diff --git a/resources/js/hooks/use-infinite-scroll.ts b/resources/js/hooks/use-infinite-scroll.ts index 43fdad3..85b33a8 100644 --- a/resources/js/hooks/use-infinite-scroll.ts +++ b/resources/js/hooks/use-infinite-scroll.ts @@ -27,7 +27,9 @@ export function useInfiniteScroll({ const sentinelRef = useRef(null); const loadMore = useCallback(() => { - if (loading || currentPage >= lastPage) return; + if (loading || currentPage >= lastPage) { +return; +} setLoading(true); @@ -42,7 +44,7 @@ export function useInfiniteScroll({ preserveState: true, replace: true, only: ['mutations'], - // eslint-disable-next-line @typescript-eslint/no-explicit-any + onSuccess: (page: any) => { const newMutations = (page.props as Record) .mutations as PaginatedData; @@ -61,7 +63,10 @@ export function useInfiniteScroll({ useEffect(() => { const sentinel = sentinelRef.current; - if (!sentinel) return; + + if (!sentinel) { +return; +} const observer = new IntersectionObserver( (entries) => { diff --git a/resources/js/lib/cutting-draft.ts b/resources/js/lib/cutting-draft.ts new file mode 100644 index 0000000..54aaa97 --- /dev/null +++ b/resources/js/lib/cutting-draft.ts @@ -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('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); +} diff --git a/resources/js/lib/rupiah.ts b/resources/js/lib/rupiah.ts new file mode 100644 index 0000000..a3a2759 --- /dev/null +++ b/resources/js/lib/rupiah.ts @@ -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); +} diff --git a/resources/js/pages/admin/hr/attendance/index.tsx b/resources/js/pages/admin/hr/attendance/index.tsx index 4b6d36e..b87f4b6 100644 --- a/resources/js/pages/admin/hr/attendance/index.tsx +++ b/resources/js/pages/admin/hr/attendance/index.tsx @@ -1,19 +1,3 @@ -import { CameraCapture } from '@/components/camera-capture'; -import { LocationMap } from '@/components/location-map'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { - index as attendanceIndex, - store, - update, -} from '@/routes/admin/hr/attendances'; import { Head, router } from '@inertiajs/react'; import { addMonths, format, subMonths } from 'date-fns'; import { id } from 'date-fns/locale'; @@ -31,6 +15,22 @@ import { } from 'lucide-react'; import { useMemo, useState } from 'react'; import { toast } from 'sonner'; +import { CameraCapture } from '@/components/camera-capture'; +import { LocationMap } from '@/components/location-map'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + index as attendanceIndex, + store, + update, +} from '@/routes/admin/hr/attendances'; type Attendance = { id: number; @@ -85,6 +85,7 @@ function isSameDay(d1: Date, d2: Date): boolean { function checkIsToday(date: Date): boolean { const t = new Date(); + return ( date.getDate() === t.getDate() && date.getMonth() === t.getMonth() && @@ -94,6 +95,7 @@ function checkIsToday(date: Date): boolean { function isWeekend(date: Date): boolean { const day = date.getDay(); + return day === 0 || day === 6; } @@ -102,10 +104,14 @@ function isLate( officeHour: number, officeMinute: number, ): boolean { - if (!checkInAt) return false; + if (!checkInAt) { +return false; +} + const d = new Date(checkInAt); const h = d.getHours(); const m = d.getMinutes(); + return h > officeHour || (h === officeHour && m > officeMinute); } @@ -114,18 +120,29 @@ function getLateMinutes( officeHour: number, officeMinute: number, ): number { - if (!checkInAt || !isLate(checkInAt, officeHour, officeMinute)) return 0; + if (!checkInAt || !isLate(checkInAt, officeHour, officeMinute)) { +return 0; +} + const d = new Date(checkInAt); const officeStart = new Date(d); officeStart.setHours(officeHour, officeMinute, 0, 0); + return Math.ceil((d.getTime() - officeStart.getTime()) / 60000); } function formatMinutes(minutes: number | null): string { - if (!minutes) return '-'; + if (!minutes) { +return '-'; +} + const hours = Math.floor(minutes / 60); const mins = Math.floor(minutes % 60); - if (mins === 0) return `${hours} jam`; + + if (mins === 0) { +return `${hours} jam`; +} + return `${hours} jam ${mins} menit`; } @@ -161,11 +178,13 @@ export default function AttendanceIndex({ attendances.forEach((att) => { dates.set(att.attendance_date, att); }); + return dates; }, [attendances]); const selectedAttendance = useMemo(() => { const dateStr = format(selectedDate, 'yyyy-MM-dd'); + return attendanceDates.get(dateStr) ?? null; }, [selectedDate, attendanceDates]); @@ -199,6 +218,7 @@ export default function AttendanceIndex({ } const remaining = 42 - days.length; + for (let i = 1; i <= remaining; i++) { const m = viewMonth === 12 ? 1 : viewMonth + 1; const y = viewMonth === 12 ? viewYear + 1 : viewYear; @@ -222,6 +242,7 @@ export default function AttendanceIndex({ if (!navigator.geolocation) { setLocationLoading(false); toast.error('Geolocation tidak didukung di browser ini.'); + return; } @@ -268,7 +289,10 @@ export default function AttendanceIndex({ }; function formatTime(dateStr: string | null): string { - if (!dateStr) return '-'; + if (!dateStr) { +return '-'; +} + return format(new Date(dateStr), 'HH:mm'); } @@ -555,8 +579,10 @@ export default function AttendanceIndex({ key={idx} onClick={() => { setSelectedDate(cell.date); - if (attendance) - setDetailAttendance(attendance); + + if (attendance) { +setDetailAttendance(attendance); +} }} className={`group relative flex min-h-[100px] flex-col p-2 text-left transition-colors hover:bg-muted/50 ${ !cell.isCurrentMonth diff --git a/resources/js/pages/admin/hr/employee/create.tsx b/resources/js/pages/admin/hr/employee/create.tsx index 6528270..fd4a3da 100644 --- a/resources/js/pages/admin/hr/employee/create.tsx +++ b/resources/js/pages/admin/hr/employee/create.tsx @@ -1,3 +1,6 @@ +import { Form, Head } from '@inertiajs/react'; +import { AlertCircle, ArrowLeft } from 'lucide-react'; +import { useState } from 'react'; import { DatePicker } from '@/components/date-picker'; import InputError from '@/components/input-error'; import { PhoneNumberInput } from '@/components/phone-number-input'; @@ -17,9 +20,6 @@ import { } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { index as employeeIndex, store } from '@/routes/admin/hr/employees'; -import { Form, Head } from '@inertiajs/react'; -import { AlertCircle, ArrowLeft } from 'lucide-react'; -import { useState } from 'react'; export default function EmployeeCreate() { const [joinDate, setJoinDate] = useState(undefined); diff --git a/resources/js/pages/admin/hr/employee/edit.tsx b/resources/js/pages/admin/hr/employee/edit.tsx index 57603f6..0926480 100644 --- a/resources/js/pages/admin/hr/employee/edit.tsx +++ b/resources/js/pages/admin/hr/employee/edit.tsx @@ -1,3 +1,6 @@ +import { Form, Head } from '@inertiajs/react'; +import { ArrowLeft } from 'lucide-react'; +import { useState } from 'react'; import { DatePicker } from '@/components/date-picker'; import InputError from '@/components/input-error'; import { PhoneNumberInput } from '@/components/phone-number-input'; @@ -16,9 +19,6 @@ import { } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { index as employeeIndex, update } from '@/routes/admin/hr/employees'; -import { Form, Head } from '@inertiajs/react'; -import { ArrowLeft } from 'lucide-react'; -import { useState } from 'react'; type EmployeeData = { id: number; diff --git a/resources/js/pages/admin/manage/cutting/columns.tsx b/resources/js/pages/admin/manage/cutting/columns.tsx new file mode 100644 index 0000000..c7e18c3 --- /dev/null +++ b/resources/js/pages/admin/manage/cutting/columns.tsx @@ -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; + }[]; + }[]; +}; diff --git a/resources/js/pages/admin/manage/cutting/create.tsx b/resources/js/pages/admin/manage/cutting/create.tsx new file mode 100644 index 0000000..742a822 --- /dev/null +++ b/resources/js/pages/admin/manage/cutting/create.tsx @@ -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(() => { + 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(() => { + 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(draft?.photo ?? null); + const [photoUrl, setPhotoUrl] = useState( + draft?.photo ? getTemporaryUrl(draft.photo) : null, + ); + const [uploading, setUploading] = useState(false); + const submittingRef = useRef(false); + + const [cartOpen, setCartOpen] = useState(false); + const [previewKey, setPreviewKey] = useState(null); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [deleteMaterialIndex, setDeleteMaterialIndex] = useState(null); + + const [cartDeleteConfirmOpen, setCartDeleteConfirmOpen] = useState(false); + const [cartDeleteIndex, setCartDeleteIndex] = useState(null); + const [comboDeleteConfirmOpen, setComboDeleteConfirmOpen] = useState(false); + const [comboDeleteIndex, setComboDeleteIndex] = useState(null); + + const [comboDialogOpen, setComboDialogOpen] = useState(false); + const [comboMaterialName, setComboMaterialName] = useState(''); + const [comboSelectedPriceIds, setComboSelectedPriceIds] = useState([]); + 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)[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 ( + <> + + +
+
+

Tambah Cutting

+ +
+ +
({ ...data, ...getPayload() })} onSubmit={() => { submittingRef.current = true; }}> + {({ errors, processing }) => ( +
+
+ + + Pilih Bahan Baku + + +
+ + m.name} + value={selectedMaterial} + onValueChange={(value) => setSelectedMaterialName(value?.name ?? '')} + > + + + Tidak ada bahan baku ditemukan. + + {(m) => ( + + {m.name} ({m.unit}) + + )} + + + +
+ + {selectedMaterial && selectedMaterial.raw_material_prices.length > 0 && ( +
+ +
+ {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 ( +
+
+ {price.photo_url ? ( + {price.variant} + ) : ( +
N/A
+ )} +
+

{price.variant}

+

+ Stok: {formatNumber(Number(price.stock))} {selectedMaterial.unit} · {formatCurrency(price.price)} + {addedCount > 0 && ` · ×${addedCount}`} +

+
+
+
+ + +
+
+ ); + })} +
+
+ )} + + +
+
+
+ +
+ + + Ringkasan + + +
+ + setProductName(e.target.value)} placeholder="Masukkan nama produk" /> + +
+ +
+
+ + + +
+
+ + + +
+
+ +
+ + + +
+ +
+
+
+ Total + {formatCurrency(totalCost)} +
+
+ {cuttingResult > 0 && ( +
+ Biaya Per Unit + {formatCurrency(costPerUnit)} +
+ )} +
+ +
+ +