From 85556f8779aafa404b1bca05d2496a43d3e05600 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Fri, 7 Aug 2026 11:23:41 +0700 Subject: [PATCH] feat: enhance product management with approval and rejection workflows - Added approval and rejection functionality for products, including new routes and methods in the ProductController. - Implemented UI changes to display product status (pending, rejected) with appropriate badges and actions. - Introduced a RejectDialog component for providing rejection reasons. - Updated product columns to handle new actions for approving and rejecting products. - Enhanced variant management to restrict actions based on product status. - Refactored various components to improve code organization and readability. --- .ai/CONTEXT.md | 10 +- .ai/CONVENTIONS.md | 54 +++++ .ai/REFERENCE.md | 13 +- app/Enums/Permission.php | 5 - app/Enums/ProductStatus.php | 4 + app/Enums/Role.php | 4 - .../Master/Product/ProductController.php | 28 +++ .../Admin/Master/Product/ProductRequest.php | 2 +- app/Models/OwnerVerificationRequest.php | 38 ---- app/Models/Product.php | 12 ++ app/Models/User.php | 10 - .../SystemConfigMigration.php | 1 - .../Admin/Master/Product/ProductService.php | 90 ++++++++- .../Master/Product/ProductVariantService.php | 23 +++ .../OwnerVerificationRequestFactory.php | 20 -- ...eate_owner_verification_requests_table.php | 31 --- ...add_rejection_reason_to_products_table.php | 28 +++ ...drop_owner_verification_requests_table.php | 24 +++ database/seeders/RolePermissionSeeder.php | 5 - resources/js/components/app-sidebar.tsx | 52 ++--- resources/js/components/form-dialog.tsx | 6 +- resources/js/hooks/use-server-table.ts | 1 + resources/js/hooks/use-thermal-printer.ts | 11 +- .../cash-account/transaction-columns.tsx | 4 +- .../finance/employee-advance/columns.tsx | 5 +- .../admin/finance/employee-advance/index.tsx | 8 +- .../js/pages/admin/hr/attendance/index.tsx | 1 + .../js/pages/admin/manage/cutting/create.tsx | 8 +- .../admin/manage/cutting/cutting-card.tsx | 2 +- .../admin/manage/cutting/cutting-sub-row.tsx | 16 +- .../js/pages/admin/manage/cutting/edit.tsx | 8 +- .../js/pages/admin/manage/purchase/index.tsx | 6 +- .../admin/manage/restock/restock-sub-row.tsx | 10 +- .../pages/admin/manage/transaction/create.tsx | 10 +- .../pages/admin/manage/transaction/edit.tsx | 9 +- .../pages/admin/manage/transaction/index.tsx | 1 + .../manage/transaction/transaction-card.tsx | 3 +- .../transaction/transaction-summary-card.tsx | 4 +- .../js/pages/admin/master/category/index.tsx | 6 +- .../js/pages/admin/master/product/columns.tsx | 190 +++++++++++++----- .../js/pages/admin/master/product/index.tsx | 26 ++- .../admin/master/product/product-card.tsx | 89 +++++++- .../admin/master/product/reject-dialog.tsx | 86 ++++++++ .../admin/master/product/variant/sub-row.tsx | 22 +- resources/js/pages/auth/forgot-password.tsx | 2 +- resources/js/pages/auth/login.tsx | 3 - routes/web.php | 3 + 47 files changed, 716 insertions(+), 278 deletions(-) delete mode 100644 app/Models/OwnerVerificationRequest.php delete mode 100644 database/factories/OwnerVerificationRequestFactory.php delete mode 100644 database/migrations/2026_06_26_100000_create_owner_verification_requests_table.php create mode 100644 database/migrations/2026_08_07_103717_add_rejection_reason_to_products_table.php create mode 100644 database/migrations/2026_08_07_105308_drop_owner_verification_requests_table.php create mode 100644 resources/js/pages/admin/master/product/reject-dialog.tsx diff --git a/.ai/CONTEXT.md b/.ai/CONTEXT.md index b4c1869..d3d1452 100644 --- a/.ai/CONTEXT.md +++ b/.ai/CONTEXT.md @@ -79,10 +79,10 @@ ## Module Overview ## Statistics | Metric | Count | |--------|-------| -| Tables | 49 | -| Models | 42 | -| Enums | 20 + 1 trait | -| Services | 28 (including 3 concerns) | -| Controllers | 30 | +| Tables | 48 | +| Models | 43 | +| Enums | 21 | +| Services | 30 | +| Controllers | 32 | | Roles | 9 | | Permissions | ~120 | diff --git a/.ai/CONVENTIONS.md b/.ai/CONVENTIONS.md index d733363..3df4dd3 100644 --- a/.ai/CONVENTIONS.md +++ b/.ai/CONVENTIONS.md @@ -4,6 +4,58 @@ # Code Conventions --- +## Namespace & Import Convention + +### Backend → SELALU pakai `use` import di atas file +```php +// ✅ SELALU import class di atas file, gunakan nama pendek di kode +use App\Models\Product; +use Illuminate\Database\Eloquent\Attributes\Scope; +use Illuminate\Database\Eloquent\Builder; + +#[Guarded(['id'])] +class Product extends Model +{ + #[Scope] + protected function active(Builder $query): void { ... } +} + +// ❌ JANGAN tulis namespace lengkap di deklarasi atribut/class +#[\Illuminate\Database\Eloquent\Attributes\Scope] // JANGAN +protected function active(\Illuminate\Database\Eloquent\Builder $query): void { ... } + +// ❌ JANGAN import di tengah file atau inline +class Product extends \App\Models\Model { ... } // JANGAN +``` + +### Aturan Import +```php +// ✅ Urutan import (group by namespace): +// 1. PHP built-in (DateTime, etc) +// 2. Laravel/Framework (Illuminate\*) +// 3. App (App\Models, App\Services, etc) +// 4. Third-party (Spatie, etc) + +// ✅ Untuk atribut PHP 8+ → gunakan short name +use Illuminate\Database\Eloquent\Attributes\Scope; +use Illuminate\Database\Eloquent\Casts\Attribute; + +#[Scope] // ✅ short name +#[Appends] // ✅ short name + +// ✅ Untuk type hint → gunakan short name +public function __construct( + private ProductService $service, // ✅ short name +) {} + +// ❌ JANGAN +public function __construct( + private \App\Services\Admin\Master\Product\ProductService $service, // JANGAN +) {} +``` + +--- + ## Language Convention ### Backend → English @@ -719,6 +771,8 @@ ### Stock Adjustment Pattern $this->adjustStock($model, $field, $quantity, $sign); ``` + + --- ## Form Request diff --git a/.ai/REFERENCE.md b/.ai/REFERENCE.md index 106bb83..abfeb1a 100644 --- a/.ai/REFERENCE.md +++ b/.ai/REFERENCE.md @@ -10,7 +10,7 @@ ### `users` → User `id` `email`(unique) `username`(unique) `password` `is_active`(bool) `last_login_at`(datetime) `created_at` `updated_at` `deleted_at` - Casts: email_verified_at(datetime), is_active(bool), last_login_at(datetime), password(hashed), two_factor_confirmed_at(datetime) - Scopes: active() -- Relations: userProfile(HasOne→UserProfile), employee(HasOne→Employee), attendances(HasMany→Attendance), cashAccounts(HasMany→CashAccount,created_by_id), cashTransactions(HasMany→CashTransaction,created_by_id), createdCuttings(HasMany→Cutting,created_by_id), submittedCuttings(HasMany→Cutting,submitted_by_id), createdExpenses(HasMany→Expense,created_by_id), createdOrders(HasMany→Order,created_by_id), marketingOrders(HasMany→Order,marketing_id), orderItems(HasMany→OrderItem), createdPurchases(HasMany→Purchase,created_by_id), createdRestocks(HasMany→Restock,created_by_id), stokOpnamesCreated(HasMany→StokOpname,created_by_id), stokOpnamesVerified(HasMany→StokOpname,verified_by_id), employeeAdvancesPaid(HasMany→EmployeeAdvance,paid_by_id), employeeAdvancesVerified(HasMany→EmployeeAdvance,verified_by_id), paidPayrolls(HasMany→Payroll,paid_by_id), payrollPeriodsClosed(HasMany→PayrollPeriod,closed_by_id), notifications(HasMany→AppNotification), ownerVerificationRequestsSubmitted(HasMany→OwnerVerificationRequest,submitted_by_id), ownerVerificationRequestsVerified(HasMany→OwnerVerificationRequest,verified_by_id), rejections(HasMany→Rejection,rejected_by_id), pushSubscriptions(HasMany→PushSubscription,morph) +- Relations: userProfile(HasOne→UserProfile), employee(HasOne→Employee), attendances(HasMany→Attendance), cashAccounts(HasMany→CashAccount,created_by_id), cashTransactions(HasMany→CashTransaction,created_by_id), createdCuttings(HasMany→Cutting,created_by_id), submittedCuttings(HasMany→Cutting,submitted_by_id), createdExpenses(HasMany→Expense,created_by_id), createdOrders(HasMany→Order,created_by_id), marketingOrders(HasMany→Order,marketing_id), orderItems(HasMany→OrderItem), createdPurchases(HasMany→Purchase,created_by_id), createdRestocks(HasMany→Restock,created_by_id), stokOpnamesCreated(HasMany→StokOpname,created_by_id), stokOpnamesVerified(HasMany→StokOpname,verified_by_id), employeeAdvancesPaid(HasMany→EmployeeAdvance,paid_by_id), employeeAdvancesVerified(HasMany→EmployeeAdvance,verified_by_id), paidPayrolls(HasMany→Payroll,paid_by_id), payrollPeriodsClosed(HasMany→PayrollPeriod,closed_by_id), notifications(HasMany→AppNotification), rejections(HasMany→Rejection,rejected_by_id), pushSubscriptions(HasMany→PushSubscription,morph) ### `user_profiles` → UserProfile `id` `user_id`(FK→users,unique) `full_name`(200) `phone_number`(20,null) `gender`(enum,null) `birth_date`(date,null) `address`(text,null) `created_at` `updated_at` `deleted_at` @@ -42,9 +42,9 @@ ### `product_categories` → Category (Pivot) - Relations: category(BelongsTo→Category), product(BelongsTo→Product) ### `products` → Product -`id` `name`(200) `slug`(200,unique) `description`(text,null) `status`(enum,default:active) `created_at` `updated_at` `deleted_at` +`id` `name`(200) `slug`(200,unique) `description`(text,null) `status`(enum,default:active) `rejection_reason`(text,null) `created_at` `updated_at` `deleted_at` - Casts: status(ProductStatus) -- Scopes: active(), draft(), inactive() +- Scopes: active(), draft(), inactive(), pending(), rejected() - Relations: categories(BelongsToMany→Category), productVariants(HasMany→ProductVariant) ### `product_variants` → ProductVariant @@ -263,10 +263,6 @@ ### `homepage_configurations` → HomepageConfiguration `id` `created_at` `updated_at` - Singleton table -### `owner_verification_requests` → OwnerVerificationRequest -`id` `action`(50) `status`(50,default:pending) `subject_id`(ubig,null,morph) `subject_type`(string,null,morph) `submitted_by_id`(FK→users) `verified_by_id`(FK→users,null) `payload`(json) `verified_at`(datetime,null) `created_at` `updated_at` -- Casts: payload(array), verified_at(datetime) -- Relations: subject(MorphTo), submittedBy(BelongsTo→User), verifiedBy(BelongsTo→User) ### `settings` (laravel-settings) `id` `group`(string) `name`(string) `locked`(bool,default:false) `payload`(json) `created_at` `updated_at` @@ -293,7 +289,7 @@ ## Enums | `PayrollStatus` | unpaid, paid, cancelled | payrolls.status | | `Permission` | — | Permission action names | | `PriceType` | retail, wholesale, capital | product_prices.type, orders.price_type | -| `ProductStatus` | active, draft, inactive | products.status | +| `ProductStatus` | active, draft, inactive, pending, rejected | products.status | | `ProductStockQuality` | good, reject | order_items.stock_quality, restocks.stock_type, stok_opname_items.stock_quality | | `RawMaterialUnit` | kg, meter, yard | raw_materials.unit | | `Role` | — | Role names | @@ -306,3 +302,4 @@ ## Service Concerns | `HandlesCashTransactions` | getCashAccount(), creditCash(), debitCash() | CashAccountService, ExpenseService, EmployeeAdvanceService, PayrollPeriodService | | `HasStockAdjustment` | adjustStock(), adjustVariantStock(), applyStock(), reverseStock() | TransactionService, RestockService | | `RegistersMedia` | registerMedia(), syncPhoto() | CashAccountService, CuttingService, ExpenseService, PurchaseService, RestockService, TransactionService | + diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php index c349473..2def17c 100644 --- a/app/Enums/Permission.php +++ b/app/Enums/Permission.php @@ -121,11 +121,6 @@ enum Permission: string case PAYROLL_CANCEL = 'payroll.cancel'; case PAYROLL_ADJUST = 'payroll.adjust'; - // Owner Verifications - case OWNER_VERIFICATIONS_VIEW = 'owner_verifications.view'; - case OWNER_VERIFICATIONS_VERIFY = 'owner_verifications.verify'; - case OWNER_VERIFICATIONS_REJECT = 'owner_verifications.reject'; - // Restocks case RESTOCKS_VIEW = 'restocks.view'; case RESTOCKS_CREATE = 'restocks.create'; diff --git a/app/Enums/ProductStatus.php b/app/Enums/ProductStatus.php index 092ab1a..adc9691 100644 --- a/app/Enums/ProductStatus.php +++ b/app/Enums/ProductStatus.php @@ -11,6 +11,8 @@ enum ProductStatus: string case ACTIVE = 'active'; case INACTIVE = 'inactive'; case DRAFT = 'draft'; + case PENDING = 'pending'; + case REJECTED = 'rejected'; public function label(): string { @@ -18,6 +20,8 @@ public function label(): string self::ACTIVE => 'Aktif', self::INACTIVE => 'Non Aktif', self::DRAFT => 'Draft', + self::PENDING => 'Menunggu Verifikasi', + self::REJECTED => 'Ditolak', }; } } diff --git a/app/Enums/Role.php b/app/Enums/Role.php index 39872f7..785555b 100644 --- a/app/Enums/Role.php +++ b/app/Enums/Role.php @@ -169,8 +169,6 @@ public function permissions(): array Permission::PRODUCTS_TOGGLE_STATUS, Permission::STOCKS_VIEW, - Permission::OWNER_VERIFICATIONS_VIEW, - Permission::ORDERS_VIEW, Permission::ORDERS_CREATE, Permission::ORDERS_UPDATE, @@ -297,8 +295,6 @@ public function permissions(): array Permission::RAW_MATERIALS_TOGGLE_STATUS, Permission::STOCKS_VIEW, - Permission::OWNER_VERIFICATIONS_VIEW, - Permission::PRODUCTS_VIEW, Permission::PRODUCTS_CREATE, Permission::PRODUCTS_UPDATE, diff --git a/app/Http/Controllers/Admin/Master/Product/ProductController.php b/app/Http/Controllers/Admin/Master/Product/ProductController.php index a247ca9..d486015 100644 --- a/app/Http/Controllers/Admin/Master/Product/ProductController.php +++ b/app/Http/Controllers/Admin/Master/Product/ProductController.php @@ -86,4 +86,32 @@ public function toggleStatus(Product $product): RedirectResponse return to_route('admin.master.products.index'); } + + public function approve(Product $product): RedirectResponse + { + $this->service->approve($product); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil disetujui.']); + + return to_route('admin.master.products.index'); + } + + public function reject(Product $product): RedirectResponse + { + $reason = request()->input('rejection_reason', ''); + $this->service->reject($product, $reason); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil ditolak.']); + + return to_route('admin.master.products.index'); + } + + public function resubmit(Product $product): RedirectResponse + { + $this->service->resubmit($product); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil diajukan ulang.']); + + return to_route('admin.master.products.index'); + } } diff --git a/app/Http/Requests/Admin/Master/Product/ProductRequest.php b/app/Http/Requests/Admin/Master/Product/ProductRequest.php index e26c112..472d9b3 100644 --- a/app/Http/Requests/Admin/Master/Product/ProductRequest.php +++ b/app/Http/Requests/Admin/Master/Product/ProductRequest.php @@ -34,7 +34,7 @@ public function rules(): array 'max:200', ], 'description' => ['nullable', 'string'], - 'status' => ['nullable', Rule::in(['active', 'inactive', 'draft'])], + 'status' => ['nullable', Rule::in(['active', 'inactive', 'draft', 'pending', 'rejected'])], 'category_ids' => ['required', 'array', 'min:1'], 'category_ids.*' => ['exists:categories,id'], 'use_same_price' => ['nullable', 'boolean'], diff --git a/app/Models/OwnerVerificationRequest.php b/app/Models/OwnerVerificationRequest.php deleted file mode 100644 index c4ed7f3..0000000 --- a/app/Models/OwnerVerificationRequest.php +++ /dev/null @@ -1,38 +0,0 @@ - 'array', - 'verified_at' => 'datetime', - ]; - } - - public function subject(): MorphTo - { - return $this->morphTo(); - } - - public function submittedBy(): BelongsTo - { - return $this->belongsTo(User::class, 'submitted_by_id'); - } - - public function verifiedBy(): BelongsTo - { - return $this->belongsTo(User::class, 'verified_by_id'); - } -} diff --git a/app/Models/Product.php b/app/Models/Product.php index 5f3d78e..7d12c09 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -61,6 +61,18 @@ protected function inactive(Builder $query): void $query->where('status', ProductStatus::INACTIVE); } + #[Scope] + protected function pending(Builder $query): void + { + $query->where('status', ProductStatus::PENDING); + } + + #[Scope] + protected function rejected(Builder $query): void + { + $query->where('status', ProductStatus::REJECTED); + } + public function categories(): BelongsToMany { return $this->belongsToMany(Category::class, 'product_categories') diff --git a/app/Models/User.php b/app/Models/User.php index 694f394..663176b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -156,16 +156,6 @@ public function orderItems(): HasMany return $this->hasMany(OrderItem::class); } - public function ownerVerificationRequestsSubmitted(): HasMany - { - return $this->hasMany(OwnerVerificationRequest::class, 'submitted_by_id'); - } - - public function ownerVerificationRequestsVerified(): HasMany - { - return $this->hasMany(OwnerVerificationRequest::class, 'verified_by_id'); - } - public function paidPayrolls(): HasMany { return $this->hasMany(Payroll::class, 'paid_by_id'); diff --git a/app/OldDataMigrations/SystemConfigMigration.php b/app/OldDataMigrations/SystemConfigMigration.php index 7affcd3..2dffb95 100644 --- a/app/OldDataMigrations/SystemConfigMigration.php +++ b/app/OldDataMigrations/SystemConfigMigration.php @@ -24,7 +24,6 @@ public function migrate(): array return $row; }); $results['homepage_configurations'] = $this->migrateTable('homepage_configurations'); - $results['owner_verification_requests'] = $this->migrateTable('owner_verification_requests'); $results['notifications'] = $this->migrateTable('notifications'); return $results; diff --git a/app/Services/Admin/Master/Product/ProductService.php b/app/Services/Admin/Master/Product/ProductService.php index bc9680c..da2bd6a 100644 --- a/app/Services/Admin/Master/Product/ProductService.php +++ b/app/Services/Admin/Master/Product/ProductService.php @@ -2,6 +2,7 @@ namespace App\Services\Admin\Master\Product; +use App\Enums\ProductStatus; use App\Models\Product; use App\Models\ProductVariant; use App\Services\NotificationService; @@ -10,6 +11,7 @@ use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; class ProductService { @@ -19,6 +21,22 @@ public function __construct( private StockMutationService $stockMutationService, ) {} + private function canVerify(): bool + { + $user = auth()->user(); + + return $user->hasAnyRole(['developer', 'owner']); + } + + private function assertNotPending(Product $product): void + { + if ($product->status === ProductStatus::PENDING && ! $this->canVerify()) { + throw ValidationException::withMessages([ + 'status' => 'Produk sedang menunggu verifikasi dan tidak dapat diubah.', + ]); + } + } + public function getNames(): array { return Product::where('status', '!=', 'deleted') @@ -31,7 +49,7 @@ public function getNames(): array public function getAll(array $filters = []): Collection { - $products = Product::select(['id', 'name', 'slug', 'description', 'status']) + $products = Product::select(['id', 'name', 'slug', 'description', 'status', 'rejection_reason']) ->with([ 'categories:id,name', 'productVariants:id,product_id,name,stock,reject_stock,retail_stock', @@ -56,7 +74,7 @@ public function getAll(array $filters = []): Collection public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator { $paginator = Product::query() - ->select(['id', 'name', 'slug', 'description', 'status']) + ->select(['id', 'name', 'slug', 'description', 'status', 'rejection_reason']) ->with([ 'categories:id,name', 'productVariants:id,product_id,name,stock,reject_stock,retail_stock', @@ -89,11 +107,15 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = public function create(array $data): Product { - $product = DB::transaction(function () use ($data) { + $status = $this->canVerify() + ? ($data['status'] ?? ProductStatus::ACTIVE) + : ProductStatus::PENDING; + + $product = DB::transaction(function () use ($data, $status) { $product = Product::create([ 'name' => $data['name'], 'description' => $data['description'] ?? null, - 'status' => $data['status'] ?? 'active', + 'status' => $status, ]); $product->categories()->sync($data['category_ids']); @@ -209,11 +231,19 @@ public function getForEdit(Product $product): array public function update(Product $product, array $data): Product { + $this->assertNotPending($product); + $product = DB::transaction(function () use ($product, $data) { + // Auto-resubmit: non-verifier editing rejected product → status becomes pending + $newStatus = $data['status'] ?? $product->status; + if ($product->status === ProductStatus::REJECTED && ! $this->canVerify()) { + $newStatus = ProductStatus::PENDING; + } + $product->update([ 'name' => $data['name'], 'description' => $data['description'] ?? null, - 'status' => $data['status'] ?? $product->status, + 'status' => $newStatus, ]); $product->categories()->sync($data['category_ids']); @@ -414,6 +444,8 @@ public function update(Product $product, array $data): Product public function delete(Product $product): bool { + $this->assertNotPending($product); + $result = DB::transaction(function () use ($product) { $product->productVariants->each(function (ProductVariant $variant) { $variant->productPrices()->delete(); @@ -438,8 +470,56 @@ public function delete(Product $product): bool public function toggleStatus(Product $product): void { + $this->assertNotPending($product); + $product->update([ 'status' => $product->status->value === 'active' ? 'inactive' : 'active', ]); } + + public function approve(Product $product): void + { + $product->update([ + 'status' => ProductStatus::ACTIVE, + ]); + + NotificationService::notify( + roles: ['Owner', 'Developer'], + title: 'Produk Disetujui', + body: "Produk \"{$product->name}\" telah disetujui oleh ".auth()->user()->full_name.'.', + url: route('admin.master.products.index'), + additionalUser: $product->createdBy ?? null, + ); + } + + public function reject(Product $product, string $reason = ''): void + { + $product->update([ + 'status' => ProductStatus::REJECTED, + 'rejection_reason' => $reason, + ]); + + NotificationService::notify( + roles: ['Owner', 'Developer'], + title: 'Produk Ditolak', + body: "Produk \"{$product->name}\" telah ditolak oleh ".auth()->user()->full_name.'.', + url: route('admin.master.products.index'), + additionalUser: $product->createdBy ?? null, + ); + } + + public function resubmit(Product $product): void + { + $product->update([ + 'status' => ProductStatus::PENDING, + 'rejection_reason' => null, + ]); + + NotificationService::notify( + roles: ['Owner', 'Developer'], + title: 'Produk Diajukan Ulang', + body: "Produk \"{$product->name}\" telah diajukan ulang oleh ".auth()->user()->full_name.'.', + url: route('admin.master.products.index'), + ); + } } diff --git a/app/Services/Admin/Master/Product/ProductVariantService.php b/app/Services/Admin/Master/Product/ProductVariantService.php index 2d32a99..055c643 100644 --- a/app/Services/Admin/Master/Product/ProductVariantService.php +++ b/app/Services/Admin/Master/Product/ProductVariantService.php @@ -2,6 +2,7 @@ namespace App\Services\Admin\Master\Product; +use App\Enums\ProductStatus; use App\Models\Product; use App\Models\ProductVariant; use App\Services\Concerns\RegistersMedia; @@ -20,6 +21,22 @@ public function __construct( private StockMutationService $stockMutationService, ) {} + private function canVerify(): bool + { + $user = auth()->user(); + + return $user->hasAnyRole(['developer', 'owner']); + } + + private function assertNotPending(Product $product): void + { + if ($product->status === ProductStatus::PENDING && ! $this->canVerify()) { + throw ValidationException::withMessages([ + 'status' => 'Produk sedang menunggu verifikasi dan tidak dapat diubah.', + ]); + } + } + public function getForEdit(ProductVariant $variant): array { $variant->load([ @@ -49,6 +66,8 @@ public function getForEdit(ProductVariant $variant): array public function update(ProductVariant $variant, array $data): ProductVariant { + $this->assertNotPending($variant->product); + DB::transaction(function () use ($variant, $data) { $oldData = $variant->only(['stock', 'reject_stock', 'retail_stock']); @@ -94,6 +113,8 @@ public function update(ProductVariant $variant, array $data): ProductVariant public function delete(Product $product, ProductVariant $variant): bool { + $this->assertNotPending($product); + $result = DB::transaction(function () use ($variant) { $variant->productPrices()->delete(); $variant->clearMediaCollection('photos'); @@ -134,6 +155,8 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari { $quantity = (int) $data['quantity']; + $this->assertNotPending($variant->product); + if ($variant->stock < $quantity) { throw ValidationException::withMessages([ 'quantity' => "Stok bagus tidak mencukupi. Stok tersedia: {$variant->stock}.", diff --git a/database/factories/OwnerVerificationRequestFactory.php b/database/factories/OwnerVerificationRequestFactory.php deleted file mode 100644 index 14bd9ac..0000000 --- a/database/factories/OwnerVerificationRequestFactory.php +++ /dev/null @@ -1,20 +0,0 @@ - fake()->randomElement(['create', 'update', 'delete']), - 'status' => 'pending', - 'submitted_by_id' => User::factory(), - 'payload' => json_encode(['key' => fake()->word(), 'value' => fake()->sentence()]), - 'verified_at' => null, - ]; - } -} diff --git a/database/migrations/2026_06_26_100000_create_owner_verification_requests_table.php b/database/migrations/2026_06_26_100000_create_owner_verification_requests_table.php deleted file mode 100644 index 9ddebd1..0000000 --- a/database/migrations/2026_06_26_100000_create_owner_verification_requests_table.php +++ /dev/null @@ -1,31 +0,0 @@ -id(); - - $table->string('action', 50); - $table->string('status', 50)->default('pending'); - $table->nullableMorphs('subject'); - $table->foreignId('submitted_by_id')->constrained('users')->restrictOnDelete(); - $table->foreignId('verified_by_id')->nullable()->constrained('users')->restrictOnDelete(); - $table->json('payload'); - $table->timestamp('verified_at')->nullable(); - - $table->timestamp('created_at')->useCurrent(); - $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); - }); - } - - public function down(): void - { - Schema::dropIfExists('owner_verification_requests'); - } -}; diff --git a/database/migrations/2026_08_07_103717_add_rejection_reason_to_products_table.php b/database/migrations/2026_08_07_103717_add_rejection_reason_to_products_table.php new file mode 100644 index 0000000..758ebf7 --- /dev/null +++ b/database/migrations/2026_08_07_103717_add_rejection_reason_to_products_table.php @@ -0,0 +1,28 @@ +text('rejection_reason')->nullable()->after('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn('rejection_reason'); + }); + } +}; diff --git a/database/migrations/2026_08_07_105308_drop_owner_verification_requests_table.php b/database/migrations/2026_08_07_105308_drop_owner_verification_requests_table.php new file mode 100644 index 0000000..22f8777 --- /dev/null +++ b/database/migrations/2026_08_07_105308_drop_owner_verification_requests_table.php @@ -0,0 +1,24 @@ + ['view'], 'employee_advances' => ['view', 'create', 'update', 'delete', 'pay', 'view_payments', 'verify'], 'payroll' => ['view', 'pay', 'cancel', 'adjust'], - 'owner_verifications' => ['view', 'verify', 'reject'], 'restocks' => ['view', 'create', 'update', 'delete'], 'raw_materials' => ['view', 'create', 'update', 'delete', 'toggle_status'], 'suppliers' => ['view', 'create', 'update', 'delete'], @@ -127,8 +126,6 @@ public function run(): void 'products.transfer_stock', 'products.view_stock_mutations', - 'owner_verifications.view', - 'orders.view', 'orders.create', 'orders.update', @@ -256,8 +253,6 @@ public function run(): void 'raw_materials.update', 'raw_materials.toggle_status', - 'owner_verifications.view', - 'cuttings.view', 'cuttings.create', 'cuttings.update', diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 527e8bf..0bf4f34 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -1,3 +1,29 @@ +import { Link, router } from '@inertiajs/react'; +import type { LucideIcon } from 'lucide-react'; +import { + Activity, + ArrowUpFromLine, + BarChart3, + Boxes, + CalendarCheck, + CalendarDays, + ClipboardCheck, + DollarSign, + HandCoins, + LayoutGrid, + Package, + RefreshCw, + Scissors, + Settings, + Shield, + ShoppingCart, + Tags, + Truck, + UserCircle, + Users, + Wallet, +} from 'lucide-react'; +import React from 'react'; import AppLogo from '@/components/app-logo'; import { Sidebar, @@ -31,32 +57,6 @@ 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'; -import { Link, router } from '@inertiajs/react'; -import type { LucideIcon } from 'lucide-react'; -import { - Activity, - ArrowUpFromLine, - BarChart3, - Boxes, - CalendarCheck, - CalendarDays, - ClipboardCheck, - DollarSign, - HandCoins, - LayoutGrid, - Package, - RefreshCw, - Scissors, - Settings, - Shield, - ShoppingCart, - Tags, - Truck, - UserCircle, - Users, - Wallet, -} from 'lucide-react'; -import React from 'react'; type NavMenuItem = { title: string; href: string; icon: LucideIcon; permission?: string | string[] }; diff --git a/resources/js/components/form-dialog.tsx b/resources/js/components/form-dialog.tsx index f10b8a3..7775def 100644 --- a/resources/js/components/form-dialog.tsx +++ b/resources/js/components/form-dialog.tsx @@ -1,3 +1,6 @@ +import { Form } from '@inertiajs/react'; +import type { ReactNode } from 'react'; +import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -6,9 +9,6 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { Form } from '@inertiajs/react'; -import type { ReactNode } from 'react'; -import { toast } from 'sonner'; type FormDialogProps = { open: boolean; diff --git a/resources/js/hooks/use-server-table.ts b/resources/js/hooks/use-server-table.ts index fa2a07f..5543baf 100644 --- a/resources/js/hooks/use-server-table.ts +++ b/resources/js/hooks/use-server-table.ts @@ -20,6 +20,7 @@ export function useServerTable({ useEffect(() => { const params = new URLSearchParams(window.location.search); + if (params.has('search')) { params.delete('search'); const cleanUrl = diff --git a/resources/js/hooks/use-thermal-printer.ts b/resources/js/hooks/use-thermal-printer.ts index 8e1e84c..07a5dcd 100644 --- a/resources/js/hooks/use-thermal-printer.ts +++ b/resources/js/hooks/use-thermal-printer.ts @@ -70,6 +70,7 @@ export function encodeOrderReceipt( for (const addressLine of options.storeAddress.split('\n')) { const trimmed = addressLine.trim(); + if (trimmed) { encoder.align('center').text(trimmed).newline(); } @@ -102,7 +103,7 @@ export function encodeOrderReceipt( [ [ `${item.quantity} x ${item.unit_price}`, - // eslint-disable-next-line @typescript-eslint/no-explicit-any + (rowEncoder: any) => rowEncoder .bold() @@ -215,6 +216,7 @@ export function useThermalPrinter() { let characteristic: BluetoothCharacteristic | null = null; const services = await server.getPrimaryServices(); + for (const svc of services) { try { const chars = await svc.getCharacteristics(); @@ -223,6 +225,7 @@ export function useThermalPrinter() { c.properties.writeWithoutResponse || c.properties.write, ); + if (writable) { characteristic = writable; break; @@ -286,6 +289,7 @@ export function useThermalPrinter() { const sendToBluetooth = useCallback(async (data: Uint8Array) => { const characteristic = bluetoothCharacteristicRef.current; + if (!characteristic) { throw new Error( 'Printer Bluetooth tidak terhubung.', @@ -298,17 +302,20 @@ export function useThermalPrinter() { for (let i = 0; i < data.length; i += chunkSize) { const chunk = data.slice(i, i + chunkSize); + if (useWriteWithoutResponse) { await characteristic.writeValueWithoutResponse(chunk); } else { await characteristic.writeValueWithResponse(chunk); } + await new Promise((r) => setTimeout(r, 10)); } }, []); const sendToUSB = useCallback(async (data: Uint8Array) => { const device = usbDeviceRef.current; + if (!device) { throw new Error('Printer USB tidak terhubung.'); } @@ -322,6 +329,7 @@ export function useThermalPrinter() { )?.endpointNumber ?? 1; const chunkSize = 512; + for (let i = 0; i < data.length; i += chunkSize) { const chunk = data.slice(i, i + chunkSize); await device.transferOut(endpoint, chunk); @@ -352,6 +360,7 @@ export function useThermalPrinter() { if (type === 'bluetooth') { return await connectBluetooth(); } + return await connectUSB(); }, [state.connected, disconnect, connectBluetooth, connectUSB], diff --git a/resources/js/pages/admin/finance/cash-account/transaction-columns.tsx b/resources/js/pages/admin/finance/cash-account/transaction-columns.tsx index f0a0a9e..abcb8f9 100644 --- a/resources/js/pages/admin/finance/cash-account/transaction-columns.tsx +++ b/resources/js/pages/admin/finance/cash-account/transaction-columns.tsx @@ -1,7 +1,7 @@ -import { ImagePreviewButton } from '@/components/image-preview-button'; -import { RowActions } from '@/components/row-actions'; import type { ColumnDef } from '@tanstack/react-table'; import { Pencil, Trash2 } from 'lucide-react'; +import { ImagePreviewButton } from '@/components/image-preview-button'; +import { RowActions } from '@/components/row-actions'; export type CashTransaction = { id: number; diff --git a/resources/js/pages/admin/finance/employee-advance/columns.tsx b/resources/js/pages/admin/finance/employee-advance/columns.tsx index 0c4e01f..bf0ea9f 100644 --- a/resources/js/pages/admin/finance/employee-advance/columns.tsx +++ b/resources/js/pages/admin/finance/employee-advance/columns.tsx @@ -1,7 +1,7 @@ -import { RowActions } from '@/components/row-actions'; -import { Badge } from '@/components/ui/badge'; import type { ColumnDef } from '@tanstack/react-table'; import { CheckCircle, CircleDollarSign, History, Pencil, Trash2 } from 'lucide-react'; +import { RowActions } from '@/components/row-actions'; +import { Badge } from '@/components/ui/badge'; export type EmployeeAdvancePayment = { id: number; @@ -126,6 +126,7 @@ export function createEmployeeAdvanceColumns( header: () => Sisa, cell: ({ row }) => { const employeeAdvance = row.original; + if (employeeAdvance.status === 'paid') { return Lunas; } diff --git a/resources/js/pages/admin/finance/employee-advance/index.tsx b/resources/js/pages/admin/finance/employee-advance/index.tsx index 91d64f1..ccaade8 100644 --- a/resources/js/pages/admin/finance/employee-advance/index.tsx +++ b/resources/js/pages/admin/finance/employee-advance/index.tsx @@ -1,3 +1,6 @@ +import { Head, router, usePage } from '@inertiajs/react'; +import { Plus } from 'lucide-react'; +import { useEffect, useState } from 'react'; import type { PaginationState } from '@/components/data-table'; import { DataTable } from '@/components/data-table'; import { DatePicker } from '@/components/date-picker'; @@ -17,7 +20,6 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Separator } from '@/components/ui/separator'; import { Select, SelectContent, @@ -25,6 +27,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Separator } from '@/components/ui/separator'; import { useCan } from '@/hooks/use-can'; import { useServerTable } from '@/hooks/use-server-table'; import { @@ -35,9 +38,6 @@ import { store, update, } from '@/routes/admin/finance/employee-advances'; -import { Head, router, usePage } from '@inertiajs/react'; -import { Plus } from 'lucide-react'; -import { useEffect, useState } from 'react'; import type { EmployeeAdvance } from './columns'; import { createEmployeeAdvanceColumns } from './columns'; diff --git a/resources/js/pages/admin/hr/attendance/index.tsx b/resources/js/pages/admin/hr/attendance/index.tsx index fb48de8..8dfce5a 100644 --- a/resources/js/pages/admin/hr/attendance/index.tsx +++ b/resources/js/pages/admin/hr/attendance/index.tsx @@ -593,6 +593,7 @@ export default function AttendanceIndex({ key={idx} onClick={() => { setSelectedDate(cell.date); + if (!isAdmin && dayAttendances.length > 0) { setDetailAttendance(dayAttendances[0]); } diff --git a/resources/js/pages/admin/manage/cutting/create.tsx b/resources/js/pages/admin/manage/cutting/create.tsx index 1b0b2d3..0a73915 100644 --- a/resources/js/pages/admin/manage/cutting/create.tsx +++ b/resources/js/pages/admin/manage/cutting/create.tsx @@ -1,5 +1,9 @@ 'use no memo'; +import { Form, Head, Link, usePage } from '@inertiajs/react'; +import { ArrowLeft, Check, Layers, Plus, ShoppingCart, Trash2 } from 'lucide-react'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { toast } from 'sonner'; import { ConfirmDialog } from '@/components/confirm-dialog'; import { FileUpload } from '@/components/file-upload'; import { ImagePreviewModal } from '@/components/image-preview-modal'; @@ -19,10 +23,6 @@ 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, Link, usePage } from '@inertiajs/react'; -import { ArrowLeft, Check, Layers, Plus, ShoppingCart, Trash2 } from 'lucide-react'; -import { useCallback, useMemo, useRef, useState } from 'react'; -import { toast } from 'sonner'; import type { CuttingCreateData } from './columns'; type MaterialState = { diff --git a/resources/js/pages/admin/manage/cutting/cutting-card.tsx b/resources/js/pages/admin/manage/cutting/cutting-card.tsx index ae74580..e974efc 100644 --- a/resources/js/pages/admin/manage/cutting/cutting-card.tsx +++ b/resources/js/pages/admin/manage/cutting/cutting-card.tsx @@ -1,3 +1,4 @@ +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'; @@ -5,7 +6,6 @@ import { Card, CardContent } from '@/components/ui/card'; import { useCan } from '@/hooks/use-can'; import { formatDateTime, formatNumber } from '@/lib/format'; import { formatCurrency } from '@/lib/utils'; -import { ChevronDown, Pencil, Trash2 } from 'lucide-react'; import type { Cutting } from './columns'; export type CuttingCardRowParams = { diff --git a/resources/js/pages/admin/manage/cutting/cutting-sub-row.tsx b/resources/js/pages/admin/manage/cutting/cutting-sub-row.tsx index e77ac58..725a6c0 100644 --- a/resources/js/pages/admin/manage/cutting/cutting-sub-row.tsx +++ b/resources/js/pages/admin/manage/cutting/cutting-sub-row.tsx @@ -1,3 +1,4 @@ +import { Fragment } from 'react'; import { ImagePreviewButton } from '@/components/image-preview-button'; import { Table, @@ -8,7 +9,6 @@ import { TableRow, } from '@/components/ui/table'; import { formatNumber } from '@/lib/format'; -import { Fragment } from 'react'; import type { Cutting } from './columns'; export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) { @@ -21,7 +21,11 @@ export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) { const comboGroups: Record = {}; comboItems.forEach((item) => { const comboId = item.combination_id!; - if (!comboGroups[comboId]) comboGroups[comboId] = []; + + if (!comboGroups[comboId]) { +comboGroups[comboId] = []; +} + comboGroups[comboId].push(item); }); @@ -29,8 +33,13 @@ export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) { (acc, item) => { const name = item.raw_material_price?.raw_material?.name ?? 'BING'; - if (!acc[name]) acc[name] = []; + + if (!acc[name]) { +acc[name] = []; +} + acc[name].push(item); + return acc; }, {} as Record, @@ -106,6 +115,7 @@ export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) { {groupItems.map((item) => { counter++; + return ( diff --git a/resources/js/pages/admin/manage/cutting/edit.tsx b/resources/js/pages/admin/manage/cutting/edit.tsx index 190a3f7..a6222f7 100644 --- a/resources/js/pages/admin/manage/cutting/edit.tsx +++ b/resources/js/pages/admin/manage/cutting/edit.tsx @@ -1,5 +1,9 @@ 'use no memo'; +import { Form, Head, Link, usePage } from '@inertiajs/react'; +import { ArrowLeft, Check, Layers, Plus, ShoppingCart, Trash2 } from 'lucide-react'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { toast } from 'sonner'; import { ConfirmDialog } from '@/components/confirm-dialog'; import { FileUpload } from '@/components/file-upload'; import { ImagePreviewModal } from '@/components/image-preview-modal'; @@ -17,10 +21,6 @@ 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, Link, usePage } from '@inertiajs/react'; -import { ArrowLeft, Check, Layers, Plus, ShoppingCart, Trash2 } from 'lucide-react'; -import { useCallback, useMemo, useRef, useState } from 'react'; -import { toast } from 'sonner'; import type { CuttingCreateData, CuttingForEdit } from './columns'; type MaterialState = { diff --git a/resources/js/pages/admin/manage/purchase/index.tsx b/resources/js/pages/admin/manage/purchase/index.tsx index 70d9305..711c493 100644 --- a/resources/js/pages/admin/manage/purchase/index.tsx +++ b/resources/js/pages/admin/manage/purchase/index.tsx @@ -1,3 +1,6 @@ +import { Head, Link, router } from '@inertiajs/react'; +import { Plus } from 'lucide-react'; +import { useMemo, useState } from 'react'; import { CardTable } from '@/components/card-table'; import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; import { FilterPopover } from '@/components/filter-popover'; @@ -20,9 +23,6 @@ import { edit as purchaseEdit, index as purchaseIndex, } from '@/routes/admin/manage/purchases'; -import { Head, Link, router } from '@inertiajs/react'; -import { Plus } from 'lucide-react'; -import { useMemo, useState } from 'react'; import type { Purchase } from './columns'; import { PurchaseCardRow } from './purchase-card'; import { PurchaseItemSubRow } from './purchase-sub-row'; diff --git a/resources/js/pages/admin/manage/restock/restock-sub-row.tsx b/resources/js/pages/admin/manage/restock/restock-sub-row.tsx index c865484..ec236b1 100644 --- a/resources/js/pages/admin/manage/restock/restock-sub-row.tsx +++ b/resources/js/pages/admin/manage/restock/restock-sub-row.tsx @@ -1,3 +1,4 @@ +import { Fragment } from 'react'; import { ImagePreviewButton } from '@/components/image-preview-button'; import { Table, @@ -7,7 +8,6 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; -import { Fragment } from 'react'; import { formatNumber } from '@/lib/format'; import { formatCurrency } from '@/lib/utils'; import type { Restock } from './columns'; @@ -18,8 +18,13 @@ export function RestockItemSubRow({ restock }: { restock: Restock }) { const groupedByProduct = items.reduce( (acc, item) => { const name = item.product_variant?.product?.name ?? 'Tanpa Produk'; - if (!acc[name]) acc[name] = []; + + if (!acc[name]) { +acc[name] = []; +} + acc[name].push(item); + return acc; }, {} as Record, @@ -84,6 +89,7 @@ export function RestockItemSubRow({ restock }: { restock: Restock }) { {groupItems.map((item) => { counter++; + return ( diff --git a/resources/js/pages/admin/manage/transaction/create.tsx b/resources/js/pages/admin/manage/transaction/create.tsx index d8a5882..901c71d 100644 --- a/resources/js/pages/admin/manage/transaction/create.tsx +++ b/resources/js/pages/admin/manage/transaction/create.tsx @@ -1,5 +1,9 @@ 'use no memo'; +import { Form, Head, Link, usePage } from '@inertiajs/react'; +import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { toast } from 'sonner'; import { ConfirmDialog } from '@/components/confirm-dialog'; import { FileUpload } from '@/components/file-upload'; import { ImagePreviewModal } from '@/components/image-preview-modal'; @@ -16,6 +20,7 @@ import { ComboboxItem, ComboboxList, } from '@/components/ui/combobox'; +import { FieldDescription } from '@/components/ui/field'; import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { @@ -40,12 +45,7 @@ import { loadTransactionDraft } from '@/lib/transaction-draft'; import { getTemporaryUrl } from '@/lib/upload'; import { formatCurrency } from '@/lib/utils'; import { store, index as transactionIndex } from '@/routes/admin/manage/transactions'; -import { Form, Head, Link, usePage } from '@inertiajs/react'; -import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { toast } from 'sonner'; import type { ProductForTransaction, TransactionCreateData } from './columns'; -import { FieldDescription } from '@/components/ui/field'; type CartLine = { key: string; diff --git a/resources/js/pages/admin/manage/transaction/edit.tsx b/resources/js/pages/admin/manage/transaction/edit.tsx index 2a499ea..bb281ce 100644 --- a/resources/js/pages/admin/manage/transaction/edit.tsx +++ b/resources/js/pages/admin/manage/transaction/edit.tsx @@ -1,5 +1,9 @@ 'use no memo'; +import { Form, Head, Link } from '@inertiajs/react'; +import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { toast } from 'sonner'; import { ConfirmDialog } from '@/components/confirm-dialog'; import { FileUpload } from '@/components/file-upload'; import { ImagePreviewModal } from '@/components/image-preview-modal'; @@ -39,10 +43,6 @@ import { formatNumber } from '@/lib/format'; import { getTemporaryUrl } from '@/lib/upload'; import { formatCurrency } from '@/lib/utils'; import { index as transactionIndex, update } from '@/routes/admin/manage/transactions'; -import { Form, Head, Link } from '@inertiajs/react'; -import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { toast } from 'sonner'; import type { TransactionCreateData, TransactionForEdit } from './columns'; type CartLine = { @@ -90,6 +90,7 @@ export default function TransactionEdit({ transaction, data }: Props) { items.some((i) => i.product_variant_id === v.id), ), ); + return product ? String(product.id) : ''; }); const [quantities, setQuantities] = useState>(() => diff --git a/resources/js/pages/admin/manage/transaction/index.tsx b/resources/js/pages/admin/manage/transaction/index.tsx index ba67100..9588895 100644 --- a/resources/js/pages/admin/manage/transaction/index.tsx +++ b/resources/js/pages/admin/manage/transaction/index.tsx @@ -166,6 +166,7 @@ export default function TransactionIndex({ function handlePrintReceipt(transaction: Transaction, paperWidth: 58 | 80) { if (!printer.connected) { alert('Hubungkan printer terlebih dahulu.'); + return; } diff --git a/resources/js/pages/admin/manage/transaction/transaction-card.tsx b/resources/js/pages/admin/manage/transaction/transaction-card.tsx index 2d690b1..31b7caf 100644 --- a/resources/js/pages/admin/manage/transaction/transaction-card.tsx +++ b/resources/js/pages/admin/manage/transaction/transaction-card.tsx @@ -1,3 +1,4 @@ +import { CheckCircle, ChevronDown, Pencil, Printer, Send, Trash2, XCircle } from 'lucide-react'; import { RowActions } from '@/components/row-actions'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -12,7 +13,6 @@ import { useCan } from '@/hooks/use-can'; import type { PaperWidth } from '@/hooks/use-thermal-printer'; import { formatDateTime, formatNumber } from '@/lib/format'; import { formatCurrency } from '@/lib/utils'; -import { CheckCircle, ChevronDown, Pencil, Printer, Send, Trash2, XCircle } from 'lucide-react'; import type { Transaction } from './columns'; const STATUS_BADGE_CLASSES: Record = { @@ -60,6 +60,7 @@ export function TransactionCardRow({ ); const statusBadgeClass = STATUS_BADGE_CLASSES[transaction.status] ?? STATUS_BADGE_CLASSES.pending; + return ( diff --git a/resources/js/pages/admin/manage/transaction/transaction-summary-card.tsx b/resources/js/pages/admin/manage/transaction/transaction-summary-card.tsx index 5c720af..04a50bc 100644 --- a/resources/js/pages/admin/manage/transaction/transaction-summary-card.tsx +++ b/resources/js/pages/admin/manage/transaction/transaction-summary-card.tsx @@ -1,5 +1,3 @@ -import { Card, CardContent } from '@/components/ui/card'; -import { formatCurrency } from '@/lib/utils'; import { Banknote, CircleDollarSign, @@ -7,6 +5,8 @@ import { Percent, TrendingUp, } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { formatCurrency } from '@/lib/utils'; type Summary = { total_orders: number; diff --git a/resources/js/pages/admin/master/category/index.tsx b/resources/js/pages/admin/master/category/index.tsx index 15f2c05..6a95849 100644 --- a/resources/js/pages/admin/master/category/index.tsx +++ b/resources/js/pages/admin/master/category/index.tsx @@ -1,3 +1,6 @@ +import { Head, router } from '@inertiajs/react'; +import { Plus } from 'lucide-react'; +import { useState } from 'react'; import type { PaginationState } from '@/components/data-table'; import { DataTable } from '@/components/data-table'; import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; @@ -15,9 +18,6 @@ import { store, update, } from '@/routes/admin/master/categories'; -import { Head, router } from '@inertiajs/react'; -import { Plus } from 'lucide-react'; -import { useState } from 'react'; import type { Category } from './columns'; import { createCategoryColumns } from './columns'; diff --git a/resources/js/pages/admin/master/product/columns.tsx b/resources/js/pages/admin/master/product/columns.tsx index f88f78d..e7faf8c 100644 --- a/resources/js/pages/admin/master/product/columns.tsx +++ b/resources/js/pages/admin/master/product/columns.tsx @@ -1,10 +1,11 @@ +import { router } from '@inertiajs/react'; +import type { ColumnDef } from '@tanstack/react-table'; +import { CheckCircle, ChevronRight, Clock, Pencil, RotateCw, Trash2, XCircle } from 'lucide-react'; import { RowActions } from '@/components/row-actions'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { formatNumber } from '@/lib/format'; -import { router } from '@inertiajs/react'; -import type { ColumnDef } from '@tanstack/react-table'; -import { ChevronRight, Pencil, Trash2 } from 'lucide-react'; export type ProductVariant = { id: number; @@ -28,6 +29,7 @@ export type Product = { slug: string; description: string | null; status: string; + rejection_reason: string | null; categories: { id: number; name: string; @@ -40,6 +42,8 @@ function getStatusLabel(status: string): string { active: 'Aktif', inactive: 'Non Aktif', draft: 'Draft', + pending: 'Menunggu Verifikasi', + rejected: 'Ditolak', }; return labels[status] ?? status; @@ -50,6 +54,8 @@ function getStatusVariant(status: string): string { active: 'bg-green-100 text-green-800', inactive: 'bg-red-100 text-red-800', draft: 'bg-yellow-100 text-yellow-800', + pending: 'bg-orange-100 text-orange-800', + rejected: 'bg-red-100 text-red-800', }; return variants[status] ?? 'bg-gray-100 text-gray-800'; @@ -69,13 +75,12 @@ function getFilteredVariants( type CreateColumnsParams = { handleEdit: (product: Product) => void; handleDeleteClick: (product: Product) => void; - handleVariantEdit: (product: Product) => void; - handleVariantDeleteClick: ( - product: Product, - variant: ProductVariant, - ) => void; + handleReject: (product: Product) => void; toggleStatusUrl: (id: number) => string; + approveUrl: (id: number) => string; + resubmitUrl: (id: number) => string; can: (permission: string) => boolean; + hasRole: (role: string) => boolean; }; export function createProductColumns( @@ -84,12 +89,16 @@ export function createProductColumns( const { handleEdit, handleDeleteClick, - handleVariantEdit, - handleVariantDeleteClick, + handleReject, toggleStatusUrl, + approveUrl, + resubmitUrl, can, + hasRole, } = params; + const isVerifier = hasRole('developer') || hasRole('owner'); + const columns: ColumnDef[] = [ { id: 'expand', @@ -139,7 +148,21 @@ export function createProductColumns( return (
- {product.name} +
+ {product.name} + {product.status === 'pending' && ( + + + Menunggu Verifikasi + + )} + {product.status === 'rejected' && ( + + + Ditolak + + )} +
{product.categories ?.map((c) => c.name) @@ -297,60 +320,123 @@ export function createProductColumns( header: () => Status, cell: ({ row }) => { const product = row.original; - const isToggleable = - product.status === 'active' || - product.status === 'inactive'; const isChecked = product.status === 'active'; - function handleToggle(checked: boolean) { + function handleToggle() { router.post( toggleStatusUrl(product.id), {}, - { - preserveScroll: true, - }, + { preserveScroll: true }, ); } - if (!isToggleable) { + if (product.status === 'active' || product.status === 'inactive') { return ( - - {getStatusLabel(product.status)} - +
+ + + {getStatusLabel(product.status)} + +
+ ); + } + + if (product.status === 'rejected' && product.rejection_reason) { + return ( +
+ + {getStatusLabel(product.status)} + + + Alasan: {product.rejection_reason} + +
); } return ( -
- - - {getStatusLabel(product.status)} - -
+ + {getStatusLabel(product.status)} + ); }, }, ]; - if (can('products.update') || can('products.delete')) { - columns.push({ - id: 'actions', - header: () => Aksi, - meta: { - className: 'w-[100px] text-center', - headerClassName: 'w-[100px] text-center', - }, - cell: ({ row }) => { - const product = row.original; + // Actions column + columns.push({ + id: 'actions', + header: () => Aksi, + meta: { + className: 'w-[100px] text-center', + headerClassName: 'w-[100px] text-center', + }, + cell: ({ row }) => { + const product = row.original; + // Pending + verifier: Setujui / Tolak + if (product.status === 'pending' && isVerifier) { + return ( + , + onClick: () => router.post(approveUrl(product.id), {}, { preserveScroll: true }), + }, + { + label: 'Tolak', + icon: , + onClick: () => handleReject(product), + }, + ]} + /> + ); + } + + // Pending + non-verifier: no actions + if (product.status === 'pending') { + return null; + } + + // Rejected + verifier: no actions + if (product.status === 'rejected' && isVerifier) { + return null; + } + + // Rejected + non-verifier: Ajukan Ulang / Edit / Hapus + if (product.status === 'rejected') { + return ( + , + onClick: () => router.post(resubmitUrl(product.id), {}, { preserveScroll: true }), + }, + { + label: 'Edit', + icon: , + show: can('products.update'), + onClick: () => handleEdit(product), + }, + { + label: 'Hapus', + icon: , + show: can('products.delete'), + onClick: () => handleDeleteClick(product), + }, + ]} + /> + ); + } + + // Active/Inactive/Draft: Edit / Hapus + if (can('products.update') || can('products.delete')) { return ( - ), + icon: , show: can('products.delete'), onClick: () => handleDeleteClick(product), }, ]} /> ); - }, - }); - } + } + + return null; + }, + }); return columns; } diff --git a/resources/js/pages/admin/master/product/index.tsx b/resources/js/pages/admin/master/product/index.tsx index 2b3f56a..cc1db96 100644 --- a/resources/js/pages/admin/master/product/index.tsx +++ b/resources/js/pages/admin/master/product/index.tsx @@ -1,4 +1,7 @@ import { Link, router } from '@inertiajs/react'; +import { Head } from '@inertiajs/react'; +import { Plus } from 'lucide-react'; +import { useMemo, useState } from 'react'; import { CardTable } from '@/components/card-table'; import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; import { FilterPopover } from '@/components/filter-popover'; @@ -28,16 +31,17 @@ import { edit as productEdit, index as productIndex, toggleStatus, + approve as productApprove, + reject as productReject, + resubmit as productResubmit, } from '@/routes/admin/master/products'; import { destroy as variantDestroy, edit as variantEdit, } from '@/routes/admin/master/products/variants'; -import { Head } from '@inertiajs/react'; -import { Plus } from 'lucide-react'; -import { useMemo, useState } from 'react'; import type { Product, ProductVariant } from './columns'; import { ProductCardRow } from './product-card'; +import { RejectDialog } from './reject-dialog'; import { VariantSubRow } from './variant/sub-row'; type Props = { @@ -68,6 +72,7 @@ export default function ProductIndex({ products, categories, productNames, filte product: Product; variant: ProductVariant; } | null>(null); + const [rejecting, setRejecting] = useState(null); const expand = useCardTableExpand(true); const pagination = { @@ -184,6 +189,8 @@ export default function ProductIndex({ products, categories, productNames, filte Aktif Non Aktif Draft + Menunggu Verifikasi + Ditolak
@@ -294,7 +301,10 @@ export default function ProductIndex({ products, categories, productNames, filte router.visit(productEdit.url(p.id)); }} onDelete={(p) => setDeleting(p)} + onReject={(p) => setRejecting(p)} toggleStatusUrl={(id) => toggleStatus.url(id)} + approveUrl={(id) => productApprove.url(id)} + resubmitUrl={(id) => productResubmit.url(id)} /> )} renderSubContent={(product) => ( @@ -342,6 +352,16 @@ export default function ProductIndex({ products, categories, productNames, filte } onConfirm={handleDeleteVariant} /> + + { + if (!open) { + setRejecting(null); + } + }} + rejectUrl={(id) => productReject.url(id)} + /> ); diff --git a/resources/js/pages/admin/master/product/product-card.tsx b/resources/js/pages/admin/master/product/product-card.tsx index 5867867..126a982 100644 --- a/resources/js/pages/admin/master/product/product-card.tsx +++ b/resources/js/pages/admin/master/product/product-card.tsx @@ -1,6 +1,8 @@ -import { ChevronDown, Pencil, Trash2 } from 'lucide-react'; +import { router } from '@inertiajs/react'; +import { CheckCircle, ChevronDown, Clock, Pencil, RotateCw, Trash2, XCircle } from 'lucide-react'; import { RowActions } from '@/components/row-actions'; import { ToggleStatus } from '@/components/toggle-status'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { useCan } from '@/hooks/use-can'; @@ -12,6 +14,8 @@ function getStatusLabel(status: string): string { active: 'Aktif', inactive: 'Non Aktif', draft: 'Draft', + pending: 'Menunggu Verifikasi', + rejected: 'Ditolak', }; return labels[status] ?? status; @@ -22,6 +26,8 @@ function getStatusVariant(status: string): string { active: 'bg-green-100 text-green-800', inactive: 'bg-red-100 text-red-800', draft: 'bg-yellow-100 text-yellow-800', + pending: 'bg-orange-100 text-orange-800', + rejected: 'bg-red-100 text-red-800', }; return variants[status] ?? 'bg-gray-100 text-gray-800'; @@ -34,7 +40,10 @@ export type ProductCardRowParams = { onToggleExpand: () => void; onEdit: (product: Product) => void; onDelete: (product: Product) => void; + onReject: (product: Product) => void; toggleStatusUrl: (id: number) => string; + approveUrl: (id: number) => string; + resubmitUrl: (id: number) => string; }; export function ProductCardRow({ @@ -44,9 +53,13 @@ export function ProductCardRow({ onToggleExpand, onEdit, onDelete, + onReject, toggleStatusUrl, + approveUrl, + resubmitUrl, }: ProductCardRowParams) { - const { can } = useCan(); + const { can, hasRole } = useCan(); + const isVerifier = hasRole('developer') || hasRole('owner'); const variants = product.product_variants ?? []; const totalStock = variants.reduce((sum, v) => sum + (v.stock ?? 0), 0); const totalReject = variants.reduce( @@ -59,8 +72,8 @@ export function ProductCardRow({ ); const totalAll = totalStock + totalReject + totalRetail; - const isToggleable = - product.status === 'active' || product.status === 'inactive'; + const isPending = product.status === 'pending'; + const isRejected = product.status === 'rejected'; const isChecked = product.status === 'active'; return ( @@ -86,6 +99,18 @@ export function ProductCardRow({

{product.name}

+ {isPending && ( + + + Menunggu Verifikasi + + )} + {isRejected && ( + + + Ditolak + + )} {product.categories?.length > 0 && ( ( @@ -97,6 +122,12 @@ export function ProductCardRow({ )} + {isRejected && product.rejection_reason && ( +
+ Alasan penolakan: {product.rejection_reason} +
+ )} +
{variants.length} varian @@ -128,7 +159,7 @@ export function ProductCardRow({
- {isToggleable ? ( + {(product.status === 'active' || product.status === 'inactive') ? (
- {(can('products.update') || can('products.delete')) && ( + {/* Actions */} + {isPending && isVerifier ? ( + , + onClick: () => router.post(approveUrl(product.id), {}, { preserveScroll: true }), + }, + { + label: 'Tolak', + icon: , + onClick: () => onReject(product), + }, + ]} + wrapperClassName="flex shrink-0 items-center gap-1" + /> + ) : isRejected && !isVerifier ? ( + , + onClick: () => router.post(resubmitUrl(product.id), {}, { preserveScroll: true }), + }, + { + label: 'Edit', + icon: , + show: can('products.update'), + onClick: () => onEdit(product), + }, + { + label: 'Hapus', + icon: , + show: can('products.delete'), + onClick: () => onDelete(product), + }, + ]} + wrapperClassName="flex shrink-0 items-center gap-1" + /> + ) : !isPending && !(isRejected && isVerifier) && (can('products.update') || can('products.delete')) ? ( - ), + icon: , show: can('products.delete'), onClick: () => onDelete(product), }, ]} wrapperClassName="flex shrink-0 items-center gap-1" /> - )} + ) : null}
diff --git a/resources/js/pages/admin/master/product/reject-dialog.tsx b/resources/js/pages/admin/master/product/reject-dialog.tsx new file mode 100644 index 0000000..6becf45 --- /dev/null +++ b/resources/js/pages/admin/master/product/reject-dialog.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react'; +import { router } from '@inertiajs/react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import type { Product } from './columns'; + +type RejectDialogProps = { + product: Product | null; + onOpenChange: (open: boolean) => void; + rejectUrl: (id: number) => string; +}; + +export function RejectDialog({ product, onOpenChange, rejectUrl }: RejectDialogProps) { + const [reason, setReason] = useState(''); + const [processing, setProcessing] = useState(false); + + function handleReject() { + if (!product) return; + + setProcessing(true); + + router.post( + rejectUrl(product.id), + { rejection_reason: reason }, + { + preserveScroll: true, + onSuccess: () => { + setReason(''); + onOpenChange(false); + }, + onFinish: () => setProcessing(false), + }, + ); + } + + return ( + + + + Tolak Produk + + Berikan alasan penolakan untuk produk "{product?.name}". + Alasan ini akan terlihat oleh pembuat produk. + + + +
+ +