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.
This commit is contained in:
Yoga Pangestu 2026-08-07 11:23:41 +07:00
parent f7a91739f9
commit 85556f8779
47 changed files with 716 additions and 278 deletions

View File

@ -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 |

View File

@ -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

View File

@ -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 |

View File

@ -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';

View File

@ -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',
};
}
}

View File

@ -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,

View File

@ -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');
}
}

View File

@ -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'],

View File

@ -1,38 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Guarded(['id'])]
class OwnerVerificationRequest extends Model
{
use HasFactory;
protected function casts(): array
{
return [
'payload' => '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');
}
}

View File

@ -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')

View File

@ -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');

View File

@ -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;

View File

@ -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'),
);
}
}

View File

@ -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}.",

View File

@ -1,20 +0,0 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class OwnerVerificationRequestFactory extends Factory
{
public function definition(): array
{
return [
'action' => fake()->randomElement(['create', 'update', 'delete']),
'status' => 'pending',
'submitted_by_id' => User::factory(),
'payload' => json_encode(['key' => fake()->word(), 'value' => fake()->sentence()]),
'verified_at' => null,
];
}
}

View File

@ -1,31 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('owner_verification_requests', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->text('rejection_reason')->nullable()->after('status');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropColumn('rejection_reason');
});
}
};

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::dropIfExists('owner_verification_requests');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Cannot recreate — table structure is gone
}
};

View File

@ -30,7 +30,6 @@ public function run(): void
'activity_logs' => ['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',

View File

@ -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[] };

View File

@ -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;

View File

