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:
parent
f7a91739f9
commit
85556f8779
@ -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 |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 |
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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')
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}.",
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
}
|
||||
};
|
||||
@ -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',
|
||||
|
||||
@ -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[] };
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -20,6 +20,7 @@ export function useServerTable({
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
if (params.has('search')) {
|
||||
params.delete('search');
|
||||
const cleanUrl =
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -593,6 +593,7 @@ export default function AttendanceIndex({
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
setSelectedDate(cell.date);
|
||||
|
||||
if (!isAdmin && dayAttendances.length > 0) {
|
||||
setDetailAttendance(dayAttendances[0]);
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>>(() =>
|
||||
|
||||
@ -166,6 +166,7 @@ export default function TransactionIndex({
|
||||
function handlePrintReceipt(transaction: Transaction, paperWidth: 58 | 80) {
|
||||
if (!printer.connected) {
|
||||
alert('Hubungkan printer terlebih dahulu.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
86
resources/js/pages/admin/master/product/reject-dialog.tsx
Normal file
86
resources/js/pages/admin/master/product/reject-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 }) => (
|
||||
<>
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user