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
|
## Statistics
|
||||||
| Metric | Count |
|
| Metric | Count |
|
||||||
|--------|-------|
|
|--------|-------|
|
||||||
| Tables | 49 |
|
| Tables | 48 |
|
||||||
| Models | 42 |
|
| Models | 43 |
|
||||||
| Enums | 20 + 1 trait |
|
| Enums | 21 |
|
||||||
| Services | 28 (including 3 concerns) |
|
| Services | 30 |
|
||||||
| Controllers | 30 |
|
| Controllers | 32 |
|
||||||
| Roles | 9 |
|
| Roles | 9 |
|
||||||
| Permissions | ~120 |
|
| 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
|
## Language Convention
|
||||||
|
|
||||||
### Backend → English
|
### Backend → English
|
||||||
@ -719,6 +771,8 @@ ### Stock Adjustment Pattern
|
|||||||
$this->adjustStock($model, $field, $quantity, $sign);
|
$this->adjustStock($model, $field, $quantity, $sign);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Form Request
|
## 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`
|
`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)
|
- Casts: email_verified_at(datetime), is_active(bool), last_login_at(datetime), password(hashed), two_factor_confirmed_at(datetime)
|
||||||
- Scopes: active()
|
- 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
|
### `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`
|
`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)
|
- Relations: category(BelongsTo→Category), product(BelongsTo→Product)
|
||||||
|
|
||||||
### `products` → 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)
|
- Casts: status(ProductStatus)
|
||||||
- Scopes: active(), draft(), inactive()
|
- Scopes: active(), draft(), inactive(), pending(), rejected()
|
||||||
- Relations: categories(BelongsToMany→Category), productVariants(HasMany→ProductVariant)
|
- Relations: categories(BelongsToMany→Category), productVariants(HasMany→ProductVariant)
|
||||||
|
|
||||||
### `product_variants` → ProductVariant
|
### `product_variants` → ProductVariant
|
||||||
@ -263,10 +263,6 @@ ### `homepage_configurations` → HomepageConfiguration
|
|||||||
`id` `created_at` `updated_at`
|
`id` `created_at` `updated_at`
|
||||||
- Singleton table
|
- 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)
|
### `settings` (laravel-settings)
|
||||||
`id` `group`(string) `name`(string) `locked`(bool,default:false) `payload`(json) `created_at` `updated_at`
|
`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 |
|
| `PayrollStatus` | unpaid, paid, cancelled | payrolls.status |
|
||||||
| `Permission` | — | Permission action names |
|
| `Permission` | — | Permission action names |
|
||||||
| `PriceType` | retail, wholesale, capital | product_prices.type, orders.price_type |
|
| `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 |
|
| `ProductStockQuality` | good, reject | order_items.stock_quality, restocks.stock_type, stok_opname_items.stock_quality |
|
||||||
| `RawMaterialUnit` | kg, meter, yard | raw_materials.unit |
|
| `RawMaterialUnit` | kg, meter, yard | raw_materials.unit |
|
||||||
| `Role` | — | Role names |
|
| `Role` | — | Role names |
|
||||||
@ -306,3 +302,4 @@ ## Service Concerns
|
|||||||
| `HandlesCashTransactions` | getCashAccount(), creditCash(), debitCash() | CashAccountService, ExpenseService, EmployeeAdvanceService, PayrollPeriodService |
|
| `HandlesCashTransactions` | getCashAccount(), creditCash(), debitCash() | CashAccountService, ExpenseService, EmployeeAdvanceService, PayrollPeriodService |
|
||||||
| `HasStockAdjustment` | adjustStock(), adjustVariantStock(), applyStock(), reverseStock() | TransactionService, RestockService |
|
| `HasStockAdjustment` | adjustStock(), adjustVariantStock(), applyStock(), reverseStock() | TransactionService, RestockService |
|
||||||
| `RegistersMedia` | registerMedia(), syncPhoto() | CashAccountService, CuttingService, ExpenseService, PurchaseService, RestockService, TransactionService |
|
| `RegistersMedia` | registerMedia(), syncPhoto() | CashAccountService, CuttingService, ExpenseService, PurchaseService, RestockService, TransactionService |
|
||||||
|
|
||||||
|
|||||||
@ -121,11 +121,6 @@ enum Permission: string
|
|||||||
case PAYROLL_CANCEL = 'payroll.cancel';
|
case PAYROLL_CANCEL = 'payroll.cancel';
|
||||||
case PAYROLL_ADJUST = 'payroll.adjust';
|
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
|
// Restocks
|
||||||
case RESTOCKS_VIEW = 'restocks.view';
|
case RESTOCKS_VIEW = 'restocks.view';
|
||||||
case RESTOCKS_CREATE = 'restocks.create';
|
case RESTOCKS_CREATE = 'restocks.create';
|
||||||
|
|||||||
@ -11,6 +11,8 @@ enum ProductStatus: string
|
|||||||
case ACTIVE = 'active';
|
case ACTIVE = 'active';
|
||||||
case INACTIVE = 'inactive';
|
case INACTIVE = 'inactive';
|
||||||
case DRAFT = 'draft';
|
case DRAFT = 'draft';
|
||||||
|
case PENDING = 'pending';
|
||||||
|
case REJECTED = 'rejected';
|
||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
@ -18,6 +20,8 @@ public function label(): string
|
|||||||
self::ACTIVE => 'Aktif',
|
self::ACTIVE => 'Aktif',
|
||||||
self::INACTIVE => 'Non Aktif',
|
self::INACTIVE => 'Non Aktif',
|
||||||
self::DRAFT => 'Draft',
|
self::DRAFT => 'Draft',
|
||||||
|
self::PENDING => 'Menunggu Verifikasi',
|
||||||
|
self::REJECTED => 'Ditolak',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -169,8 +169,6 @@ public function permissions(): array
|
|||||||
Permission::PRODUCTS_TOGGLE_STATUS,
|
Permission::PRODUCTS_TOGGLE_STATUS,
|
||||||
Permission::STOCKS_VIEW,
|
Permission::STOCKS_VIEW,
|
||||||
|
|
||||||
Permission::OWNER_VERIFICATIONS_VIEW,
|
|
||||||
|
|
||||||
Permission::ORDERS_VIEW,
|
Permission::ORDERS_VIEW,
|
||||||
Permission::ORDERS_CREATE,
|
Permission::ORDERS_CREATE,
|
||||||
Permission::ORDERS_UPDATE,
|
Permission::ORDERS_UPDATE,
|
||||||
@ -297,8 +295,6 @@ public function permissions(): array
|
|||||||
Permission::RAW_MATERIALS_TOGGLE_STATUS,
|
Permission::RAW_MATERIALS_TOGGLE_STATUS,
|
||||||
Permission::STOCKS_VIEW,
|
Permission::STOCKS_VIEW,
|
||||||
|
|
||||||
Permission::OWNER_VERIFICATIONS_VIEW,
|
|
||||||
|
|
||||||
Permission::PRODUCTS_VIEW,
|
Permission::PRODUCTS_VIEW,
|
||||||
Permission::PRODUCTS_CREATE,
|
Permission::PRODUCTS_CREATE,
|
||||||
Permission::PRODUCTS_UPDATE,
|
Permission::PRODUCTS_UPDATE,
|
||||||
|
|||||||
@ -86,4 +86,32 @@ public function toggleStatus(Product $product): RedirectResponse
|
|||||||
|
|
||||||
return to_route('admin.master.products.index');
|
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',
|
'max:200',
|
||||||
],
|
],
|
||||||
'description' => ['nullable', 'string'],
|
'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' => ['required', 'array', 'min:1'],
|
||||||
'category_ids.*' => ['exists:categories,id'],
|
'category_ids.*' => ['exists:categories,id'],
|
||||||
'use_same_price' => ['nullable', 'boolean'],
|
'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);
|
$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
|
public function categories(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Category::class, 'product_categories')
|
return $this->belongsToMany(Category::class, 'product_categories')
|
||||||
|
|||||||
@ -156,16 +156,6 @@ public function orderItems(): HasMany
|
|||||||
return $this->hasMany(OrderItem::class);
|
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
|
public function paidPayrolls(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Payroll::class, 'paid_by_id');
|
return $this->hasMany(Payroll::class, 'paid_by_id');
|
||||||
|
|||||||
@ -24,7 +24,6 @@ public function migrate(): array
|
|||||||
return $row;
|
return $row;
|
||||||
});
|
});
|
||||||
$results['homepage_configurations'] = $this->migrateTable('homepage_configurations');
|
$results['homepage_configurations'] = $this->migrateTable('homepage_configurations');
|
||||||
$results['owner_verification_requests'] = $this->migrateTable('owner_verification_requests');
|
|
||||||
$results['notifications'] = $this->migrateTable('notifications');
|
$results['notifications'] = $this->migrateTable('notifications');
|
||||||
|
|
||||||
return $results;
|
return $results;
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Services\Admin\Master\Product;
|
namespace App\Services\Admin\Master\Product;
|
||||||
|
|
||||||
|
use App\Enums\ProductStatus;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
@ -10,6 +11,7 @@
|
|||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class ProductService
|
class ProductService
|
||||||
{
|
{
|
||||||
@ -19,6 +21,22 @@ public function __construct(
|
|||||||
private StockMutationService $stockMutationService,
|
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
|
public function getNames(): array
|
||||||
{
|
{
|
||||||
return Product::where('status', '!=', 'deleted')
|
return Product::where('status', '!=', 'deleted')
|
||||||
@ -31,7 +49,7 @@ public function getNames(): array
|
|||||||
|
|
||||||
public function getAll(array $filters = []): Collection
|
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([
|
->with([
|
||||||
'categories:id,name',
|
'categories:id,name',
|
||||||
'productVariants:id,product_id,name,stock,reject_stock,retail_stock',
|
'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
|
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
$paginator = Product::query()
|
$paginator = Product::query()
|
||||||
->select(['id', 'name', 'slug', 'description', 'status'])
|
->select(['id', 'name', 'slug', 'description', 'status', 'rejection_reason'])
|
||||||
->with([
|
->with([
|
||||||
'categories:id,name',
|
'categories:id,name',
|
||||||
'productVariants:id,product_id,name,stock,reject_stock,retail_stock',
|
'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
|
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([
|
$product = Product::create([
|
||||||
'name' => $data['name'],
|
'name' => $data['name'],
|
||||||
'description' => $data['description'] ?? null,
|
'description' => $data['description'] ?? null,
|
||||||
'status' => $data['status'] ?? 'active',
|
'status' => $status,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$product->categories()->sync($data['category_ids']);
|
$product->categories()->sync($data['category_ids']);
|
||||||
@ -209,11 +231,19 @@ public function getForEdit(Product $product): array
|
|||||||
|
|
||||||
public function update(Product $product, array $data): Product
|
public function update(Product $product, array $data): Product
|
||||||
{
|
{
|
||||||
|
$this->assertNotPending($product);
|
||||||
|
|
||||||
$product = DB::transaction(function () use ($product, $data) {
|
$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([
|
$product->update([
|
||||||
'name' => $data['name'],
|
'name' => $data['name'],
|
||||||
'description' => $data['description'] ?? null,
|
'description' => $data['description'] ?? null,
|
||||||
'status' => $data['status'] ?? $product->status,
|
'status' => $newStatus,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$product->categories()->sync($data['category_ids']);
|
$product->categories()->sync($data['category_ids']);
|
||||||
@ -414,6 +444,8 @@ public function update(Product $product, array $data): Product
|
|||||||
|
|
||||||
public function delete(Product $product): bool
|
public function delete(Product $product): bool
|
||||||
{
|
{
|
||||||
|
$this->assertNotPending($product);
|
||||||
|
|
||||||
$result = DB::transaction(function () use ($product) {
|
$result = DB::transaction(function () use ($product) {
|
||||||
$product->productVariants->each(function (ProductVariant $variant) {
|
$product->productVariants->each(function (ProductVariant $variant) {
|
||||||
$variant->productPrices()->delete();
|
$variant->productPrices()->delete();
|
||||||
@ -438,8 +470,56 @@ public function delete(Product $product): bool
|
|||||||
|
|
||||||
public function toggleStatus(Product $product): void
|
public function toggleStatus(Product $product): void
|
||||||
{
|
{
|
||||||
|
$this->assertNotPending($product);
|
||||||
|
|
||||||
$product->update([
|
$product->update([
|
||||||
'status' => $product->status->value === 'active' ? 'inactive' : 'active',
|
'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;
|
namespace App\Services\Admin\Master\Product;
|
||||||
|
|
||||||
|
use App\Enums\ProductStatus;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
use App\Services\Concerns\RegistersMedia;
|
use App\Services\Concerns\RegistersMedia;
|
||||||
@ -20,6 +21,22 @@ public function __construct(
|
|||||||
private StockMutationService $stockMutationService,
|
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
|
public function getForEdit(ProductVariant $variant): array
|
||||||
{
|
{
|
||||||
$variant->load([
|
$variant->load([
|
||||||
@ -49,6 +66,8 @@ public function getForEdit(ProductVariant $variant): array
|
|||||||
|
|
||||||
public function update(ProductVariant $variant, array $data): ProductVariant
|
public function update(ProductVariant $variant, array $data): ProductVariant
|
||||||
{
|
{
|
||||||
|
$this->assertNotPending($variant->product);
|
||||||
|
|
||||||
DB::transaction(function () use ($variant, $data) {
|
DB::transaction(function () use ($variant, $data) {
|
||||||
$oldData = $variant->only(['stock', 'reject_stock', 'retail_stock']);
|
$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
|
public function delete(Product $product, ProductVariant $variant): bool
|
||||||
{
|
{
|
||||||
|
$this->assertNotPending($product);
|
||||||
|
|
||||||
$result = DB::transaction(function () use ($variant) {
|
$result = DB::transaction(function () use ($variant) {
|
||||||
$variant->productPrices()->delete();
|
$variant->productPrices()->delete();
|
||||||
$variant->clearMediaCollection('photos');
|
$variant->clearMediaCollection('photos');
|
||||||
@ -134,6 +155,8 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari
|
|||||||
{
|
{
|
||||||
$quantity = (int) $data['quantity'];
|
$quantity = (int) $data['quantity'];
|
||||||
|
|
||||||
|
$this->assertNotPending($variant->product);
|
||||||
|
|
||||||
if ($variant->stock < $quantity) {
|
if ($variant->stock < $quantity) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'quantity' => "Stok bagus tidak mencukupi. Stok tersedia: {$variant->stock}.",
|
'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'],
|
'activity_logs' => ['view'],
|
||||||
'employee_advances' => ['view', 'create', 'update', 'delete', 'pay', 'view_payments', 'verify'],
|
'employee_advances' => ['view', 'create', 'update', 'delete', 'pay', 'view_payments', 'verify'],
|
||||||
'payroll' => ['view', 'pay', 'cancel', 'adjust'],
|
'payroll' => ['view', 'pay', 'cancel', 'adjust'],
|
||||||
'owner_verifications' => ['view', 'verify', 'reject'],
|
|
||||||
'restocks' => ['view', 'create', 'update', 'delete'],
|
'restocks' => ['view', 'create', 'update', 'delete'],
|
||||||
'raw_materials' => ['view', 'create', 'update', 'delete', 'toggle_status'],
|
'raw_materials' => ['view', 'create', 'update', 'delete', 'toggle_status'],
|
||||||
'suppliers' => ['view', 'create', 'update', 'delete'],
|
'suppliers' => ['view', 'create', 'update', 'delete'],
|
||||||
@ -127,8 +126,6 @@ public function run(): void
|
|||||||
'products.transfer_stock',
|
'products.transfer_stock',
|
||||||
'products.view_stock_mutations',
|
'products.view_stock_mutations',
|
||||||
|
|
||||||
'owner_verifications.view',
|
|
||||||
|
|
||||||
'orders.view',
|
'orders.view',
|
||||||
'orders.create',
|
'orders.create',
|
||||||
'orders.update',
|
'orders.update',
|
||||||
@ -256,8 +253,6 @@ public function run(): void
|
|||||||
'raw_materials.update',
|
'raw_materials.update',
|
||||||
'raw_materials.toggle_status',
|
'raw_materials.toggle_status',
|
||||||
|
|
||||||
'owner_verifications.view',
|
|
||||||
|
|
||||||
'cuttings.view',
|
'cuttings.view',
|
||||||
'cuttings.create',
|
'cuttings.create',
|
||||||
'cuttings.update',
|
'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 AppLogo from '@/components/app-logo';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
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 rawMaterialsIndex } from '@/routes/admin/master/raw-materials';
|
||||||
import { index as suppliersIndex } from '@/routes/admin/master/suppliers';
|
import { index as suppliersIndex } from '@/routes/admin/master/suppliers';
|
||||||
import { index as rolesIndex } from '@/routes/admin/settings/roles';
|
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[] };
|
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 { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -6,9 +9,6 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Form } from '@inertiajs/react';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
type FormDialogProps = {
|
type FormDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|||||||
@ -20,6 +20,7 @@ export function useServerTable({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
if (params.has('search')) {
|
if (params.has('search')) {
|
||||||
params.delete('search');
|
params.delete('search');
|
||||||
const cleanUrl =
|
const cleanUrl =
|
||||||
|
|||||||
@ -70,6 +70,7 @@ export function encodeOrderReceipt(
|
|||||||
|
|
||||||
for (const addressLine of options.storeAddress.split('\n')) {
|
for (const addressLine of options.storeAddress.split('\n')) {
|
||||||
const trimmed = addressLine.trim();
|
const trimmed = addressLine.trim();
|
||||||
|
|
||||||
if (trimmed) {
|
if (trimmed) {
|
||||||
encoder.align('center').text(trimmed).newline();
|
encoder.align('center').text(trimmed).newline();
|
||||||
}
|
}
|
||||||
@ -102,7 +103,7 @@ export function encodeOrderReceipt(
|
|||||||
[
|
[
|
||||||
[
|
[
|
||||||
`${item.quantity} x ${item.unit_price}`,
|
`${item.quantity} x ${item.unit_price}`,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
(rowEncoder: any) =>
|
(rowEncoder: any) =>
|
||||||
rowEncoder
|
rowEncoder
|
||||||
.bold()
|
.bold()
|
||||||
@ -215,6 +216,7 @@ export function useThermalPrinter() {
|
|||||||
let characteristic: BluetoothCharacteristic | null = null;
|
let characteristic: BluetoothCharacteristic | null = null;
|
||||||
|
|
||||||
const services = await server.getPrimaryServices();
|
const services = await server.getPrimaryServices();
|
||||||
|
|
||||||
for (const svc of services) {
|
for (const svc of services) {
|
||||||
try {
|
try {
|
||||||
const chars = await svc.getCharacteristics();
|
const chars = await svc.getCharacteristics();
|
||||||
@ -223,6 +225,7 @@ export function useThermalPrinter() {
|
|||||||
c.properties.writeWithoutResponse ||
|
c.properties.writeWithoutResponse ||
|
||||||
c.properties.write,
|
c.properties.write,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (writable) {
|
if (writable) {
|
||||||
characteristic = writable;
|
characteristic = writable;
|
||||||
break;
|
break;
|
||||||
@ -286,6 +289,7 @@ export function useThermalPrinter() {
|
|||||||
|
|
||||||
const sendToBluetooth = useCallback(async (data: Uint8Array) => {
|
const sendToBluetooth = useCallback(async (data: Uint8Array) => {
|
||||||
const characteristic = bluetoothCharacteristicRef.current;
|
const characteristic = bluetoothCharacteristicRef.current;
|
||||||
|
|
||||||
if (!characteristic) {
|
if (!characteristic) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Printer Bluetooth tidak terhubung.',
|
'Printer Bluetooth tidak terhubung.',
|
||||||
@ -298,17 +302,20 @@ export function useThermalPrinter() {
|
|||||||
|
|
||||||
for (let i = 0; i < data.length; i += chunkSize) {
|
for (let i = 0; i < data.length; i += chunkSize) {
|
||||||
const chunk = data.slice(i, i + chunkSize);
|
const chunk = data.slice(i, i + chunkSize);
|
||||||
|
|
||||||
if (useWriteWithoutResponse) {
|
if (useWriteWithoutResponse) {
|
||||||
await characteristic.writeValueWithoutResponse(chunk);
|
await characteristic.writeValueWithoutResponse(chunk);
|
||||||
} else {
|
} else {
|
||||||
await characteristic.writeValueWithResponse(chunk);
|
await characteristic.writeValueWithResponse(chunk);
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sendToUSB = useCallback(async (data: Uint8Array) => {
|
const sendToUSB = useCallback(async (data: Uint8Array) => {
|
||||||
const device = usbDeviceRef.current;
|
const device = usbDeviceRef.current;
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
throw new Error('Printer USB tidak terhubung.');
|
throw new Error('Printer USB tidak terhubung.');
|
||||||
}
|
}
|
||||||
@ -322,6 +329,7 @@ export function useThermalPrinter() {
|
|||||||
)?.endpointNumber ?? 1;
|
)?.endpointNumber ?? 1;
|
||||||
|
|
||||||
const chunkSize = 512;
|
const chunkSize = 512;
|
||||||
|
|
||||||
for (let i = 0; i < data.length; i += chunkSize) {
|
for (let i = 0; i < data.length; i += chunkSize) {
|
||||||
const chunk = data.slice(i, i + chunkSize);
|
const chunk = data.slice(i, i + chunkSize);
|
||||||
await device.transferOut(endpoint, chunk);
|
await device.transferOut(endpoint, chunk);
|
||||||
@ -352,6 +360,7 @@ export function useThermalPrinter() {
|
|||||||
if (type === 'bluetooth') {
|
if (type === 'bluetooth') {
|
||||||
return await connectBluetooth();
|
return await connectBluetooth();
|
||||||
}
|
}
|
||||||
|
|
||||||
return await connectUSB();
|
return await connectUSB();
|
||||||
},
|
},
|
||||||
[state.connected, disconnect, connectBluetooth, 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 type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Pencil, Trash2 } from 'lucide-react';
|
import { Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { ImagePreviewButton } from '@/components/image-preview-button';
|
||||||
|
import { RowActions } from '@/components/row-actions';
|
||||||
|
|
||||||
export type CashTransaction = {
|
export type CashTransaction = {
|
||||||
id: number;
|
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 type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { CheckCircle, CircleDollarSign, History, Pencil, Trash2 } from 'lucide-react';
|
import { CheckCircle, CircleDollarSign, History, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { RowActions } from '@/components/row-actions';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
export type EmployeeAdvancePayment = {
|
export type EmployeeAdvancePayment = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -126,6 +126,7 @@ export function createEmployeeAdvanceColumns(
|
|||||||
header: () => <span>Sisa</span>,
|
header: () => <span>Sisa</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const employeeAdvance = row.original;
|
const employeeAdvance = row.original;
|
||||||
|
|
||||||
if (employeeAdvance.status === 'paid') {
|
if (employeeAdvance.status === 'paid') {
|
||||||
return <span className="text-green-600">Lunas</span>;
|
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 type { PaginationState } from '@/components/data-table';
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import { DatePicker } from '@/components/date-picker';
|
import { DatePicker } from '@/components/date-picker';
|
||||||
@ -17,7 +20,6 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -25,6 +27,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { useCan } from '@/hooks/use-can';
|
import { useCan } from '@/hooks/use-can';
|
||||||
import { useServerTable } from '@/hooks/use-server-table';
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
import {
|
import {
|
||||||
@ -35,9 +38,6 @@ import {
|
|||||||
store,
|
store,
|
||||||
update,
|
update,
|
||||||
} from '@/routes/admin/finance/employee-advances';
|
} 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 type { EmployeeAdvance } from './columns';
|
||||||
import { createEmployeeAdvanceColumns } from './columns';
|
import { createEmployeeAdvanceColumns } from './columns';
|
||||||
|
|
||||||
|
|||||||
@ -593,6 +593,7 @@ export default function AttendanceIndex({
|
|||||||
key={idx}
|
key={idx}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedDate(cell.date);
|
setSelectedDate(cell.date);
|
||||||
|
|
||||||
if (!isAdmin && dayAttendances.length > 0) {
|
if (!isAdmin && dayAttendances.length > 0) {
|
||||||
setDetailAttendance(dayAttendances[0]);
|
setDetailAttendance(dayAttendances[0]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
'use no memo';
|
'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 { ConfirmDialog } from '@/components/confirm-dialog';
|
||||||
import { FileUpload } from '@/components/file-upload';
|
import { FileUpload } from '@/components/file-upload';
|
||||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||||
@ -19,10 +23,6 @@ import { formatNumber } from '@/lib/format';
|
|||||||
import { getTemporaryUrl } from '@/lib/upload';
|
import { getTemporaryUrl } from '@/lib/upload';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import { index as cuttingIndex, store } from '@/routes/admin/manage/cuttings';
|
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';
|
import type { CuttingCreateData } from './columns';
|
||||||
|
|
||||||
type MaterialState = {
|
type MaterialState = {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
||||||
import { ImagePreviewButton } from '@/components/image-preview-button';
|
import { ImagePreviewButton } from '@/components/image-preview-button';
|
||||||
import { RowActions } from '@/components/row-actions';
|
import { RowActions } from '@/components/row-actions';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -5,7 +6,6 @@ import { Card, CardContent } from '@/components/ui/card';
|
|||||||
import { useCan } from '@/hooks/use-can';
|
import { useCan } from '@/hooks/use-can';
|
||||||
import { formatDateTime, formatNumber } from '@/lib/format';
|
import { formatDateTime, formatNumber } from '@/lib/format';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
|
||||||
import type { Cutting } from './columns';
|
import type { Cutting } from './columns';
|
||||||
|
|
||||||
export type CuttingCardRowParams = {
|
export type CuttingCardRowParams = {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
import { ImagePreviewButton } from '@/components/image-preview-button';
|
import { ImagePreviewButton } from '@/components/image-preview-button';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@ -8,7 +9,6 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { formatNumber } from '@/lib/format';
|
import { formatNumber } from '@/lib/format';
|
||||||
import { Fragment } from 'react';
|
|
||||||
import type { Cutting } from './columns';
|
import type { Cutting } from './columns';
|
||||||
|
|
||||||
export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) {
|
export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) {
|
||||||
@ -21,7 +21,11 @@ export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) {
|
|||||||
const comboGroups: Record<number, typeof items> = {};
|
const comboGroups: Record<number, typeof items> = {};
|
||||||
comboItems.forEach((item) => {
|
comboItems.forEach((item) => {
|
||||||
const comboId = item.combination_id!;
|
const comboId = item.combination_id!;
|
||||||
if (!comboGroups[comboId]) comboGroups[comboId] = [];
|
|
||||||
|
if (!comboGroups[comboId]) {
|
||||||
|
comboGroups[comboId] = [];
|
||||||
|
}
|
||||||
|
|
||||||
comboGroups[comboId].push(item);
|
comboGroups[comboId].push(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -29,8 +33,13 @@ export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) {
|
|||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
const name =
|
const name =
|
||||||
item.raw_material_price?.raw_material?.name ?? 'BING';
|
item.raw_material_price?.raw_material?.name ?? 'BING';
|
||||||
if (!acc[name]) acc[name] = [];
|
|
||||||
|
if (!acc[name]) {
|
||||||
|
acc[name] = [];
|
||||||
|
}
|
||||||
|
|
||||||
acc[name].push(item);
|
acc[name].push(item);
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, typeof singles>,
|
{} as Record<string, typeof singles>,
|
||||||
@ -106,6 +115,7 @@ export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
{groupItems.map((item) => {
|
{groupItems.map((item) => {
|
||||||
counter++;
|
counter++;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={item.id}>
|
<TableRow key={item.id}>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
'use no memo';
|
'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 { ConfirmDialog } from '@/components/confirm-dialog';
|
||||||
import { FileUpload } from '@/components/file-upload';
|
import { FileUpload } from '@/components/file-upload';
|
||||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||||
@ -17,10 +21,6 @@ import { formatNumber } from '@/lib/format';
|
|||||||
import { getTemporaryUrl } from '@/lib/upload';
|
import { getTemporaryUrl } from '@/lib/upload';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import { index as cuttingIndex, update } from '@/routes/admin/manage/cuttings';
|
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';
|
import type { CuttingCreateData, CuttingForEdit } from './columns';
|
||||||
|
|
||||||
type MaterialState = {
|
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 { CardTable } from '@/components/card-table';
|
||||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||||
import { FilterPopover } from '@/components/filter-popover';
|
import { FilterPopover } from '@/components/filter-popover';
|
||||||
@ -20,9 +23,6 @@ import {
|
|||||||
edit as purchaseEdit,
|
edit as purchaseEdit,
|
||||||
index as purchaseIndex,
|
index as purchaseIndex,
|
||||||
} from '@/routes/admin/manage/purchases';
|
} 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 type { Purchase } from './columns';
|
||||||
import { PurchaseCardRow } from './purchase-card';
|
import { PurchaseCardRow } from './purchase-card';
|
||||||
import { PurchaseItemSubRow } from './purchase-sub-row';
|
import { PurchaseItemSubRow } from './purchase-sub-row';
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
import { ImagePreviewButton } from '@/components/image-preview-button';
|
import { ImagePreviewButton } from '@/components/image-preview-button';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@ -7,7 +8,6 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { Fragment } from 'react';
|
|
||||||
import { formatNumber } from '@/lib/format';
|
import { formatNumber } from '@/lib/format';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import type { Restock } from './columns';
|
import type { Restock } from './columns';
|
||||||
@ -18,8 +18,13 @@ export function RestockItemSubRow({ restock }: { restock: Restock }) {
|
|||||||
const groupedByProduct = items.reduce(
|
const groupedByProduct = items.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
const name = item.product_variant?.product?.name ?? 'Tanpa Produk';
|
const name = item.product_variant?.product?.name ?? 'Tanpa Produk';
|
||||||
if (!acc[name]) acc[name] = [];
|
|
||||||
|
if (!acc[name]) {
|
||||||
|
acc[name] = [];
|
||||||
|
}
|
||||||
|
|
||||||
acc[name].push(item);
|
acc[name].push(item);
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, typeof items>,
|
{} as Record<string, typeof items>,
|
||||||
@ -84,6 +89,7 @@ export function RestockItemSubRow({ restock }: { restock: Restock }) {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
{groupItems.map((item) => {
|
{groupItems.map((item) => {
|
||||||
counter++;
|
counter++;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={item.id}>
|
<TableRow key={item.id}>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
'use no memo';
|
'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 { ConfirmDialog } from '@/components/confirm-dialog';
|
||||||
import { FileUpload } from '@/components/file-upload';
|
import { FileUpload } from '@/components/file-upload';
|
||||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||||
@ -16,6 +20,7 @@ import {
|
|||||||
ComboboxItem,
|
ComboboxItem,
|
||||||
ComboboxList,
|
ComboboxList,
|
||||||
} from '@/components/ui/combobox';
|
} from '@/components/ui/combobox';
|
||||||
|
import { FieldDescription } from '@/components/ui/field';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
import {
|
import {
|
||||||
@ -40,12 +45,7 @@ import { loadTransactionDraft } from '@/lib/transaction-draft';
|
|||||||
import { getTemporaryUrl } from '@/lib/upload';
|
import { getTemporaryUrl } from '@/lib/upload';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import { store, index as transactionIndex } from '@/routes/admin/manage/transactions';
|
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 type { ProductForTransaction, TransactionCreateData } from './columns';
|
||||||
import { FieldDescription } from '@/components/ui/field';
|
|
||||||
|
|
||||||
type CartLine = {
|
type CartLine = {
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
'use no memo';
|
'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 { ConfirmDialog } from '@/components/confirm-dialog';
|
||||||
import { FileUpload } from '@/components/file-upload';
|
import { FileUpload } from '@/components/file-upload';
|
||||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||||
@ -39,10 +43,6 @@ import { formatNumber } from '@/lib/format';
|
|||||||
import { getTemporaryUrl } from '@/lib/upload';
|
import { getTemporaryUrl } from '@/lib/upload';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import { index as transactionIndex, update } from '@/routes/admin/manage/transactions';
|
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';
|
import type { TransactionCreateData, TransactionForEdit } from './columns';
|
||||||
|
|
||||||
type CartLine = {
|
type CartLine = {
|
||||||
@ -90,6 +90,7 @@ export default function TransactionEdit({ transaction, data }: Props) {
|
|||||||
items.some((i) => i.product_variant_id === v.id),
|
items.some((i) => i.product_variant_id === v.id),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return product ? String(product.id) : '';
|
return product ? String(product.id) : '';
|
||||||
});
|
});
|
||||||
const [quantities, setQuantities] = useState<Record<number, number>>(() =>
|
const [quantities, setQuantities] = useState<Record<number, number>>(() =>
|
||||||
|
|||||||
@ -166,6 +166,7 @@ export default function TransactionIndex({
|
|||||||
function handlePrintReceipt(transaction: Transaction, paperWidth: 58 | 80) {
|
function handlePrintReceipt(transaction: Transaction, paperWidth: 58 | 80) {
|
||||||
if (!printer.connected) {
|
if (!printer.connected) {
|
||||||
alert('Hubungkan printer terlebih dahulu.');
|
alert('Hubungkan printer terlebih dahulu.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { CheckCircle, ChevronDown, Pencil, Printer, Send, Trash2, XCircle } from 'lucide-react';
|
||||||
import { RowActions } from '@/components/row-actions';
|
import { RowActions } from '@/components/row-actions';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
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 type { PaperWidth } from '@/hooks/use-thermal-printer';
|
||||||
import { formatDateTime, formatNumber } from '@/lib/format';
|
import { formatDateTime, formatNumber } from '@/lib/format';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import { CheckCircle, ChevronDown, Pencil, Printer, Send, Trash2, XCircle } from 'lucide-react';
|
|
||||||
import type { Transaction } from './columns';
|
import type { Transaction } from './columns';
|
||||||
|
|
||||||
const STATUS_BADGE_CLASSES: Record<string, string> = {
|
const STATUS_BADGE_CLASSES: Record<string, string> = {
|
||||||
@ -60,6 +60,7 @@ export function TransactionCardRow({
|
|||||||
);
|
);
|
||||||
const statusBadgeClass =
|
const statusBadgeClass =
|
||||||
STATUS_BADGE_CLASSES[transaction.status] ?? STATUS_BADGE_CLASSES.pending;
|
STATUS_BADGE_CLASSES[transaction.status] ?? STATUS_BADGE_CLASSES.pending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { formatCurrency } from '@/lib/utils';
|
|
||||||
import {
|
import {
|
||||||
Banknote,
|
Banknote,
|
||||||
CircleDollarSign,
|
CircleDollarSign,
|
||||||
@ -7,6 +5,8 @@ import {
|
|||||||
Percent,
|
Percent,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { formatCurrency } from '@/lib/utils';
|
||||||
|
|
||||||
type Summary = {
|
type Summary = {
|
||||||
total_orders: number;
|
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 type { PaginationState } from '@/components/data-table';
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||||
@ -15,9 +18,6 @@ import {
|
|||||||
store,
|
store,
|
||||||
update,
|
update,
|
||||||
} from '@/routes/admin/master/categories';
|
} 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 type { Category } from './columns';
|
||||||
import { createCategoryColumns } 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 { RowActions } from '@/components/row-actions';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { formatNumber } from '@/lib/format';
|
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 = {
|
export type ProductVariant = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -28,6 +29,7 @@ export type Product = {
|
|||||||
slug: string;
|
slug: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
rejection_reason: string | null;
|
||||||
categories: {
|
categories: {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@ -40,6 +42,8 @@ function getStatusLabel(status: string): string {
|
|||||||
active: 'Aktif',
|
active: 'Aktif',
|
||||||
inactive: 'Non Aktif',
|
inactive: 'Non Aktif',
|
||||||
draft: 'Draft',
|
draft: 'Draft',
|
||||||
|
pending: 'Menunggu Verifikasi',
|
||||||
|
rejected: 'Ditolak',
|
||||||
};
|
};
|
||||||
|
|
||||||
return labels[status] ?? status;
|
return labels[status] ?? status;
|
||||||
@ -50,6 +54,8 @@ function getStatusVariant(status: string): string {
|
|||||||
active: 'bg-green-100 text-green-800',
|
active: 'bg-green-100 text-green-800',
|
||||||
inactive: 'bg-red-100 text-red-800',
|
inactive: 'bg-red-100 text-red-800',
|
||||||
draft: 'bg-yellow-100 text-yellow-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';
|
return variants[status] ?? 'bg-gray-100 text-gray-800';
|
||||||
@ -69,13 +75,12 @@ function getFilteredVariants(
|
|||||||
type CreateColumnsParams = {
|
type CreateColumnsParams = {
|
||||||
handleEdit: (product: Product) => void;
|
handleEdit: (product: Product) => void;
|
||||||
handleDeleteClick: (product: Product) => void;
|
handleDeleteClick: (product: Product) => void;
|
||||||
handleVariantEdit: (product: Product) => void;
|
handleReject: (product: Product) => void;
|
||||||
handleVariantDeleteClick: (
|
|
||||||
product: Product,
|
|
||||||
variant: ProductVariant,
|
|
||||||
) => void;
|
|
||||||
toggleStatusUrl: (id: number) => string;
|
toggleStatusUrl: (id: number) => string;
|
||||||
|
approveUrl: (id: number) => string;
|
||||||
|
resubmitUrl: (id: number) => string;
|
||||||
can: (permission: string) => boolean;
|
can: (permission: string) => boolean;
|
||||||
|
hasRole: (role: string) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createProductColumns(
|
export function createProductColumns(
|
||||||
@ -84,12 +89,16 @@ export function createProductColumns(
|
|||||||
const {
|
const {
|
||||||
handleEdit,
|
handleEdit,
|
||||||
handleDeleteClick,
|
handleDeleteClick,
|
||||||
handleVariantEdit,
|
handleReject,
|
||||||
handleVariantDeleteClick,
|
|
||||||
toggleStatusUrl,
|
toggleStatusUrl,
|
||||||
|
approveUrl,
|
||||||
|
resubmitUrl,
|
||||||
can,
|
can,
|
||||||
|
hasRole,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
|
const isVerifier = hasRole('developer') || hasRole('owner');
|
||||||
|
|
||||||
const columns: ColumnDef<Product>[] = [
|
const columns: ColumnDef<Product>[] = [
|
||||||
{
|
{
|
||||||
id: 'expand',
|
id: 'expand',
|
||||||
@ -139,7 +148,21 @@ export function createProductColumns(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<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">
|
<span className="text-xs text-muted-foreground">
|
||||||
{product.categories
|
{product.categories
|
||||||
?.map((c) => c.name)
|
?.map((c) => c.name)
|
||||||
@ -297,60 +320,123 @@ export function createProductColumns(
|
|||||||
header: () => <span>Status</span>,
|
header: () => <span>Status</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const product = row.original;
|
const product = row.original;
|
||||||
const isToggleable =
|
|
||||||
product.status === 'active' ||
|
|
||||||
product.status === 'inactive';
|
|
||||||
const isChecked = product.status === 'active';
|
const isChecked = product.status === 'active';
|
||||||
|
|
||||||
function handleToggle(checked: boolean) {
|
function handleToggle() {
|
||||||
router.post(
|
router.post(
|
||||||
toggleStatusUrl(product.id),
|
toggleStatusUrl(product.id),
|
||||||
{},
|
{},
|
||||||
{
|
{ preserveScroll: true },
|
||||||
preserveScroll: true,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isToggleable) {
|
if (product.status === 'active' || product.status === 'inactive') {
|
||||||
return (
|
return (
|
||||||
<span
|
<div className="flex items-center gap-2">
|
||||||
className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${getStatusVariant(product.status)}`}
|
<Switch
|
||||||
>
|
size="sm"
|
||||||
{getStatusLabel(product.status)}
|
checked={isChecked}
|
||||||
</span>
|
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 (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<span className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${getStatusVariant(product.status)}`}>
|
||||||
<Switch
|
{getStatusLabel(product.status)}
|
||||||
size="sm"
|
</span>
|
||||||
checked={isChecked}
|
|
||||||
onCheckedChange={handleToggle}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={`text-xs font-medium ${isChecked ? 'text-green-700' : 'text-red-700'}`}
|
|
||||||
>
|
|
||||||
{getStatusLabel(product.status)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (can('products.update') || can('products.delete')) {
|
// Actions column
|
||||||
columns.push({
|
columns.push({
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: () => <span className="block text-center">Aksi</span>,
|
header: () => <span className="block text-center">Aksi</span>,
|
||||||
meta: {
|
meta: {
|
||||||
className: 'w-[100px] text-center',
|
className: 'w-[100px] text-center',
|
||||||
headerClassName: 'w-[100px] text-center',
|
headerClassName: 'w-[100px] text-center',
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const product = row.original;
|
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 (
|
return (
|
||||||
<RowActions
|
<RowActions
|
||||||
actions={[
|
actions={[
|
||||||
@ -362,18 +448,18 @@ export function createProductColumns(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Hapus',
|
label: 'Hapus',
|
||||||
icon: (
|
icon: <Trash2 className="h-4 w-4 text-destructive" />,
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
|
||||||
),
|
|
||||||
show: can('products.delete'),
|
show: can('products.delete'),
|
||||||
onClick: () => handleDeleteClick(product),
|
onClick: () => handleDeleteClick(product),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
});
|
|
||||||
}
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return columns;
|
return columns;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
import { Link, router } from '@inertiajs/react';
|
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 { CardTable } from '@/components/card-table';
|
||||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||||
import { FilterPopover } from '@/components/filter-popover';
|
import { FilterPopover } from '@/components/filter-popover';
|
||||||
@ -28,16 +31,17 @@ import {
|
|||||||
edit as productEdit,
|
edit as productEdit,
|
||||||
index as productIndex,
|
index as productIndex,
|
||||||
toggleStatus,
|
toggleStatus,
|
||||||
|
approve as productApprove,
|
||||||
|
reject as productReject,
|
||||||
|
resubmit as productResubmit,
|
||||||
} from '@/routes/admin/master/products';
|
} from '@/routes/admin/master/products';
|
||||||
import {
|
import {
|
||||||
destroy as variantDestroy,
|
destroy as variantDestroy,
|
||||||
edit as variantEdit,
|
edit as variantEdit,
|
||||||
} from '@/routes/admin/master/products/variants';
|
} 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 type { Product, ProductVariant } from './columns';
|
||||||
import { ProductCardRow } from './product-card';
|
import { ProductCardRow } from './product-card';
|
||||||
|
import { RejectDialog } from './reject-dialog';
|
||||||
import { VariantSubRow } from './variant/sub-row';
|
import { VariantSubRow } from './variant/sub-row';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -68,6 +72,7 @@ export default function ProductIndex({ products, categories, productNames, filte
|
|||||||
product: Product;
|
product: Product;
|
||||||
variant: ProductVariant;
|
variant: ProductVariant;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [rejecting, setRejecting] = useState<Product | null>(null);
|
||||||
const expand = useCardTableExpand(true);
|
const expand = useCardTableExpand(true);
|
||||||
|
|
||||||
const pagination = {
|
const pagination = {
|
||||||
@ -184,6 +189,8 @@ export default function ProductIndex({ products, categories, productNames, filte
|
|||||||
<SelectItem value="active">Aktif</SelectItem>
|
<SelectItem value="active">Aktif</SelectItem>
|
||||||
<SelectItem value="inactive">Non Aktif</SelectItem>
|
<SelectItem value="inactive">Non Aktif</SelectItem>
|
||||||
<SelectItem value="draft">Draft</SelectItem>
|
<SelectItem value="draft">Draft</SelectItem>
|
||||||
|
<SelectItem value="pending">Menunggu Verifikasi</SelectItem>
|
||||||
|
<SelectItem value="rejected">Ditolak</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@ -294,7 +301,10 @@ export default function ProductIndex({ products, categories, productNames, filte
|
|||||||
router.visit(productEdit.url(p.id));
|
router.visit(productEdit.url(p.id));
|
||||||
}}
|
}}
|
||||||
onDelete={(p) => setDeleting(p)}
|
onDelete={(p) => setDeleting(p)}
|
||||||
|
onReject={(p) => setRejecting(p)}
|
||||||
toggleStatusUrl={(id) => toggleStatus.url(id)}
|
toggleStatusUrl={(id) => toggleStatus.url(id)}
|
||||||
|
approveUrl={(id) => productApprove.url(id)}
|
||||||
|
resubmitUrl={(id) => productResubmit.url(id)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderSubContent={(product) => (
|
renderSubContent={(product) => (
|
||||||
@ -342,6 +352,16 @@ export default function ProductIndex({ products, categories, productNames, filte
|
|||||||
}
|
}
|
||||||
onConfirm={handleDeleteVariant}
|
onConfirm={handleDeleteVariant}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<RejectDialog
|
||||||
|
product={rejecting}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setRejecting(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
rejectUrl={(id) => productReject.url(id)}
|
||||||
|
/>
|
||||||
</div>
|
</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 { RowActions } from '@/components/row-actions';
|
||||||
import { ToggleStatus } from '@/components/toggle-status';
|
import { ToggleStatus } from '@/components/toggle-status';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { useCan } from '@/hooks/use-can';
|
import { useCan } from '@/hooks/use-can';
|
||||||
@ -12,6 +14,8 @@ function getStatusLabel(status: string): string {
|
|||||||
active: 'Aktif',
|
active: 'Aktif',
|
||||||
inactive: 'Non Aktif',
|
inactive: 'Non Aktif',
|
||||||
draft: 'Draft',
|
draft: 'Draft',
|
||||||
|
pending: 'Menunggu Verifikasi',
|
||||||
|
rejected: 'Ditolak',
|
||||||
};
|
};
|
||||||
|
|
||||||
return labels[status] ?? status;
|
return labels[status] ?? status;
|
||||||
@ -22,6 +26,8 @@ function getStatusVariant(status: string): string {
|
|||||||
active: 'bg-green-100 text-green-800',
|
active: 'bg-green-100 text-green-800',
|
||||||
inactive: 'bg-red-100 text-red-800',
|
inactive: 'bg-red-100 text-red-800',
|
||||||
draft: 'bg-yellow-100 text-yellow-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';
|
return variants[status] ?? 'bg-gray-100 text-gray-800';
|
||||||
@ -34,7 +40,10 @@ export type ProductCardRowParams = {
|
|||||||
onToggleExpand: () => void;
|
onToggleExpand: () => void;
|
||||||
onEdit: (product: Product) => void;
|
onEdit: (product: Product) => void;
|
||||||
onDelete: (product: Product) => void;
|
onDelete: (product: Product) => void;
|
||||||
|
onReject: (product: Product) => void;
|
||||||
toggleStatusUrl: (id: number) => string;
|
toggleStatusUrl: (id: number) => string;
|
||||||
|
approveUrl: (id: number) => string;
|
||||||
|
resubmitUrl: (id: number) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProductCardRow({
|
export function ProductCardRow({
|
||||||
@ -44,9 +53,13 @@ export function ProductCardRow({
|
|||||||
onToggleExpand,
|
onToggleExpand,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onReject,
|
||||||
toggleStatusUrl,
|
toggleStatusUrl,
|
||||||
|
approveUrl,
|
||||||
|
resubmitUrl,
|
||||||
}: ProductCardRowParams) {
|
}: ProductCardRowParams) {
|
||||||
const { can } = useCan();
|
const { can, hasRole } = useCan();
|
||||||
|
const isVerifier = hasRole('developer') || hasRole('owner');
|
||||||
const variants = product.product_variants ?? [];
|
const variants = product.product_variants ?? [];
|
||||||
const totalStock = variants.reduce((sum, v) => sum + (v.stock ?? 0), 0);
|
const totalStock = variants.reduce((sum, v) => sum + (v.stock ?? 0), 0);
|
||||||
const totalReject = variants.reduce(
|
const totalReject = variants.reduce(
|
||||||
@ -59,8 +72,8 @@ export function ProductCardRow({
|
|||||||
);
|
);
|
||||||
const totalAll = totalStock + totalReject + totalRetail;
|
const totalAll = totalStock + totalReject + totalRetail;
|
||||||
|
|
||||||
const isToggleable =
|
const isPending = product.status === 'pending';
|
||||||
product.status === 'active' || product.status === 'inactive';
|
const isRejected = product.status === 'rejected';
|
||||||
const isChecked = product.status === 'active';
|
const isChecked = product.status === 'active';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -86,6 +99,18 @@ export function ProductCardRow({
|
|||||||
<h3 className="truncate font-medium">
|
<h3 className="truncate font-medium">
|
||||||
{product.name}
|
{product.name}
|
||||||
</h3>
|
</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 && (
|
{product.categories?.length > 0 && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
(
|
(
|
||||||
@ -97,6 +122,12 @@ export function ProductCardRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<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">
|
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-foreground">
|
||||||
{variants.length} varian
|
{variants.length} varian
|
||||||
@ -128,7 +159,7 @@ export function ProductCardRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
{isToggleable ? (
|
{(product.status === 'active' || product.status === 'inactive') ? (
|
||||||
<ToggleStatus
|
<ToggleStatus
|
||||||
url={toggleStatusUrl(product.id)}
|
url={toggleStatusUrl(product.id)}
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
@ -144,7 +175,47 @@ export function ProductCardRow({
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<RowActions
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
@ -155,16 +226,14 @@ export function ProductCardRow({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Hapus',
|
label: 'Hapus',
|
||||||
icon: (
|
icon: <Trash2 className="h-4 w-4 text-destructive" />,
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
|
||||||
),
|
|
||||||
show: can('products.delete'),
|
show: can('products.delete'),
|
||||||
onClick: () => onDelete(product),
|
onClick: () => onDelete(product),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
wrapperClassName="flex shrink-0 items-center gap-1"
|
wrapperClassName="flex shrink-0 items-center gap-1"
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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;
|
onEditVariant: (product: Product, variant: ProductVariant) => void;
|
||||||
onDeleteVariantClick: (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 variants = product.product_variants ?? [];
|
||||||
const [transferVariant, setTransferVariant] = useState<{
|
const [transferVariant, setTransferVariant] = useState<{
|
||||||
product: Product;
|
product: Product;
|
||||||
variant: ProductVariant;
|
variant: ProductVariant;
|
||||||
} | null>(null);
|
} | 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 (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@ -129,7 +139,7 @@ export function VariantSubRow({
|
|||||||
icon: (
|
icon: (
|
||||||
<ArrowRightLeft className="h-4 w-4" />
|
<ArrowRightLeft className="h-4 w-4" />
|
||||||
),
|
),
|
||||||
show: can('products.transfer_stock'),
|
show: showTransfer,
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
setTransferVariant({
|
setTransferVariant({
|
||||||
product,
|
product,
|
||||||
@ -141,7 +151,7 @@ export function VariantSubRow({
|
|||||||
icon: (
|
icon: (
|
||||||
<ScrollText className="h-4 w-4" />
|
<ScrollText className="h-4 w-4" />
|
||||||
),
|
),
|
||||||
show: can('products.view_stock_mutations'),
|
show: showMutations,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
router.visit(
|
router.visit(
|
||||||
stockMutations.url({
|
stockMutations.url({
|
||||||
@ -156,7 +166,7 @@ export function VariantSubRow({
|
|||||||
icon: (
|
icon: (
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
),
|
),
|
||||||
show: can('products.update'),
|
show: showEdit,
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
onEditVariant(
|
onEditVariant(
|
||||||
product,
|
product,
|
||||||
@ -168,7 +178,7 @@ export function VariantSubRow({
|
|||||||
icon: (
|
icon: (
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
),
|
),
|
||||||
show: can('products.delete'),
|
show: showDelete,
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
onDeleteVariantClick(
|
onDeleteVariantClick(
|
||||||
product,
|
product,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// Components
|
// Components
|
||||||
import { Form, Head } from '@inertiajs/react';
|
import { Form, Head } from '@inertiajs/react';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { LoaderCircle } from 'lucide-react';
|
import { LoaderCircle } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
import TextLink from '@/components/text-link';
|
import TextLink from '@/components/text-link';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|||||||
@ -18,9 +18,6 @@ export default function Login() {
|
|||||||
action={store()}
|
action={store()}
|
||||||
resetOnSuccess={['password']}
|
resetOnSuccess={['password']}
|
||||||
className="flex flex-col gap-6"
|
className="flex flex-col gap-6"
|
||||||
onError={() => {
|
|
||||||
toast.error('Terjadi kesalahan saat menyimpan data. Silakan periksa kembali input Anda.');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{({ processing, errors }) => (
|
{({ processing, errors }) => (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -46,6 +46,9 @@
|
|||||||
|
|
||||||
Route::resource('products', ProductController::class)->except(['show'])->middleware('permission:products.view|products.create|products.update|products.delete');
|
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}/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::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::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');
|
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