Compare commits

...

22 Commits

Author SHA1 Message Date
Yoga Pangestu
1f39565754 feat: update material_result handling in CuttingRequest, CuttingCreate, and CuttingEdit components 2026-08-06 11:19:04 +07:00
Yoga Pangestu
3ef948c47f feat: enhance CuttingItemSubRow to display total material usage and results by raw material 2026-08-06 10:57:31 +07:00
Yoga Pangestu
659104fa19 fix: update terminology from "Per Unit" to "Per Produk" in CuttingCreate and CuttingEdit components 2026-08-06 10:46:57 +07:00
Yoga Pangestu
71128002d7 refactor: streamline material validation and stock management in create and update methods of CuttingService 2026-08-06 10:41:41 +07:00
Yoga Pangestu
aaafbb92a2 feat: update validation rules in CuttingRequest and enhance error handling in CuttingCreate and CuttingEdit components 2026-08-06 10:35:08 +07:00
Yoga Pangestu
b45052dc55 feat: refactor CuttingItemSubRow to improve material grouping and display logic 2026-08-06 10:14:49 +07:00
Yoga Pangestu
577329365a feat: add active and nonactive scopes to RawMaterial model and apply active scope in CuttingService 2026-08-06 09:57:01 +07:00
Yoga Pangestu
62341ac50a refactor: clean up imports and improve code readability across multiple components 2026-08-06 09:55:11 +07:00
Yoga Pangestu
bcf5842c4a feat: enhance Purchase management with supplier filtering and pagination adjustments 2026-08-06 09:52:17 +07:00
Yoga Pangestu
b4cb3f4d48 feat: add rowClassName prop to CardTable for dynamic row styling 2026-08-06 09:29:35 +07:00
Yoga Pangestu
f49cebb35a refactor: remove unused variant handling and clean up PurchaseEdit component 2026-08-06 09:16:27 +07:00
Yoga Pangestu
fb70cca1dc fix: update condition for rendering button based on variants length in create and edit pages 2026-08-06 08:57:09 +07:00
Yoga Pangestu
3b49f5ff3a refactor: update user relationship in PushSubscription model and clean up User model attributes 2026-08-05 23:29:37 +07:00
Yoga Pangestu
4ac83c8e78 feat: add permission checks for various actions across admin pages
- Integrated permission checks using `can` function in payroll period management to control visibility of adjustment, pay, and cancel actions.
- Updated employee management to conditionally render edit, reset password, and delete actions based on permissions.
- Enhanced leave request management with permission checks for approve, reject, edit, and delete actions.
- Implemented permission checks in cutting management for edit and delete actions.
- Added permission checks for purchase management actions including create, edit, and delete.
- Integrated permission checks in restock management for create, edit, and delete actions.
- Updated transaction management to conditionally render create, edit, and delete actions based on permissions.
- Enhanced category management with permission checks for edit and delete actions.
- Integrated permission checks in customer management for edit and delete actions.
- Updated product management to conditionally render create, edit, and delete actions based on permissions.
- Enhanced raw material management with permission checks for edit and delete actions.
- Integrated permission checks in supplier management for edit and delete actions.
- Updated role management to conditionally render edit and delete actions based on permissions.
2026-08-05 22:52:35 +07:00
Yoga Pangestu
fc1c84f420 refactor: remove readonly modifier from S3PresignedService property for consistency 2026-08-05 22:35:46 +07:00
Yoga Pangestu
c887eddd88 refactor: remove unused import of router for cleaner code 2026-08-05 22:25:26 +07:00
Yoga Pangestu
bd20162c13 refactor: replace anchor tags with Link components for improved navigation consistency 2026-08-05 20:48:58 +07:00
Yoga Pangestu
705f0d3efa refactor: update select statements to use array syntax for improved readability 2026-08-05 20:45:16 +07:00
Yoga Pangestu
d853e742ed feat: add custom scrollbar styles for improved aesthetics and usability 2026-08-05 20:31:17 +07:00
Yoga Pangestu
87439677fc refactor: remove unused permissions for categories and products in RolePermissionSeeder 2026-08-05 20:29:57 +07:00
Yoga Pangestu
3f77a9436d refactor: improve formatting consistency in various models and services 2026-08-05 20:27:27 +07:00
Yoga Pangestu
b70a40af4d feat: implement sorting for lines in purchase and restock create/edit components; enhance user experience 2026-08-05 20:26:58 +07:00
140 changed files with 1561 additions and 1389 deletions

View File