@ -20,6 +20,7 @@ export function useServerTable({
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.has('search')) {
params.delete('search');
const cleanUrl =

View File

@ -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],

View File

@ -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;

View File

@ -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: () => <span>Sisa</span>,
cell: ({ row }) => {
const employeeAdvance = row.original;
if (employeeAdvance.status === 'paid') {
return <span className="text-green-600">Lunas</span>;
}

View File

@ -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';

View File

@ -593,6 +593,7 @@ export default function AttendanceIndex({
key={idx}
onClick={() => {
setSelectedDate(cell.date);
if (!isAdmin && dayAttendances.length > 0) {
setDetailAttendance(dayAttendances[0]);
}

View File

@ -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 = {

View File

@ -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 = {

View File

@ -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<number, typeof items> = {};
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<string, typeof singles>,
@ -106,6 +115,7 @@ export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) {
</TableRow>
{groupItems.map((item) => {
counter++;
return (
<TableRow key={item.id}>
<TableCell className="text-center">

View File

@ -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 = {

View File

@ -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';

View File

@ -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<string, typeof items>,
@ -84,6 +89,7 @@ export function RestockItemSubRow({ restock }: { restock: Restock }) {
</TableRow>
{groupItems.map((item) => {
counter++;
return (
<TableRow key={item.id}>
<TableCell className="text-center">

View File

@ -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;

View File

@ -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<Record<number, number>>(() =>

View File

@ -166,6 +166,7 @@ export default function TransactionIndex({
function handlePrintReceipt(transaction: Transaction, paperWidth: 58 | 80) {
if (!printer.connected) {
alert('Hubungkan printer terlebih dahulu.');
return;
}

View File

@ -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<string, string> = {
@ -60,6 +60,7 @@ export function TransactionCardRow({
);
const statusBadgeClass =
STATUS_BADGE_CLASSES[transaction.status] ?? STATUS_BADGE_CLASSES.pending;
return (
<Card className="overflow-hidden">
<CardContent className="p-0">

View File

@ -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;

View File

@ -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';

View File

@ -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<Product>[] = [
{
id: 'expand',
@ -139,7 +148,21 @@ export function createProductColumns(
return (
<div className="flex flex-col">
<span className="font-medium">{product.name}</span>
<div className="flex items-center gap-2">
<span className="font-medium">{product.name}</span>
{product.status === 'pending' && (
<Badge variant="secondary" className="bg-orange-100 text-orange-800 hover:bg-orange-100">
<Clock className="mr-1 h-3 w-3" />
Menunggu Verifikasi
</Badge>
)}
{product.status === 'rejected' && (
<Badge variant="secondary" className="bg-red-100 text-red-800 hover:bg-red-100">
<XCircle className="mr-1 h-3 w-3" />
Ditolak
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground">
{product.categories
?.map((c) => c.name)
@ -297,60 +320,123 @@ export function createProductColumns(
header: () => <span>Status</span>,
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 (
<span
className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${getStatusVariant(product.status)}`}
>
{getStatusLabel(product.status)}
</span>
<div className="flex items-center gap-2">
<Switch
size="sm"
checked={isChecked}
onCheckedChange={handleToggle}
/>
<span className={`text-xs font-medium ${isChecked ? 'text-green-700' : 'text-red-700'}`}>
{getStatusLabel(product.status)}
</span>
</div>
);
}
if (product.status === 'rejected' && product.rejection_reason) {
return (
<div className="flex flex-col gap-1">
<span className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${getStatusVariant(product.status)}`}>
{getStatusLabel(product.status)}
</span>
<span className="text-[0.65rem] text-muted-foreground max-w-[200px] truncate block" title={product.rejection_reason}>
Alasan: {product.rejection_reason}
</span>
</div>
);
}
return (
<div className="flex items-center gap-2">
<Switch
size="sm"
checked={isChecked}
onCheckedChange={handleToggle}
/>
<span
className={`text-xs font-medium ${isChecked ? 'text-green-700' : 'text-red-700'}`}
>
{getStatusLabel(product.status)}
</span>
</div>
<span className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${getStatusVariant(product.status)}`}>
{getStatusLabel(product.status)}
</span>
);
},
},
];
if (can('products.update') || can('products.delete')) {
columns.push({
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => {
const product = row.original;
// Actions column
columns.push({
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
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 (
<RowActions
actions={[
{
label: 'Setujui',
icon: <CheckCircle className="h-4 w-4 text-green-600" />,
onClick: () => router.post(approveUrl(product.id), {}, { preserveScroll: true }),
},
{
label: 'Tolak',
icon: <XCircle className="h-4 w-4 text-destructive" />,
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 (
<RowActions
actions={[
{
label: 'Ajukan Ulang',
icon: <RotateCw className="h-4 w-4 text-blue-600" />,
onClick: () => router.post(resubmitUrl(product.id), {}, { preserveScroll: true }),
},
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: can('products.update'),
onClick: () => handleEdit(product),
},
{
label: 'Hapus',
icon: <Trash2 className="h-4 w-4 text-destructive" />,
show: can('products.delete'),
onClick: () => handleDeleteClick(product),
},
]}
/>
);
}
// Active/Inactive/Draft: Edit / Hapus
if (can('products.update') || can('products.delete')) {
return (
<RowActions
actions={[
@ -362,18 +448,18 @@ export function createProductColumns(
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
icon: <Trash2 className="h-4 w-4 text-destructive" />,
show: can('products.delete'),
onClick: () => handleDeleteClick(product),
},
]}
/>
);
},
});
}
}
return null;
},
});
return columns;
}

View File

@ -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<Product | null>(null);
const expand = useCardTableExpand(true);
const pagination = {
@ -184,6 +189,8 @@ export default function ProductIndex({ products, categories, productNames, filte
<SelectItem value="active">Aktif</SelectItem>
<SelectItem value="inactive">Non Aktif</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="pending">Menunggu Verifikasi</SelectItem>
<SelectItem value="rejected">Ditolak</SelectItem>
</SelectContent>
</Select>
</div>
@ -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}
/>
<RejectDialog
product={rejecting}
onOpenChange={(open) => {
if (!open) {
setRejecting(null);
}
}}
rejectUrl={(id) => productReject.url(id)}
/>
</div>
</>
);

View File

@ -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({
<h3 className="truncate font-medium">
{product.name}
</h3>
{isPending && (
<Badge variant="secondary" className="bg-orange-100 text-orange-800 hover:bg-orange-100">
<Clock className="mr-1 h-3 w-3" />
Menunggu Verifikasi
</Badge>
)}
{isRejected && (
<Badge variant="secondary" className="bg-red-100 text-red-800 hover:bg-red-100">
<XCircle className="mr-1 h-3 w-3" />
Ditolak
</Badge>
)}
{product.categories?.length > 0 && (
<span className="text-xs text-muted-foreground">
(
@ -97,6 +122,12 @@ export function ProductCardRow({
)}
</div>
{isRejected && product.rejection_reason && (
<div className="mt-1 text-[0.65rem] text-muted-foreground max-w-[300px]">
Alasan penolakan: {product.rejection_reason}
</div>
)}
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-foreground">
{variants.length} varian
@ -128,7 +159,7 @@ export function ProductCardRow({
</div>
<div className="mt-2">
{isToggleable ? (
{(product.status === 'active' || product.status === 'inactive') ? (
<ToggleStatus
url={toggleStatusUrl(product.id)}
checked={isChecked}
@ -144,7 +175,47 @@ export function ProductCardRow({
</div>
</div>
{(can('products.update') || can('products.delete')) && (
{/* Actions */}
{isPending && isVerifier ? (
<RowActions
actions={[
{
label: 'Setujui',
icon: <CheckCircle className="h-4 w-4 text-green-600" />,
onClick: () => router.post(approveUrl(product.id), {}, { preserveScroll: true }),
},
{
label: 'Tolak',
icon: <XCircle className="h-4 w-4 text-destructive" />,
onClick: () => onReject(product),
},
]}
wrapperClassName="flex shrink-0 items-center gap-1"
/>
) : isRejected && !isVerifier ? (
<RowActions
actions={[
{
label: 'Ajukan Ulang',
icon: <RotateCw className="h-4 w-4 text-blue-600" />,
onClick: () => router.post(resubmitUrl(product.id), {}, { preserveScroll: true }),
},
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: can('products.update'),
onClick: () => onEdit(product),
},
{
label: 'Hapus',
icon: <Trash2 className="h-4 w-4 text-destructive" />,
show: can('products.delete'),
onClick: () => onDelete(product),
},
]}
wrapperClassName="flex shrink-0 items-center gap-1"
/>
) : !isPending && !(isRejected && isVerifier) && (can('products.update') || can('products.delete')) ? (
<RowActions
actions={[
{
@ -155,16 +226,14 @@ export function ProductCardRow({
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
icon: <Trash2 className="h-4 w-4 text-destructive" />,
show: can('products.delete'),
onClick: () => onDelete(product),
},
]}
wrapperClassName="flex shrink-0 items-center gap-1"
/>
)}
) : null}
</div>
</CardContent>
</Card>

View File

@ -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 (
<Dialog open={product !== null} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Tolak Produk</DialogTitle>
<DialogDescription>
Berikan alasan penolakan untuk produk "{product?.name}".
Alasan ini akan terlihat oleh pembuat produk.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label className="text-sm font-medium">
Alasan Penolakan <span className="text-destructive">*</span>
</label>
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Masukkan alasan penolakan..."
rows={3}
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={processing}
>
Batal
</Button>
<Button
variant="destructive"
onClick={handleReject}
disabled={processing || !reason.trim()}
>
Tolak
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -27,14 +27,24 @@ export function VariantSubRow({
onEditVariant: (product: Product, variant: ProductVariant) => void;
onDeleteVariantClick: (product: Product, variant: ProductVariant) => void;
}) {
const { can } = useCan();
const { can, hasRole } = useCan();
const isVerifier = hasRole('developer') || hasRole('owner');
const isPending = product.status === 'pending';
const isRejected = product.status === 'rejected';
const variants = product.product_variants ?? [];
const [transferVariant, setTransferVariant] = useState<{
product: Product;
variant: ProductVariant;
} | null>(null);
const canAnyAction = can('products.transfer_stock') || can('products.view_stock_mutations') || can('products.update') || can('products.delete');
// Pending: no actions at all
// Rejected: only edit/delete for non-verifier
// Active/Inactive/Draft: all actions
const showTransfer = !isPending && !isRejected && can('products.transfer_stock');
const showMutations = !isPending && !isRejected && can('products.view_stock_mutations');
const showEdit = !isPending && (isRejected ? !isVerifier && can('products.update') : can('products.update'));
const showDelete = !isPending && (isRejected ? !isVerifier && can('products.delete') : can('products.delete'));
const canAnyAction = showTransfer || showMutations || showEdit || showDelete;
return (
<div className="overflow-x-auto">
@ -129,7 +139,7 @@ export function VariantSubRow({
icon: (
<ArrowRightLeft className="h-4 w-4" />
),
show: can('products.transfer_stock'),
show: showTransfer,
onClick: () =>
setTransferVariant({
product,
@ -141,7 +151,7 @@ export function VariantSubRow({
icon: (
<ScrollText className="h-4 w-4" />
),
show: can('products.view_stock_mutations'),
show: showMutations,
onClick: () => {
router.visit(
stockMutations.url({
@ -156,7 +166,7 @@ export function VariantSubRow({
icon: (
<Pencil className="h-4 w-4" />
),
show: can('products.update'),
show: showEdit,
onClick: () =>
onEditVariant(
product,
@ -168,7 +178,7 @@ export function VariantSubRow({
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: can('products.delete'),
show: showDelete,
onClick: () =>
onDeleteVariantClick(
product,

View File

@ -1,7 +1,7 @@
// Components
import { Form, Head } from '@inertiajs/react';
import { toast } from 'sonner';
import { LoaderCircle } from 'lucide-react';
import { toast } from 'sonner';
import InputError from '@/components/input-error';
import TextLink from '@/components/text-link';
import { Button } from '@/components/ui/button';

View File

@ -18,9 +18,6 @@ export default function Login() {
action={store()}
resetOnSuccess={['password']}
className="flex flex-col gap-6"
onError={() => {
toast.error('Terjadi kesalahan saat menyimpan data. Silakan periksa kembali input Anda.');
}}
>
{({ processing, errors }) => (
<>

View File

@ -46,6 +46,9 @@
Route::resource('products', ProductController::class)->except(['show'])->middleware('permission:products.view|products.create|products.update|products.delete');
Route::post('products/{product}/toggle-status', [ProductController::class, 'toggleStatus'])->name('products.toggle-status')->middleware('permission:products.toggle_status');
Route::post('products/{product}/approve', [ProductController::class, 'approve'])->name('products.approve')->middleware('permission:products.update');
Route::post('products/{product}/reject', [ProductController::class, 'reject'])->name('products.reject')->middleware('permission:products.update');
Route::post('products/{product}/resubmit', [ProductController::class, 'resubmit'])->name('products.resubmit')->middleware('permission:products.update');
Route::delete('products/{product}/variants/{variant}', [ProductVariantController::class, 'destroy'])->name('products.variants.destroy')->middleware('permission:products.delete');
Route::get('products/{product}/variants/{variant}/edit', [ProductVariantController::class, 'edit'])->name('products.variants.edit')->middleware('permission:products.update');
Route::put('products/{product}/variants/{variant}', [ProductVariantController::class, 'update'])->name('products.variants.update')->middleware('permission:products.update');