@ -16,7 +16,7 @@
class AdminSettingsController extends Controller class AdminSettingsController extends Controller
{ {
public function __construct( public function __construct(
private readonly AdminSettingsService $service private AdminSettingsService $service
) {} ) {}
public function index(): Response public function index(): Response

View File

@ -15,7 +15,7 @@
class CashAccountController extends Controller class CashAccountController extends Controller
{ {
public function __construct( public function __construct(
private readonly CashAccountService $service private CashAccountService $service
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response

View File

@ -14,7 +14,7 @@
class EmployeeAdvanceController extends Controller class EmployeeAdvanceController extends Controller
{ {
public function __construct( public function __construct(
private readonly EmployeeAdvanceService $service private EmployeeAdvanceService $service
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response

View File

@ -14,7 +14,7 @@
class ExpenseController extends Controller class ExpenseController extends Controller
{ {
public function __construct( public function __construct(
private readonly ExpenseService $service private ExpenseService $service
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response

View File

@ -12,7 +12,7 @@
class PayrollAdjustmentController extends Controller class PayrollAdjustmentController extends Controller
{ {
public function __construct( public function __construct(
private readonly PayrollAdjustmentService $service private PayrollAdjustmentService $service
) {} ) {}
public function store(PayrollAdjustmentRequest $request, Payroll $payroll): RedirectResponse public function store(PayrollAdjustmentRequest $request, Payroll $payroll): RedirectResponse

View File

@ -10,7 +10,7 @@
class PayrollController extends Controller class PayrollController extends Controller
{ {
public function __construct( public function __construct(
private readonly PayrollPeriodService $service private PayrollPeriodService $service
) {} ) {}
public function pay(Payroll $payroll): RedirectResponse public function pay(Payroll $payroll): RedirectResponse

View File

@ -14,7 +14,7 @@
class PayrollPeriodController extends Controller class PayrollPeriodController extends Controller
{ {
public function __construct( public function __construct(
private readonly PayrollPeriodService $service private PayrollPeriodService $service
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response

View File

@ -15,7 +15,7 @@
class AttendanceController extends Controller class AttendanceController extends Controller
{ {
public function __construct( public function __construct(
private readonly AttendanceService $service private AttendanceService $service
) {} ) {}
public function index(Request $request): Response public function index(Request $request): Response

View File

@ -15,7 +15,7 @@
class EmployeeController extends Controller class EmployeeController extends Controller
{ {
public function __construct( public function __construct(
private readonly EmployeeService $service private EmployeeService $service
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response

View File

@ -15,7 +15,7 @@
class LeaveRequestController extends Controller class LeaveRequestController extends Controller
{ {
public function __construct( public function __construct(
private readonly LeaveRequestService $service private LeaveRequestService $service
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response

View File

@ -14,7 +14,7 @@
class CuttingController extends Controller class CuttingController extends Controller
{ {
public function __construct( public function __construct(
private readonly CuttingService $service, private CuttingService $service,
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response

View File

@ -7,6 +7,7 @@
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\Purchase; use App\Models\Purchase;
use App\Services\Admin\Manage\PurchaseService; use App\Services\Admin\Manage\PurchaseService;
use App\Services\Admin\Master\SupplierService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -15,6 +16,7 @@ class PurchaseController extends Controller
{ {
public function __construct( public function __construct(
private readonly PurchaseService $service, private readonly PurchaseService $service,
private readonly SupplierService $supplierService,
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
@ -22,7 +24,10 @@ public function index(PaginatedRequest $request): Response
return Inertia::render('admin/manage/purchase/index', [ return Inertia::render('admin/manage/purchase/index', [
'purchases' => $this->service->paginated( 'purchases' => $this->service->paginated(
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
filters: $request->only(['supplier_id']),
), ),
'suppliers' => $this->supplierService->getAll(),
'filters' => $request->only(['supplier_id']),
]); ]);
} }

View File

@ -14,7 +14,7 @@
class RestockController extends Controller class RestockController extends Controller
{ {
public function __construct( public function __construct(
private readonly RestockService $service, private RestockService $service,
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response

View File

@ -14,7 +14,7 @@
class TransactionController extends Controller class TransactionController extends Controller
{ {
public function __construct( public function __construct(
private readonly TransactionService $service, private TransactionService $service,
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response

View File

@ -14,7 +14,7 @@
class CategoryController extends Controller class CategoryController extends Controller
{ {
public function __construct( public function __construct(
private readonly CategoryService $service private CategoryService $service
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response

View File

@ -14,7 +14,7 @@
class CustomerController extends Controller class CustomerController extends Controller
{ {
public function __construct( public function __construct(
private readonly CustomerService $service private CustomerService $service
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response

View File

@ -15,8 +15,8 @@
class ProductController extends Controller class ProductController extends Controller
{ {
public function __construct( public function __construct(
private readonly ProductService $service, private ProductService $service,
private readonly CategoryService $categoryService, private CategoryService $categoryService,
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response

View File

@ -15,7 +15,7 @@
class ProductVariantController extends Controller class ProductVariantController extends Controller
{ {
public function __construct( public function __construct(
private readonly ProductVariantService $variantService, private ProductVariantService $variantService,
) {} ) {}
public function edit(Product $product, ProductVariant $variant): Response public function edit(Product $product, ProductVariant $variant): Response

View File

@ -13,7 +13,7 @@
class StockMutationController extends Controller class StockMutationController extends Controller
{ {
public function __construct( public function __construct(
private readonly StockMutationService $service, private StockMutationService $service,
) {} ) {}
public function index(StockMutationRequest $request, Product $product, ProductVariant $variant): Response public function index(StockMutationRequest $request, Product $product, ProductVariant $variant): Response

View File

@ -14,7 +14,7 @@
class RawMaterialController extends Controller class RawMaterialController extends Controller
{ {
public function __construct( public function __construct(
private readonly RawMaterialService $service, private RawMaterialService $service,
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response

View File

@ -14,7 +14,7 @@
class RawMaterialVariantController extends Controller class RawMaterialVariantController extends Controller
{ {
public function __construct( public function __construct(
private readonly RawMaterialVariantService $variantService, private RawMaterialVariantService $variantService,
) {} ) {}
public function edit(RawMaterial $rawMaterial, RawMaterialPrice $variant): Response public function edit(RawMaterial $rawMaterial, RawMaterialPrice $variant): Response

View File

@ -14,7 +14,7 @@
class SupplierController extends Controller class SupplierController extends Controller
{ {
public function __construct( public function __construct(
private readonly SupplierService $service private SupplierService $service
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response

View File

@ -14,7 +14,7 @@
class RoleController extends Controller class RoleController extends Controller
{ {
public function __construct( public function __construct(
private readonly RoleService $service private RoleService $service
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response

View File

@ -11,7 +11,7 @@
class PresignedUrlController extends Controller class PresignedUrlController extends Controller
{ {
public function __construct( public function __construct(
private readonly S3PresignedService $service private S3PresignedService $service
) {} ) {}
public function store(PresignedUrlRequest $request): JsonResponse public function store(PresignedUrlRequest $request): JsonResponse

View File

@ -11,7 +11,7 @@
class PushSubscriptionController extends Controller class PushSubscriptionController extends Controller
{ {
public function __construct( public function __construct(
private readonly PushSubscriptionService $service, private PushSubscriptionService $service,
) {} ) {}
public function store(StorePushSubscriptionRequest $request): JsonResponse public function store(StorePushSubscriptionRequest $request): JsonResponse

View File

@ -16,16 +16,16 @@ public function rules(): array
return [ return [
'description' => ['nullable', 'string', 'max:100'], 'description' => ['nullable', 'string', 'max:100'],
'product_name' => ['required', 'string', 'max:255'], 'product_name' => ['required', 'string', 'max:255'],
'sample' => ['required', 'integer', 'min:0'], 'sample' => ['required', 'integer'],
'original_outside_sample' => ['required', 'integer', 'min:0'], 'original_outside_sample' => ['required', 'integer'],
'cutting_result' => ['required', 'integer', 'min:0'], 'cutting_result' => ['required', 'integer', 'min:1'],
'materials' => ['required', 'array', 'min:1'], 'materials' => ['required', 'array', 'min:1'],
'materials.*.raw_material_price_id' => ['required', 'integer', 'exists:raw_material_prices,id'], 'materials.*.raw_material_price_id' => ['required', 'integer', 'exists:raw_material_prices,id'],
'materials.*.material_usage' => ['required', 'integer', 'min:0'], 'materials.*.material_usage' => ['required', 'integer', 'min:1'],
'materials.*.material_result' => ['required', 'integer', 'min:0'], 'materials.*.material_result' => ['required', 'integer'],
'materials.*.combination_index' => ['nullable', 'integer', 'min:0'], 'materials.*.combination_index' => ['nullable', 'integer'],
'combinations' => ['nullable', 'array'], 'combinations' => ['nullable', 'array'],
'combinations.*.material_result' => ['nullable', 'integer', 'min:0'], 'combinations.*.material_result' => ['nullable', 'integer'],
'photo_key' => ['nullable', 'string', 'max:500'], 'photo_key' => ['nullable', 'string', 'max:500'],
]; ];
} }

View File

@ -39,7 +39,7 @@ protected function formattedName(): Attribute
protected function statusLabel(): Attribute protected function statusLabel(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn () => $this->status->label(), get: fn () => $this->status?->label(),
); );
} }

View File

@ -2,8 +2,10 @@
namespace App\Models; namespace App\Models;
use App\Models\Scopes\ProductVariantScope;
use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -15,6 +17,7 @@
#[Appends(['formatted_name'])] #[Appends(['formatted_name'])]
#[Guarded(['id'])] #[Guarded(['id'])]
#[ScopedBy([ProductVariantScope::class])]
class ProductVariant extends Model implements HasMedia class ProductVariant extends Model implements HasMedia
{ {
use HasFactory, InteractsWithMedia, SoftDeletes; use HasFactory, InteractsWithMedia, SoftDeletes;

View File

@ -5,15 +5,15 @@
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Guarded(['id'])] #[Guarded(['id'])]
class PushSubscription extends Model class PushSubscription extends Model
{ {
use HasFactory; use HasFactory;
public function user(): BelongsTo public function user(): MorphTo
{ {
return $this->belongsTo(User::class); return $this->morphTo();
} }
} }

View File

@ -41,6 +41,18 @@ protected function unitLabel(): Attribute
); );
} }
#[Scope]
protected function active(Builder $query): void
{
$query->where('is_active', true);
}
#[Scope]
protected function nonactive(Builder $query): void
{
$query->where('is_active', false);
}
#[Scope] #[Scope]
protected function kg(Builder $query): void protected function kg(Builder $query): void
{ {

View File

@ -2,9 +2,11 @@
namespace App\Models; namespace App\Models;
use App\Models\Scopes\RawMaterialPriceScope;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -16,6 +18,7 @@
#[Appends(['formatted_price'])] #[Appends(['formatted_price'])]
#[Guarded(['id'])] #[Guarded(['id'])]
#[ScopedBy([RawMaterialPriceScope::class])]
class RawMaterialPrice extends Model implements HasMedia class RawMaterialPrice extends Model implements HasMedia
{ {
use HasFactory, InteractsWithMedia, SoftDeletes; use HasFactory, InteractsWithMedia, SoftDeletes;

View File

@ -0,0 +1,15 @@
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class ProductVariantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->orderBy('name');
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class RawMaterialPriceScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->orderBy('variant');
}
}

View File

@ -181,11 +181,6 @@ public function purchaseItems(): HasMany
return $this->hasMany(PurchaseItem::class); return $this->hasMany(PurchaseItem::class);
} }
public function pushSubscriptions(): HasMany
{
return $this->hasMany(PushSubscription::class);
}
public function rejections(): HasMany public function rejections(): HasMany
{ {
return $this->hasMany(Rejection::class, 'rejected_by_id'); return $this->hasMany(Rejection::class, 'rejected_by_id');

View File

@ -13,7 +13,7 @@
class AdminSettingsService class AdminSettingsService
{ {
public function __construct( public function __construct(
private readonly S3PresignedService $s3Service, private S3PresignedService $s3Service,
) {} ) {}
public function getSystemData(): array public function getSystemData(): array

View File

@ -20,12 +20,12 @@ class CashAccountService
use HandlesCashTransactions, RegistersMedia; use HandlesCashTransactions, RegistersMedia;
public function __construct( public function __construct(
private readonly S3PresignedService $s3Service, private S3PresignedService $s3Service,
) {} ) {}
public function get(): ?CashAccount public function get(): ?CashAccount
{ {
return CashAccount::select('id', 'name', 'balance')->first(); return CashAccount::select(['id', 'name', 'balance'])->first();
} }
public function getAllTransactions(array $filters = []): Collection public function getAllTransactions(array $filters = []): Collection
@ -37,7 +37,7 @@ public function getAllTransactions(array $filters = []): Collection
} }
return $cashAccount->cashTransactions() return $cashAccount->cashTransactions()
->select('id', 'created_by_id', 'reference_type', 'reference_id', 'amount', 'balance_after', 'type', 'description', 'created_at') ->select(['id', 'created_by_id', 'reference_type', 'reference_id', 'amount', 'balance_after', 'type', 'description', 'created_at'])
->with('createdBy.userProfile', 'media') ->with('createdBy.userProfile', 'media')
->when($filters['type'] ?? null, function ($query, $type) { ->when($filters['type'] ?? null, function ($query, $type) {
$query->where('type', $type); $query->where('type', $type);
@ -56,7 +56,7 @@ public function paginatedTransactions(int $perPage = 25, string $search = '', st
} }
$paginator = $cashAccount->cashTransactions() $paginator = $cashAccount->cashTransactions()
->select('id', 'created_by_id', 'reference_type', 'reference_id', 'amount', 'balance_after', 'type', 'description', 'created_at') ->select(['id', 'created_by_id', 'reference_type', 'reference_id', 'amount', 'balance_after', 'type', 'description', 'created_at'])
->with('createdBy.userProfile', 'media') ->with('createdBy.userProfile', 'media')
->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%"))
->when($filters['type'] ?? null, function ($query, $type) { ->when($filters['type'] ?? null, function ($query, $type) {

View File

@ -2,10 +2,8 @@
namespace App\Services\Admin\Finance; namespace App\Services\Admin\Finance;
use App\Enums\CashTransactionType;
use App\Enums\EmployeeAdvanceStatus; use App\Enums\EmployeeAdvanceStatus;
use App\Models\CashAccount; use App\Models\CashAccount;
use App\Models\CashTransaction;
use App\Models\EmployeeAdvance; use App\Models\EmployeeAdvance;
use App\Services\Concerns\HandlesCashTransactions; use App\Services\Concerns\HandlesCashTransactions;
use App\Services\NotificationService; use App\Services\NotificationService;
@ -17,9 +15,10 @@
class EmployeeAdvanceService class EmployeeAdvanceService
{ {
use HandlesCashTransactions; use HandlesCashTransactions;
public function getAll(array $filters = []): Collection public function getAll(array $filters = []): Collection
{ {
return EmployeeAdvance::select('id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at') return EmployeeAdvance::select(['id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at'])
->with(['employee.user.userProfile']) ->with(['employee.user.userProfile'])
->latest() ->latest()
->get(); ->get();
@ -28,7 +27,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
{ {
return EmployeeAdvance::query() return EmployeeAdvance::query()
->select('id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at') ->select(['id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at'])
->with(['employee.user.userProfile']) ->with(['employee.user.userProfile'])
->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%"))
->orderBy($sort, $direction) ->orderBy($sort, $direction)

View File

@ -2,9 +2,7 @@
namespace App\Services\Admin\Finance; namespace App\Services\Admin\Finance;
use App\Enums\CashTransactionType;
use App\Models\CashAccount; use App\Models\CashAccount;
use App\Models\CashTransaction;
use App\Models\Expense; use App\Models\Expense;
use App\Services\Concerns\HandlesCashTransactions; use App\Services\Concerns\HandlesCashTransactions;
use App\Services\Concerns\RegistersMedia; use App\Services\Concerns\RegistersMedia;
@ -21,12 +19,12 @@ class ExpenseService
use HandlesCashTransactions, RegistersMedia; use HandlesCashTransactions, RegistersMedia;
public function __construct( public function __construct(
private readonly S3PresignedService $s3Service, private S3PresignedService $s3Service,
) {} ) {}
public function getAll(array $filters = []): Collection public function getAll(array $filters = []): Collection
{ {
return Expense::select('id', 'created_by_id', 'amount', 'description', 'created_at') return Expense::select(['id', 'created_by_id', 'amount', 'description', 'created_at'])
->with('createdBy.userProfile', 'media') ->with('createdBy.userProfile', 'media')
->latest() ->latest()
->get() ->get()
@ -36,7 +34,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 = Expense::query() $paginator = Expense::query()
->select('id', 'created_by_id', 'amount', 'description', 'created_at') ->select(['id', 'created_by_id', 'amount', 'description', 'created_at'])
->with('createdBy.userProfile', 'media') ->with('createdBy.userProfile', 'media')
->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%"))
->orderBy($sort, $direction) ->orderBy($sort, $direction)

View File

@ -2,11 +2,8 @@
namespace App\Services\Admin\Finance; namespace App\Services\Admin\Finance;
use App\Enums\CashTransactionType;
use App\Enums\PayrollPeriodStatus; use App\Enums\PayrollPeriodStatus;
use App\Enums\PayrollStatus; use App\Enums\PayrollStatus;
use App\Models\CashAccount;
use App\Models\CashTransaction;
use App\Models\Payroll; use App\Models\Payroll;
use App\Models\PayrollPeriod; use App\Models\PayrollPeriod;
use App\Services\Concerns\HandlesCashTransactions; use App\Services\Concerns\HandlesCashTransactions;
@ -19,9 +16,10 @@
class PayrollPeriodService class PayrollPeriodService
{ {
use HandlesCashTransactions; use HandlesCashTransactions;
public function getAll(array $filters = []): Collection public function getAll(array $filters = []): Collection
{ {
return PayrollPeriod::select('id', 'year', 'month', 'status', 'closed_at', 'created_at') return PayrollPeriod::select(['id', 'year', 'month', 'status', 'closed_at', 'created_at'])
->withCount('payrolls') ->withCount('payrolls')
->withSum('payrolls', 'total_amount') ->withSum('payrolls', 'total_amount')
->withSum('payrolls', 'bonus_amount') ->withSum('payrolls', 'bonus_amount')
@ -40,7 +38,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
{ {
return PayrollPeriod::query() return PayrollPeriod::query()
->select('id', 'year', 'month', 'status', 'closed_at', 'created_at') ->select(['id', 'year', 'month', 'status', 'closed_at', 'created_at'])
->withCount('payrolls') ->withCount('payrolls')
->withSum('payrolls', 'total_amount') ->withSum('payrolls', 'total_amount')
->withSum('payrolls', 'bonus_amount') ->withSum('payrolls', 'bonus_amount')

View File

@ -16,7 +16,7 @@ class AttendanceService
use RegistersMedia; use RegistersMedia;
public function __construct( public function __construct(
private readonly S3PresignedService $s3Service, private S3PresignedService $s3Service,
) {} ) {}
public function getAll(): Collection public function getAll(): Collection

View File

@ -14,9 +14,9 @@ public function getAll(array $filters = []): Collection
return User::select(['id', 'email', 'username', 'is_active']) return User::select(['id', 'email', 'username', 'is_active'])
->where(fn ($q) => $q->whereHas('employee')->orWhereHas('roles', fn ($rq) => $rq->where('name', 'Owner'))) ->where(fn ($q) => $q->whereHas('employee')->orWhereHas('roles', fn ($rq) => $rq->where('name', 'Owner')))
->with([ ->with([
'userProfile' => fn($q) => $q->select('id', 'user_id', 'full_name', 'phone_number', 'gender'), 'userProfile' => fn ($q) => $q->select(['id', 'user_id', 'full_name', 'phone_number', 'gender']),
'employee' => fn($q) => $q->select('id', 'user_id', 'join_date', 'employment_status', 'base_salary'), 'employee' => fn ($q) => $q->select(['id', 'user_id', 'join_date', 'employment_status', 'base_salary']),
'roles' => fn($q) => $q->select('id', 'name'), 'roles' => fn ($q) => $q->select(['id', 'name']),
]) ])
->when($filters['employment_status'] ?? null, fn ($q, $status) => $q->whereHas('employee', fn ($eq) => $eq->where('employment_status', $status))) ->when($filters['employment_status'] ?? null, fn ($q, $status) => $q->whereHas('employee', fn ($eq) => $eq->where('employment_status', $status)))
->when(isset($filters['is_active']) && $filters['is_active'] !== '', fn ($q) => $q->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN))) ->when(isset($filters['is_active']) && $filters['is_active'] !== '', fn ($q) => $q->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN)))
@ -31,9 +31,9 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->select(['id', 'email', 'username', 'is_active']) ->select(['id', 'email', 'username', 'is_active'])
->where(fn ($q) => $q->whereHas('employee')->orWhereHas('roles', fn ($rq) => $rq->where('name', 'Owner'))) ->where(fn ($q) => $q->whereHas('employee')->orWhereHas('roles', fn ($rq) => $rq->where('name', 'Owner')))
->with([ ->with([
'userProfile' => fn($q) => $q->select('id', 'user_id', 'full_name', 'phone_number', 'gender'), 'userProfile' => fn ($q) => $q->select(['id', 'user_id', 'full_name', 'phone_number', 'gender']),
'employee' => fn($q) => $q->select('id', 'user_id', 'join_date', 'employment_status', 'base_salary'), 'employee' => fn ($q) => $q->select(['id', 'user_id', 'join_date', 'employment_status', 'base_salary']),
'roles' => fn($q) => $q->select('id', 'name'), 'roles' => fn ($q) => $q->select(['id', 'name']),
]) ])
->when($search, fn ($q) => $q->whereHas('userProfile', fn ($uq) => $uq->where('full_name', 'like', "%{$search}%"))) ->when($search, fn ($q) => $q->whereHas('userProfile', fn ($uq) => $uq->where('full_name', 'like', "%{$search}%")))
->when($filters['employment_status'] ?? null, fn ($q, $status) => $q->whereHas('employee', fn ($eq) => $eq->where('employment_status', $status))) ->when($filters['employment_status'] ?? null, fn ($q, $status) => $q->whereHas('employee', fn ($eq) => $eq->where('employment_status', $status)))

View File

@ -14,7 +14,7 @@ class LeaveRequestService
{ {
public function getAll(array $filters = []): Collection public function getAll(array $filters = []): Collection
{ {
return LeaveRequest::select('id', 'employee_id', 'start_date', 'end_date', 'total_days', 'status', 'created_at') return LeaveRequest::select(['id', 'employee_id', 'start_date', 'end_date', 'total_days', 'status', 'created_at'])
->with(['employee.user.userProfile']) ->with(['employee.user.userProfile'])
->when($filters['status'] ?? null, function ($query, $status) { ->when($filters['status'] ?? null, function ($query, $status) {
$query->where('status', $status); $query->where('status', $status);
@ -26,7 +26,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
{ {
return LeaveRequest::query() return LeaveRequest::query()
->select('id', 'employee_id', 'start_date', 'end_date', 'total_days', 'status', 'created_at') ->select(['id', 'employee_id', 'start_date', 'end_date', 'total_days', 'status', 'created_at'])
->with(['employee.user.userProfile']) ->with(['employee.user.userProfile'])
->when($search, fn ($q) => $q->whereHas('employee.user.userProfile', fn ($uq) => $uq->where('full_name', 'like', "%{$search}%"))) ->when($search, fn ($q) => $q->whereHas('employee.user.userProfile', fn ($uq) => $uq->where('full_name', 'like', "%{$search}%")))
->when($filters['status'] ?? null, function ($query, $status) { ->when($filters['status'] ?? null, function ($query, $status) {

View File

@ -19,13 +19,13 @@ class CuttingService
use RegistersMedia; use RegistersMedia;
public function __construct( public function __construct(
private readonly S3PresignedService $s3Service, private S3PresignedService $s3Service,
) {} ) {}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
{ {
$paginator = Cutting::query() $paginator = Cutting::query()
->select('id', 'created_by_id', 'status', 'description', 'total_material_cost', 'cost_per_unit', 'created_at') ->select(['id', 'created_by_id', 'status', 'description', 'total_material_cost', 'cost_per_unit', 'created_at'])
->with([ ->with([
'createdBy:id', 'createdBy:id',
'createdBy.userProfile:id,user_id,full_name', 'createdBy.userProfile:id,user_id,full_name',
@ -65,10 +65,11 @@ public function getForCreate(): array
{ {
return [ return [
'rawMaterials' => RawMaterial::query() 'rawMaterials' => RawMaterial::query()
->select('id', 'name', 'unit', 'is_active') ->select(['id', 'name', 'unit', 'is_active'])
->with([ ->with([
'rawMaterialPrices:id,raw_material_id,variant,price,stock', 'rawMaterialPrices:id,raw_material_id,variant,price,stock',
]) ])
->active()
->orderBy('name') ->orderBy('name')
->get() ->get()
->each(function (RawMaterial $rawMaterial) { ->each(function (RawMaterial $rawMaterial) {
@ -144,6 +145,7 @@ public function getForEdit(Cutting $cutting): array
public function create(array $data): Cutting public function create(array $data): Cutting
{ {
return DB::transaction(function () use ($data) {
foreach ($data['materials'] as $materialData) { foreach ($data['materials'] as $materialData) {
$usage = (int) ($materialData['material_usage'] ?? 0); $usage = (int) ($materialData['material_usage'] ?? 0);
if ($usage <= 0) { if ($usage <= 0) {
@ -160,7 +162,6 @@ public function create(array $data): Cutting
} }
} }
return DB::transaction(function () use ($data) {
$cutting = Cutting::create([ $cutting = Cutting::create([
'created_by_id' => auth()->id(), 'created_by_id' => auth()->id(),
'status' => 'in_progress', 'status' => 'in_progress',
@ -246,6 +247,7 @@ public function create(array $data): Cutting
public function update(Cutting $cutting, array $data): Cutting public function update(Cutting $cutting, array $data): Cutting
{ {
return DB::transaction(function () use ($cutting, $data) {
$cutting->load(['cuttingMaterials.rawMaterialPrice']); $cutting->load(['cuttingMaterials.rawMaterialPrice']);
foreach ($cutting->cuttingMaterials as $oldMaterial) { foreach ($cutting->cuttingMaterials as $oldMaterial) {
@ -270,7 +272,6 @@ public function update(Cutting $cutting, array $data): Cutting
} }
} }
return DB::transaction(function () use ($cutting, $data) {
$cutting->load(['cuttingMaterials', 'cuttingMaterialCombinations', 'cuttingResults']); $cutting->load(['cuttingMaterials', 'cuttingMaterialCombinations', 'cuttingResults']);
$cutting->cuttingResults()->delete(); $cutting->cuttingResults()->delete();

View File

@ -18,18 +18,20 @@ class PurchaseService
use RegistersMedia; use RegistersMedia;
public function __construct( public function __construct(
private readonly S3PresignedService $s3Service, private S3PresignedService $s3Service,
) {} ) {}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
$paginator = Purchase::query() $paginator = Purchase::query()
->select('id', 'supplier_id', 'created_by_id', 'subtotal', 'discount', 'shipping_cost', 'total', 'notes', 'created_at') ->select(['id', 'supplier_id', 'created_by_id', 'subtotal', 'discount', 'shipping_cost', 'total', 'notes', 'created_at'])
->with([ ->with([
'supplier:id,name', 'supplier:id,name',
'createdBy:id', 'createdBy:id',
'createdBy.userProfile:id,user_id,full_name', 'createdBy.userProfile:id,user_id,full_name',
'purchaseItems:id,purchase_id,raw_material_price_id,quantity,unit_price,subtotal', 'purchaseItems' => fn ($q) => $q
->select(['id', 'purchase_id', 'raw_material_price_id', 'quantity', 'unit_price', 'subtotal'])
->orderByRaw('(SELECT variant FROM raw_material_prices WHERE raw_material_prices.id = purchase_items.raw_material_price_id)'),
'purchaseItems.rawMaterialPrice:id,raw_material_id,variant,price,stock', 'purchaseItems.rawMaterialPrice:id,raw_material_id,variant,price,stock',
'purchaseItems.rawMaterialPrice.rawMaterial:id,name,unit', 'purchaseItems.rawMaterialPrice.rawMaterial:id,name,unit',
]) ])
@ -37,6 +39,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
$q->whereHas('supplier', fn ($sq) => $sq->where('name', 'like', "%{$search}%")) $q->whereHas('supplier', fn ($sq) => $sq->where('name', 'like', "%{$search}%"))
->orWhere('notes', 'like', "%{$search}%"); ->orWhere('notes', 'like', "%{$search}%");
}) })
->when($filters['supplier_id'] ?? null, fn ($q, $supplierId) => $q->where('supplier_id', $supplierId))
->orderBy($sort, $direction) ->orderBy($sort, $direction)
->paginate($perPage); ->paginate($perPage);
@ -64,9 +67,9 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
public function getForCreate(): array public function getForCreate(): array
{ {
return [ return [
'suppliers' => Supplier::select('id', 'name')->latest()->get(), 'suppliers' => Supplier::select(['id', 'name'])->latest()->get(),
'rawMaterials' => RawMaterial::query() 'rawMaterials' => RawMaterial::query()
->select('id', 'name', 'unit', 'is_active') ->select(['id', 'name', 'unit', 'is_active'])
->with([ ->with([
'rawMaterialPrices:id,raw_material_id,variant,price,stock', 'rawMaterialPrices:id,raw_material_id,variant,price,stock',
]) ])
@ -86,6 +89,8 @@ public function getForCreate(): array
public function getForEdit(Purchase $purchase): array public function getForEdit(Purchase $purchase): array
{ {
$purchase->load([ $purchase->load([
'purchaseItems' => fn ($q) => $q
->orderByRaw('(SELECT variant FROM raw_material_prices WHERE raw_material_prices.id = purchase_items.raw_material_price_id)'),
'purchaseItems.rawMaterialPrice.rawMaterial', 'purchaseItems.rawMaterialPrice.rawMaterial',
'supplier', 'supplier',
]); ]);

View File

@ -20,17 +20,19 @@ class RestockService
use HasStockAdjustment, RegistersMedia; use HasStockAdjustment, RegistersMedia;
public function __construct( public function __construct(
private readonly S3PresignedService $s3Service, private S3PresignedService $s3Service,
) {} ) {}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
{ {
$paginator = Restock::query() $paginator = Restock::query()
->select('id', 'created_by_id', 'subtotal', 'total', 'notes', 'stock_type', 'created_at') ->select(['id', 'created_by_id', 'subtotal', 'total', 'notes', 'stock_type', 'created_at'])
->with([ ->with([
'createdBy:id', 'createdBy:id',
'createdBy.userProfile:id,user_id,full_name', 'createdBy.userProfile:id,user_id,full_name',
'restockItems:id,restock_id,product_variant_id,quantity,unit_price,subtotal', 'restockItems' => fn ($q) => $q
->select(['id', 'restock_id', 'product_variant_id', 'quantity', 'unit_price', 'subtotal'])
->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = restock_items.product_variant_id)'),
'restockItems.productVariant:id,product_id,name,stock,reject_stock,retail_stock', 'restockItems.productVariant:id,product_id,name,stock,reject_stock,retail_stock',
'restockItems.productVariant.product:id,name', 'restockItems.productVariant.product:id,name',
]) ])
@ -61,7 +63,7 @@ public function getForCreate(): array
{ {
return [ return [
'products' => Product::query() 'products' => Product::query()
->select('id', 'name', 'status') ->select(['id', 'name', 'status'])
->with([ ->with([
'productVariants:id,product_id,name,stock,reject_stock', 'productVariants:id,product_id,name,stock,reject_stock',
'productVariants.productPrices:id,variant_id,type,price', 'productVariants.productPrices:id,variant_id,type,price',
@ -89,7 +91,11 @@ public function getForCreate(): array
public function getForEdit(Restock $restock): array public function getForEdit(Restock $restock): array
{ {
$restock->load('restockItems.productVariant.product'); $restock->load([
'restockItems' => fn ($q) => $q
->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = restock_items.product_variant_id)'),
'restockItems.productVariant.product',
]);
$media = $restock->getFirstMedia('photos'); $media = $restock->getFirstMedia('photos');
@ -235,5 +241,4 @@ private function buildItemRows(array $items, string $stockType, $now, int &$subt
]; ];
})->toArray(); })->toArray();
} }
} }

View File

@ -35,13 +35,13 @@ class TransactionService
]; ];
public function __construct( public function __construct(
private readonly S3PresignedService $s3Service, private S3PresignedService $s3Service,
) {} ) {}
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 = Order::query() $paginator = Order::query()
->select('id', 'created_by_id', 'customer_id', 'marketing_id', 'order_number', 'channel', 'price_type', 'status', 'payment_type', 'subtotal', 'discount', 'nego_price', 'total_amount', 'cogs', 'notes', 'created_at') ->select(['id', 'created_by_id', 'customer_id', 'marketing_id', 'order_number', 'channel', 'price_type', 'status', 'payment_type', 'subtotal', 'discount', 'nego_price', 'total_amount', 'cogs', 'notes', 'created_at'])
->with([ ->with([
'createdBy:id', 'createdBy:id',
'createdBy.userProfile:id,user_id,full_name', 'createdBy.userProfile:id,user_id,full_name',
@ -96,7 +96,7 @@ public function getFilterOptions(): array
'channelOptions' => OrderChannel::toSelect(), 'channelOptions' => OrderChannel::toSelect(),
'paymentTypeOptions' => PaymentType::toSelect(), 'paymentTypeOptions' => PaymentType::toSelect(),
'customers' => Customer::query() 'customers' => Customer::query()
->select('id', 'name') ->select(['id', 'name'])
->orderBy('name') ->orderBy('name')
->get(), ->get(),
'employees' => User::query() 'employees' => User::query()
@ -118,7 +118,7 @@ public function getForCreate(): array
{ {
return [ return [
'products' => Product::query() 'products' => Product::query()
->select('id', 'name', 'status') ->select(['id', 'name', 'status'])
->with([ ->with([
'productVariants:id,product_id,name,stock,reject_stock', 'productVariants:id,product_id,name,stock,reject_stock',
'productVariants.productPrices:id,variant_id,type,price', 'productVariants.productPrices:id,variant_id,type,price',
@ -137,7 +137,7 @@ public function getForCreate(): array
}); });
}), }),
'customers' => Customer::query() 'customers' => Customer::query()
->select('id', 'name') ->select(['id', 'name'])
->orderBy('name') ->orderBy('name')
->get(), ->get(),
'employees' => User::query() 'employees' => User::query()

View File

@ -10,13 +10,13 @@ class CategoryService
{ {
public function getAll(array $filters = []): Collection public function getAll(array $filters = []): Collection
{ {
return Category::select('id', 'name')->latest()->get(); return Category::select(['id', 'name'])->latest()->get();
} }
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
{ {
return Category::query() return Category::query()
->select('id', 'name') ->select(['id', 'name'])
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
->orderBy($sort, $direction) ->orderBy($sort, $direction)
->paginate($perPage); ->paginate($perPage);

View File

@ -10,13 +10,13 @@ class CustomerService
{ {
public function getAll(array $filters = []): Collection public function getAll(array $filters = []): Collection
{ {
return Customer::select('id', 'name', 'phone_number', 'address')->latest()->get(); return Customer::select(['id', 'name', 'phone_number', 'address'])->latest()->get();
} }
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
{ {
return Customer::query() return Customer::query()
->select('id', 'name', 'phone_number', 'address') ->select(['id', 'name', 'phone_number', 'address'])
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
->orderBy($sort, $direction) ->orderBy($sort, $direction)
->paginate($perPage); ->paginate($perPage);

View File

@ -14,14 +14,14 @@
class ProductService class ProductService
{ {
public function __construct( public function __construct(
private readonly ProductVariantService $variantService, private ProductVariantService $variantService,
private readonly S3PresignedService $s3Service, private S3PresignedService $s3Service,
private readonly StockMutationService $stockMutationService, private StockMutationService $stockMutationService,
) {} ) {}
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'])
->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',
@ -46,7 +46,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'])
->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',

View File

@ -16,8 +16,8 @@ class ProductVariantService
use RegistersMedia; use RegistersMedia;
public function __construct( public function __construct(
private readonly S3PresignedService $s3Service, private S3PresignedService $s3Service,
private readonly StockMutationService $stockMutationService, private StockMutationService $stockMutationService,
) {} ) {}
public function getForEdit(ProductVariant $variant): array public function getForEdit(ProductVariant $variant): array

View File

@ -15,12 +15,12 @@ class RawMaterialService
use RegistersMedia; use RegistersMedia;
public function __construct( public function __construct(
private readonly S3PresignedService $s3Service, private S3PresignedService $s3Service,
) {} ) {}
public function getAll(array $filters = []): Collection public function getAll(array $filters = []): Collection
{ {
return RawMaterial::select('id', 'name', 'unit', 'is_active') return RawMaterial::select(['id', 'name', 'unit', 'is_active'])
->with([ ->with([
'rawMaterialPrices:id,raw_material_id,variant,price,stock', 'rawMaterialPrices:id,raw_material_id,variant,price,stock',
'rawMaterialPrices.media', 'rawMaterialPrices.media',
@ -42,7 +42,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 = RawMaterial::query() $paginator = RawMaterial::query()
->select('id', 'name', 'unit', 'is_active') ->select(['id', 'name', 'unit', 'is_active'])
->with([ ->with([
'rawMaterialPrices:id,raw_material_id,variant,price,stock', 'rawMaterialPrices:id,raw_material_id,variant,price,stock',
]) ])

View File

@ -14,7 +14,7 @@ class RawMaterialVariantService
use RegistersMedia; use RegistersMedia;
public function __construct( public function __construct(
private readonly S3PresignedService $s3Service, private S3PresignedService $s3Service,
) {} ) {}
public function getForEdit(RawMaterialPrice $variant): array public function getForEdit(RawMaterialPrice $variant): array

View File

@ -10,13 +10,13 @@ class SupplierService
{ {
public function getAll(array $filters = []): Collection public function getAll(array $filters = []): Collection
{ {
return Supplier::select('id', 'name', 'phone_number', 'address')->latest()->get(); return Supplier::select(['id', 'name', 'phone_number', 'address'])->latest()->get();
} }
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
{ {
return Supplier::query() return Supplier::query()
->select('id', 'name', 'phone_number', 'address') ->select(['id', 'name', 'phone_number', 'address'])
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
->orderBy($sort, $direction) ->orderBy($sort, $direction)
->paginate($perPage); ->paginate($perPage);

View File

@ -5,7 +5,6 @@
use App\Enums\CashTransactionType; use App\Enums\CashTransactionType;
use App\Models\CashAccount; use App\Models\CashAccount;
use App\Models\CashTransaction; use App\Models\CashTransaction;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
trait HandlesCashTransactions trait HandlesCashTransactions

View File

@ -3,6 +3,7 @@
namespace App\Services\Concerns; namespace App\Services\Concerns;
use App\Enums\ProductStockQuality; use App\Enums\ProductStockQuality;
use App\Models\ProductVariant;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
trait HasStockAdjustment trait HasStockAdjustment
@ -26,7 +27,7 @@ private function adjustVariantStock(int $variantId, int $quantity, int $sign, st
$field = self::QUALITY_STOCK_MAP[$stockType] ?? 'stock'; $field = self::QUALITY_STOCK_MAP[$stockType] ?? 'stock';
$this->adjustStock( $this->adjustStock(
model: app(\App\Models\ProductVariant::class)->newQuery()->findOrFail($variantId), model: app(ProductVariant::class)->newQuery()->findOrFail($variantId),
field: $field, field: $field,
quantity: $quantity, quantity: $quantity,
sign: $sign, sign: $sign,

View File

@ -241,11 +241,6 @@ public function run(): void
'leave_requests.update', 'leave_requests.update',
'leave_requests.delete', 'leave_requests.delete',
'categories.view',
'categories.create',
'categories.update',
'categories.delete',
'suppliers.view', 'suppliers.view',
'suppliers.create', 'suppliers.create',
'suppliers.update', 'suppliers.update',
@ -259,12 +254,6 @@ public function run(): void
'owner_verifications.view', 'owner_verifications.view',
'products.view',
'products.create',
'products.update',
'products.delete',
'products.toggle_status',
'cuttings.view', 'cuttings.view',
'cuttings.create', 'cuttings.create',
'cuttings.update', 'cuttings.update',

View File

@ -140,6 +140,29 @@ @layer base {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: oklch(0.708 0 0 / 30%);
border-radius: 3px;
}
*::-webkit-scrollbar-thumb:hover {
background-color: oklch(0.708 0 0 / 50%);
}
* {
scrollbar-width: thin;
scrollbar-color: oklch(0.708 0 0 / 30%) transparent;
}
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }

View File

@ -1,3 +1,29 @@
import { Link, router } from '@inertiajs/react';
import type { LucideIcon } from 'lucide-react';
import {
Activity,
ArrowUpFromLine,
BarChart3,
Boxes,
CalendarCheck,
CalendarDays,
ClipboardCheck,
DollarSign,
HandCoins,
LayoutGrid,
Package,
RefreshCw,
Scissors,
Settings,
Shield,
ShoppingCart,
Tags,
Truck,
UserCircle,
Users,
Wallet,
} from 'lucide-react';
import React from 'react';
import AppLogo from '@/components/app-logo'; import AppLogo from '@/components/app-logo';
import { import {
Sidebar, Sidebar,
@ -30,32 +56,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[] };
@ -111,12 +111,20 @@ function MenuGroup({ label, items }: { label: string; items: NavMenuItem[] }) {
const { can, canAny } = useCan(); const { can, canAny } = useCan();
const filtered = items.filter((item) => { const filtered = items.filter((item) => {
if (!item.permission) return true; if (!item.permission) {
if (Array.isArray(item.permission)) return canAny(...item.permission); return true;
}
if (Array.isArray(item.permission)) {
return canAny(...item.permission);
}
return can(item.permission); return can(item.permission);
}); });
if (filtered.length === 0) return null; if (filtered.length === 0) {
return null;
}
return ( return (
<SidebarGroup> <SidebarGroup>

View File

@ -53,6 +53,7 @@ interface CardTableProps<TData> {
onPerPageChange?: (perPage: number) => void; onPerPageChange?: (perPage: number) => void;
emptyText?: string; emptyText?: string;
rowClassName?: (item: TData) => string;
} }
function useDebounce(callback: (value: string) => void, delay: number) { function useDebounce(callback: (value: string) => void, delay: number) {
@ -87,6 +88,7 @@ export function CardTable<TData>({
onPageChange, onPageChange,
onPerPageChange, onPerPageChange,
emptyText = 'Tidak ada data.', emptyText = 'Tidak ada data.',
rowClassName,
}: CardTableProps<TData>) { }: CardTableProps<TData>) {
const [localSearch, setLocalSearch] = React.useState(searchValue ?? ''); const [localSearch, setLocalSearch] = React.useState(searchValue ?? '');
@ -176,7 +178,7 @@ return true;
const isExpanded = isItemExpanded(key); const isExpanded = isItemExpanded(key);
return ( return (
<div key={key} className="flex flex-col"> <div key={key} className={`flex flex-col ${rowClassName?.(item) ?? ''}`}>
{renderCard({ {renderCard({
item, item,
index, index,

View File

@ -1,6 +1,6 @@
import { Clock, LogIn, LogOut } from 'lucide-react'; import { Clock, LogIn, LogOut } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
type TodayAttendance = { type TodayAttendance = {
id: number; id: number;
@ -29,8 +29,12 @@ export function AttendanceCard({ todayAttendance, isOnLeave, canCheckIn, onCheck
const hasCheckedOut = !!todayAttendance?.check_out_at; const hasCheckedOut = !!todayAttendance?.check_out_at;
function formatTime(dateStr: string | null): string { function formatTime(dateStr: string | null): string {
if (!dateStr) return '-'; if (!dateStr) {
return '-';
}
const d = new Date(dateStr); const d = new Date(dateStr);
return d.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', hour12: false }); return d.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', hour12: false });
} }

View File

@ -6,8 +6,14 @@ export function useCardTableExpand(
defaultExpanded: boolean | (number | string)[] = false, defaultExpanded: boolean | (number | string)[] = false,
) { ) {
const [expandedKeys, setExpandedKeys] = useState<ExpandState>(() => { const [expandedKeys, setExpandedKeys] = useState<ExpandState>(() => {
if (defaultExpanded === true) return 'all'; if (defaultExpanded === true) {
if (Array.isArray(defaultExpanded)) return new Set(defaultExpanded); return 'all';
}
if (Array.isArray(defaultExpanded)) {
return new Set(defaultExpanded);
}
return new Set(); return new Set();
}); });
@ -16,12 +22,15 @@ export function useCardTableExpand(
if (prev === 'all') { if (prev === 'all') {
return new Set([key]); return new Set([key]);
} }
const next = new Set(prev); const next = new Set(prev);
if (next.has(key)) { if (next.has(key)) {
next.delete(key); next.delete(key);
} else { } else {
next.add(key); next.add(key);
} }
return next; return next;
}); });
}, []); }, []);
@ -36,7 +45,10 @@ export function useCardTableExpand(
const isExpanded = useCallback( const isExpanded = useCallback(
(key: number | string): boolean => { (key: number | string): boolean => {
if (expandedKeys === 'all') return true; if (expandedKeys === 'all') {
return true;
}
return expandedKeys.has(key); return expandedKeys.has(key);
}, },
[expandedKeys], [expandedKeys],

View File

@ -1,3 +1,4 @@
import { router } from '@inertiajs/react';
import { Bell, Check, Trash2 } from 'lucide-react'; import { Bell, Check, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -189,15 +190,11 @@ export function NotificationBell() {
: 'opacity-60' : 'opacity-60'
}`} }`}
> >
<a <div
href={notification.url || '#'}
className="min-w-0 flex-1 cursor-pointer" className="min-w-0 flex-1 cursor-pointer"
onClick={(e) => { onClick={() => {
e.preventDefault();
if (notification.url) { if (notification.url) {
window.location.href = router.visit(notification.url);
notification.url;
} }
}} }}
> >
@ -225,7 +222,7 @@ export function NotificationBell() {
minute: '2-digit', minute: '2-digit',
})} })}
</span> </span>
</a> </div>
<div className="flex shrink-0 flex-col gap-1 pt-0.5"> <div className="flex shrink-0 flex-col gap-1 pt-0.5">
{!notification.is_read && ( {!notification.is_read && (
<Button <Button

View File

@ -17,7 +17,10 @@ type PageProps = {
}; };
function extractNames(items?: RoleOrPermission[]): string[] { function extractNames(items?: RoleOrPermission[]): string[] {
if (!items) return []; if (!items) {
return [];
}
return items.map((item) => (typeof item === 'string' ? item : item.name)); return items.map((item) => (typeof item === 'string' ? item : item.name));
} }
@ -29,24 +32,42 @@ export function useCan() {
const permissionNames = extractNames(user?.permissions); const permissionNames = extractNames(user?.permissions);
function can(permission: string): boolean { function can(permission: string): boolean {
if (!user) return false; if (!user) {
if (roleNames.includes('developer') || roleNames.includes('owner')) return true; return false;
}
if (roleNames.includes('developer') || roleNames.includes('owner')) {
return true;
}
return permissionNames.includes(permission); return permissionNames.includes(permission);
} }
function canAny(...permissions: string[]): boolean { function canAny(...permissions: string[]): boolean {
if (!user) return false; if (!user) {
if (roleNames.includes('developer') || roleNames.includes('owner')) return true; return false;
}
if (roleNames.includes('developer') || roleNames.includes('owner')) {
return true;
}
return permissions.some((p) => permissionNames.includes(p)); return permissions.some((p) => permissionNames.includes(p));
} }
function hasRole(role: string): boolean { function hasRole(role: string): boolean {
if (!user) return false; if (!user) {
return false;
}
return roleNames.includes(role); return roleNames.includes(role);
} }
function hasAnyRole(roles: string[]): boolean { function hasAnyRole(roles: string[]): boolean {
if (!user) return false; if (!user) {
return false;
}
return roles.some((role) => roleNames.includes(role)); return roles.some((role) => roleNames.includes(role));
} }

View File

@ -6,12 +6,15 @@ export function formatRupiahShort(value: number): string {
if (value >= 1_000_000_000) { if (value >= 1_000_000_000) {
return (value / 1_000_000_000).toFixed(1).replace('.0', '') + 'jt'; return (value / 1_000_000_000).toFixed(1).replace('.0', '') + 'jt';
} }
if (value >= 1_000_000) { if (value >= 1_000_000) {
return (value / 1_000_000).toFixed(1).replace('.0', '') + 'jt'; return (value / 1_000_000).toFixed(1).replace('.0', '') + 'jt';
} }
if (value >= 1_000) { if (value >= 1_000) {
return (value / 1_000).toFixed(0) + 'rb'; return (value / 1_000).toFixed(0) + 'rb';
} }
return value.toString(); return value.toString();
} }

View File

@ -12,12 +12,13 @@ export type CashAccount = {
type CreateColumnsParams = { type CreateColumnsParams = {
handleEdit: (cashAccount: CashAccount) => void; handleEdit: (cashAccount: CashAccount) => void;
handleDeleteClick: (cashAccount: CashAccount) => void; handleDeleteClick: (cashAccount: CashAccount) => void;
can: (permission: string) => boolean;
}; };
export function createCashAccountColumns( export function createCashAccountColumns(
params: CreateColumnsParams, params: CreateColumnsParams,
): ColumnDef<CashAccount>[] { ): ColumnDef<CashAccount>[] {
const { handleEdit, handleDeleteClick } = params; const { handleEdit, handleDeleteClick, can } = params;
return [ return [
{ {
@ -54,6 +55,7 @@ export function createCashAccountColumns(
{ {
label: 'Edit', label: 'Edit',
icon: <Pencil className="h-4 w-4" />, icon: <Pencil className="h-4 w-4" />,
show: can('cash.update'),
onClick: () => handleEdit(cashAccount), onClick: () => handleEdit(cashAccount),
}, },
{ {
@ -61,6 +63,7 @@ export function createCashAccountColumns(
icon: ( icon: (
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
), ),
show: can('cash.delete'),
onClick: () => handleDeleteClick(cashAccount), onClick: () => handleDeleteClick(cashAccount),
}, },
]} ]}

View File

@ -21,6 +21,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table'; import { useServerTable } from '@/hooks/use-server-table';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { import {
@ -69,6 +70,7 @@ export default function CashAccountIndex({
filters, filters,
filterOptions, filterOptions,
}: Props) { }: Props) {
const { can } = useCan();
const [depositOpen, setDepositOpen] = useState(false); const [depositOpen, setDepositOpen] = useState(false);
const [withdrawalOpen, setWithdrawalOpen] = useState(false); const [withdrawalOpen, setWithdrawalOpen] = useState(false);
const [editing, setEditing] = useState<CashTransaction | null>(null); const [editing, setEditing] = useState<CashTransaction | null>(null);
@ -137,6 +139,7 @@ export default function CashAccountIndex({
setEditReceiptKey(transaction.receipt_key ?? null); setEditReceiptKey(transaction.receipt_key ?? null);
}, },
handleDeleteClick: (transaction) => setDeleting(transaction), handleDeleteClick: (transaction) => setDeleting(transaction),
can,
}); });
const filterToolbar = ( const filterToolbar = (
@ -180,6 +183,7 @@ export default function CashAccountIndex({
title="Kas Toko" title="Kas Toko"
actions={ actions={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{can('cash.deposit') && (
<Button <Button
variant="outline" variant="outline"
onClick={() => setDepositOpen(true)} onClick={() => setDepositOpen(true)}
@ -187,6 +191,8 @@ export default function CashAccountIndex({
<ArrowDownToLine className="h-4 w-4" /> <ArrowDownToLine className="h-4 w-4" />
Deposit Deposit
</Button> </Button>
)}
{can('cash.withdraw') && (
<Button <Button
variant="outline" variant="outline"
onClick={() => setWithdrawalOpen(true)} onClick={() => setWithdrawalOpen(true)}
@ -194,6 +200,7 @@ export default function CashAccountIndex({
<ArrowUpFromLine className="h-4 w-4" /> <ArrowUpFromLine className="h-4 w-4" />
Withdrawal Withdrawal
</Button> </Button>
)}
</div> </div>
} }
/> />

View File

@ -50,12 +50,13 @@ function getReferenceLabel(type: string): string {
type CreateColumnsParams = { type CreateColumnsParams = {
handleEdit: (transaction: CashTransaction) => void; handleEdit: (transaction: CashTransaction) => void;
handleDeleteClick: (transaction: CashTransaction) => void; handleDeleteClick: (transaction: CashTransaction) => void;
can: (permission: string) => boolean;
}; };
export function createTransactionColumns( export function createTransactionColumns(
params: CreateColumnsParams, params: CreateColumnsParams,
): ColumnDef<CashTransaction>[] { ): ColumnDef<CashTransaction>[] {
const { handleEdit, handleDeleteClick } = params; const { handleEdit, handleDeleteClick, can } = params;
return [ return [
{ {
@ -174,6 +175,7 @@ export function createTransactionColumns(
{ {
label: 'Edit', label: 'Edit',
icon: <Pencil className="h-4 w-4" />, icon: <Pencil className="h-4 w-4" />,
show: can('cash.update'),
onClick: () => handleEdit(transaction), onClick: () => handleEdit(transaction),
}, },
{ {
@ -181,6 +183,7 @@ export function createTransactionColumns(
icon: ( icon: (
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
), ),
show: can('cash.delete'),
onClick: () => handleDeleteClick(transaction), onClick: () => handleDeleteClick(transaction),
}, },
]} ]}

View File

@ -62,12 +62,14 @@ type CreateColumnsParams = {
handleDeleteClick: (employeeAdvance: EmployeeAdvance) => void; handleDeleteClick: (employeeAdvance: EmployeeAdvance) => void;
handleApprove: (employeeAdvance: EmployeeAdvance) => void; handleApprove: (employeeAdvance: EmployeeAdvance) => void;
handlePay: (employeeAdvance: EmployeeAdvance) => void; handlePay: (employeeAdvance: EmployeeAdvance) => void;
can: (permission: string) => boolean;
}; };
export function createEmployeeAdvanceColumns( export function createEmployeeAdvanceColumns(
params: CreateColumnsParams, params: CreateColumnsParams,
): ColumnDef<EmployeeAdvance>[] { ): ColumnDef<EmployeeAdvance>[] {
const { handleEdit, handleDeleteClick, handleApprove, handlePay } = params; const { handleEdit, handleDeleteClick, handleApprove, handlePay, can } =
params;
return [ return [
{ {
@ -142,7 +144,9 @@ export function createEmployeeAdvanceColumns(
icon: ( icon: (
<CheckCircle className="h-4 w-4 text-green-600" /> <CheckCircle className="h-4 w-4 text-green-600" />
), ),
show: employeeAdvance.status === 'pending', show:
can('employee_advances.verify') &&
employeeAdvance.status === 'pending',
onClick: () => handleApprove(employeeAdvance), onClick: () => handleApprove(employeeAdvance),
}, },
{ {
@ -150,12 +154,15 @@ export function createEmployeeAdvanceColumns(
icon: ( icon: (
<CircleDollarSign className="h-4 w-4 text-blue-600" /> <CircleDollarSign className="h-4 w-4 text-blue-600" />
), ),
show: employeeAdvance.status === 'approved', show:
can('employee_advances.pay') &&
employeeAdvance.status === 'approved',
onClick: () => handlePay(employeeAdvance), onClick: () => handlePay(employeeAdvance),
}, },
{ {
label: 'Edit', label: 'Edit',
icon: <Pencil className="h-4 w-4" />, icon: <Pencil className="h-4 w-4" />,
show: can('employee_advances.update'),
onClick: () => handleEdit(employeeAdvance), onClick: () => handleEdit(employeeAdvance),
}, },
{ {
@ -163,6 +170,7 @@ export function createEmployeeAdvanceColumns(
icon: ( icon: (
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
), ),
show: can('employee_advances.delete'),
onClick: () => onClick: () =>
handleDeleteClick(employeeAdvance), handleDeleteClick(employeeAdvance),
}, },

View File

@ -12,6 +12,7 @@ import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
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 { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table'; import { useServerTable } from '@/hooks/use-server-table';
import { import {
destroy, destroy,
@ -35,6 +36,7 @@ type Props = {
}; };
export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) { export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
const { can } = useCan();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<EmployeeAdvance | null>(null); const [editing, setEditing] = useState<EmployeeAdvance | null>(null);
const [deleting, setDeleting] = useState<EmployeeAdvance | null>(null); const [deleting, setDeleting] = useState<EmployeeAdvance | null>(null);
@ -113,6 +115,7 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
handleDeleteClick: (employeeAdvance) => setDeleting(employeeAdvance), handleDeleteClick: (employeeAdvance) => setDeleting(employeeAdvance),
handleApprove: (employeeAdvance) => setApproving(employeeAdvance), handleApprove: (employeeAdvance) => setApproving(employeeAdvance),
handlePay: (employeeAdvance) => setPaying(employeeAdvance), handlePay: (employeeAdvance) => setPaying(employeeAdvance),
can,
}); });
return ( return (
@ -123,6 +126,7 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
<PageHeader <PageHeader
title="Kasbon" title="Kasbon"
actions={ actions={
can('employee_advances.create') ? (
<Button asChild> <Button asChild>
<button <button
type="button" type="button"
@ -132,6 +136,7 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
Tambah Tambah
</button> </button>
</Button> </Button>
) : undefined
} }
/> />

View File

@ -22,12 +22,13 @@ export type Expense = {
type CreateColumnsParams = { type CreateColumnsParams = {
handleEdit: (expense: Expense) => void; handleEdit: (expense: Expense) => void;
handleDeleteClick: (expense: Expense) => void; handleDeleteClick: (expense: Expense) => void;
can: (permission: string) => boolean;
}; };
export function createExpenseColumns( export function createExpenseColumns(
params: CreateColumnsParams, params: CreateColumnsParams,
): ColumnDef<Expense>[] { ): ColumnDef<Expense>[] {
const { handleEdit, handleDeleteClick } = params; const { handleEdit, handleDeleteClick, can } = params;
return [ return [
{ {
@ -98,6 +99,7 @@ export function createExpenseColumns(
{ {
label: 'Edit', label: 'Edit',
icon: <Pencil className="h-4 w-4" />, icon: <Pencil className="h-4 w-4" />,
show: can('expenses.update'),
onClick: () => handleEdit(expense), onClick: () => handleEdit(expense),
}, },
{ {
@ -105,6 +107,7 @@ export function createExpenseColumns(
icon: ( icon: (
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
), ),
show: can('expenses.delete'),
onClick: () => handleDeleteClick(expense), onClick: () => handleDeleteClick(expense),
}, },
]} ]}

View File

@ -12,6 +12,7 @@ import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
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 { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table'; import { useServerTable } from '@/hooks/use-server-table';
import { import {
destroy, destroy,
@ -33,6 +34,7 @@ type Props = {
}; };
export default function ExpenseIndex({ expenses }: Props) { export default function ExpenseIndex({ expenses }: Props) {
const { can } = useCan();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Expense | null>(null); const [editing, setEditing] = useState<Expense | null>(null);
const [deleting, setDeleting] = useState<Expense | null>(null); const [deleting, setDeleting] = useState<Expense | null>(null);
@ -84,6 +86,7 @@ export default function ExpenseIndex({ expenses }: Props) {
setEditReceiptKey(expense.receipt_key ?? null); setEditReceiptKey(expense.receipt_key ?? null);
}, },
handleDeleteClick: (expense) => setDeleting(expense), handleDeleteClick: (expense) => setDeleting(expense),
can,
}); });
return ( return (
@ -94,6 +97,7 @@ export default function ExpenseIndex({ expenses }: Props) {
<PageHeader <PageHeader
title="Pengeluaran" title="Pengeluaran"
actions={ actions={
can('expenses.create') ? (
<Button asChild> <Button asChild>
<button <button
type="button" type="button"
@ -103,6 +107,7 @@ export default function ExpenseIndex({ expenses }: Props) {
Tambah Tambah
</button> </button>
</Button> </Button>
) : undefined
} }
/> />

View File

@ -49,12 +49,13 @@ type CreateColumnsParams = {
showUrl: (id: number) => string; showUrl: (id: number) => string;
handleClose: (period: PayrollPeriod) => void; handleClose: (period: PayrollPeriod) => void;
handleReopen: (period: PayrollPeriod) => void; handleReopen: (period: PayrollPeriod) => void;
can: (permission: string) => boolean;
}; };
export function createPayrollPeriodColumns( export function createPayrollPeriodColumns(
params: CreateColumnsParams, params: CreateColumnsParams,
): ColumnDef<PayrollPeriod>[] { ): ColumnDef<PayrollPeriod>[] {
const { showUrl, handleClose, handleReopen } = params; const { showUrl, handleClose, handleReopen, can } = params;
return [ return [
{ {
@ -191,7 +192,9 @@ export function createPayrollPeriodColumns(
icon: ( icon: (
<Lock className="h-4 w-4 text-orange-600" /> <Lock className="h-4 w-4 text-orange-600" />
), ),
show: period.status === 'open', show:
can('payroll.adjust') &&
period.status === 'open',
onClick: () => handleClose(period), onClick: () => handleClose(period),
}, },
{ {
@ -199,7 +202,9 @@ export function createPayrollPeriodColumns(
icon: ( icon: (
<Unlock className="h-4 w-4 text-blue-600" /> <Unlock className="h-4 w-4 text-blue-600" />
), ),
show: period.status === 'closed', show:
can('payroll.adjust') &&
period.status === 'closed',
onClick: () => handleReopen(period), onClick: () => handleReopen(period),
}, },
]} ]}

View File

@ -4,6 +4,7 @@ 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';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table'; import { useServerTable } from '@/hooks/use-server-table';
import { MONTH_NAMES } from '@/lib/constants'; import { MONTH_NAMES } from '@/lib/constants';
import { import {
@ -26,6 +27,7 @@ type Props = {
}; };
export default function PayrollPeriodIndex({ payrollPeriods }: Props) { export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
const { can } = useCan();
const [closing, setClosing] = useState<PayrollPeriod | null>(null); const [closing, setClosing] = useState<PayrollPeriod | null>(null);
const [reopening, setReopening] = useState<PayrollPeriod | null>(null); const [reopening, setReopening] = useState<PayrollPeriod | null>(null);
@ -78,6 +80,7 @@ export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
showUrl: (id) => payrollPeriodShow(id).url, showUrl: (id) => payrollPeriodShow(id).url,
handleClose: (period) => setClosing(period), handleClose: (period) => setClosing(period),
handleReopen: (period) => setReopening(period), handleReopen: (period) => setReopening(period),
can,
}); });
return ( return (

View File

@ -67,6 +67,7 @@ type CreateColumnsParams = {
adjustment: PayrollAdjustment, adjustment: PayrollAdjustment,
payrollId: number, payrollId: number,
) => void; ) => void;
can: (permission: string) => boolean;
}; };
export function createPayrollColumns( export function createPayrollColumns(
@ -77,6 +78,7 @@ export function createPayrollColumns(
handleCancel, handleCancel,
handleAddAdjustment, handleAddAdjustment,
handleDeleteAdjustment, handleDeleteAdjustment,
can,
} = params; } = params;
return [ return [
@ -218,7 +220,9 @@ export function createPayrollColumns(
{ {
label: 'Tambah Adjustment', label: 'Tambah Adjustment',
icon: <Pencil className="h-4 w-4" />, icon: <Pencil className="h-4 w-4" />,
show: payroll.status === 'unpaid', show:
can('payroll.adjust') &&
payroll.status === 'unpaid',
onClick: () => handleAddAdjustment(payroll), onClick: () => handleAddAdjustment(payroll),
}, },
{ {
@ -226,7 +230,9 @@ export function createPayrollColumns(
icon: ( icon: (
<CircleDollarSign className="h-4 w-4 text-green-600" /> <CircleDollarSign className="h-4 w-4 text-green-600" />
), ),
show: payroll.status === 'unpaid', show:
can('payroll.pay') &&
payroll.status === 'unpaid',
onClick: () => handlePay(payroll), onClick: () => handlePay(payroll),
}, },
{ {
@ -234,7 +240,9 @@ export function createPayrollColumns(
icon: ( icon: (
<XCircle className="h-4 w-4 text-destructive" /> <XCircle className="h-4 w-4 text-destructive" />
), ),
show: payroll.status === 'unpaid', show:
can('payroll.cancel') &&
payroll.status === 'unpaid',
onClick: () => handleCancel(payroll), onClick: () => handleCancel(payroll),
}, },
]} ]}

View File

@ -1,4 +1,4 @@
import { Form, Head, router } from '@inertiajs/react'; import { Form, Head, Link, router } from '@inertiajs/react';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog'; import { ConfirmDialog } from '@/components/confirm-dialog';
@ -16,6 +16,7 @@ import {
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 { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { useCan } from '@/hooks/use-can';
import { MONTH_NAMES } from '@/lib/constants'; import { MONTH_NAMES } from '@/lib/constants';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { destroy as adjustmentDestroy } from '@/routes/admin/finance/payroll-adjustments'; import { destroy as adjustmentDestroy } from '@/routes/admin/finance/payroll-adjustments';
@ -39,6 +40,7 @@ type Props = {
}; };
export default function PayrollPeriodShow({ payrollPeriod }: Props) { export default function PayrollPeriodShow({ payrollPeriod }: Props) {
const { can } = useCan();
const [paying, setPaying] = useState<Payroll | null>(null); const [paying, setPaying] = useState<Payroll | null>(null);
const [cancelling, setCancelling] = useState<Payroll | null>(null); const [cancelling, setCancelling] = useState<Payroll | null>(null);
const [addingAdjustment, setAddingAdjustment] = useState<Payroll | null>( const [addingAdjustment, setAddingAdjustment] = useState<Payroll | null>(
@ -98,6 +100,7 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
handleDeleteAdjustment: (adjustment, payrollId) => { handleDeleteAdjustment: (adjustment, payrollId) => {
setDeletingAdjustment({ adjustment, payrollId }); setDeletingAdjustment({ adjustment, payrollId });
}, },
can,
}); });
const totalBaseSalary = payrollPeriod.payrolls.reduce( const totalBaseSalary = payrollPeriod.payrolls.reduce(
@ -139,10 +142,10 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
</p> </p>
</div> </div>
<Button asChild variant="outline"> <Button asChild variant="outline">
<a href={payrollPeriodsIndex.url()}> <Link href={payrollPeriodsIndex.url()}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Kembali Kembali
</a> </Link>
</Button> </Button>
</div> </div>

View File

@ -38,6 +38,7 @@ type CreateColumnsParams = {
handleDeleteClick: (employee: Employee) => void; handleDeleteClick: (employee: Employee) => void;
handleResetPassword: (employee: Employee) => void; handleResetPassword: (employee: Employee) => void;
toggleActiveUrl: (id: number) => string; toggleActiveUrl: (id: number) => string;
can: (permission: string) => boolean;
}; };
export function createEmployeeColumns( export function createEmployeeColumns(
@ -48,6 +49,7 @@ export function createEmployeeColumns(
handleDeleteClick, handleDeleteClick,
handleResetPassword, handleResetPassword,
toggleActiveUrl, toggleActiveUrl,
can,
} = params; } = params;
return [ return [
@ -161,6 +163,7 @@ export function createEmployeeColumns(
{ {
label: 'Edit', label: 'Edit',
icon: <Pencil className="h-4 w-4" />, icon: <Pencil className="h-4 w-4" />,
show: can('employees.update'),
onClick: () => handleEdit(employee), onClick: () => handleEdit(employee),
}, },
{ {
@ -168,6 +171,7 @@ export function createEmployeeColumns(
icon: ( icon: (
<KeyRound className="h-4 w-4 text-muted-foreground" /> <KeyRound className="h-4 w-4 text-muted-foreground" />
), ),
show: can('employees.reset_password'),
onClick: () => handleResetPassword(employee), onClick: () => handleResetPassword(employee),
}, },
{ {
@ -175,6 +179,7 @@ export function createEmployeeColumns(
icon: ( icon: (
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
), ),
show: can('employees.delete'),
onClick: () => handleDeleteClick(employee), onClick: () => handleDeleteClick(employee),
}, },
]} ]}

View File

@ -1,4 +1,4 @@
import { Form, Head } from '@inertiajs/react'; import { Form, Head, Link } from '@inertiajs/react';
import { AlertCircle, ArrowLeft } from 'lucide-react'; import { AlertCircle, ArrowLeft } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { DatePicker } from '@/components/date-picker'; import { DatePicker } from '@/components/date-picker';
@ -55,10 +55,10 @@ export default function EmployeeCreate({ roles }: Props) {
</h2> </h2>
</div> </div>
<Button asChild variant="outline"> <Button asChild variant="outline">
<a href={employeeIndex.url()}> <Link href={employeeIndex.url()}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Kembali Kembali
</a> </Link>
</Button> </Button>
</div> </div>

View File

@ -1,4 +1,4 @@
import { Form, Head } from '@inertiajs/react'; import { Form, Head, Link } from '@inertiajs/react';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { DatePicker } from '@/components/date-picker'; import { DatePicker } from '@/components/date-picker';
@ -94,10 +94,10 @@ export default function EmployeeEdit({ employee, roles }: Props) {
</h2> </h2>
</div> </div>
<Button asChild variant="outline"> <Button asChild variant="outline">
<a href={employeeIndex.url()}> <Link href={employeeIndex.url()}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Kembali Kembali
</a> </Link>
</Button> </Button>
</div> </div>

View File

@ -1,4 +1,4 @@
import { Head, router } from '@inertiajs/react'; import { Head, Link, router } from '@inertiajs/react';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import type { PaginationState } from '@/components/data-table'; import type { PaginationState } from '@/components/data-table';
@ -14,6 +14,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table'; import { useServerTable } from '@/hooks/use-server-table';
import { import {
destroy, destroy,
@ -42,6 +43,7 @@ type Props = {
}; };
export default function EmployeeIndex({ employees, filters }: Props) { export default function EmployeeIndex({ employees, filters }: Props) {
const { can } = useCan();
const [deleting, setDeleting] = useState<Employee | null>(null); const [deleting, setDeleting] = useState<Employee | null>(null);
const [resetPasswordTarget, setResetPasswordTarget] = const [resetPasswordTarget, setResetPasswordTarget] =
useState<Employee | null>(null); useState<Employee | null>(null);
@ -94,11 +96,12 @@ export default function EmployeeIndex({ employees, filters }: Props) {
const columns = createEmployeeColumns({ const columns = createEmployeeColumns({
handleEdit: (employee) => { handleEdit: (employee) => {
window.location.href = employeeEdit.url(employee.id); router.visit(employeeEdit.url(employee.id));
}, },
handleDeleteClick: (employee) => setDeleting(employee), handleDeleteClick: (employee) => setDeleting(employee),
handleResetPassword: (employee) => setResetPasswordTarget(employee), handleResetPassword: (employee) => setResetPasswordTarget(employee),
toggleActiveUrl: (id) => toggleActive.url(id), toggleActiveUrl: (id) => toggleActive.url(id),
can,
}); });
const filterToolbar = ( const filterToolbar = (
@ -183,12 +186,14 @@ export default function EmployeeIndex({ employees, filters }: Props) {
<PageHeader <PageHeader
title="Pegawai" title="Pegawai"
actions={ actions={
can('employees.create') ? (
<Button asChild> <Button asChild>
<a href={employeeCreate.url()}> <Link href={employeeCreate.url()}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Tambah Tambah
</a> </Link>
</Button> </Button>
) : undefined
} }
/> />

View File

@ -55,12 +55,13 @@ type CreateColumnsParams = {
handleDeleteClick: (leaveRequest: LeaveRequest) => void; handleDeleteClick: (leaveRequest: LeaveRequest) => void;
handleApprove: (leaveRequest: LeaveRequest) => void; handleApprove: (leaveRequest: LeaveRequest) => void;
handleReject: (leaveRequest: LeaveRequest) => void; handleReject: (leaveRequest: LeaveRequest) => void;
can: (permission: string) => boolean;
}; };
export function createLeaveRequestColumns( export function createLeaveRequestColumns(
params: CreateColumnsParams, params: CreateColumnsParams,
): ColumnDef<LeaveRequest>[] { ): ColumnDef<LeaveRequest>[] {
const { handleEdit, handleDeleteClick, handleApprove, handleReject } = const { handleEdit, handleDeleteClick, handleApprove, handleReject, can } =
params; params;
return [ return [
@ -129,7 +130,9 @@ export function createLeaveRequestColumns(
icon: ( icon: (
<CheckCircle className="h-4 w-4 text-green-600" /> <CheckCircle className="h-4 w-4 text-green-600" />
), ),
show: leaveRequest.status === 'pending', show:
can('leave_requests.verify') &&
leaveRequest.status === 'pending',
onClick: () => handleApprove(leaveRequest), onClick: () => handleApprove(leaveRequest),
}, },
{ {
@ -137,12 +140,15 @@ export function createLeaveRequestColumns(
icon: ( icon: (
<XCircle className="h-4 w-4 text-red-600" /> <XCircle className="h-4 w-4 text-red-600" />
), ),
show: leaveRequest.status === 'pending', show:
can('leave_requests.verify') &&
leaveRequest.status === 'pending',
onClick: () => handleReject(leaveRequest), onClick: () => handleReject(leaveRequest),
}, },
{ {
label: 'Edit', label: 'Edit',
icon: <Pencil className="h-4 w-4" />, icon: <Pencil className="h-4 w-4" />,
show: can('leave_requests.update'),
onClick: () => handleEdit(leaveRequest), onClick: () => handleEdit(leaveRequest),
}, },
{ {
@ -150,6 +156,7 @@ export function createLeaveRequestColumns(
icon: ( icon: (
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
), ),
show: can('leave_requests.delete'),
onClick: () => handleDeleteClick(leaveRequest), onClick: () => handleDeleteClick(leaveRequest),
}, },
]} ]}

View File

@ -18,6 +18,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table'; import { useServerTable } from '@/hooks/use-server-table';
import { import {
approve, approve,
@ -52,6 +53,7 @@ type Props = {
}; };
export default function LeaveRequestIndex({ leaveRequests, filters, filterOptions }: Props) { export default function LeaveRequestIndex({ leaveRequests, filters, filterOptions }: Props) {
const { can } = useCan();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<LeaveRequest | null>(null); const [editing, setEditing] = useState<LeaveRequest | null>(null);
const [deleting, setDeleting] = useState<LeaveRequest | null>(null); const [deleting, setDeleting] = useState<LeaveRequest | null>(null);
@ -141,6 +143,7 @@ export default function LeaveRequestIndex({ leaveRequests, filters, filterOption
handleDeleteClick: (leaveRequest) => setDeleting(leaveRequest), handleDeleteClick: (leaveRequest) => setDeleting(leaveRequest),
handleApprove: (leaveRequest) => setApproving(leaveRequest), handleApprove: (leaveRequest) => setApproving(leaveRequest),
handleReject: (leaveRequest) => setRejecting(leaveRequest), handleReject: (leaveRequest) => setRejecting(leaveRequest),
can,
}); });
const filterToolbar = ( const filterToolbar = (
@ -181,6 +184,7 @@ export default function LeaveRequestIndex({ leaveRequests, filters, filterOption
<PageHeader <PageHeader
title="Cuti" title="Cuti"
actions={ actions={
can('leave_requests.create') ? (
<Button asChild> <Button asChild>
<button <button
type="button" type="button"
@ -190,6 +194,7 @@ export default function LeaveRequestIndex({ leaveRequests, filters, filterOption
Tambah Tambah
</button> </button>
</Button> </Button>
) : undefined
} }
/> />

View File

@ -19,7 +19,7 @@ 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, usePage } from '@inertiajs/react'; import { Form, Head, Link, usePage } from '@inertiajs/react';
import { ArrowLeft, Check, Layers, Plus, ShoppingCart, Trash2 } from 'lucide-react'; import { ArrowLeft, Check, Layers, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import type { CuttingCreateData } from './columns'; import type { CuttingCreateData } from './columns';
@ -45,7 +45,7 @@ type Props = {
export default function CuttingCreate({ data }: Props) { export default function CuttingCreate({ data }: Props) {
const { rawMaterials } = data; const { rawMaterials } = data;
const { auth } = usePage().props as { auth: { user?: { id?: number } } }; const { auth, errors } = usePage().props as { auth: { user?: { id?: number } }; errors: Record<string, string> };
const userId = auth.user?.id; const userId = auth.user?.id;
const draft = loadCuttingDraft('create', userId); const draft = loadCuttingDraft('create', userId);
@ -63,6 +63,7 @@ export default function CuttingCreate({ data }: Props) {
photo_url: m.photo_url, photo_url: m.photo_url,
})); }));
} }
return []; return [];
}); });
const [combinations, setCombinations] = useState<CombinationState[]>(() => { const [combinations, setCombinations] = useState<CombinationState[]>(() => {
@ -71,6 +72,7 @@ export default function CuttingCreate({ data }: Props) {
material_result: c.material_result ?? 0, material_result: c.material_result ?? 0,
})); }));
} }
return []; return [];
}); });
const [selectedMaterialName, setSelectedMaterialName] = useState(draft?.selectedMaterialName ?? ''); const [selectedMaterialName, setSelectedMaterialName] = useState(draft?.selectedMaterialName ?? '');
@ -149,12 +151,21 @@ export default function CuttingCreate({ data }: Props) {
const addVariant = useCallback( const addVariant = useCallback(
(priceId: number) => { (priceId: number) => {
if (!selectedMaterial) return; if (!selectedMaterial) {
return;
}
const price = selectedMaterial.raw_material_prices.find((p) => p.id === priceId); const price = selectedMaterial.raw_material_prices.find((p) => p.id === priceId);
if (!price) return;
if (!price) {
return;
}
setMaterials((prev) => { setMaterials((prev) => {
if (prev.some((m) => m.raw_material_price_id === priceId && m.combination_id === null)) return prev; if (prev.some((m) => m.raw_material_price_id === priceId && m.combination_id === null)) {
return prev;
}
return [ return [
...prev, ...prev,
{ {
@ -187,21 +198,26 @@ export default function CuttingCreate({ data }: Props) {
}, []); }, []);
const confirmCombo = useCallback(() => { const confirmCombo = useCallback(() => {
if (comboSelectedPriceIds.length < 2) return; if (comboSelectedPriceIds.length < 2) {
return;
}
const comboIndex = combinations.length; const comboIndex = combinations.length;
const newMaterials: MaterialState[] = comboSelectedPriceIds.map((priceId) => { const newMaterials: MaterialState[] = comboSelectedPriceIds.map((priceId) => {
let foundMaterial: typeof rawMaterials[number] | undefined; let foundMaterial: typeof rawMaterials[number] | undefined;
let foundPrice: typeof rawMaterials[number]['raw_material_prices'][number] | undefined; let foundPrice: typeof rawMaterials[number]['raw_material_prices'][number] | undefined;
for (const rm of rawMaterials) { for (const rm of rawMaterials) {
const p = rm.raw_material_prices.find((pp) => pp.id === priceId); const p = rm.raw_material_prices.find((pp) => pp.id === priceId);
if (p) { if (p) {
foundMaterial = rm; foundMaterial = rm;
foundPrice = p; foundPrice = p;
break; break;
} }
} }
return { return {
raw_material_price_id: priceId, raw_material_price_id: priceId,
material_usage: 0, material_usage: 0,
@ -238,6 +254,7 @@ export default function CuttingCreate({ data }: Props) {
setMaterials((prev) => { setMaterials((prev) => {
const updated = [...prev]; const updated = [...prev];
(updated[index] as Record<string, unknown>)[field] = value; (updated[index] as Record<string, unknown>)[field] = value;
return updated; return updated;
}); });
}, },
@ -251,6 +268,7 @@ export default function CuttingCreate({ data }: Props) {
const totalMaterialCost = useMemo(() => { const totalMaterialCost = useMemo(() => {
return materials.reduce((sum, m) => { return materials.reduce((sum, m) => {
const price = priceMap.get(m.raw_material_price_id); const price = priceMap.get(m.raw_material_price_id);
return sum + (price ? price.price * m.material_usage : 0); return sum + (price ? price.price * m.material_usage : 0);
}, 0); }, 0);
}, [materials, priceMap]); }, [materials, priceMap]);
@ -266,9 +284,9 @@ export default function CuttingCreate({ data }: Props) {
return { return {
description: notes || null, description: notes || null,
product_name: productName || null, product_name: productName || null,
sample: sample || null, sample: sample,
original_outside_sample: originalOutsideSample || null, original_outside_sample: originalOutsideSample,
cutting_result: cuttingResult || null, cutting_result: cuttingResult,
materials: materialsRef.current.map((m) => ({ materials: materialsRef.current.map((m) => ({
raw_material_price_id: m.raw_material_price_id, raw_material_price_id: m.raw_material_price_id,
material_usage: m.material_usage, material_usage: m.material_usage,
@ -290,14 +308,16 @@ export default function CuttingCreate({ data }: Props) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">Tambah Cutting</h2> <h2 className="text-2xl font-semibold tracking-tight">Tambah Cutting</h2>
<Button asChild variant="outline"> <Button asChild variant="outline">
<a href={cuttingIndex.url()}> <Link href={cuttingIndex.url()}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Kembali Kembali
</a> </Link>
</Button> </Button>
</div> </div>
<Form action={store()} transform={(data) => ({ ...data, ...getPayload() })} onSubmit={() => { submittingRef.current = true; }}> <Form action={store()} transform={(data) => ({ ...data, ...getPayload() })} onSubmit={() => {
submittingRef.current = true;
}}>
{({ errors, processing }) => ( {({ errors, processing }) => (
<div className="grid gap-6 md:grid-cols-3"> <div className="grid gap-6 md:grid-cols-3">
<div className="space-y-6 md:col-span-2"> <div className="space-y-6 md:col-span-2">
@ -337,6 +357,7 @@ export default function CuttingCreate({ data }: Props) {
{selectedMaterial.raw_material_prices.map((price) => { {selectedMaterial.raw_material_prices.map((price) => {
const isAdded = materials.some((m) => m.raw_material_price_id === price.id); const isAdded = materials.some((m) => m.raw_material_price_id === price.id);
const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length; const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length;
return ( return (
<div key={price.id} className={isAdded ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'}> <div key={price.id} className={isAdded ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'}>
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@ -369,8 +390,6 @@ export default function CuttingCreate({ data }: Props) {
</div> </div>
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@ -421,7 +440,7 @@ export default function CuttingCreate({ data }: Props) {
</div> </div>
{cuttingResult > 0 && ( {cuttingResult > 0 && (
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Biaya Per Unit</span> <span className="text-muted-foreground">Biaya Per Produk</span>
<span className="font-medium">{formatCurrency(costPerUnit)}</span> <span className="font-medium">{formatCurrency(costPerUnit)}</span>
</div> </div>
)} )}
@ -435,11 +454,13 @@ export default function CuttingCreate({ data }: Props) {
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Foto</Label> <Label>Foto</Label>
<FileUpload value={photo} onChange={(key) => { setPhoto(key); setPhotoUrl(key ? getTemporaryUrl(key) : null); }} folder="cutting" existingUrl={photoUrl} onUploadingChange={setUploading} /> <FileUpload value={photo} onChange={(key) => {
setPhoto(key); setPhotoUrl(key ? getTemporaryUrl(key) : null);
}} folder="cutting" existingUrl={photoUrl} onUploadingChange={setUploading} />
<InputError message={errors.photo_key} /> <InputError message={errors.photo_key} />
</div> </div>
<Button type="submit" className="w-full" disabled={processing || submittingRef.current || uploading || materials.length === 0 || !productName || !sample}> <Button type="submit" className="w-full" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'} {processing ? 'Menyimpan...' : 'Simpan'}
</Button> </Button>
</CardContent> </CardContent>
@ -474,7 +495,10 @@ export default function CuttingCreate({ data }: Props) {
materials.forEach((m, i) => { materials.forEach((m, i) => {
if (m.combination_id !== null) { if (m.combination_id !== null) {
if (!comboMap.has(m.combination_id)) comboMap.set(m.combination_id, []); if (!comboMap.has(m.combination_id)) {
comboMap.set(m.combination_id, []);
}
comboMap.get(m.combination_id)!.push({ m, index: i }); comboMap.get(m.combination_id)!.push({ m, index: i });
} else { } else {
singleItems.push({ m, index: i }); singleItems.push({ m, index: i });
@ -487,6 +511,7 @@ export default function CuttingCreate({ data }: Props) {
return groups.map((group, gi) => ( return groups.map((group, gi) => (
<div key={gi} className="space-y-2 rounded-lg border p-3"> <div key={gi} className="space-y-2 rounded-lg border p-3">
{group.comboIndex !== null && ( {group.comboIndex !== null && (
<>
<div className="flex items-center justify-between border-b pb-2"> <div className="flex items-center justify-between border-b pb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-semibold text-muted-foreground">Kombinasi {group.comboIndex + 1}</span> <span className="text-xs font-semibold text-muted-foreground">Kombinasi {group.comboIndex + 1}</span>
@ -505,10 +530,13 @@ export default function CuttingCreate({ data }: Props) {
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
</Button> </Button>
</div> </div>
<InputError message={errors[`combinations.${group.comboIndex}.material_result` as keyof typeof errors]} />
</>
)} )}
{group.items.map(({ m, index }) => { {group.items.map(({ m, index }) => {
const price = priceMap.get(m.raw_material_price_id); const price = priceMap.get(m.raw_material_price_id);
const cartKey = `material-${index}`; const cartKey = `material-${index}`;
return ( return (
<div key={cartKey} className="space-y-2"> <div key={cartKey} className="space-y-2">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
@ -542,6 +570,7 @@ export default function CuttingCreate({ data }: Props) {
onValueChange={(val) => updateMaterial(index, 'material_usage', val)} onValueChange={(val) => updateMaterial(index, 'material_usage', val)}
/> />
</div> </div>
<InputError message={errors[`materials.${index}.material_usage` as keyof typeof errors]} />
{group.comboIndex === null && ( {group.comboIndex === null && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">Hasil <span className="text-destructive">*</span></span> <span className="text-xs text-muted-foreground whitespace-nowrap">Hasil <span className="text-destructive">*</span></span>
@ -552,6 +581,7 @@ export default function CuttingCreate({ data }: Props) {
/> />
</div> </div>
)} )}
{group.comboIndex === null && <InputError message={errors[`materials.${index}.material_result` as keyof typeof errors]} />}
</div> </div>
); );
})} })}
@ -571,7 +601,11 @@ export default function CuttingCreate({ data }: Props) {
<ImagePreviewModal <ImagePreviewModal
open={previewKey !== null} open={previewKey !== null}
onOpenChange={(open) => { if (!open) setPreviewKey(null); }} onOpenChange={(open) => {
if (!open) {
setPreviewKey(null);
}
}}
src={previewKey && previewKey.startsWith('material-') ? (materials[Number(previewKey.replace('material-', ''))]?.photo_url ?? null) : null} src={previewKey && previewKey.startsWith('material-') ? (materials[Number(previewKey.replace('material-', ''))]?.photo_url ?? null) : null}
title={previewKey && previewKey.startsWith('material-') ? `${materials[Number(previewKey.replace('material-', ''))]?.material_name}${materials[Number(previewKey.replace('material-', ''))]?.variant}` : undefined} title={previewKey && previewKey.startsWith('material-') ? `${materials[Number(previewKey.replace('material-', ''))]?.material_name}${materials[Number(previewKey.replace('material-', ''))]?.variant}` : undefined}
sources={materials.filter((m) => m.photo_url).map((m) => m.photo_url!)} sources={materials.filter((m) => m.photo_url).map((m) => m.photo_url!)}
@ -619,8 +653,10 @@ export default function CuttingCreate({ data }: Props) {
let variantName = ''; let variantName = '';
let materialName = ''; let materialName = '';
let photoUrl: string | null = null; let photoUrl: string | null = null;
for (const rm of rawMaterials) { for (const rm of rawMaterials) {
const p = rm.raw_material_prices.find((pp) => pp.id === priceId); const p = rm.raw_material_prices.find((pp) => pp.id === priceId);
if (p) { if (p) {
variantName = p.variant; variantName = p.variant;
materialName = rm.name; materialName = rm.name;
@ -628,6 +664,7 @@ export default function CuttingCreate({ data }: Props) {
break; break;
} }
} }
return ( return (
<div key={priceId} className="flex items-center justify-between gap-2 rounded-md border border-primary bg-primary/5 px-3 py-2"> <div key={priceId} className="flex items-center justify-between gap-2 rounded-md border border-primary bg-primary/5 px-3 py-2">
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@ -657,6 +694,7 @@ export default function CuttingCreate({ data }: Props) {
<div className="space-y-2"> <div className="space-y-2">
{comboMaterial.raw_material_prices.map((price) => { {comboMaterial.raw_material_prices.map((price) => {
const isSelected = comboSelectedPriceIds.includes(price.id); const isSelected = comboSelectedPriceIds.includes(price.id);
return ( return (
<div key={price.id} className={`flex items-center justify-between gap-3 rounded-lg border p-3 ${isSelected ? 'border-primary' : ''}`}> <div key={price.id} className={`flex items-center justify-between gap-3 rounded-lg border p-3 ${isSelected ? 'border-primary' : ''}`}>
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@ -689,11 +727,41 @@ export default function CuttingCreate({ data }: Props) {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<ConfirmDialog open={deleteConfirmOpen} onOpenChange={(open) => { if (!open) { setDeleteConfirmOpen(false); setDeleteMaterialIndex(null); } }} title="Hapus Bahan Baku" description="Apakah Anda yakin ingin menghapus bahan baku ini?" confirmLabel="Hapus" onConfirm={() => { if (deleteMaterialIndex !== null) setMaterials((prev) => prev.filter((_, i) => i !== deleteMaterialIndex)); setDeleteConfirmOpen(false); setDeleteMaterialIndex(null); }} /> <ConfirmDialog open={deleteConfirmOpen} onOpenChange={(open) => {
if (!open) {
setDeleteConfirmOpen(false); setDeleteMaterialIndex(null);
}
}} title="Hapus Bahan Baku" description="Apakah Anda yakin ingin menghapus bahan baku ini?" confirmLabel="Hapus" onConfirm={() => {
if (deleteMaterialIndex !== null) {
setMaterials((prev) => prev.filter((_, i) => i !== deleteMaterialIndex));
}
<ConfirmDialog open={cartDeleteConfirmOpen} onOpenChange={(open) => { if (!open) { setCartDeleteConfirmOpen(false); setCartDeleteIndex(null); } }} title="Hapus dari Keranjang" description="Apakah Anda yakin ingin menghapus item ini dari keranjang?" confirmLabel="Hapus" variant="destructive" onConfirm={() => { if (cartDeleteIndex !== null) setMaterials((prev) => prev.filter((_, i) => i !== cartDeleteIndex)); setCartDeleteConfirmOpen(false); setCartDeleteIndex(null); }} /> setDeleteConfirmOpen(false); setDeleteMaterialIndex(null);
}} />
<ConfirmDialog open={comboDeleteConfirmOpen} onOpenChange={(open) => { if (!open) { setComboDeleteConfirmOpen(false); setComboDeleteIndex(null); } }} title="Hapus Kombinasi" description="Apakah Anda yakin ingin menghapus kombinasi ini beserta semua bahannya?" confirmLabel="Hapus" variant="destructive" onConfirm={() => { if (comboDeleteIndex !== null) { setCombinations((prev) => prev.filter((_, i) => i !== comboDeleteIndex)); setMaterials((prev) => prev.filter((m) => m.combination_id !== comboDeleteIndex)); } setComboDeleteConfirmOpen(false); setComboDeleteIndex(null); }} /> <ConfirmDialog open={cartDeleteConfirmOpen} onOpenChange={(open) => {
if (!open) {
setCartDeleteConfirmOpen(false); setCartDeleteIndex(null);
}
}} title="Hapus dari Keranjang" description="Apakah Anda yakin ingin menghapus item ini dari keranjang?" confirmLabel="Hapus" variant="destructive" onConfirm={() => {
if (cartDeleteIndex !== null) {
setMaterials((prev) => prev.filter((_, i) => i !== cartDeleteIndex));
}
setCartDeleteConfirmOpen(false); setCartDeleteIndex(null);
}} />
<ConfirmDialog open={comboDeleteConfirmOpen} onOpenChange={(open) => {
if (!open) {
setComboDeleteConfirmOpen(false); setComboDeleteIndex(null);
}
}} title="Hapus Kombinasi" description="Apakah Anda yakin ingin menghapus kombinasi ini beserta semua bahannya?" confirmLabel="Hapus" variant="destructive" onConfirm={() => {
if (comboDeleteIndex !== null) {
setCombinations((prev) => prev.filter((_, i) => i !== comboDeleteIndex)); setMaterials((prev) => prev.filter((m) => m.combination_id !== comboDeleteIndex));
}
setComboDeleteConfirmOpen(false); setComboDeleteIndex(null);
}} />
</div> </div>
</> </>
); );

View File

@ -1,10 +1,11 @@
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';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
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 = {
@ -24,9 +25,18 @@ export function CuttingCardRow({
onEdit, onEdit,
onDelete, onDelete,
}: CuttingCardRowParams) { }: CuttingCardRowParams) {
const { can } = useCan();
const items = cutting.cutting_materials ?? []; const items = cutting.cutting_materials ?? [];
const result = cutting.cutting_results?.[0]; const result = cutting.cutting_results?.[0];
const materialCount = items.length; const singleCount = items.filter(
(item) => item.combination_id === null,
).length;
const comboCount = new Set(
items
.filter((item) => item.combination_id !== null)
.map((item) => item.combination_id),
).size;
const materialCount = singleCount + comboCount;
const productName = result?.product_name ?? '-'; const productName = result?.product_name ?? '-';
const totalUsage = items.reduce( const totalUsage = items.reduce(
(sum, item) => sum + Number(item.material_usage), (sum, item) => sum + Number(item.material_usage),
@ -103,11 +113,19 @@ export function CuttingCardRow({
{cutting.cost_per_unit && ( {cutting.cost_per_unit && (
<span className="font-semibold"> <span className="font-semibold">
<span className="font-normal text-muted-foreground"> <span className="font-normal text-muted-foreground">
Per Unit:{' '} Per Produk:{' '}
</span> </span>
{formatCurrency(cutting.cost_per_unit)} {formatCurrency(cutting.cost_per_unit)}
</span> </span>
)} )}
{cutting.total_material_cost && (
<span className="font-semibold">
<span className="font-normal text-muted-foreground">
Biaya Keseluruhan:{' '}
</span>
{formatCurrency(cutting.total_material_cost)}
</span>
)}
</div> </div>
{cutting.photo_url && ( {cutting.photo_url && (
@ -126,6 +144,7 @@ export function CuttingCardRow({
{ {
label: 'Edit', label: 'Edit',
icon: <Pencil className="h-4 w-4" />, icon: <Pencil className="h-4 w-4" />,
show: can('cuttings.update'),
onClick: () => onEdit(cutting), onClick: () => onEdit(cutting),
}, },
{ {
@ -133,6 +152,7 @@ export function CuttingCardRow({
icon: ( icon: (
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
), ),
show: can('cuttings.delete'),
onClick: () => onDelete(cutting), onClick: () => onDelete(cutting),
}, },
]} ]}

View File

@ -8,55 +8,41 @@ 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 }) {
const items = cutting.cutting_materials ?? []; const items = cutting.cutting_materials ?? [];
const combinations = cutting.cutting_material_combinations ?? []; const combinations = cutting.cutting_material_combinations ?? [];
const groupedMaterials = items.reduce( const singles = items.filter((item) => item.combination_id === null);
const comboItems = items.filter((item) => item.combination_id !== null);
const comboGroups: Record<number, typeof items> = {};
comboItems.forEach((item) => {
const comboId = item.combination_id!;
if (!comboGroups[comboId]) comboGroups[comboId] = [];
comboGroups[comboId].push(item);
});
const singleByRawMaterial = singles.reduce(
(acc, item) => { (acc, item) => {
const key = item.combination_id ?? `single-${item.id}`; const name =
item.raw_material_price?.raw_material?.name ?? 'BING';
if (!acc[key]) { if (!acc[name]) acc[name] = [];
acc[key] = { acc[name].push(item);
combination: item.combination_id
? (combinations.find((c) => c.id === item.combination_id) ?? null)
: null,
materials: [],
};
}
acc[key].materials.push(item);
return acc; return acc;
}, },
{} as Record< {} as Record<string, typeof singles>,
string,
{
combination: { id: number; material_result: number | null } | null;
materials: typeof items;
}
>,
); );
return ( const hasSingle = singles.length > 0;
<div className="space-y-4 overflow-x-auto"> const hasCombo = comboItems.length > 0;
{Object.entries(groupedMaterials).map(([key, group]) => (
<div key={key} className="space-y-2">
{group.combination && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-foreground">
Kombinasi
</span>
{group.combination.material_result !== null && (
<span>
Hasil: {formatNumber(group.combination.material_result)}
</span>
)}
</div>
)}
let counter = 0;
return (
<div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@ -64,37 +50,80 @@ export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) {
No No
</TableHead> </TableHead>
<TableHead className="w-[60px]">Foto</TableHead> <TableHead className="w-[60px]">Foto</TableHead>
<TableHead>Bahan Baku</TableHead>
<TableHead>Varian</TableHead> <TableHead>Varian</TableHead>
<TableHead className="text-right">Pemakaian</TableHead> <TableHead className="text-right">
Pemakaian
</TableHead>
<TableHead className="text-right">Hasil</TableHead> <TableHead className="text-right">Hasil</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{group.materials.length === 0 ? ( {hasSingle && (
<>
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={6} colSpan={5}
className="text-center text-muted-foreground" className="text-center font-bold bg-muted/50"
> >
Tidak ada item. Single
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( {Object.entries(singleByRawMaterial).map(
group.materials.map((item, index) => ( ([rawMaterialName, groupItems]) => {
const totalPemakaian = groupItems.reduce(
(sum, item) =>
sum + Number(item.material_usage),
0,
);
const totalHasil = groupItems.reduce(
(sum, item) =>
sum +
(item.material_result !== null
? Number(item.material_result)
: 0),
0,
);
return (
<Fragment key={rawMaterialName}>
<TableRow>
<TableCell
colSpan={3}
className="text-center font-medium text-muted-foreground bg-muted/20"
>
{rawMaterialName}
</TableCell>
<TableCell className="text-right font-medium text-muted-foreground bg-muted/20">
{formatNumber(
totalPemakaian,
)}
</TableCell>
<TableCell className="text-right font-medium text-muted-foreground bg-muted/20">
{formatNumber(
totalHasil,
)}
</TableCell>
</TableRow>
{groupItems.map((item) => {
counter++;
return (
<TableRow key={item.id}> <TableRow key={item.id}>
<TableCell className="text-center"> <TableCell className="text-center">
{index + 1} {counter}
</TableCell> </TableCell>
<TableCell> <TableCell>
{item.raw_material_price?.photo_url ? ( {item.raw_material_price
?.photo_url ? (
<ImagePreviewButton <ImagePreviewButton
srcs={[ srcs={[
item.raw_material_price item
.raw_material_price
.photo_url, .photo_url,
]} ]}
title={ title={
item.raw_material_price.variant item
.raw_material_price
.variant
} }
/> />
) : ( ) : (
@ -104,28 +133,139 @@ export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) {
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
{item.raw_material_price?.raw_material?.name ?? '-'} {item.raw_material_price
</TableCell> ?.variant ?? '-'}
<TableCell>
{item.raw_material_price?.variant ?? '-'}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{formatNumber(item.material_usage)} {formatNumber(
item.material_usage,
)}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{group.combination {item.material_result !==
? '-' null
: (item.material_result !== null ? formatNumber(
? formatNumber(item.material_result) item.material_result,
: '-')} )
: '-'}
</TableCell>
</TableRow>
);
})}
</Fragment>
);
},
)}
</>
)}
{hasCombo && (
<>
<TableRow>
<TableCell
colSpan={5}
className="text-center font-bold bg-muted/50"
>
Kombinasi
</TableCell>
</TableRow>
{Object.entries(comboGroups).map(
([comboId, materials]) => {
counter++;
const combo = combinations.find(
(c) => c.id === Number(comboId),
);
return (
<Fragment key={comboId}>
<TableRow>
<TableCell className="text-center font-medium">
{counter}
</TableCell>
<TableCell
colSpan={4}
className="font-medium text-muted-foreground bg-muted/20"
>
Kombinasi{' '}
{counter}
{combo?.material_result !==
null &&
combo?.material_result !==
undefined && (
<span className="ml-2">
(Hasil:{' '}
{formatNumber(
combo.material_result,
)}
)
</span>
)}
</TableCell>
</TableRow>
{materials.map((item) => (
<TableRow key={item.id}>
<TableCell></TableCell>
<TableCell>
{item
.raw_material_price
?.photo_url ? (
<ImagePreviewButton
srcs={[
item
.raw_material_price
.photo_url,
]}
title={
item
.raw_material_price
.variant
}
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
N/A
</div>
)}
</TableCell>
<TableCell>
{item
.raw_material_price
?.raw_material
?.name ?? '-'}{' '}
-{' '}
{item
.raw_material_price
?.variant ?? '-'}
</TableCell>
<TableCell className="text-right">
{formatNumber(
item.material_usage,
)}
</TableCell>
<TableCell className="text-right">
-
</TableCell>
</TableRow>
))}
</Fragment>
);
},
)}
</>
)}
{items.length === 0 && (
<TableRow>
<TableCell
colSpan={5}
className="text-center text-muted-foreground"
>
Tidak ada bahan baku.
</TableCell> </TableCell>
</TableRow> </TableRow>
))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
))}
</div>
); );
} }

View File

@ -17,7 +17,7 @@ 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 } from '@inertiajs/react'; import { Form, Head, Link, usePage } from '@inertiajs/react';
import { ArrowLeft, Check, Layers, Plus, ShoppingCart, Trash2 } from 'lucide-react'; import { ArrowLeft, Check, Layers, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import type { CuttingCreateData, CuttingForEdit } from './columns'; import type { CuttingCreateData, CuttingForEdit } from './columns';
@ -46,6 +46,7 @@ type Props = {
export default function CuttingEdit({ cutting, data }: Props) { export default function CuttingEdit({ cutting, data }: Props) {
const { rawMaterials } = data; const { rawMaterials } = data;
const { errors } = usePage().props as { errors: Record<string, string> };
const [materials, setMaterials] = useState<MaterialState[]>(() => { const [materials, setMaterials] = useState<MaterialState[]>(() => {
const comboIdToIndex = new Map<number, number>(); const comboIdToIndex = new Map<number, number>();
@ -131,12 +132,21 @@ export default function CuttingEdit({ cutting, data }: Props) {
const addVariant = useCallback( const addVariant = useCallback(
(priceId: number) => { (priceId: number) => {
if (!selectedMaterial) return; if (!selectedMaterial) {
return;
}
const price = selectedMaterial.raw_material_prices.find((p) => p.id === priceId); const price = selectedMaterial.raw_material_prices.find((p) => p.id === priceId);
if (!price) return;
if (!price) {
return;
}
setMaterials((prev) => { setMaterials((prev) => {
if (prev.some((m) => m.raw_material_price_id === priceId && m.combination_id === null)) return prev; if (prev.some((m) => m.raw_material_price_id === priceId && m.combination_id === null)) {
return prev;
}
return [ return [
...prev, ...prev,
{ {
@ -169,21 +179,26 @@ export default function CuttingEdit({ cutting, data }: Props) {
}, []); }, []);
const confirmCombo = useCallback(() => { const confirmCombo = useCallback(() => {
if (comboSelectedPriceIds.length < 2) return; if (comboSelectedPriceIds.length < 2) {
return;
}
const comboIndex = combinations.length; const comboIndex = combinations.length;
const newMaterials: MaterialState[] = comboSelectedPriceIds.map((priceId) => { const newMaterials: MaterialState[] = comboSelectedPriceIds.map((priceId) => {
let foundMaterial: (typeof rawMaterials)[number] | undefined; let foundMaterial: (typeof rawMaterials)[number] | undefined;
let foundPrice: (typeof rawMaterials)[number]['raw_material_prices'][number] | undefined; let foundPrice: (typeof rawMaterials)[number]['raw_material_prices'][number] | undefined;
for (const rm of rawMaterials) { for (const rm of rawMaterials) {
const p = rm.raw_material_prices.find((pp) => pp.id === priceId); const p = rm.raw_material_prices.find((pp) => pp.id === priceId);
if (p) { if (p) {
foundMaterial = rm; foundMaterial = rm;
foundPrice = p; foundPrice = p;
break; break;
} }
} }
return { return {
raw_material_price_id: priceId, raw_material_price_id: priceId,
material_usage: 0, material_usage: 0,
@ -215,6 +230,7 @@ export default function CuttingEdit({ cutting, data }: Props) {
setMaterials((prev) => { setMaterials((prev) => {
const updated = [...prev]; const updated = [...prev];
(updated[index] as Record<string, unknown>)[field] = value; (updated[index] as Record<string, unknown>)[field] = value;
return updated; return updated;
}); });
}, },
@ -228,6 +244,7 @@ export default function CuttingEdit({ cutting, data }: Props) {
const totalMaterialCost = useMemo(() => { const totalMaterialCost = useMemo(() => {
return materials.reduce((sum, m) => { return materials.reduce((sum, m) => {
const price = priceMap.get(m.raw_material_price_id); const price = priceMap.get(m.raw_material_price_id);
return sum + (price ? price.price * m.material_usage : 0); return sum + (price ? price.price * m.material_usage : 0);
}, 0); }, 0);
}, [materials, priceMap]); }, [materials, priceMap]);
@ -243,9 +260,9 @@ export default function CuttingEdit({ cutting, data }: Props) {
return { return {
description: notes || null, description: notes || null,
product_name: productName || null, product_name: productName || null,
sample: sample || null, sample: sample,
original_outside_sample: originalOutsideSample || null, original_outside_sample: originalOutsideSample,
cutting_result: cuttingResult || null, cutting_result: cuttingResult,
materials: materialsRef.current.map((m) => ({ materials: materialsRef.current.map((m) => ({
raw_material_price_id: m.raw_material_price_id, raw_material_price_id: m.raw_material_price_id,
material_usage: m.material_usage, material_usage: m.material_usage,
@ -267,14 +284,16 @@ export default function CuttingEdit({ cutting, data }: Props) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">Edit Cutting</h2> <h2 className="text-2xl font-semibold tracking-tight">Edit Cutting</h2>
<Button asChild variant="outline"> <Button asChild variant="outline">
<a href={cuttingIndex.url()}> <Link href={cuttingIndex.url()}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Kembali Kembali
</a> </Link>
</Button> </Button>
</div> </div>
<Form action={update(cutting.id)} transform={(data) => ({ ...data, ...getPayload() })} onSubmit={() => { submittingRef.current = true; }}> <Form action={update(cutting.id)} transform={(data) => ({ ...data, ...getPayload() })} onSubmit={() => {
submittingRef.current = true;
}}>
{({ errors, processing }) => ( {({ errors, processing }) => (
<div className="grid gap-6 md:grid-cols-3"> <div className="grid gap-6 md:grid-cols-3">
<div className="space-y-6 md:col-span-2"> <div className="space-y-6 md:col-span-2">
@ -314,6 +333,7 @@ export default function CuttingEdit({ cutting, data }: Props) {
{selectedMaterial.raw_material_prices.map((price) => { {selectedMaterial.raw_material_prices.map((price) => {
const isAdded = materials.some((m) => m.raw_material_price_id === price.id); const isAdded = materials.some((m) => m.raw_material_price_id === price.id);
const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length; const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length;
return ( return (
<div key={price.id} className={isAdded ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'}> <div key={price.id} className={isAdded ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'}>
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@ -396,7 +416,7 @@ export default function CuttingEdit({ cutting, data }: Props) {
</div> </div>
{cuttingResult > 0 && ( {cuttingResult > 0 && (
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Biaya Per Unit</span> <span className="text-muted-foreground">Biaya Per Produk</span>
<span className="font-medium">{formatCurrency(costPerUnit)}</span> <span className="font-medium">{formatCurrency(costPerUnit)}</span>
</div> </div>
)} )}
@ -410,11 +430,13 @@ export default function CuttingEdit({ cutting, data }: Props) {
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Foto</Label> <Label>Foto</Label>
<FileUpload value={photo} onChange={(key) => { setPhoto(key); setPhotoUrl(key ? getTemporaryUrl(key) : null); }} folder="cutting" existingUrl={photoUrl} onUploadingChange={setUploading} /> <FileUpload value={photo} onChange={(key) => {
setPhoto(key); setPhotoUrl(key ? getTemporaryUrl(key) : null);
}} folder="cutting" existingUrl={photoUrl} onUploadingChange={setUploading} />
<InputError message={errors.photo_key} /> <InputError message={errors.photo_key} />
</div> </div>
<Button type="submit" className="w-full" disabled={processing || submittingRef.current || uploading || materials.length === 0 || !productName || !sample}> <Button type="submit" className="w-full" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'} {processing ? 'Menyimpan...' : 'Simpan'}
</Button> </Button>
</CardContent> </CardContent>
@ -449,7 +471,10 @@ export default function CuttingEdit({ cutting, data }: Props) {
materials.forEach((m, i) => { materials.forEach((m, i) => {
if (m.combination_id !== null) { if (m.combination_id !== null) {
if (!comboMap.has(m.combination_id)) comboMap.set(m.combination_id, []); if (!comboMap.has(m.combination_id)) {
comboMap.set(m.combination_id, []);
}
comboMap.get(m.combination_id)!.push({ m, index: i }); comboMap.get(m.combination_id)!.push({ m, index: i });
} else { } else {
singleItems.push({ m, index: i }); singleItems.push({ m, index: i });
@ -462,6 +487,7 @@ export default function CuttingEdit({ cutting, data }: Props) {
return groups.map((group, gi) => ( return groups.map((group, gi) => (
<div key={gi} className="space-y-2 rounded-lg border p-3"> <div key={gi} className="space-y-2 rounded-lg border p-3">
{group.comboIndex !== null && ( {group.comboIndex !== null && (
<>
<div className="flex items-center justify-between border-b pb-2"> <div className="flex items-center justify-between border-b pb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-semibold text-muted-foreground">Kombinasi {group.comboIndex + 1}</span> <span className="text-xs font-semibold text-muted-foreground">Kombinasi {group.comboIndex + 1}</span>
@ -480,10 +506,13 @@ export default function CuttingEdit({ cutting, data }: Props) {
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
</Button> </Button>
</div> </div>
<InputError message={errors[`combinations.${group.comboIndex}.material_result` as keyof typeof errors]} />
</>
)} )}
{group.items.map(({ m, index }) => { {group.items.map(({ m, index }) => {
const price = priceMap.get(m.raw_material_price_id); const price = priceMap.get(m.raw_material_price_id);
const cartKey = `material-${index}`; const cartKey = `material-${index}`;
return ( return (
<div key={cartKey} className="space-y-2"> <div key={cartKey} className="space-y-2">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
@ -517,6 +546,7 @@ export default function CuttingEdit({ cutting, data }: Props) {
onValueChange={(val) => updateMaterial(index, 'material_usage', val)} onValueChange={(val) => updateMaterial(index, 'material_usage', val)}
/> />
</div> </div>
<InputError message={errors[`materials.${index}.material_usage` as keyof typeof errors]} />
{group.comboIndex === null && ( {group.comboIndex === null && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">Hasil <span className="text-destructive">*</span></span> <span className="text-xs text-muted-foreground whitespace-nowrap">Hasil <span className="text-destructive">*</span></span>
@ -527,6 +557,7 @@ export default function CuttingEdit({ cutting, data }: Props) {
/> />
</div> </div>
)} )}
{group.comboIndex === null && <InputError message={errors[`materials.${index}.material_result` as keyof typeof errors]} />}
</div> </div>
); );
})} })}
@ -546,7 +577,11 @@ export default function CuttingEdit({ cutting, data }: Props) {
<ImagePreviewModal <ImagePreviewModal
open={previewKey !== null} open={previewKey !== null}
onOpenChange={(open) => { if (!open) setPreviewKey(null); }} onOpenChange={(open) => {
if (!open) {
setPreviewKey(null);
}
}}
src={previewKey && previewKey.startsWith('material-') ? (materials[Number(previewKey.replace('material-', ''))]?.photo_url ?? null) : null} src={previewKey && previewKey.startsWith('material-') ? (materials[Number(previewKey.replace('material-', ''))]?.photo_url ?? null) : null}
title={previewKey && previewKey.startsWith('material-') ? `${materials[Number(previewKey.replace('material-', ''))]?.material_name}${materials[Number(previewKey.replace('material-', ''))]?.variant}` : undefined} title={previewKey && previewKey.startsWith('material-') ? `${materials[Number(previewKey.replace('material-', ''))]?.material_name}${materials[Number(previewKey.replace('material-', ''))]?.variant}` : undefined}
sources={materials.filter((m) => m.photo_url).map((m) => m.photo_url!)} sources={materials.filter((m) => m.photo_url).map((m) => m.photo_url!)}
@ -594,8 +629,10 @@ export default function CuttingEdit({ cutting, data }: Props) {
let variantName = ''; let variantName = '';
let materialName = ''; let materialName = '';
let photoUrl: string | null = null; let photoUrl: string | null = null;
for (const rm of rawMaterials) { for (const rm of rawMaterials) {
const p = rm.raw_material_prices.find((pp) => pp.id === priceId); const p = rm.raw_material_prices.find((pp) => pp.id === priceId);
if (p) { if (p) {
variantName = p.variant; variantName = p.variant;
materialName = rm.name; materialName = rm.name;
@ -603,6 +640,7 @@ export default function CuttingEdit({ cutting, data }: Props) {
break; break;
} }
} }
return ( return (
<div key={priceId} className="flex items-center justify-between gap-2 rounded-md border border-primary bg-primary/5 px-3 py-2"> <div key={priceId} className="flex items-center justify-between gap-2 rounded-md border border-primary bg-primary/5 px-3 py-2">
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@ -632,6 +670,7 @@ export default function CuttingEdit({ cutting, data }: Props) {
<div className="space-y-2"> <div className="space-y-2">
{comboMaterial.raw_material_prices.map((price) => { {comboMaterial.raw_material_prices.map((price) => {
const isSelected = comboSelectedPriceIds.includes(price.id); const isSelected = comboSelectedPriceIds.includes(price.id);
return ( return (
<div key={price.id} className={`flex items-center justify-between gap-3 rounded-lg border p-3 ${isSelected ? 'border-primary' : ''}`}> <div key={price.id} className={`flex items-center justify-between gap-3 rounded-lg border p-3 ${isSelected ? 'border-primary' : ''}`}>
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@ -664,11 +703,41 @@ export default function CuttingEdit({ cutting, data }: Props) {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<ConfirmDialog open={deleteConfirmOpen} onOpenChange={(open) => { if (!open) { setDeleteConfirmOpen(false); setDeleteMaterialIndex(null); } }} title="Hapus Bahan Baku" description="Apakah Anda yakin ingin menghapus bahan baku ini?" confirmLabel="Hapus" onConfirm={() => { if (deleteMaterialIndex !== null) setMaterials((prev) => prev.filter((_, i) => i !== deleteMaterialIndex)); setDeleteConfirmOpen(false); setDeleteMaterialIndex(null); }} /> <ConfirmDialog open={deleteConfirmOpen} onOpenChange={(open) => {
if (!open) {
setDeleteConfirmOpen(false); setDeleteMaterialIndex(null);
}
}} title="Hapus Bahan Baku" description="Apakah Anda yakin ingin menghapus bahan baku ini?" confirmLabel="Hapus" onConfirm={() => {
if (deleteMaterialIndex !== null) {
setMaterials((prev) => prev.filter((_, i) => i !== deleteMaterialIndex));
}
<ConfirmDialog open={cartDeleteConfirmOpen} onOpenChange={(open) => { if (!open) { setCartDeleteConfirmOpen(false); setCartDeleteIndex(null); } }} title="Hapus dari Keranjang" description="Apakah Anda yakin ingin menghapus item ini dari keranjang?" confirmLabel="Hapus" variant="destructive" onConfirm={() => { if (cartDeleteIndex !== null) setMaterials((prev) => prev.filter((_, i) => i !== cartDeleteIndex)); setCartDeleteConfirmOpen(false); setCartDeleteIndex(null); }} /> setDeleteConfirmOpen(false); setDeleteMaterialIndex(null);
}} />
<ConfirmDialog open={comboDeleteConfirmOpen} onOpenChange={(open) => { if (!open) { setComboDeleteConfirmOpen(false); setComboDeleteIndex(null); } }} title="Hapus Kombinasi" description="Apakah Anda yakin ingin menghapus kombinasi ini beserta semua bahannya?" confirmLabel="Hapus" variant="destructive" onConfirm={() => { if (comboDeleteIndex !== null) { setCombinations((prev) => prev.filter((_, i) => i !== comboDeleteIndex)); setMaterials((prev) => prev.filter((m) => m.combination_id !== comboDeleteIndex)); } setComboDeleteConfirmOpen(false); setComboDeleteIndex(null); }} /> <ConfirmDialog open={cartDeleteConfirmOpen} onOpenChange={(open) => {
if (!open) {
setCartDeleteConfirmOpen(false); setCartDeleteIndex(null);
}
}} title="Hapus dari Keranjang" description="Apakah Anda yakin ingin menghapus item ini dari keranjang?" confirmLabel="Hapus" variant="destructive" onConfirm={() => {
if (cartDeleteIndex !== null) {
setMaterials((prev) => prev.filter((_, i) => i !== cartDeleteIndex));
}
setCartDeleteConfirmOpen(false); setCartDeleteIndex(null);
}} />
<ConfirmDialog open={comboDeleteConfirmOpen} onOpenChange={(open) => {
if (!open) {
setComboDeleteConfirmOpen(false); setComboDeleteIndex(null);
}
}} title="Hapus Kombinasi" description="Apakah Anda yakin ingin menghapus kombinasi ini beserta semua bahannya?" confirmLabel="Hapus" variant="destructive" onConfirm={() => {
if (comboDeleteIndex !== null) {
setCombinations((prev) => prev.filter((_, i) => i !== comboDeleteIndex)); setMaterials((prev) => prev.filter((m) => m.combination_id !== comboDeleteIndex));
}
setComboDeleteConfirmOpen(false); setComboDeleteIndex(null);
}} />
</div> </div>
</> </>
); );

View File

@ -1,4 +1,4 @@
import { Head, router } from '@inertiajs/react'; import { Head, Link, router } from '@inertiajs/react';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { CardTable } from '@/components/card-table'; import { CardTable } from '@/components/card-table';
@ -6,6 +6,7 @@ import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand'; import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table'; import { useServerTable } from '@/hooks/use-server-table';
import { import {
destroy, destroy,
@ -28,6 +29,7 @@ type Props = {
}; };
export default function CuttingIndex({ cuttings }: Props) { export default function CuttingIndex({ cuttings }: Props) {
const { can } = useCan();
const [deleting, setDeleting] = useState<Cutting | null>(null); const [deleting, setDeleting] = useState<Cutting | null>(null);
const expand = useCardTableExpand(true); const expand = useCardTableExpand(true);
@ -66,12 +68,14 @@ export default function CuttingIndex({ cuttings }: Props) {
<PageHeader <PageHeader
title="Cutting" title="Cutting"
actions={ actions={
can('cuttings.create') ? (
<Button asChild> <Button asChild>
<a href={cuttingCreate.url()}> <Link href={cuttingCreate.url()}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Tambah Tambah
</a> </Link>
</Button> </Button>
) : undefined
} }
/> />
@ -86,6 +90,11 @@ export default function CuttingIndex({ cuttings }: Props) {
pagination={pagination} pagination={pagination}
onPageChange={handlePageChange} onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange} onPerPageChange={handlePerPageChange}
rowClassName={(c) =>
c.status === 'in_progress'
? 'border-l-4 border-l-yellow-500'
: ''
}
renderCard={({ renderCard={({
item, item,
index, index,
@ -103,7 +112,7 @@ export default function CuttingIndex({ cuttings }: Props) {
isExpanded={isExpanded} isExpanded={isExpanded}
onToggleExpand={onToggleExpand} onToggleExpand={onToggleExpand}
onEdit={(c) => { onEdit={(c) => {
window.location.href = cuttingEdit.url(c.id); router.visit(cuttingEdit.url(c.id));
}} }}
onDelete={(c) => setDeleting(c)} onDelete={(c) => setDeleting(c)}
/> />

View File

@ -1,6 +1,6 @@
'use no memo'; 'use no memo';
import { Form, Head, usePage } from '@inertiajs/react'; import { Form, Head, Link, usePage } from '@inertiajs/react';
import { import {
ArrowLeft, ArrowLeft,
Check, Check,
@ -360,7 +360,7 @@ export default function PurchaseCreate({ data }: Props) {
} }
} }
return lines; return lines.sort((a, b) => a.title.localeCompare(b.title));
} }
return variants return variants
@ -428,10 +428,10 @@ export default function PurchaseCreate({ data }: Props) {
Tambah Belanja Tambah Belanja
</h2> </h2>
<Button asChild variant="outline"> <Button asChild variant="outline">
<a href={purchaseIndex.url()}> <Link href={purchaseIndex.url()}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Kembali Kembali
</a> </Link>
</Button> </Button>
</div> </div>

View File

@ -1,17 +1,14 @@
'use no memo'; 'use no memo';
import { Form, Head } from '@inertiajs/react'; import { Form, Head, Link } from '@inertiajs/react';
import { import {
ArrowLeft, ArrowLeft,
Check,
ClipboardPaste,
Copy,
Minus, Minus,
Plus, Plus,
ShoppingCart, ShoppingCart,
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
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';
@ -28,7 +25,6 @@ import {
ComboboxItem, ComboboxItem,
ComboboxList, ComboboxList,
} from '@/components/ui/combobox'; } from '@/components/ui/combobox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { import {
Sheet, Sheet,
@ -37,10 +33,8 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { formatNumber } from '@/lib/format'; import { formatNumber } from '@/lib/format';
import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { import {
index as purchaseIndex, index as purchaseIndex,
@ -48,16 +42,6 @@ import {
} from '@/routes/admin/manage/purchases'; } from '@/routes/admin/manage/purchases';
import type { PurchaseCreateData, PurchaseForEdit } from './columns'; import type { PurchaseCreateData, PurchaseForEdit } from './columns';
type VariantState = {
id?: number;
variant: string;
price: number;
stock: number;
photo: string | null;
photoUrl: string | null;
uploading: boolean;
};
type CartLine = { type CartLine = {
key: string; key: string;
photoUrl: string | null; photoUrl: string | null;
@ -78,19 +62,6 @@ type Props = {
export default function PurchaseEdit({ purchase, data }: Props) { export default function PurchaseEdit({ purchase, data }: Props) {
const { suppliers, rawMaterials } = data; const { suppliers, rawMaterials } = data;
const [name] = useState(purchase.name);
const [variants, setVariants] = useState<VariantState[]>(() =>
purchase.variants.map((v) => ({
id: v.id,
variant: v.variant,
price: v.price,
stock: v.stock,
photo: v.photo_key ?? null,
photoUrl: v.photo_url ?? null,
uploading: false,
})),
);
const [supplierId, setSupplierId] = useState(String(purchase.supplier_id)); const [supplierId, setSupplierId] = useState(String(purchase.supplier_id));
const selectedSupplier = const selectedSupplier =
suppliers.find((s) => String(s.id) === supplierId) ?? null; suppliers.find((s) => String(s.id) === supplierId) ?? null;
@ -101,7 +72,6 @@ export default function PurchaseEdit({ purchase, data }: Props) {
const [photoUrl, setPhotoUrl] = useState<string | null>(purchase.photo_url); const [photoUrl, setPhotoUrl] = useState<string | null>(purchase.photo_url);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [mode, setMode] = useState<'new' | 'existing'>(purchase.default_mode);
const [selectedMaterialName, setSelectedMaterialName] = useState( const [selectedMaterialName, setSelectedMaterialName] = useState(
purchase.existing_material_name ?? '', purchase.existing_material_name ?? '',
); );
@ -117,9 +87,6 @@ export default function PurchaseEdit({ purchase, data }: Props) {
const [previewKey, setPreviewKey] = useState<string | null>(null); const [previewKey, setPreviewKey] = useState<string | null>(null);
const [cartRemoveKey, setCartRemoveKey] = useState<string | null>(null); const [cartRemoveKey, setCartRemoveKey] = useState<string | null>(null);
const variantsRef = useRef(variants);
variantsRef.current = variants;
const priceMap = useMemo( const priceMap = useMemo(
() => () =>
new Map( new Map(
@ -143,11 +110,7 @@ export default function PurchaseEdit({ purchase, data }: Props) {
[rawMaterials], [rawMaterials],
); );
const newSubtotal = variants.reduce( const subtotal = Object.entries(quantities).reduce(
(sum, v) => sum + Number(v.price) * Number(v.stock),
0,
);
const existingSubtotal = Object.entries(quantities).reduce(
(sum, [priceId, quantity]) => { (sum, [priceId, quantity]) => {
const price = priceMap.get(Number(priceId)); const price = priceMap.get(Number(priceId));
@ -155,93 +118,8 @@ export default function PurchaseEdit({ purchase, data }: Props) {
}, },
0, 0,
); );
const subtotal = mode === 'existing' ? existingSubtotal : newSubtotal;
const total = subtotal - discount + shippingCost; const total = subtotal - discount + shippingCost;
const addVariant = useCallback(() => {
setVariants((prev) => [
...prev,
{
variant: '',
price: 0,
stock: 0,
photo: null,
photoUrl: null,
uploading: false,
},
]);
}, []);
const removeVariant = useCallback((index: number) => {
setVariants((prev) => prev.filter((_, i) => i !== index));
}, []);
const updateVariant = useCallback(
(index: number, field: keyof VariantState, value: unknown) => {
setVariants((prev) => {
const updated = [...prev];
(updated[index] as Record<string, unknown>)[field] = value;
return updated;
});
},
[],
);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteVariantIndex, setDeleteVariantIndex] = useState<number | null>(
null,
);
const confirmRemoveVariant = useCallback((index: number) => {
setDeleteVariantIndex(index);
setDeleteConfirmOpen(true);
}, []);
const copyPrice = useCallback((variantIndex: number) => {
setVariants((prev) => {
const price = prev[variantIndex].price;
navigator.clipboard.writeText(String(price));
setCopiedIndex(variantIndex);
setTimeout(() => setCopiedIndex(null), 1500);
return prev;
});
}, []);
const pastePrice = useCallback((variantIndex: number) => {
navigator.clipboard.readText().then((text) => {
try {
const price = Number(text);
if (!isNaN(price)) {
setVariants((prev) => {
const updated = [...prev];
updated[variantIndex] = {
...updated[variantIndex],
price,
};
return updated;
});
}
} catch {
// invalid clipboard data
}
});
}, []);
const applyToAll = useCallback((variantIndex: number) => {
setVariants((prev) => {
const sourcePrice = prev[variantIndex].price;
return prev.map((v, i) =>
i === variantIndex ? v : { ...v, price: sourcePrice },
);
});
}, []);
const selectedMaterial = useMemo( const selectedMaterial = useMemo(
() => rawMaterials.find((m) => m.name === selectedMaterialName) ?? null, () => rawMaterials.find((m) => m.name === selectedMaterialName) ?? null,
[rawMaterials, selectedMaterialName], [rawMaterials, selectedMaterialName],
@ -261,20 +139,7 @@ export default function PurchaseEdit({ purchase, data }: Props) {
})); }));
}, []); }, []);
const adjustVariantStock = useCallback((index: number, amount: number) => {
setVariants((prev) => {
const updated = [...prev];
updated[index] = {
...updated[index],
stock: Math.max(0, Number(updated[index].stock) + amount),
};
return updated;
});
}, []);
const cartItems: CartLine[] = (() => { const cartItems: CartLine[] = (() => {
if (mode === 'existing') {
const lines: CartLine[] = []; const lines: CartLine[] = [];
for (const [priceId, quantity] of Object.entries(quantities)) { for (const [priceId, quantity] of Object.entries(quantities)) {
@ -301,22 +166,7 @@ export default function PurchaseEdit({ purchase, data }: Props) {
} }
} }
return lines; return lines.sort((a, b) => a.title.localeCompare(b.title));
}
return variants
.map((v, index): CartLine => ({
key: `new-${index}`,
photoUrl: v.photoUrl,
title: `${name || 'Bahan Baku Baru'}${v.variant || `Varian ${index + 1}`}`,
subtitle: `${formatCurrency(v.price)} / ${purchase.unit}`,
price: v.price,
quantity: v.stock,
onAdjust: (delta) => adjustVariantStock(index, delta),
onSet: (value) => updateVariant(index, 'stock', value),
onRemove: () => removeVariant(index),
}))
.filter((line) => line.quantity > 0);
})(); })();
function formatQuantity(value: number): string { function formatQuantity(value: number): string {
@ -324,18 +174,13 @@ export default function PurchaseEdit({ purchase, data }: Props) {
} }
function getPayload() { function getPayload() {
const base = { return {
mode, mode: 'existing',
supplier_id: supplierId ? Number(supplierId) : null, supplier_id: supplierId ? Number(supplierId) : null,
discount, discount,
shipping_cost: shippingCost, shipping_cost: shippingCost,
notes: notes || null, notes: notes || null,
photo_key: photo, photo_key: photo,
};
if (mode === 'existing') {
return {
...base,
existing_items: Object.entries(quantities) existing_items: Object.entries(quantities)
.map(([priceId, quantity]) => ({ .map(([priceId, quantity]) => ({
raw_material_price_id: Number(priceId), raw_material_price_id: Number(priceId),
@ -346,19 +191,6 @@ export default function PurchaseEdit({ purchase, data }: Props) {
}; };
} }
return {
...base,
name,
variants: variantsRef.current.map((v) => ({
id: v.id,
variant: v.variant,
price: Number(v.price),
stock: Number(v.stock),
photo_key: v.photo,
})),
};
}
return ( return (
<> <>
<Head title="Edit Belanja" /> <Head title="Edit Belanja" />
@ -369,10 +201,10 @@ export default function PurchaseEdit({ purchase, data }: Props) {
Edit Belanja Edit Belanja
</h2> </h2>
<Button asChild variant="outline"> <Button asChild variant="outline">
<a href={purchaseIndex.url()}> <Link href={purchaseIndex.url()}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Kembali Kembali
</a> </Link>
</Button> </Button>
</div> </div>
@ -387,303 +219,6 @@ export default function PurchaseEdit({ purchase, data }: Props) {
{({ errors, processing }) => ( {({ errors, processing }) => (
<div className="grid gap-6 md:grid-cols-3"> <div className="grid gap-6 md:grid-cols-3">
<div className="space-y-6 md:col-span-2"> <div className="space-y-6 md:col-span-2">
<Tabs
value={mode}
onValueChange={(value) =>
setMode(value as 'new' | 'existing')
}
>
<TabsList>
<TabsTrigger value="new">
Baru
</TabsTrigger>
<TabsTrigger value="existing">
Lama
</TabsTrigger>
</TabsList>
<TabsContent
value="new"
className="mt-0 space-y-6"
>
<Card>
<CardHeader>
<CardTitle>
Informasi Bahan Baku
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4">
<div className="grid gap-2">
<Label htmlFor="edit-name">
Nama Bahan Baku{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="edit-name"
name="name"
value={name}
disabled
placeholder="Masukkan nama bahan baku"
/>
<InputError
message={errors.name}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>
Varian Bahan Baku
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{variants.map(
(variant, variantIndex) => (
<div
key={variantIndex}
className="space-y-4 rounded-lg border p-4"
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h4 className="font-medium">
Varian{' '}
{variantIndex +
1}
</h4>
<div className="flex flex-wrap items-center gap-1">
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
onClick={() =>
copyPrice(
variantIndex,
)
}
>
{copiedIndex ===
variantIndex ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
Salin
Harga
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
onClick={() =>
pastePrice(
variantIndex,
)
}
>
<ClipboardPaste className="h-4 w-4" />
Tempel
Harga
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
onClick={() =>
applyToAll(
variantIndex,
)
}
>
Terapkan
ke Semua
</Button>
{variantIndex >
0 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() =>
confirmRemoveVariant(
variantIndex,
)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label>
Nama
Varian{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
value={
variant.variant
}
onChange={(
e,
) =>
updateVariant(
variantIndex,
'variant',
e
.target
.value,
)
}
placeholder="Contoh: Ukuran L, Warna Merah"
/>
<InputError
message={
errors[
`variants.${variantIndex}.variant`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Harga{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
value={
variant.price
}
onValueChange={(
val,
) =>
updateVariant(
variantIndex,
'price',
val,
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.price`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Stok{' '}
<span className="text-destructive">
*
</span>
</Label>
<NumberInput
value={
variant.stock
}
onValueChange={(
val,
) =>
updateVariant(
variantIndex,
'stock',
val,
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.stock`
]
}
/>
</div>
</div>
<div className="grid gap-2">
<Label>
Foto Varian{' '}
<span className="text-destructive">
*
</span>
</Label>
<FileUpload
value={
variant.photo
}
onChange={(
key,
) => {
updateVariant(
variantIndex,
'photo',
key,
);
updateVariant(
variantIndex,
'photoUrl',
key
? getTemporaryUrl(
key,
)
: null,
);
}}
folder="raw-material-variant"
existingUrl={
variant.photoUrl
}
onUploadingChange={(
uploading,
) =>
updateVariant(
variantIndex,
'uploading',
uploading,
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.photo_key`
]
}
/>
</div>
</div>
),
)}
<Button
type="button"
variant="outline"
onClick={addVariant}
>
<Plus className="h-4 w-4" />
Tambah Varian
</Button>
</CardContent>
</Card>
</TabsContent>
<TabsContent
value="existing"
className="mt-0 space-y-6"
>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
@ -716,6 +251,7 @@ export default function PurchaseEdit({ purchase, data }: Props) {
<ComboboxInput <ComboboxInput
placeholder="Cari bahan baku..." placeholder="Cari bahan baku..."
className="w-full" className="w-full"
disabled
/> />
<ComboboxContent> <ComboboxContent>
<ComboboxEmpty> <ComboboxEmpty>
@ -868,8 +404,6 @@ export default function PurchaseEdit({ purchase, data }: Props) {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent>
</Tabs>
</div> </div>
<div className="space-y-6 md:col-span-1"> <div className="space-y-6 md:col-span-1">
@ -1020,15 +554,10 @@ export default function PurchaseEdit({ purchase, data }: Props) {
disabled={ disabled={
processing || processing ||
uploading || uploading ||
variants.some(
(v) => v.uploading,
) ||
!supplierId || !supplierId ||
(mode === 'existing' Object.values(
? Object.values(
quantities, quantities,
).every((q) => q <= 0) ).every((q) => q <= 0)
: !name)
} }
> >
{processing {processing
@ -1184,27 +713,6 @@ export default function PurchaseEdit({ purchase, data }: Props) {
title={cartItems.find((i) => i.key === previewKey)?.title} title={cartItems.find((i) => i.key === previewKey)?.title}
/> />
<ConfirmDialog
open={deleteConfirmOpen}
onOpenChange={(open) => {
if (!open) {
setDeleteConfirmOpen(false);
setDeleteVariantIndex(null);
}
}}
title="Hapus Varian"
description="Apakah Anda yakin ingin menghapus varian ini?"
confirmLabel="Hapus"
onConfirm={() => {
if (deleteVariantIndex !== null) {
removeVariant(deleteVariantIndex);
}
setDeleteConfirmOpen(false);
setDeleteVariantIndex(null);
}}
/>
<ConfirmDialog <ConfirmDialog
open={cartRemoveKey !== null} open={cartRemoveKey !== null}
onOpenChange={(open) => { onOpenChange={(open) => {

View File

@ -1,18 +1,28 @@
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { 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 { useCardTableExpand } from '@/components/hooks/use-card-table-expand'; import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from '@/components/ui/combobox';
import { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table'; import { useServerTable } from '@/hooks/use-server-table';
import { import {
destroy, destroy,
create as purchaseCreate, create as purchaseCreate,
index as purchaseIndex,
edit as purchaseEdit, edit as purchaseEdit,
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';
@ -25,9 +35,21 @@ type Props = {
per_page: number; per_page: number;
total: number; total: number;
}; };
suppliers: {
id: number;
name: string;
}[];
filters: {
supplier_id?: string;
};
}; };
export default function PurchaseIndex({ purchases }: Props) { export default function PurchaseIndex({
purchases,
filters,
suppliers,
}: Props) {
const { can } = useCan();
const [deleting, setDeleting] = useState<Purchase | null>(null); const [deleting, setDeleting] = useState<Purchase | null>(null);
const expand = useCardTableExpand(true); const expand = useCardTableExpand(true);
@ -40,14 +62,28 @@ export default function PurchaseIndex({ purchases }: Props) {
const { const {
search, search,
filterOpen,
setFilterOpen,
handlePageChange, handlePageChange,
handlePerPageChange, handlePerPageChange,
handleSearchChange, handleSearchChange,
applyFilter,
clearFilters,
} = useServerTable({ } = useServerTable({
route: () => purchaseIndex.url(), route: () => purchaseIndex.url(),
pagination, pagination,
filters,
filterWithParams: false,
}); });
const selectedSupplier = useMemo(
() =>
suppliers.find(
(s) => String(s.id) === filters.supplier_id,
) ?? null,
[suppliers, filters.supplier_id],
);
function handleDelete() { function handleDelete() {
if (!deleting) { if (!deleting) {
return; return;
@ -58,6 +94,50 @@ export default function PurchaseIndex({ purchases }: Props) {
}); });
} }
const filterToolbar = (
<FilterPopover
open={filterOpen}
onOpenChange={setFilterOpen}
filters={filters}
hasActiveFilters={Boolean(filters.supplier_id)}
onClear={clearFilters}
>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Supplier
</label>
<Combobox
items={suppliers}
itemToStringLabel={(supplier) => supplier.name}
value={selectedSupplier}
onValueChange={(value) =>
applyFilter(
'supplier_id',
value ? String(value.id) : '',
)
}
>
<ComboboxInput
placeholder="Pilih supplier..."
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Tidak ada supplier ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(supplier) => (
<ComboboxItem value={supplier}>
{supplier.name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
</FilterPopover>
);
return ( return (
<> <>
<Head title="Belanja" /> <Head title="Belanja" />
@ -66,12 +146,14 @@ export default function PurchaseIndex({ purchases }: Props) {
<PageHeader <PageHeader
title="Belanja" title="Belanja"
actions={ actions={
can('purchases.create') ? (
<Button asChild> <Button asChild>
<a href={purchaseCreate.url()}> <Link href={purchaseCreate.url()}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Tambah Tambah
</a> </Link>
</Button> </Button>
) : undefined
} }
/> />
@ -83,6 +165,7 @@ export default function PurchaseIndex({ purchases }: Props) {
searchValue={search} searchValue={search}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
searchPlaceholder="Cari berdasarkan supplier..." searchPlaceholder="Cari berdasarkan supplier..."
toolbar={filterToolbar}
pagination={pagination} pagination={pagination}
onPageChange={handlePageChange} onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange} onPerPageChange={handlePerPageChange}
@ -103,7 +186,7 @@ export default function PurchaseIndex({ purchases }: Props) {
isExpanded={isExpanded} isExpanded={isExpanded}
onToggleExpand={onToggleExpand} onToggleExpand={onToggleExpand}
onEdit={(p) => { onEdit={(p) => {
window.location.href = purchaseEdit.url(p.id); router.visit(purchaseEdit.url(p.id));
}} }}
onDelete={(p) => setDeleting(p)} onDelete={(p) => setDeleting(p)}
/> />

View File

@ -3,6 +3,7 @@ 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';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
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 type { Purchase } from './columns'; import type { Purchase } from './columns';
@ -24,6 +25,7 @@ export function PurchaseCardRow({
onEdit, onEdit,
onDelete, onDelete,
}: PurchaseCardRowParams) { }: PurchaseCardRowParams) {
const { can } = useCan();
const items = purchase.purchase_items ?? []; const items = purchase.purchase_items ?? [];
const variantCount = items.length; const variantCount = items.length;
const rawMaterialName = const rawMaterialName =
@ -129,6 +131,7 @@ export function PurchaseCardRow({
{ {
label: 'Edit', label: 'Edit',
icon: <Pencil className="h-4 w-4" />, icon: <Pencil className="h-4 w-4" />,
show: can('purchases.update'),
onClick: () => onEdit(purchase), onClick: () => onEdit(purchase),
}, },
{ {
@ -136,6 +139,7 @@ export function PurchaseCardRow({
icon: ( icon: (
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
), ),
show: can('purchases.delete'),
onClick: () => onDelete(purchase), onClick: () => onDelete(purchase),
}, },
]} ]}

View File

@ -1,6 +1,6 @@
'use no memo'; 'use no memo';
import { Form, Head, usePage } from '@inertiajs/react'; import { Form, Head, Link, usePage } from '@inertiajs/react';
import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react'; import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog'; import { ConfirmDialog } from '@/components/confirm-dialog';
@ -188,7 +188,7 @@ return 0;
} }
} }
return lines; return lines.sort((a, b) => a.title.localeCompare(b.title));
})(); })();
function formatQuantity(value: number): string { function formatQuantity(value: number): string {
@ -219,10 +219,10 @@ return 0;
Tambah Restock Tambah Restock
</h2> </h2>
<Button asChild variant="outline"> <Button asChild variant="outline">
<a href={restockIndex.url()}> <Link href={restockIndex.url()}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Kembali Kembali
</a> </Link>
</Button> </Button>
</div> </div>

View File

@ -1,6 +1,6 @@
'use no memo'; 'use no memo';
import { Form, Head } from '@inertiajs/react'; import { Form, Head, Link } from '@inertiajs/react';
import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react'; import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog'; import { ConfirmDialog } from '@/components/confirm-dialog';
@ -162,7 +162,7 @@ return 0;
} }
} }
return lines; return lines.sort((a, b) => a.title.localeCompare(b.title));
})(); })();
function formatQuantity(value: number): string { function formatQuantity(value: number): string {
@ -193,10 +193,10 @@ return 0;
Edit Restock Edit Restock
</h2> </h2>
<Button asChild variant="outline"> <Button asChild variant="outline">
<a href={restockIndex.url()}> <Link href={restockIndex.url()}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Kembali Kembali
</a> </Link>
</Button> </Button>
</div> </div>

View File

@ -1,4 +1,4 @@
import { Head, router } from '@inertiajs/react'; import { Head, Link, router } from '@inertiajs/react';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { CardTable } from '@/components/card-table'; import { CardTable } from '@/components/card-table';
@ -6,6 +6,7 @@ import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand'; import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table'; import { useServerTable } from '@/hooks/use-server-table';
import { import {
destroy, destroy,
@ -28,6 +29,7 @@ type Props = {
}; };
export default function RestockIndex({ restocks }: Props) { export default function RestockIndex({ restocks }: Props) {
const { can } = useCan();
const [deleting, setDeleting] = useState<Restock | null>(null); const [deleting, setDeleting] = useState<Restock | null>(null);
const expand = useCardTableExpand(true); const expand = useCardTableExpand(true);
@ -66,12 +68,14 @@ export default function RestockIndex({ restocks }: Props) {
<PageHeader <PageHeader
title="Restock" title="Restock"
actions={ actions={
can('restocks.create') ? (
<Button asChild> <Button asChild>
<a href={restockCreate.url()}> <Link href={restockCreate.url()}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Tambah Tambah
</a> </Link>
</Button> </Button>
) : undefined
} }
/> />
@ -103,7 +107,7 @@ export default function RestockIndex({ restocks }: Props) {
isExpanded={isExpanded} isExpanded={isExpanded}
onToggleExpand={onToggleExpand} onToggleExpand={onToggleExpand}
onEdit={(r) => { onEdit={(r) => {
window.location.href = restockEdit.url(r.id); router.visit(restockEdit.url(r.id));
}} }}
onDelete={(r) => setDeleting(r)} onDelete={(r) => setDeleting(r)}
/> />

View File

@ -3,6 +3,7 @@ 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';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
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 type { Restock, RestockStockType } from './columns'; import type { Restock, RestockStockType } from './columns';
@ -38,6 +39,7 @@ export function RestockCardRow({
onEdit, onEdit,
onDelete, onDelete,
}: RestockCardRowParams) { }: RestockCardRowParams) {
const { can } = useCan();
const items = restock.restock_items ?? []; const items = restock.restock_items ?? [];
const variantCount = items.length; const variantCount = items.length;
const productNames = [ const productNames = [
@ -138,6 +140,7 @@ export function RestockCardRow({
{ {
label: 'Edit', label: 'Edit',
icon: <Pencil className="h-4 w-4" />, icon: <Pencil className="h-4 w-4" />,
show: can('restocks.update'),
onClick: () => onEdit(restock), onClick: () => onEdit(restock),
}, },
{ {
@ -145,6 +148,7 @@ export function RestockCardRow({
icon: ( icon: (
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
), ),
show: can('restocks.delete'),
onClick: () => onDelete(restock), onClick: () => onDelete(restock),
}, },
]} ]}

View File

@ -1,6 +1,6 @@
'use no memo'; 'use no memo';
import { Form, Head, usePage } from '@inertiajs/react'; import { Form, Head, Link, usePage } from '@inertiajs/react';
import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react'; import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog'; import { ConfirmDialog } from '@/components/confirm-dialog';
@ -28,7 +28,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@ -36,6 +35,7 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useTransactionDraftSave } from '@/hooks/use-transaction-draft'; import { useTransactionDraftSave } from '@/hooks/use-transaction-draft';
import { formatNumber } from '@/lib/format'; import { formatNumber } from '@/lib/format';
@ -182,6 +182,7 @@ export default function TransactionCreate({ data }: Props) {
if (stockType === 'reject') { if (stockType === 'reject') {
return priceTypeOptions.filter((o) => o.value === 'reject'); return priceTypeOptions.filter((o) => o.value === 'reject');
} }
return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value)); return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value));
}, [stockType, priceTypeOptions]); }, [stockType, priceTypeOptions]);
@ -299,10 +300,10 @@ export default function TransactionCreate({ data }: Props) {
Tambah Transaksi Tambah Transaksi
</h2> </h2>
<Button asChild variant="outline"> <Button asChild variant="outline">
<a href={transactionIndex.url()}> <Link href={transactionIndex.url()}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Kembali Kembali
</a> </Link>
</Button> </Button>
</div> </div>

View File

@ -1,6 +1,6 @@
'use no memo'; 'use no memo';
import { Form, Head } from '@inertiajs/react'; import { Form, Head, Link } from '@inertiajs/react';
import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react'; import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog'; import { ConfirmDialog } from '@/components/confirm-dialog';
@ -28,7 +28,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@ -36,6 +35,7 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { formatNumber } from '@/lib/format'; import { formatNumber } from '@/lib/format';
import { getTemporaryUrl } from '@/lib/upload'; import { getTemporaryUrl } from '@/lib/upload';
@ -151,6 +151,7 @@ export default function TransactionEdit({ transaction, data }: Props) {
if (stockType === 'reject') { if (stockType === 'reject') {
return priceTypeOptions.filter((o) => o.value === 'reject'); return priceTypeOptions.filter((o) => o.value === 'reject');
} }
return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value)); return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value));
}, [stockType, priceTypeOptions]); }, [stockType, priceTypeOptions]);
@ -276,10 +277,10 @@ export default function TransactionEdit({ transaction, data }: Props) {
Edit Transaksi Edit Transaksi
</h2> </h2>
<Button asChild variant="outline"> <Button asChild variant="outline">
<a href={transactionIndex.url()}> <Link href={transactionIndex.url()}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Kembali Kembali
</a> </Link>
</Button> </Button>
</div> </div>

View File

@ -1,4 +1,4 @@
import { Head, router } from '@inertiajs/react'; import { Head, Link, router } from '@inertiajs/react';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { CardTable } from '@/components/card-table'; import { CardTable } from '@/components/card-table';
@ -22,6 +22,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table'; import { useServerTable } from '@/hooks/use-server-table';
import { import {
destroy, destroy,
@ -73,6 +74,7 @@ export default function TransactionIndex({
filters, filters,
filterOptions, filterOptions,
}: Props) { }: Props) {
const { can } = useCan();
const [deleting, setDeleting] = useState<Transaction | null>(null); const [deleting, setDeleting] = useState<Transaction | null>(null);
const expand = useCardTableExpand(true); const expand = useCardTableExpand(true);
@ -320,12 +322,14 @@ export default function TransactionIndex({
<PageHeader <PageHeader
title="Transaksi" title="Transaksi"
actions={ actions={
can('orders.create') ? (
<Button asChild> <Button asChild>
<a href={transactionCreate.url()}> <Link href={transactionCreate.url()}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Tambah Tambah
</a> </Link>
</Button> </Button>
) : undefined
} }
/> />
@ -358,7 +362,7 @@ export default function TransactionIndex({
isExpanded={isExpanded} isExpanded={isExpanded}
onToggleExpand={onToggleExpand} onToggleExpand={onToggleExpand}
onEdit={(t) => { onEdit={(t) => {
window.location.href = transactionEdit.url(t.id); router.visit(transactionEdit.url(t.id));
}} }}
onDelete={(t) => setDeleting(t)} onDelete={(t) => setDeleting(t)}
/> />

View File

@ -3,6 +3,7 @@ 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';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
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 type { Transaction } from './columns'; import type { Transaction } from './columns';
@ -32,6 +33,7 @@ export function TransactionCardRow({
onEdit, onEdit,
onDelete, onDelete,
}: TransactionCardRowParams) { }: TransactionCardRowParams) {
const { can } = useCan();
const items = transaction.order_items ?? []; const items = transaction.order_items ?? [];
const variantCount = items.length; const variantCount = items.length;
const productNames = [ const productNames = [
@ -176,6 +178,7 @@ export function TransactionCardRow({
{ {
label: 'Edit', label: 'Edit',
icon: <Pencil className="h-4 w-4" />, icon: <Pencil className="h-4 w-4" />,
show: can('orders.update'),
onClick: () => onEdit(transaction), onClick: () => onEdit(transaction),
}, },
{ {
@ -183,6 +186,7 @@ export function TransactionCardRow({
icon: ( icon: (
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
), ),
show: can('orders.delete'),
onClick: () => onDelete(transaction), onClick: () => onDelete(transaction),
}, },
]} ]}

View File

@ -10,12 +10,13 @@ export type Category = {
type CreateColumnsParams = { type CreateColumnsParams = {
handleEdit: (category: Category) => void; handleEdit: (category: Category) => void;
handleDeleteClick: (category: Category) => void; handleDeleteClick: (category: Category) => void;
can: (permission: string) => boolean;
}; };
export function createCategoryColumns( export function createCategoryColumns(
params: CreateColumnsParams, params: CreateColumnsParams,
): ColumnDef<Category>[] { ): ColumnDef<Category>[] {
const { handleEdit, handleDeleteClick } = params; const { handleEdit, handleDeleteClick, can } = params;
return [ return [
{ {
@ -43,6 +44,7 @@ export function createCategoryColumns(
{ {
label: 'Edit', label: 'Edit',
icon: <Pencil className="h-4 w-4" />, icon: <Pencil className="h-4 w-4" />,
show: can('categories.update'),
onClick: () => handleEdit(category), onClick: () => handleEdit(category),
}, },
{ {
@ -50,6 +52,7 @@ export function createCategoryColumns(
icon: ( icon: (
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
), ),
show: can('categories.delete'),
onClick: () => handleDeleteClick(category), onClick: () => handleDeleteClick(category),
}, },
]} ]}

View File

@ -10,6 +10,7 @@ import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
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 { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table'; import { useServerTable } from '@/hooks/use-server-table';
import { import {
destroy, destroy,
@ -32,6 +33,7 @@ type Props = {
}; };
export default function CategoryIndex({ categories, highlight }: Props) { export default function CategoryIndex({ categories, highlight }: Props) {
const { can } = useCan();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Category | null>(null); const [editing, setEditing] = useState<Category | null>(null);
const [deleting, setDeleting] = useState<Category | null>(null); const [deleting, setDeleting] = useState<Category | null>(null);
@ -66,6 +68,7 @@ export default function CategoryIndex({ categories, highlight }: Props) {
const columns = createCategoryColumns({ const columns = createCategoryColumns({
handleEdit: (category) => setEditing(category), handleEdit: (category) => setEditing(category),
handleDeleteClick: (category) => setDeleting(category), handleDeleteClick: (category) => setDeleting(category),
can,
}); });
return ( return (
@ -98,6 +101,7 @@ export default function CategoryIndex({ categories, highlight }: Props) {
) )
} }
actions={ actions={
can('categories.create') ? (
<Button asChild> <Button asChild>
<button <button
type="button" type="button"
@ -107,6 +111,7 @@ export default function CategoryIndex({ categories, highlight }: Props) {
Tambah Tambah
</button> </button>
</Button> </Button>
) : undefined
} }
/> />

Some files were not shown because too many files have changed in this diff Show More