Compare commits

..

No commits in common. "6ebcbc24c0733c1602a9ac8b4dbb95d2bd32cd67" and "1f395657545d793479028af2c7b7af978895d219" have entirely different histories.

45 changed files with 339 additions and 1330 deletions

View File

@ -2,9 +2,7 @@
namespace App\Http\Controllers\Admin\Finance; namespace App\Http\Controllers\Admin\Finance;
use App\Enums\EmployeeAdvanceStatus;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Finance\EmployeeAdvancePaymentRequest;
use App\Http\Requests\Admin\Finance\EmployeeAdvanceRequest; use App\Http\Requests\Admin\Finance\EmployeeAdvanceRequest;
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\EmployeeAdvance; use App\Models\EmployeeAdvance;
@ -22,14 +20,7 @@ public function __construct(
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
{ {
return Inertia::render('admin/finance/employee-advance/index', [ return Inertia::render('admin/finance/employee-advance/index', [
'employeeAdvances' => $this->service->paginated( 'employeeAdvances' => $this->service->paginated(...$request->validatedWithDefaults()),
...$request->validatedWithDefaults(),
filters: $request->only(['status']),
),
'filters' => $request->only(['status']),
'filterOptions' => [
'statusOptions' => EmployeeAdvanceStatus::toSelect(),
],
]); ]);
} }
@ -69,11 +60,11 @@ public function approve(EmployeeAdvance $employeeAdvance): RedirectResponse
); );
} }
public function pay(EmployeeAdvancePaymentRequest $request, EmployeeAdvance $employeeAdvance): RedirectResponse public function pay(EmployeeAdvance $employeeAdvance): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->pay($employeeAdvance, $request->validated('amount')), fn () => $this->service->pay($employeeAdvance),
'Pembayaran kasbon berhasil.', 'Kasbon berhasil dibayar.',
'admin.finance.employee-advances.index' 'admin.finance.employee-advances.index'
); );
} }

View File

@ -26,19 +26,13 @@ public function index(PaginatedRequest $request): Response
filters: $request->only(['employment_status', 'is_active', 'gender']), filters: $request->only(['employment_status', 'is_active', 'gender']),
), ),
'filters' => $request->only(['employment_status', 'is_active', 'gender']), 'filters' => $request->only(['employment_status', 'is_active', 'gender']),
'canViewAll' => $this->service->canViewAll(),
]); ]);
} }
public function create(): Response public function create(): Response
{ {
$user = auth()->user();
return Inertia::render('admin/hr/employee/create', [ return Inertia::render('admin/hr/employee/create', [
'roles' => $this->service->canViewAll() 'roles' => Role::where('name', '!=', 'Developer')->get(['id', 'name']),
? Role::where('name', '!=', 'Developer')->get(['id', 'name'])
: Role::where('name', '=', $user->roles->first()?->name)->get(['id', 'name']),
'canViewAll' => $this->service->canViewAll(),
]); ]);
} }

View File

@ -19,14 +19,13 @@ public function __construct(
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
{ {
$filters = $request->only(['status', 'channel', 'payment_type', 'customer_id', 'marketing_id', 'created_by_id', 'date_from', 'date_to']); $filters = $request->only(['status', 'channel', 'payment_type', 'customer_id', 'marketing_id', 'created_by_id']);
return Inertia::render('admin/manage/transaction/index', [ return Inertia::render('admin/manage/transaction/index', [
'transactions' => $this->service->paginated( 'transactions' => $this->service->paginated(
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
filters: $filters, filters: $filters,
), ),
'summary' => $this->service->getSummary($filters),
'filters' => $filters, 'filters' => $filters,
'filterOptions' => $this->service->getFilterOptions(), 'filterOptions' => $this->service->getFilterOptions(),
]); ]);
@ -76,13 +75,4 @@ public function destroy(Order $transaction): RedirectResponse
'admin.manage.transactions.index' 'admin.manage.transactions.index'
); );
} }
public function updateStatus(Order $transaction): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->updateStatus($transaction, request('status')),
'Status transaksi berhasil diperbarui.',
'admin.manage.transactions.index'
);
}
} }

View File

@ -24,11 +24,10 @@ public function index(PaginatedRequest $request): Response
return Inertia::render('admin/master/product/index', [ return Inertia::render('admin/master/product/index', [
'products' => $this->service->paginated( 'products' => $this->service->paginated(
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
filters: $request->only(['status', 'stock', 'category', 'name']), filters: $request->only(['status', 'stock', 'category']),
), ),
'categories' => $this->categoryService->getAll(), 'categories' => $this->categoryService->getAll(),
'productNames' => $this->service->getNames(), 'filters' => $request->only(['status', 'stock', 'category']),
'filters' => $request->only(['status', 'stock', 'category', 'name']),
]); ]);
} }

View File

@ -15,7 +15,7 @@ protected function handleAction(callable $action, string $successMessage, string
$action(); $action();
Inertia::flash('toast', ['type' => 'success', 'message' => $successMessage]); Inertia::flash('toast', ['type' => 'success', 'message' => $successMessage]);
return to_route($redirectRoute, $parameters); return to_route($redirectRoute);
} catch (ValidationException $e) { } catch (ValidationException $e) {
$firstError = collect($e->errors())->flatten()->first(); $firstError = collect($e->errors())->flatten()->first();
Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']); Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']);

View File

@ -1,37 +0,0 @@
<?php
namespace App\Http\Requests\Admin\Finance;
use App\Concerns\CurrencyStripping;
use Illuminate\Foundation\Http\FormRequest;
use Override;
class EmployeeAdvancePaymentRequest extends FormRequest
{
use CurrencyStripping;
public function authorize(): bool
{
return true;
}
#[Override]
public function prepareForValidation(): void
{
$this->merge($this->stripCurrencyDot($this->all(), 'amount'));
}
public function rules(): array
{
return [
'amount' => ['required', 'integer', 'min:1'],
];
}
public function attributes(): array
{
return [
'amount' => 'jumlah bayar',
];
}
}

View File

@ -11,10 +11,9 @@
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\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
#[Appends(['formatted_amount', 'formatted_created_at', 'formatted_due_date', 'formatted_paid_amount', 'formatted_remaining_amount', 'status_label'])] #[Appends(['formatted_amount', 'formatted_created_at', 'formatted_due_date', 'formatted_paid_amount', 'status_label'])]
#[Guarded(['id'])] #[Guarded(['id'])]
class EmployeeAdvance extends Model class EmployeeAdvance extends Model
{ {
@ -60,13 +59,6 @@ protected function formattedPaidAmount(): Attribute
); );
} }
protected function formattedRemainingAmount(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->amount - $this->paid_amount, 0, ',', '.'),
);
}
protected function statusLabel(): Attribute protected function statusLabel(): Attribute
{ {
return Attribute::make( return Attribute::make(
@ -114,11 +106,6 @@ public function employee(): BelongsTo
return $this->belongsTo(Employee::class); return $this->belongsTo(Employee::class);
} }
public function payments(): HasMany
{
return $this->hasMany(EmployeeAdvancePayment::class);
}
public function paidBy(): BelongsTo public function paidBy(): BelongsTo
{ {
return $this->belongsTo(User::class, 'paid_by_id'); return $this->belongsTo(User::class, 'paid_by_id');

View File

@ -1,54 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Appends(['formatted_amount', 'formatted_paid_at'])]
#[Guarded(['id'])]
class EmployeeAdvancePayment extends Model
{
use HasFactory;
protected function casts(): array
{
return [
'amount' => 'integer',
'paid_at' => 'datetime',
];
}
protected function formattedAmount(): Attribute
{
return Attribute::make(
get: fn() => 'Rp ' . number_format($this->amount, 0, ',', '.'),
);
}
protected function formattedPaidAt(): Attribute
{
return Attribute::make(
get: fn() => $this->paid_at?->translatedFormat('l, d F Y'),
);
}
public function employeeAdvance(): BelongsTo
{
return $this->belongsTo(EmployeeAdvance::class);
}
public function paidBy(): BelongsTo
{
return $this->belongsTo(User::class, 'paid_by_id');
}
public function cashTransaction(): BelongsTo
{
return $this->belongsTo(CashTransaction::class);
}
}

View File

@ -15,7 +15,7 @@
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\InteractsWithMedia;
#[Appends(['formatted_name', 'formatted_stock'])] #[Appends(['formatted_name'])]
#[Guarded(['id'])] #[Guarded(['id'])]
#[ScopedBy([ProductVariantScope::class])] #[ScopedBy([ProductVariantScope::class])]
class ProductVariant extends Model implements HasMedia class ProductVariant extends Model implements HasMedia
@ -34,14 +34,7 @@ protected function casts(): array
protected function formattedName(): Attribute protected function formattedName(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn() => ucfirst($this->name), get: fn () => ucfirst($this->name),
);
}
protected function formattedStock(): Attribute
{
return Attribute::make(
get: fn() => number_format($this->stock, 0, ',', '.'),
); );
} }

View File

@ -3,8 +3,8 @@
namespace App\Services\Admin\Finance; namespace App\Services\Admin\Finance;
use App\Enums\EmployeeAdvanceStatus; use App\Enums\EmployeeAdvanceStatus;
use App\Models\CashAccount;
use App\Models\EmployeeAdvance; use App\Models\EmployeeAdvance;
use App\Models\EmployeeAdvancePayment;
use App\Services\Concerns\HandlesCashTransactions; use App\Services\Concerns\HandlesCashTransactions;
use App\Services\NotificationService; use App\Services\NotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@ -16,16 +16,10 @@ class EmployeeAdvanceService
{ {
use HandlesCashTransactions; use HandlesCashTransactions;
private function canViewAll(): bool
{
return auth()->user()->hasAnyRole(['developer', 'owner', 'direktur', 'admin-toko']);
}
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', 'payments.paidBy.userProfile']) ->with(['employee.user.userProfile'])
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->latest() ->latest()
->get(); ->get();
} }
@ -34,9 +28,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
{ {
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', 'payments.paidBy.userProfile']) ->with(['employee.user.userProfile'])
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->when($filters['status'] ?? null, fn ($q) => $q->where('status', $filters['status']))
->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%"))
->orderBy($sort, $direction) ->orderBy($sort, $direction)
->paginate($perPage); ->paginate($perPage);
@ -44,19 +36,27 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
public function create(array $data): EmployeeAdvance public function create(array $data): EmployeeAdvance
{ {
$employeeAdvance = DB::transaction(function () use ($data) {
$employee = auth()->user()->employee; $employee = auth()->user()->employee;
if (! $employee) { if (! $employee) {
throw new \Exception('Anda tidak terdaftar sebagai karyawan.'); throw new \Exception('Anda tidak terdaftar sebagai karyawan.');
} }
$employeeAdvance = EmployeeAdvance::create([ $cashTransaction = $this->debitCash(
amount: $data['amount'],
description: 'Kasbon: '.$data['description'],
);
return EmployeeAdvance::create([
'cash_transaction_id' => $cashTransaction->id,
'employee_id' => $employee->id, 'employee_id' => $employee->id,
'amount' => $data['amount'], 'amount' => $data['amount'],
'description' => $data['description'], 'description' => $data['description'],
'due_date' => $data['due_date'], 'due_date' => $data['due_date'],
'status' => EmployeeAdvanceStatus::PENDING, 'status' => EmployeeAdvanceStatus::PENDING,
]); ]);
});
$employeeAdvance->load('employee.user'); $employeeAdvance->load('employee.user');
@ -73,36 +73,56 @@ public function create(array $data): EmployeeAdvance
public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeAdvance public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeAdvance
{ {
return DB::transaction(function () use ($employeeAdvance, $data) {
$cashAccount = CashAccount::firstOrFail();
$cashTransaction = $employeeAdvance->cashTransaction;
$oldAmount = $employeeAdvance->amount;
$newAmount = $data['amount'];
$difference = $newAmount - $oldAmount;
if ($difference > 0 && $cashAccount->balance < $difference) {
throw ValidationException::withMessages([
'amount' => 'Saldo tidak mencukupi.',
]);
}
$newBalance = $cashAccount->balance - $difference;
$cashAccount->update(['balance' => $newBalance]);
$cashTransaction->update([
'amount' => $newAmount,
'balance_after' => $newBalance,
'description' => 'Kasbon: '.$data['description'],
]);
$employeeAdvance->update([ $employeeAdvance->update([
'amount' => $data['amount'], 'amount' => $newAmount,
'description' => $data['description'], 'description' => $data['description'],
'due_date' => $data['due_date'], 'due_date' => $data['due_date'],
]); ]);
return $employeeAdvance; return $employeeAdvance;
});
} }
public function delete(EmployeeAdvance $employeeAdvance): bool public function delete(EmployeeAdvance $employeeAdvance): bool
{ {
if ($employeeAdvance->status === EmployeeAdvanceStatus::APPROVED && $employeeAdvance->cash_transaction_id) { return DB::transaction(function () use ($employeeAdvance) {
$cashAccount = $this->getCashAccount(); $cashAccount = CashAccount::firstOrFail();
if ($employeeAdvance->status === EmployeeAdvanceStatus::PAID) {
$newBalance = $cashAccount->balance + $employeeAdvance->amount; $newBalance = $cashAccount->balance + $employeeAdvance->amount;
$cashAccount->update(['balance' => $newBalance]); $cashAccount->update(['balance' => $newBalance]);
$employeeAdvance->cashTransaction()->delete();
} }
return $employeeAdvance->delete(); return $employeeAdvance->delete();
});
} }
public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
{ {
$cashTransaction = $this->debitCash(
amount: $employeeAdvance->amount,
description: 'Kasbon: '.$employeeAdvance->description,
);
$employeeAdvance->update([ $employeeAdvance->update([
'cash_transaction_id' => $cashTransaction->id,
'status' => EmployeeAdvanceStatus::APPROVED, 'status' => EmployeeAdvanceStatus::APPROVED,
'verified_by_id' => auth()->id(), 'verified_by_id' => auth()->id(),
'verified_at' => now(), 'verified_at' => now(),
@ -119,51 +139,29 @@ public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
return $employeeAdvance; return $employeeAdvance;
} }
public function pay(EmployeeAdvance $employeeAdvance, int $amount): EmployeeAdvance public function pay(EmployeeAdvance $employeeAdvance): EmployeeAdvance
{ {
$remaining = $employeeAdvance->amount - $employeeAdvance->paid_amount; $employeeAdvance = DB::transaction(function () use ($employeeAdvance) {
if ($amount > $remaining) {
throw ValidationException::withMessages([
'amount' => 'Jumlah bayar melebihi sisa kasbon.',
]);
}
$employeeAdvance = DB::transaction(function () use ($employeeAdvance, $amount) {
$cashTransaction = $this->creditCash( $cashTransaction = $this->creditCash(
amount: $amount, amount: $employeeAdvance->amount,
description: 'Pembayaran kasbon: '.$employeeAdvance->description, description: 'Pembayaran kasbon: '.$employeeAdvance->description,
); );
EmployeeAdvancePayment::create([
'employee_advance_id' => $employeeAdvance->id,
'paid_by_id' => auth()->id(),
'cash_transaction_id' => $cashTransaction->id,
'amount' => $amount,
'paid_at' => now(),
]);
$newPaidAmount = $employeeAdvance->paid_amount + $amount;
$isFullyPaid = $newPaidAmount >= $employeeAdvance->amount;
$employeeAdvance->update([ $employeeAdvance->update([
'paid_amount' => $newPaidAmount, 'status' => EmployeeAdvanceStatus::PAID,
'status' => $isFullyPaid ? EmployeeAdvanceStatus::PAID : $employeeAdvance->status, 'paid_by_id' => auth()->id(),
'paid_by_id' => $isFullyPaid ? auth()->id() : $employeeAdvance->paid_by_id, 'paid_amount' => $employeeAdvance->amount,
'paid_at' => $isFullyPaid ? now() : $employeeAdvance->paid_at, 'paid_at' => now(),
'repayment_cash_transaction_id' => $cashTransaction->id,
]); ]);
return $employeeAdvance; return $employeeAdvance;
}); });
$notificationBody = $employeeAdvance->status === EmployeeAdvanceStatus::PAID
? 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah dibayar lunas'.' oleh '.auth()->user()->full_name.'.'
: 'Pembayaran kasbon sebesar Rp '.number_format($amount, 0, ',', '.').' oleh '.auth()->user()->full_name.'. Sisa: Rp '.number_format($employeeAdvance->amount - $employeeAdvance->paid_amount, 0, ',', '.').'.';
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: $employeeAdvance->status === EmployeeAdvanceStatus::PAID ? 'Kasbon Dibayar' : 'Pembayaran Kasbon', title: 'Kasbon Dibayar',
body: $notificationBody, body: 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah dibayar lunas'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.employee-advances.index'), url: route('admin.finance.employee-advances.index'),
additionalUser: $employeeAdvance->employee->user ?? null, additionalUser: $employeeAdvance->employee->user ?? null,
); );

View File

@ -17,30 +17,19 @@ class PayrollPeriodService
{ {
use HandlesCashTransactions; use HandlesCashTransactions;
private function canViewAll(): bool
{
return auth()->user()->hasAnyRole(['developer', 'owner', 'direktur', 'admin-toko']);
}
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'])
->when(! $this->canViewAll(), function ($query) { ->withCount('payrolls')
$query->withCount(['payrolls as payrolls_count' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))])
->withSum(['payrolls as payrolls_sum_total_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'total_amount')
->withSum(['payrolls as payrolls_sum_bonus_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'bonus_amount')
->withSum(['payrolls as payrolls_sum_deduction_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'deduction_amount')
->withCount(['payrolls as paid_count' => fn ($q) => $q->paid()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))])
->withCount(['payrolls as cancelled_count' => fn ($q) => $q->cancelled()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))]);
})
->when($this->canViewAll(), function ($query) {
$query->withCount('payrolls')
->withSum('payrolls', 'total_amount') ->withSum('payrolls', 'total_amount')
->withSum('payrolls', 'bonus_amount') ->withSum('payrolls', 'bonus_amount')
->withSum('payrolls', 'deduction_amount') ->withSum('payrolls', 'deduction_amount')
->withCount(['payrolls as paid_count' => fn ($q) => $q->paid()]) ->withCount(['payrolls as paid_count' => function ($q) {
->withCount(['payrolls as cancelled_count' => fn ($q) => $q->cancelled()]); $q->paid();
}) }])
->withCount(['payrolls as cancelled_count' => function ($q) {
$q->cancelled();
}])
->latest('year') ->latest('year')
->latest('month') ->latest('month')
->get(); ->get();
@ -50,22 +39,16 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
{ {
return PayrollPeriod::query() return PayrollPeriod::query()
->select(['id', 'year', 'month', 'status', 'closed_at', 'created_at']) ->select(['id', 'year', 'month', 'status', 'closed_at', 'created_at'])
->when(! $this->canViewAll(), function ($query) { ->withCount('payrolls')
$query->withCount(['payrolls as payrolls_count' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))])
->withSum(['payrolls as payrolls_sum_total_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'total_amount')
->withSum(['payrolls as payrolls_sum_bonus_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'bonus_amount')
->withSum(['payrolls as payrolls_sum_deduction_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'deduction_amount')
->withCount(['payrolls as paid_count' => fn ($q) => $q->paid()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))])
->withCount(['payrolls as cancelled_count' => fn ($q) => $q->cancelled()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))]);
})
->when($this->canViewAll(), function ($query) {
$query->withCount('payrolls')
->withSum('payrolls', 'total_amount') ->withSum('payrolls', 'total_amount')
->withSum('payrolls', 'bonus_amount') ->withSum('payrolls', 'bonus_amount')
->withSum('payrolls', 'deduction_amount') ->withSum('payrolls', 'deduction_amount')
->withCount(['payrolls as paid_count' => fn ($q) => $q->paid()]) ->withCount(['payrolls as paid_count' => function ($q) {
->withCount(['payrolls as cancelled_count' => fn ($q) => $q->cancelled()]); $q->paid();
}) }])
->withCount(['payrolls as cancelled_count' => function ($q) {
$q->cancelled();
}])
->when($search, fn ($q) => $q->where('year', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('year', 'like', "%{$search}%"))
->orderBy($sort, $direction) ->orderBy($sort, $direction)
->paginate($perPage); ->paginate($perPage);
@ -76,7 +59,6 @@ public function getDetail(PayrollPeriod $period): PayrollPeriod
return $period->load([ return $period->load([
'payrolls' => function ($query) { 'payrolls' => function ($query) {
$query->with(['employee.user.userProfile', 'payrollAdjustments']) $query->with(['employee.user.userProfile', 'payrollAdjustments'])
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->orderBy('id'); ->orderBy('id');
}, },
]); ]);

View File

@ -9,13 +9,6 @@
class EmployeeService class EmployeeService
{ {
private const ADMIN_ROLES = ['developer', 'owner', 'direktur', 'admin-toko'];
public function canViewAll(): bool
{
return auth()->user()->hasAnyRole(self::ADMIN_ROLES);
}
public function getAll(array $filters = []): Collection public function getAll(array $filters = []): Collection
{ {
return User::select(['id', 'email', 'username', 'is_active']) return User::select(['id', 'email', 'username', 'is_active'])
@ -25,10 +18,6 @@ public function getAll(array $filters = []): Collection
'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(! $this->canViewAll(), function ($q) {
$userRoles = auth()->user()->roles->pluck('name');
$q->whereHas('roles', fn ($rq) => $rq->whereIn('name', $userRoles));
})
->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)))
->when($filters['gender'] ?? null, fn ($q, $gender) => $q->whereHas('userProfile', fn ($uq) => $uq->where('gender', $gender))) ->when($filters['gender'] ?? null, fn ($q, $gender) => $q->whereHas('userProfile', fn ($uq) => $uq->where('gender', $gender)))
@ -46,10 +35,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
'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(! $this->canViewAll(), function ($q) {
$userRoles = auth()->user()->roles->pluck('name');
$q->whereHas('roles', fn ($rq) => $rq->whereIn('name', $userRoles));
})
->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)))
->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)))

View File

@ -30,14 +30,14 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->with([ ->with([
'createdBy:id', 'createdBy:id',
'createdBy.userProfile:id,user_id,full_name', 'createdBy.userProfile:id,user_id,full_name',
'restockItems' => fn($q) => $q 'restockItems' => fn ($q) => $q
->select(['id', 'restock_id', 'product_variant_id', 'quantity', 'unit_price', 'subtotal']) ->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)'), ->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',
]) ])
->when($search, function ($q) use ($search) { ->when($search, function ($q) use ($search) {
$q->whereHas('restockItems.productVariant.product', fn($sq) => $sq->where('name', 'like', "%{$search}%")) $q->whereHas('restockItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%"))
->orWhere('notes', 'like', "%{$search}%"); ->orWhere('notes', 'like', "%{$search}%");
}) })
->orderBy($sort, $direction) ->orderBy($sort, $direction)
@ -68,7 +68,6 @@ public function getForCreate(): array
'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',
]) ])
->active()
->orderBy('name') ->orderBy('name')
->get() ->get()
->each(function (Product $product) { ->each(function (Product $product) {
@ -79,11 +78,11 @@ public function getForCreate(): array
: null; : null;
$capitalPrice = $variant->productPrices $capitalPrice = $variant->productPrices
->first(fn($price) => $price->type === PriceType::CAPITAL); ->first(fn ($price) => $price->type === PriceType::CAPITAL);
$variant->capital_price = $capitalPrice?->price ?? 0; $variant->capital_price = $capitalPrice?->price ?? 0;
$rejectPrice = $variant->productPrices $rejectPrice = $variant->productPrices
->first(fn($price) => $price->type === PriceType::REJECT); ->first(fn ($price) => $price->type === PriceType::REJECT);
$variant->reject_price = $rejectPrice?->price ?? 0; $variant->reject_price = $rejectPrice?->price ?? 0;
}); });
}), }),
@ -93,7 +92,7 @@ public function getForCreate(): array
public function getForEdit(Restock $restock): array public function getForEdit(Restock $restock): array
{ {
$restock->load([ $restock->load([
'restockItems' => fn($q) => $q 'restockItems' => fn ($q) => $q
->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = restock_items.product_variant_id)'), ->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = restock_items.product_variant_id)'),
'restockItems.productVariant.product', 'restockItems.productVariant.product',
]); ]);
@ -108,7 +107,7 @@ public function getForEdit(Restock $restock): array
'photo_url' => $media 'photo_url' => $media
? $this->s3Service->getTemporaryUrl($media->file_name) ? $this->s3Service->getTemporaryUrl($media->file_name)
: null, : null,
'items' => $restock->restockItems->map(fn(RestockItem $item) => [ 'items' => $restock->restockItems->map(fn (RestockItem $item) => [
'id' => $item->id, 'id' => $item->id,
'product_variant_id' => $item->product_variant_id, 'product_variant_id' => $item->product_variant_id,
'quantity' => $item->quantity, 'quantity' => $item->quantity,
@ -145,7 +144,7 @@ public function create(array $data): Restock
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Toko'], roles: ['Owner', 'Developer', 'Admin Toko'],
title: 'Restock Baru', title: 'Restock Baru',
body: 'Restock ' . ($stockType === ProductStockQuality::GOOD->value ? 'produk' : 'reject') . ' sebesar Rp ' . number_format($subtotal, 0, ',', '.') . ' berhasil dicatat oleh ' . auth()->user()->full_name . '.', body: 'Restock '.($stockType === ProductStockQuality::GOOD->value ? 'produk' : 'reject').' sebesar Rp '.number_format($subtotal, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.restocks.index'), url: route('admin.manage.restocks.index'),
); );
@ -219,7 +218,7 @@ private function buildItemRows(array $items, string $stockType, $now, int &$subt
->get() ->get()
->mapWithKeys(function (ProductVariant $variant) use ($priceType) { ->mapWithKeys(function (ProductVariant $variant) use ($priceType) {
$price = $variant->productPrices $price = $variant->productPrices
->first(fn($p) => $p->type === $priceType); ->first(fn ($p) => $p->type === $priceType);
return [$variant->id => $price?->price ?? 0]; return [$variant->id => $price?->price ?? 0];
}); });

View File

@ -53,18 +53,16 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
'orderItems.productVariant.product:id,name', 'orderItems.productVariant.product:id,name',
]) ])
->when($search, function ($q) use ($search) { ->when($search, function ($q) use ($search) {
$q->whereHas('orderItems.productVariant.product', fn($sq) => $sq->where('name', 'like', "%{$search}%")) $q->whereHas('orderItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%"))
->orWhere('order_number', 'like', "%{$search}%") ->orWhere('order_number', 'like', "%{$search}%")
->orWhere('notes', 'like', "%{$search}%"); ->orWhere('notes', 'like', "%{$search}%");
}) })
->when($filters['status'] ?? null, fn($q, $status) => $q->where('status', $status)) ->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
->when($filters['channel'] ?? null, fn($q, $channel) => $q->where('channel', $channel)) ->when($filters['channel'] ?? null, fn ($q, $channel) => $q->where('channel', $channel))
->when($filters['payment_type'] ?? null, fn($q, $paymentType) => $q->where('payment_type', $paymentType)) ->when($filters['payment_type'] ?? null, fn ($q, $paymentType) => $q->where('payment_type', $paymentType))
->when($filters['customer_id'] ?? null, fn($q, $customerId) => $q->where('customer_id', $customerId)) ->when($filters['customer_id'] ?? null, fn ($q, $customerId) => $q->where('customer_id', $customerId))
->when($filters['marketing_id'] ?? null, fn($q, $marketingId) => $q->where('marketing_id', $marketingId)) ->when($filters['marketing_id'] ?? null, fn ($q, $marketingId) => $q->where('marketing_id', $marketingId))
->when($filters['created_by_id'] ?? null, fn($q, $createdById) => $q->where('created_by_id', $createdById)) ->when($filters['created_by_id'] ?? null, fn ($q, $createdById) => $q->where('created_by_id', $createdById))
->when($filters['date_from'] ?? null, fn($q, $dateFrom) => $q->whereDate('created_at', '>=', $dateFrom))
->when($filters['date_to'] ?? null, fn($q, $dateTo) => $q->whereDate('created_at', '<=', $dateTo))
->orderBy($sort, $direction) ->orderBy($sort, $direction)
->paginate($perPage); ->paginate($perPage);
@ -91,33 +89,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator; return $paginator;
} }
public function getSummary(array $filters = []): array
{
$query = Order::query()
->selectRaw('COUNT(*) as total_orders')
->selectRaw('COALESCE(SUM(subtotal), 0) as total_subtotal')
->selectRaw('COALESCE(SUM(subtotal) - SUM(COALESCE(nego_price, subtotal)), 0) as total_discount')
->selectRaw('COALESCE(SUM(total_amount), 0) as total_amount')
->selectRaw('COALESCE(SUM(cogs), 0) as total_cogs')
->when($filters['status'] ?? null, fn($q, $status) => $q->where('status', $status))
->when($filters['channel'] ?? null, fn($q, $channel) => $q->where('channel', $channel))
->when($filters['payment_type'] ?? null, fn($q, $paymentType) => $q->where('payment_type', $paymentType))
->when($filters['customer_id'] ?? null, fn($q, $customerId) => $q->where('customer_id', $customerId))
->when($filters['marketing_id'] ?? null, fn($q, $marketingId) => $q->where('marketing_id', $marketingId))
->when($filters['created_by_id'] ?? null, fn($q, $createdById) => $q->where('created_by_id', $createdById))
->when($filters['date_from'] ?? null, fn($q, $dateFrom) => $q->whereDate('created_at', '>=', $dateFrom))
->when($filters['date_to'] ?? null, fn($q, $dateTo) => $q->whereDate('created_at', '<=', $dateTo))
->first();
return [
'total_orders' => $query->total_orders,
'total_subtotal' => $query->total_subtotal,
'total_discount' => $query->total_discount,
'total_amount' => $query->total_amount,
'net_total' => $query->total_amount - $query->total_cogs,
];
}
public function getFilterOptions(): array public function getFilterOptions(): array
{ {
return [ return [
@ -134,9 +105,9 @@ public function getFilterOptions(): array
->with('userProfile:id,user_id,full_name') ->with('userProfile:id,user_id,full_name')
->orderBy('id') ->orderBy('id')
->get() ->get()
->filter(fn(User $user) => $user->userProfile?->full_name) ->filter(fn (User $user) => $user->userProfile?->full_name)
->values() ->values()
->map(fn(User $user) => [ ->map(fn (User $user) => [
'id' => $user->id, 'id' => $user->id,
'name' => $user->userProfile->full_name, 'name' => $user->userProfile->full_name,
]), ]),
@ -152,7 +123,6 @@ public function getForCreate(): array
'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',
]) ])
->active()
->orderBy('name') ->orderBy('name')
->get() ->get()
->each(function (Product $product) { ->each(function (Product $product) {
@ -162,7 +132,7 @@ public function getForCreate(): array
? $this->s3Service->getTemporaryUrl($media->file_name) ? $this->s3Service->getTemporaryUrl($media->file_name)
: null; : null;
$prices = $variant->productPrices->mapWithKeys(fn($p) => [$p->type->value => $p->price]); $prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]);
$variant->prices = $prices; $variant->prices = $prices;
}); });
}), }),
@ -176,11 +146,11 @@ public function getForCreate(): array
->with('userProfile:id,user_id,full_name') ->with('userProfile:id,user_id,full_name')
->orderBy('id') ->orderBy('id')
->get() ->get()
->filter(fn(User $user) => $user->userProfile?->full_name) ->filter(fn (User $user) => $user->userProfile?->full_name)
->values(), ->values(),
'channelOptions' => OrderChannel::toSelect(), 'channelOptions' => OrderChannel::toSelect(),
'paymentTypeOptions' => PaymentType::toSelect(), 'paymentTypeOptions' => PaymentType::toSelect(),
'priceTypeOptions' => PriceType::toSelect()->filter(fn($p) => $p['value'] !== PriceType::CAPITAL->value)->values(), 'priceTypeOptions' => PriceType::toSelect()->filter(fn ($p) => $p['value'] !== PriceType::CAPITAL->value)->values(),
]; ];
} }
@ -210,7 +180,7 @@ public function getForEdit(Order $order): array
'photo_url' => $media 'photo_url' => $media
? $this->s3Service->getTemporaryUrl($media->file_name) ? $this->s3Service->getTemporaryUrl($media->file_name)
: null, : null,
'items' => $order->orderItems->map(fn(OrderItem $item) => [ 'items' => $order->orderItems->map(fn (OrderItem $item) => [
'id' => $item->id, 'id' => $item->id,
'product_variant_id' => $item->product_variant_id, 'product_variant_id' => $item->product_variant_id,
'quantity' => $item->quantity, 'quantity' => $item->quantity,
@ -270,7 +240,7 @@ public function create(array $data): Order
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Toko'], roles: ['Owner', 'Developer', 'Admin Toko'],
title: 'Transaksi Baru', title: 'Transaksi Baru',
body: 'Transaksi ' . $order->order_number . ' sebesar Rp ' . number_format($totalAmount, 0, ',', '.') . ' berhasil dicatat oleh ' . auth()->user()->full_name . '.', body: 'Transaksi '.$order->order_number.' sebesar Rp '.number_format($totalAmount, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.transactions.index'), url: route('admin.manage.transactions.index'),
); );
@ -358,13 +328,6 @@ public function delete(Order $order): bool
}); });
} }
public function updateStatus(Order $order, string $status): Order
{
$order->update(['status' => $status]);
return $order;
}
private function buildItemRows(array $items, string $stockType, string $priceType, $now, int &$subtotal, int &$totalCost): array private function buildItemRows(array $items, string $stockType, string $priceType, $now, int &$subtotal, int &$totalCost): array
{ {
$resolvedPriceType = $stockType === ProductStockQuality::REJECT->value $resolvedPriceType = $stockType === ProductStockQuality::REJECT->value
@ -379,14 +342,14 @@ private function buildItemRows(array $items, string $stockType, string $priceTyp
$prices = $variants->mapWithKeys(function (ProductVariant $variant) use ($resolvedPriceType) { $prices = $variants->mapWithKeys(function (ProductVariant $variant) use ($resolvedPriceType) {
$price = $variant->productPrices $price = $variant->productPrices
->first(fn($p) => $p->type === $resolvedPriceType); ->first(fn ($p) => $p->type === $resolvedPriceType);
return [$variant->id => $price?->price ?? 0]; return [$variant->id => $price?->price ?? 0];
}); });
$capitalPrices = $variants->mapWithKeys(function (ProductVariant $variant) { $capitalPrices = $variants->mapWithKeys(function (ProductVariant $variant) {
$price = $variant->productPrices $price = $variant->productPrices
->first(fn($p) => $p->type === PriceType::CAPITAL); ->first(fn ($p) => $p->type === PriceType::CAPITAL);
return [$variant->id => $price?->price ?? 0]; return [$variant->id => $price?->price ?? 0];
}); });
@ -429,6 +392,6 @@ private function generateOrderNumber(): string
$sequence = 1; $sequence = 1;
} }
return $prefix . $date . str_pad($sequence, 4, '0', STR_PAD_LEFT); return $prefix.$date.str_pad($sequence, 4, '0', STR_PAD_LEFT);
} }
} }

View File

@ -19,16 +19,6 @@ public function __construct(
private StockMutationService $stockMutationService, private StockMutationService $stockMutationService,
) {} ) {}
public function getNames(): array
{
return Product::where('status', '!=', 'deleted')
->orderBy('name')
->pluck('name')
->unique()
->values()
->toArray();
}
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'])
@ -63,7 +53,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
'productVariants.productPrices:id,variant_id,type,price', 'productVariants.productPrices:id,variant_id,type,price',
]) ])
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%"))
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status)) ->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
->when($filters['category'] ?? null, fn ($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) { ->when($filters['category'] ?? null, fn ($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) {
$cq->where('categories.id', $categoryId); $cq->where('categories.id', $categoryId);

View File

@ -5,7 +5,6 @@
use App\Enums\ProductStockQuality; use App\Enums\ProductStockQuality;
use App\Models\ProductVariant; use App\Models\ProductVariant;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\ValidationException;
trait HasStockAdjustment trait HasStockAdjustment
{ {
@ -19,17 +18,6 @@ private function adjustStock(Model $model, string $field, int $quantity, int $si
if ($sign > 0) { if ($sign > 0) {
$model->increment($field, $quantity); $model->increment($field, $quantity);
} else { } else {
if ($model->{$field} < $quantity) {
$label = match ($field) {
'stock' => 'stok bagus',
'reject_stock' => 'stok reject',
'retail_stock' => 'stok ecer',
default => $field,
};
throw ValidationException::withMessages([
'stock' => "Stok {$label} tidak mencukupi. Tersedia: {$model->{$field}}, dibutuhkan: {$quantity}.",
]);
}
$model->decrement($field, $quantity); $model->decrement($field, $quantity);
} }
} }

View File

@ -268,6 +268,7 @@ public function run(): void
'employee_advances.create', 'employee_advances.create',
'employee_advances.update', 'employee_advances.update',
'employee_advances.delete', 'employee_advances.delete',
'employee_advances.pay',
'activity_logs.view', 'activity_logs.view',

View File

@ -1,246 +0,0 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

View File

@ -1,3 +1,5 @@
"use client"
import * as React from "react" import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui" import { Label as LabelPrimitive } from "radix-ui"
@ -11,7 +13,7 @@ function Label({
<LabelPrimitive.Root <LabelPrimitive.Root
data-slot="label" data-slot="label"
className={cn( className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", "flex items-center gap-2 text-xs/relaxed leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className className
)} )}
{...props} {...props}

View File

@ -1,5 +1,3 @@
"use client"
import * as React from "react" import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui" import { Separator as SeparatorPrimitive } from "radix-ui"
@ -17,7 +15,7 @@ function Separator({
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className className
)} )}
{...props} {...props}

View File

@ -120,7 +120,7 @@ export function createTransactionColumns(
accessorKey: 'description', accessorKey: 'description',
header: () => <span>Keterangan</span>, header: () => <span>Keterangan</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="block max-w-[200px]"> <span className="block max-w-[200px] truncate">
{row.getValue('description') as string} {row.getValue('description') as string}
</span> </span>
), ),

View File

@ -1,21 +1,7 @@
import type { ColumnDef } from '@tanstack/react-table';
import { CheckCircle, CircleDollarSign, Pencil, Trash2 } from 'lucide-react';
import { RowActions } from '@/components/row-actions'; import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import type { ColumnDef } from '@tanstack/react-table';
import { CheckCircle, CircleDollarSign, History, Pencil, Trash2 } from 'lucide-react';
export type EmployeeAdvancePayment = {
id: number;
amount: number;
formatted_amount: string;
description: string | null;
paid_at: string;
formatted_paid_at: string;
paid_by: {
user_profile: {
full_name: string;
};
} | null;
};
export type EmployeeAdvance = { export type EmployeeAdvance = {
id: number; id: number;
@ -23,8 +9,6 @@ export type EmployeeAdvance = {
formatted_amount: string; formatted_amount: string;
paid_amount: number; paid_amount: number;
formatted_paid_amount: string; formatted_paid_amount: string;
remaining_amount: number;
formatted_remaining_amount: string;
description: string; description: string;
due_date: string; due_date: string;
formatted_due_date: string; formatted_due_date: string;
@ -33,13 +17,11 @@ export type EmployeeAdvance = {
formatted_created_at: string; formatted_created_at: string;
employee: { employee: {
user: { user: {
id: number;
user_profile: { user_profile: {
full_name: string; full_name: string;
}; };
}; };
}; };
payments: EmployeeAdvancePayment[];
}; };
function getStatusBadge(status: string) { function getStatusBadge(status: string) {
@ -80,15 +62,13 @@ 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;
handleShowPayments: (employeeAdvance: EmployeeAdvance) => void;
can: (permission: string) => boolean; can: (permission: string) => boolean;
authUserId: number;
}; };
export function createEmployeeAdvanceColumns( export function createEmployeeAdvanceColumns(
params: CreateColumnsParams, params: CreateColumnsParams,
): ColumnDef<EmployeeAdvance>[] { ): ColumnDef<EmployeeAdvance>[] {
const { handleEdit, handleDeleteClick, handleApprove, handlePay, handleShowPayments, can, authUserId } = const { handleEdit, handleDeleteClick, handleApprove, handlePay, can } =
params; params;
return [ return [
@ -116,27 +96,11 @@ export function createEmployeeAdvanceColumns(
accessorKey: 'formatted_amount', accessorKey: 'formatted_amount',
header: () => <span>Jumlah</span>, header: () => <span>Jumlah</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-medium"> <span className="font-medium text-red-600">
{row.getValue('formatted_amount') as string} - {row.getValue('formatted_amount') as string}
</span> </span>
), ),
}, },
{
accessorKey: 'formatted_remaining_amount',
header: () => <span>Sisa</span>,
cell: ({ row }) => {
const employeeAdvance = row.original;
if (employeeAdvance.status === 'paid') {
return <span className="text-green-600">Lunas</span>;
}
return (
<span className="font-medium text-orange-600">
{row.getValue('formatted_remaining_amount') as string}
</span>
);
},
},
{ {
accessorKey: 'description', accessorKey: 'description',
header: () => <span>Keterangan</span>, header: () => <span>Keterangan</span>,
@ -195,19 +159,10 @@ export function createEmployeeAdvanceColumns(
employeeAdvance.status === 'approved', employeeAdvance.status === 'approved',
onClick: () => handlePay(employeeAdvance), onClick: () => handlePay(employeeAdvance),
}, },
{
label: 'Riwayat',
icon: <History className="h-4 w-4" />,
show: employeeAdvance.payments.length > 0,
onClick: () => handleShowPayments(employeeAdvance),
},
{ {
label: 'Edit', label: 'Edit',
icon: <Pencil className="h-4 w-4" />, icon: <Pencil className="h-4 w-4" />,
show: show: can('employee_advances.update'),
can('employee_advances.update') &&
employeeAdvance.status === 'pending' &&
employeeAdvance.employee?.user?.id === authUserId,
onClick: () => handleEdit(employeeAdvance), onClick: () => handleEdit(employeeAdvance),
}, },
{ {
@ -215,10 +170,7 @@ export function createEmployeeAdvanceColumns(
icon: ( icon: (
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
), ),
show: show: can('employee_advances.delete'),
can('employee_advances.delete') &&
employeeAdvance.status === 'pending' &&
employeeAdvance.employee?.user?.id === authUserId,
onClick: () => onClick: () =>
handleDeleteClick(employeeAdvance), handleDeleteClick(employeeAdvance),
}, },

View File

@ -1,50 +1,29 @@
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { PaginationState } from '@/components/data-table'; import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { DatePicker } from '@/components/date-picker'; import { DatePicker } from '@/components/date-picker';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FilterPopover } from '@/components/filter-popover';
import { FormDialog } from '@/components/form-dialog'; import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error'; import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { RupiahInput } from '@/components/rupiah-input'; import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useCan } from '@/hooks/use-can'; import { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table'; import { useServerTable } from '@/hooks/use-server-table';
import { import {
approve,
destroy, destroy,
index as employeeAdvanceIndex, index as employeeAdvanceIndex,
pay,
store, store,
update, update,
approve,
pay,
} from '@/routes/admin/finance/employee-advances'; } from '@/routes/admin/finance/employee-advances';
import { Head, router, usePage } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { EmployeeAdvance } from './columns';
import { createEmployeeAdvanceColumns } from './columns'; import { createEmployeeAdvanceColumns } from './columns';
import type { EmployeeAdvance } from './columns';
type TypeOption = {
value: string;
label: string;
};
type Props = { type Props = {
employeeAdvances: { employeeAdvances: {
@ -54,27 +33,15 @@ type Props = {
per_page: number; per_page: number;
total: number; total: number;
}; };
filters: {
status?: string;
};
filterOptions: {
statusOptions: TypeOption[];
};
}; };
export default function EmployeeAdvanceIndex({ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
employeeAdvances,
filters,
filterOptions,
}: Props) {
const { can } = useCan(); const { can } = useCan();
const { auth } = usePage().props as { auth: { user: { id: number } } };
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);
const [approving, setApproving] = useState<EmployeeAdvance | null>(null); const [approving, setApproving] = useState<EmployeeAdvance | null>(null);
const [paying, setPaying] = useState<EmployeeAdvance | null>(null); const [paying, setPaying] = useState<EmployeeAdvance | null>(null);
const [viewingPayments, setViewingPayments] = useState<EmployeeAdvance | null>(null);
const [dueDate, setDueDate] = useState<Date | undefined>(undefined); const [dueDate, setDueDate] = useState<Date | undefined>(undefined);
const [editingDueDate, setEditingDueDate] = useState<Date | undefined>( const [editingDueDate, setEditingDueDate] = useState<Date | undefined>(
undefined, undefined,
@ -89,17 +56,12 @@ export default function EmployeeAdvanceIndex({
const { const {
search, search,
filterOpen,
setFilterOpen,
handlePageChange, handlePageChange,
handlePerPageChange, handlePerPageChange,
handleSearchChange, handleSearchChange,
applyFilter,
clearFilters,
} = useServerTable({ } = useServerTable({
route: () => employeeAdvanceIndex.url(), route: () => employeeAdvanceIndex.url(),
pagination, pagination,
filters,
}); });
useEffect(() => { useEffect(() => {
@ -134,46 +96,28 @@ export default function EmployeeAdvanceIndex({
); );
} }
function handlePay() {
if (!paying) {
return;
}
router.post(
pay(paying.id),
{},
{
onSuccess: () => setPaying(null),
},
);
}
const columns = createEmployeeAdvanceColumns({ const columns = createEmployeeAdvanceColumns({
handleEdit: (employeeAdvance) => setEditing(employeeAdvance), handleEdit: (employeeAdvance) => setEditing(employeeAdvance),
handleDeleteClick: (employeeAdvance) => setDeleting(employeeAdvance), handleDeleteClick: (employeeAdvance) => setDeleting(employeeAdvance),
handleApprove: (employeeAdvance) => setApproving(employeeAdvance), handleApprove: (employeeAdvance) => setApproving(employeeAdvance),
handlePay: (employeeAdvance) => setPaying(employeeAdvance), handlePay: (employeeAdvance) => setPaying(employeeAdvance),
handleShowPayments: (employeeAdvance) => setViewingPayments(employeeAdvance),
can, can,
authUserId: auth.user.id,
}); });
const filterToolbar = (
<FilterPopover
open={filterOpen}
onOpenChange={setFilterOpen}
filters={filters}
hasActiveFilters={Boolean(filters.status)}
onClear={clearFilters}
>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">Status</label>
<Select
value={filters.status ?? 'all'}
onValueChange={(value) => applyFilter('status', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
{filterOptions.statusOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</FilterPopover>
);
return ( return (
<> <>
<Head title="Kasbon" /> <Head title="Kasbon" />
@ -270,7 +214,6 @@ export default function EmployeeAdvanceIndex({
onPerPageChange={handlePerPageChange} onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
searchValue={search} searchValue={search}
toolbar={filterToolbar}
/> />
<FormDialog <FormDialog
@ -379,128 +322,20 @@ export default function EmployeeAdvanceIndex({
onConfirm={handleApprove} onConfirm={handleApprove}
/> />
<FormDialog <DeleteConfirmDialog
open={paying !== null} target={paying}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
setPaying(null); setPaying(null);
} }
}} }}
title="Bayar Kasbon" title="Bayar Kasbon"
action={paying ? pay(paying.id) : ''} description={(advance) =>
resetOnSuccess `Apakah Anda yakin ingin membayar kasbon "${advance.description}" sebesar ${advance.amount}? Saldo kas akan dikembalikan.`
onSuccess={() => setPaying(null)} }
> confirmLabel="Bayar"
{({ errors }) => onConfirm={handlePay}
paying && (
<>
<div className="mb-4 rounded-md bg-muted p-3 text-sm">
<p>
Sisa:{' '}
<span className="font-medium">
{paying.formatted_remaining_amount}
</span>
</p>
</div>
<div className="grid gap-2">
<Label>
Jumlah Bayar{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
name="amount"
defaultValue={paying.remaining_amount}
/> />
<InputError message={errors.amount} />
</div>
</>
)
}
</FormDialog>
<Dialog
open={viewingPayments !== null}
onOpenChange={(open) => {
if (!open) {
setViewingPayments(null);
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Riwayat Pembayaran</DialogTitle>
<DialogDescription>
{viewingPayments?.description}
</DialogDescription>
</DialogHeader>
{viewingPayments && (
<div className="space-y-4">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
Total:
</span>
<span className="font-medium">
{viewingPayments.formatted_amount}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
Terbayar:
</span>
<span className="font-medium text-green-600">
{viewingPayments.formatted_paid_amount}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
Sisa:
</span>
<span className="font-medium text-orange-600">
{viewingPayments.formatted_remaining_amount}
</span>
</div>
<Separator />
{viewingPayments.payments.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
Belum ada pembayaran.
</p>
) : (
<div className="space-y-3">
{viewingPayments.payments.map(
(payment) => (
<div
key={payment.id}
className="rounded-md border p-3"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-green-600">
{payment.formatted_amount}
</span>
<span className="text-xs text-muted-foreground">
{payment.formatted_paid_at}
</span>
</div>
{payment.paid_by && (
<p className="mt-1 text-xs text-muted-foreground">
oleh{' '}
{payment.paid_by
.user_profile
.full_name ?? '-'}
</p>
)}
</div>
),
)}
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</div> </div>
</> </>
); );

View File

@ -42,7 +42,7 @@ export function createExpenseColumns(
accessorKey: 'description', accessorKey: 'description',
header: () => <span>Keterangan</span>, header: () => <span>Keterangan</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="block max-w-[200px]"> <span className="block max-w-[200px] truncate">
{row.getValue('description') as string} {row.getValue('description') as string}
</span> </span>
), ),

View File

@ -50,15 +50,14 @@ type CreateColumnsParams = {
handleClose: (period: PayrollPeriod) => void; handleClose: (period: PayrollPeriod) => void;
handleReopen: (period: PayrollPeriod) => void; handleReopen: (period: PayrollPeriod) => void;
can: (permission: string) => boolean; can: (permission: string) => boolean;
canViewAll: boolean;
}; };
export function createPayrollPeriodColumns( export function createPayrollPeriodColumns(
params: CreateColumnsParams, params: CreateColumnsParams,
): ColumnDef<PayrollPeriod>[] { ): ColumnDef<PayrollPeriod>[] {
const { showUrl, handleClose, handleReopen, can, canViewAll } = params; const { showUrl, handleClose, handleReopen, can } = params;
const columns: ColumnDef<PayrollPeriod>[] = [ return [
{ {
accessorKey: 'year', accessorKey: 'year',
header: () => <span>Periode</span>, header: () => <span>Periode</span>,
@ -68,10 +67,7 @@ export function createPayrollPeriodColumns(
</span> </span>
), ),
}, },
]; {
if (canViewAll) {
columns.push({
accessorKey: 'payrolls_count', accessorKey: 'payrolls_count',
header: () => <span>Jumlah Karyawan</span>, header: () => <span>Jumlah Karyawan</span>,
cell: ({ row }) => ( cell: ({ row }) => (
@ -79,10 +75,7 @@ export function createPayrollPeriodColumns(
{row.getValue('payrolls_count') as number} {row.getValue('payrolls_count') as number}
</span> </span>
), ),
}); },
}
columns.push(
{ {
accessorKey: 'payrolls_sum_total_amount', accessorKey: 'payrolls_sum_total_amount',
header: () => <span>Total Gaji</span>, header: () => <span>Total Gaji</span>,
@ -138,10 +131,7 @@ export function createPayrollPeriodColumns(
); );
}, },
}, },
); {
if (canViewAll) {
columns.push({
id: 'payment_status', id: 'payment_status',
header: () => <span>Status Bayar</span>, header: () => <span>Status Bayar</span>,
cell: ({ row }) => { cell: ({ row }) => {
@ -171,10 +161,7 @@ export function createPayrollPeriodColumns(
</div> </div>
); );
}, },
}); },
}
columns.push(
{ {
accessorKey: 'status', accessorKey: 'status',
header: () => <span>Status</span>, header: () => <span>Status</span>,
@ -225,7 +212,5 @@ export function createPayrollPeriodColumns(
); );
}, },
}, },
); ];
return columns;
} }

View File

@ -27,8 +27,7 @@ type Props = {
}; };
export default function PayrollPeriodIndex({ payrollPeriods }: Props) { export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
const { can, hasAnyRole } = useCan(); const { can } = useCan();
const canViewAll = hasAnyRole(['developer', 'owner', 'direktur', 'admin-toko']);
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);
@ -82,7 +81,6 @@ export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
handleClose: (period) => setClosing(period), handleClose: (period) => setClosing(period),
handleReopen: (period) => setReopening(period), handleReopen: (period) => setReopening(period),
can, can,
canViewAll,
}); });
return ( return (

View File

@ -81,9 +81,7 @@ export function createPayrollColumns(
can, can,
} = params; } = params;
const hasAnyPayrollAction = can('payroll.adjust') || can('payroll.pay') || can('payroll.cancel'); return [
const columns: ColumnDef<Payroll>[] = [
{ {
id: 'employee_name', id: 'employee_name',
header: () => <span>Nama Karyawan</span>, header: () => <span>Nama Karyawan</span>,
@ -184,10 +182,10 @@ export function createPayrollColumns(
{adj.type === 'bonus' ? '+' : '-'}{' '} {adj.type === 'bonus' ? '+' : '-'}{' '}
{formatCurrency(adj.amount)} {formatCurrency(adj.amount)}
</span> </span>
<span className="max-w-[100px] text-muted-foreground"> <span className="max-w-[100px] truncate text-muted-foreground">
{adj.description} {adj.description}
</span> </span>
{can('payroll.adjust') && payroll.status === 'unpaid' && ( {payroll.status === 'unpaid' && (
<button <button
onClick={() => onClick={() =>
handleDeleteAdjustment( handleDeleteAdjustment(
@ -206,10 +204,7 @@ export function createPayrollColumns(
); );
}, },
}, },
]; {
if (hasAnyPayrollAction) {
columns.push({
id: 'actions', id: 'actions',
header: () => <span className="block text-center">Aksi</span>, header: () => <span className="block text-center">Aksi</span>,
meta: { meta: {
@ -254,8 +249,6 @@ export function createPayrollColumns(
/> />
); );
}, },
}); },
} ];
return columns;
} }

View File

@ -40,8 +40,7 @@ type Props = {
}; };
export default function PayrollPeriodShow({ payrollPeriod }: Props) { export default function PayrollPeriodShow({ payrollPeriod }: Props) {
const { can, hasAnyRole } = useCan(); const { can } = useCan();
const canViewAll = hasAnyRole(['developer', 'owner', 'direktur', 'admin-toko']);
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>(
@ -104,23 +103,19 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
can, can,
}); });
const activePayrolls = payrollPeriod.payrolls.filter( const totalBaseSalary = payrollPeriod.payrolls.reduce(
(p) => p.status !== 'cancelled',
);
const totalBaseSalary = activePayrolls.reduce(
(sum, p) => sum + p.base_salary, (sum, p) => sum + p.base_salary,
0, 0,
); );
const totalBonus = activePayrolls.reduce( const totalBonus = payrollPeriod.payrolls.reduce(
(sum, p) => sum + p.bonus_amount, (sum, p) => sum + p.bonus_amount,
0, 0,
); );
const totalDeduction = activePayrolls.reduce( const totalDeduction = payrollPeriod.payrolls.reduce(
(sum, p) => sum + p.deduction_amount, (sum, p) => sum + p.deduction_amount,
0, 0,
); );
const totalAmount = activePayrolls.reduce( const totalAmount = payrollPeriod.payrolls.reduce(
(sum, p) => sum + p.total_amount, (sum, p) => sum + p.total_amount,
0, 0,
); );
@ -138,7 +133,6 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
Gaji {MONTH_NAMES[payrollPeriod.month]}{' '} Gaji {MONTH_NAMES[payrollPeriod.month]}{' '}
{payrollPeriod.year} {payrollPeriod.year}
</h2> </h2>
{canViewAll && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{payrollPeriod.payrolls.length} karyawan &middot; {payrollPeriod.payrolls.length} karyawan &middot;
Status:{' '} Status:{' '}
@ -146,7 +140,6 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
? 'Terbuka' ? 'Terbuka'
: 'Ditutup'} : 'Ditutup'}
</p> </p>
)}
</div> </div>
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link href={payrollPeriodsIndex.url()}> <Link href={payrollPeriodsIndex.url()}>

View File

@ -39,7 +39,6 @@ type CreateColumnsParams = {
handleResetPassword: (employee: Employee) => void; handleResetPassword: (employee: Employee) => void;
toggleActiveUrl: (id: number) => string; toggleActiveUrl: (id: number) => string;
can: (permission: string) => boolean; can: (permission: string) => boolean;
canViewAll: boolean;
}; };
export function createEmployeeColumns( export function createEmployeeColumns(
@ -51,7 +50,6 @@ export function createEmployeeColumns(
handleResetPassword, handleResetPassword,
toggleActiveUrl, toggleActiveUrl,
can, can,
canViewAll,
} = params; } = params;
return [ return [
@ -95,15 +93,12 @@ export function createEmployeeColumns(
); );
}, },
}, },
...(canViewAll
? [
{ {
id: 'role', id: 'role',
header: () => <span>Role</span>, header: () => <span>Role</span>,
cell: ({ row }) => { cell: ({ row }) => {
const employee = row.original; const employee = row.original;
const roleName = const roleName = employee.roles?.[0]?.name ?? '-';
employee.roles?.[0]?.name ?? '-';
return ( return (
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium"> <span className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium">
@ -112,8 +107,6 @@ export function createEmployeeColumns(
); );
}, },
}, },
]
: []),
{ {
accessorKey: 'employee.employment_status', accessorKey: 'employee.employment_status',
id: 'employment_status', id: 'employment_status',

View File

@ -36,15 +36,12 @@ type Role = {
type Props = { type Props = {
roles: Role[]; roles: Role[];
canViewAll: boolean;
}; };
export default function EmployeeCreate({ roles, canViewAll }: Props) { export default function EmployeeCreate({ roles }: Props) {
const [joinDate, setJoinDate] = useState<Date | undefined>(undefined); const [joinDate, setJoinDate] = useState<Date | undefined>(undefined);
const [resignDate, setResignDate] = useState<Date | undefined>(undefined); const [resignDate, setResignDate] = useState<Date | undefined>(undefined);
const [selectedRole, setSelectedRole] = useState<Role | null>( const [selectedRole, setSelectedRole] = useState<Role | null>(null);
!canViewAll && roles.length === 1 ? roles[0] : null,
);
return ( return (
<> <>
@ -120,7 +117,6 @@ export default function EmployeeCreate({ roles, canViewAll }: Props) {
message={errors.username} message={errors.username}
/> />
</div> </div>
{canViewAll ? (
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Role{' '} Role{' '}
@ -130,9 +126,7 @@ export default function EmployeeCreate({ roles, canViewAll }: Props) {
</Label> </Label>
<Combobox <Combobox
items={roles} items={roles}
itemToStringLabel={(r) => itemToStringLabel={(r) => r.name}
r.name
}
value={selectedRole} value={selectedRole}
onValueChange={(value) => onValueChange={(value) =>
setSelectedRole(value) setSelectedRole(value)
@ -150,12 +144,8 @@ export default function EmployeeCreate({ roles, canViewAll }: Props) {
<ComboboxList> <ComboboxList>
{(role) => ( {(role) => (
<ComboboxItem <ComboboxItem
key={ key={role.id}
role.id value={role}
}
value={
role
}
> >
{role.name} {role.name}
</ComboboxItem> </ComboboxItem>
@ -167,14 +157,6 @@ export default function EmployeeCreate({ roles, canViewAll }: Props) {
message={errors.role} message={errors.role}
/> />
</div> </div>
) : (
<div className="grid gap-2">
<Label>Role</Label>
<div className="flex h-10 w-full items-center rounded-md border border-input bg-muted px-3 py-2 text-sm">
{selectedRole?.name ?? '-'}
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>

View File

@ -56,10 +56,9 @@ type EmployeeData = {
type Props = { type Props = {
employee: EmployeeData; employee: EmployeeData;
roles: Role[]; roles: Role[];
canViewAll: boolean;
}; };
export default function EmployeeEdit({ employee, roles, canViewAll }: Props) { export default function EmployeeEdit({ employee, roles }: Props) {
const currentRole = useMemo( const currentRole = useMemo(
() => () =>
roles.find((r) => r.name === employee.roles?.[0]?.name) ?? null, roles.find((r) => r.name === employee.roles?.[0]?.name) ?? null,

View File

@ -40,10 +40,9 @@ type Props = {
is_active?: string; is_active?: string;
gender?: string; gender?: string;
}; };
canViewAll: boolean;
}; };
export default function EmployeeIndex({ employees, filters, canViewAll }: Props) { export default function EmployeeIndex({ employees, filters }: Props) {
const { can } = useCan(); const { can } = useCan();
const [deleting, setDeleting] = useState<Employee | null>(null); const [deleting, setDeleting] = useState<Employee | null>(null);
const [resetPasswordTarget, setResetPasswordTarget] = const [resetPasswordTarget, setResetPasswordTarget] =
@ -103,7 +102,6 @@ export default function EmployeeIndex({ employees, filters, canViewAll }: Props)
handleResetPassword: (employee) => setResetPasswordTarget(employee), handleResetPassword: (employee) => setResetPasswordTarget(employee),
toggleActiveUrl: (id) => toggleActive.url(id), toggleActiveUrl: (id) => toggleActive.url(id),
can, can,
canViewAll,
}); });
const filterToolbar = ( const filterToolbar = (

View File

@ -148,9 +148,7 @@ export function createLeaveRequestColumns(
{ {
label: 'Edit', label: 'Edit',
icon: <Pencil className="h-4 w-4" />, icon: <Pencil className="h-4 w-4" />,
show: show: can('leave_requests.update'),
can('leave_requests.update') &&
leaveRequest.status === 'pending',
onClick: () => handleEdit(leaveRequest), onClick: () => handleEdit(leaveRequest),
}, },
{ {
@ -158,9 +156,7 @@ export function createLeaveRequestColumns(
icon: ( icon: (
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
), ),
show: show: can('leave_requests.delete'),
can('leave_requests.delete') &&
leaveRequest.status === 'pending',
onClick: () => handleDeleteClick(leaveRequest), onClick: () => handleDeleteClick(leaveRequest),
}, },
]} ]}

View File

@ -7,7 +7,6 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import { Fragment } from 'react';
import { formatNumber } from '@/lib/format'; import { formatNumber } from '@/lib/format';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import type { Restock } from './columns'; import type { Restock } from './columns';
@ -15,20 +14,8 @@ import type { Restock } from './columns';
export function RestockItemSubRow({ restock }: { restock: Restock }) { export function RestockItemSubRow({ restock }: { restock: Restock }) {
const items = restock.restock_items ?? []; const items = restock.restock_items ?? [];
const groupedByProduct = items.reduce(
(acc, item) => {
const name = item.product_variant?.product?.name ?? 'Tanpa Produk';
if (!acc[name]) acc[name] = [];
acc[name].push(item);
return acc;
},
{} as Record<string, typeof items>,
);
let counter = 0;
return ( return (
<div className="overflow-x-auto"> <div className="space-y-4 overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@ -36,6 +23,7 @@ export function RestockItemSubRow({ restock }: { restock: Restock }) {
No No
</TableHead> </TableHead>
<TableHead className="w-[60px]">Foto</TableHead> <TableHead className="w-[60px]">Foto</TableHead>
<TableHead>Produk</TableHead>
<TableHead>Varian</TableHead> <TableHead>Varian</TableHead>
<TableHead className="text-right"> <TableHead className="text-right">
Harga Modal Harga Modal
@ -48,61 +36,25 @@ export function RestockItemSubRow({ restock }: { restock: Restock }) {
{items.length === 0 ? ( {items.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={6} colSpan={7}
className="text-center text-muted-foreground" className="text-center text-muted-foreground"
> >
Tidak ada item. Tidak ada item.
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
Object.entries(groupedByProduct).map( items.map((item, index) => (
([productName, groupItems]) => {
const totalQty = groupItems.reduce(
(sum, item) => sum + (item.quantity ?? 0),
0,
);
const totalSubtotal = groupItems.reduce(
(sum, item) => sum + (item.subtotal ?? 0),
0,
);
return (
<Fragment key={productName}>
<TableRow>
<TableCell
colSpan={4}
className="font-medium text-muted-foreground bg-muted/20"
>
{productName}
</TableCell>
<TableCell className="text-center font-medium text-muted-foreground bg-muted/20">
{formatNumber(totalQty)}
</TableCell>
<TableCell className="text-right font-medium text-muted-foreground bg-muted/20">
{formatCurrency(totalSubtotal)}
</TableCell>
</TableRow>
{groupItems.map((item) => {
counter++;
return (
<TableRow key={item.id}> <TableRow key={item.id}>
<TableCell className="text-center"> <TableCell className="text-center">
{counter} {index + 1}
</TableCell> </TableCell>
<TableCell> <TableCell>
{item.product_variant {item.product_variant?.photo_url ? (
?.photo_url ? (
<ImagePreviewButton <ImagePreviewButton
srcs={[ srcs={[
item item.product_variant.photo_url,
.product_variant
.photo_url,
]} ]}
title={ title={item.product_variant.name}
item
.product_variant
.name
}
/> />
) : ( ) : (
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground"> <div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
@ -111,31 +63,22 @@ export function RestockItemSubRow({ restock }: { restock: Restock }) {
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
{item.product_variant {item.product_variant?.product?.name ?? '-'}
?.name ?? '-'} </TableCell>
<TableCell>
{item.product_variant?.name ?? '-'}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{formatCurrency( {formatCurrency(item.unit_price)}
item.unit_price,
)}
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
{formatNumber( {formatNumber(item.quantity)}
item.quantity,
)}
</TableCell> </TableCell>
<TableCell className="text-right font-medium"> <TableCell className="text-right font-medium">
{formatCurrency( {formatCurrency(item.subtotal)}
item.subtotal,
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
); ))
})}
</Fragment>
);
},
)
)} )}
</TableBody> </TableBody>
</Table> </Table>

View File

@ -1,5 +1,8 @@
'use no memo'; 'use no memo';
import { Form, Head, Link, usePage } from '@inertiajs/react';
import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog'; import { ConfirmDialog } from '@/components/confirm-dialog';
import { FileUpload } from '@/components/file-upload'; import { FileUpload } from '@/components/file-upload';
import { ImagePreviewModal } from '@/components/image-preview-modal'; import { ImagePreviewModal } from '@/components/image-preview-modal';
@ -39,12 +42,8 @@ import { formatNumber } from '@/lib/format';
import { loadTransactionDraft } from '@/lib/transaction-draft'; import { loadTransactionDraft } from '@/lib/transaction-draft';
import { getTemporaryUrl } from '@/lib/upload'; import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { store, index as transactionIndex } from '@/routes/admin/manage/transactions'; import { index as transactionIndex, store } from '@/routes/admin/manage/transactions';
import { Form, Head, Link, usePage } from '@inertiajs/react';
import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ProductForTransaction, TransactionCreateData } from './columns'; import type { ProductForTransaction, TransactionCreateData } from './columns';
import { FieldDescription } from '@/components/ui/field';
type CartLine = { type CartLine = {
key: string; key: string;
@ -798,9 +797,6 @@ export default function TransactionCreate({ data }: Props) {
} }
placeholder="0" placeholder="0"
/> />
<FieldDescription>
Jika diisi, harga nego menjadi total akhir.
</FieldDescription>
<InputError <InputError
message={errors.nego_price} message={errors.nego_price}
/> />

View File

@ -1,5 +1,8 @@
'use no memo'; 'use no memo';
import { Form, Head, Link } from '@inertiajs/react';
import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { 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';
@ -38,11 +41,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 transactionIndex, update } from '@/routes/admin/manage/transactions'; import { index as transactionIndex, update } from '@/routes/admin/manage/transactions';
import { FieldDescription } from '@base-ui/react'; import type { TransactionCreateData, TransactionForEdit, OptionItem } from './columns';
import { Form, Head, Link } from '@inertiajs/react';
import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { TransactionCreateData, TransactionForEdit } from './columns';
type CartLine = { type CartLine = {
key: string; key: string;
@ -734,9 +733,6 @@ export default function TransactionEdit({ transaction, data }: Props) {
} }
placeholder="0" placeholder="0"
/> />
<FieldDescription>
Jika diisi, harga nego menjadi total akhir.
</FieldDescription>
<InputError <InputError
message={errors.nego_price} message={errors.nego_price}
/> />

View File

@ -1,9 +1,7 @@
import { Head, Link, router } from '@inertiajs/react'; import { Head, Link, router } from '@inertiajs/react';
import { format } from 'date-fns';
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';
import { DatePicker } from '@/components/date-picker';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FilterPopover } from '@/components/filter-popover'; import { FilterPopover } from '@/components/filter-popover';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand'; import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
@ -31,12 +29,10 @@ import {
create as transactionCreate, create as transactionCreate,
index as transactionIndex, index as transactionIndex,
edit as transactionEdit, edit as transactionEdit,
updateStatus as transactionUpdateStatus,
} from '@/routes/admin/manage/transactions'; } from '@/routes/admin/manage/transactions';
import type { Transaction } from './columns'; import type { Transaction } from './columns';
import { TransactionCardRow } from './transaction-card'; import { TransactionCardRow } from './transaction-card';
import { TransactionItemSubRow } from './transaction-sub-row'; import { TransactionItemSubRow } from './transaction-sub-row';
import { TransactionSummaryCard } from './transaction-summary-card';
type FilterOption = { type FilterOption = {
id: number; id: number;
@ -56,13 +52,6 @@ type Props = {
per_page: number; per_page: number;
total: number; total: number;
}; };
summary: {
total_orders: number;
total_subtotal: number;
total_discount: number;
total_amount: number;
net_total: number;
};
filters: { filters: {
status?: string; status?: string;
channel?: string; channel?: string;
@ -70,8 +59,6 @@ type Props = {
customer_id?: string; customer_id?: string;
marketing_id?: string; marketing_id?: string;
created_by_id?: string; created_by_id?: string;
date_from?: string;
date_to?: string;
}; };
filterOptions: { filterOptions: {
statusOptions: StatusOption[]; statusOptions: StatusOption[];
@ -84,7 +71,6 @@ type Props = {
export default function TransactionIndex({ export default function TransactionIndex({
transactions, transactions,
summary,
filters, filters,
filterOptions, filterOptions,
}: Props) { }: Props) {
@ -148,10 +134,6 @@ export default function TransactionIndex({
}); });
} }
function handleUpdateStatus(transaction: Transaction, status: string) {
router.patch(transactionUpdateStatus.url(transaction.id), { status });
}
const filterToolbar = ( const filterToolbar = (
<FilterPopover <FilterPopover
open={filterOpen} open={filterOpen}
@ -163,9 +145,7 @@ export default function TransactionIndex({
Boolean(filters.payment_type) || Boolean(filters.payment_type) ||
Boolean(filters.customer_id) || Boolean(filters.customer_id) ||
Boolean(filters.marketing_id) || Boolean(filters.marketing_id) ||
Boolean(filters.created_by_id) || Boolean(filters.created_by_id)
Boolean(filters.date_from) ||
Boolean(filters.date_to)
} }
onClear={clearFilters} onClear={clearFilters}
> >
@ -331,22 +311,6 @@ export default function TransactionIndex({
</ComboboxContent> </ComboboxContent>
</Combobox> </Combobox>
</div> </div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">Dari Tanggal</label>
<DatePicker
value={filters.date_from ?? null}
onChange={(date) => applyFilter('date_from', date ? format(date, 'yyyy-MM-dd') : '')}
placeholder="Pilih tanggal mulai"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">Sampai Tanggal</label>
<DatePicker
value={filters.date_to ?? null}
onChange={(date) => applyFilter('date_to', date ? format(date, 'yyyy-MM-dd') : '')}
placeholder="Pilih tanggal akhir"
/>
</div>
</FilterPopover> </FilterPopover>
); );
@ -369,8 +333,6 @@ export default function TransactionIndex({
} }
/> />
<TransactionSummaryCard summary={summary} />
<CardTable <CardTable
data={transactions.data} data={transactions.data}
getItemKey={(t) => t.id} getItemKey={(t) => t.id}
@ -403,7 +365,6 @@ export default function TransactionIndex({
router.visit(transactionEdit.url(t.id)); router.visit(transactionEdit.url(t.id));
}} }}
onDelete={(t) => setDeleting(t)} onDelete={(t) => setDeleting(t)}
onUpdateStatus={(t, status) => handleUpdateStatus(t, status)}
/> />
)} )}
renderSubContent={(transaction) => ( renderSubContent={(transaction) => (

View File

@ -1,3 +1,4 @@
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
import { RowActions } from '@/components/row-actions'; import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -5,7 +6,6 @@ import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/hooks/use-can'; import { useCan } from '@/hooks/use-can';
import { formatDateTime, formatNumber } from '@/lib/format'; import { formatDateTime, formatNumber } from '@/lib/format';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { CheckCircle, ChevronDown, Pencil, Send, Trash2, XCircle } from 'lucide-react';
import type { Transaction } from './columns'; import type { Transaction } from './columns';
const STATUS_BADGE_CLASSES: Record<string, string> = { const STATUS_BADGE_CLASSES: Record<string, string> = {
@ -23,7 +23,6 @@ export type TransactionCardRowParams = {
onToggleExpand: () => void; onToggleExpand: () => void;
onEdit: (transaction: Transaction) => void; onEdit: (transaction: Transaction) => void;
onDelete: (transaction: Transaction) => void; onDelete: (transaction: Transaction) => void;
onUpdateStatus: (transaction: Transaction, status: string) => void;
}; };
export function TransactionCardRow({ export function TransactionCardRow({
@ -33,7 +32,6 @@ export function TransactionCardRow({
onToggleExpand, onToggleExpand,
onEdit, onEdit,
onDelete, onDelete,
onUpdateStatus,
}: TransactionCardRowParams) { }: TransactionCardRowParams) {
const { can } = useCan(); const { can } = useCan();
const items = transaction.order_items ?? []; const items = transaction.order_items ?? [];
@ -72,7 +70,7 @@ export function TransactionCardRow({
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{index}. {index}.
</span> </span>
<h3 className="font-medium"> <h3 className="truncate font-medium">
{transaction.order_number} {transaction.order_number}
</h3> </h3>
<Badge <Badge
@ -115,7 +113,7 @@ export function TransactionCardRow({
</span> </span>
)} )}
{transaction.notes && ( {transaction.notes && (
<span className="max-w-[200px]"> <span className="max-w-[200px] truncate">
{transaction.notes} {transaction.notes}
</span> </span>
)} )}
@ -154,14 +152,6 @@ export function TransactionCardRow({
{formatCurrency(transaction.discount)} {formatCurrency(transaction.discount)}
</span> </span>
)} )}
{transaction.nego_price != null && transaction.nego_price > 0 && (
<span>
<span className="text-muted-foreground">
Nego:{' '}
</span>
{formatCurrency(transaction.nego_price)}
</span>
)}
<span className="font-semibold"> <span className="font-semibold">
<span className="font-normal text-muted-foreground"> <span className="font-normal text-muted-foreground">
Total:{' '} Total:{' '}
@ -185,38 +175,6 @@ export function TransactionCardRow({
<RowActions <RowActions
actions={[ actions={[
...(transaction.status === 'pending' && can('orders.update')
? [
{
label: 'Kirim',
icon: <Send className="h-4 w-4" />,
onClick: () => onUpdateStatus(transaction, 'processing'),
},
]
: []),
...((transaction.status === 'pending' || transaction.status === 'processing') && can('orders.update')
? [
{
label: 'Selesai',
icon: <CheckCircle className="h-4 w-4" />,
onClick: () => onUpdateStatus(transaction, 'completed'),
},
{
label: 'Dibatalkan',
icon: <XCircle className="h-4 w-4 text-destructive" />,
onClick: () => onUpdateStatus(transaction, 'cancelled'),
},
]
: []),
...(transaction.status === 'completed' && can('orders.update')
? [
{
label: 'Refund',
icon: <XCircle className="h-4 w-4 text-destructive" />,
onClick: () => onUpdateStatus(transaction, 'refunded'),
},
]
: []),
{ {
label: 'Edit', label: 'Edit',
icon: <Pencil className="h-4 w-4" />, icon: <Pencil className="h-4 w-4" />,

View File

@ -1,90 +0,0 @@
import { Card, CardContent } from '@/components/ui/card';
import { formatCurrency } from '@/lib/utils';
import {
Banknote,
CircleDollarSign,
FileText,
Percent,
TrendingUp,
} from 'lucide-react';
type Summary = {
total_orders: number;
total_subtotal: number;
total_discount: number;
total_amount: number;
net_total: number;
};
type TransactionSummaryCardProps = {
summary: Summary;
};
const summaryItems = [
{
key: 'total_orders',
label: 'Total Pesanan',
icon: FileText,
color: 'bg-blue-100',
iconColor: 'text-blue-600',
format: (value: number) => value.toLocaleString('id-ID'),
},
{
key: 'total_subtotal',
label: 'Subtotal',
icon: Banknote,
color: 'bg-emerald-100',
iconColor: 'text-emerald-600',
format: formatCurrency,
},
{
key: 'total_discount',
label: 'Diskon',
icon: Percent,
color: 'bg-amber-100',
iconColor: 'text-amber-600',
format: formatCurrency,
},
{
key: 'total_amount',
label: 'Total Uang',
icon: CircleDollarSign,
color: 'bg-purple-100',
iconColor: 'text-purple-600',
format: formatCurrency,
},
{
key: 'net_total',
label: 'Total Bersih',
icon: TrendingUp,
color: 'bg-sky-100',
iconColor: 'text-sky-600',
format: formatCurrency,
},
] as const;
export function TransactionSummaryCard({ summary }: TransactionSummaryCardProps) {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{summaryItems.map((item) => (
<Card key={item.key}>
<CardContent className="flex items-center gap-3 py-3">
<div
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${item.color}`}
>
<item.icon className={`h-5 w-5 ${item.iconColor}`} />
</div>
<div>
<p className="text-xs text-muted-foreground">
{item.label}
</p>
<p className="text-lg font-bold">
{item.format(summary[item.key] as number)}
</p>
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@ -41,7 +41,7 @@ export function createCustomerColumns(
accessorKey: 'address', accessorKey: 'address',
header: () => <span>Alamat</span>, header: () => <span>Alamat</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="block max-w-[200px]"> <span className="block max-w-[200px] truncate">
{(row.getValue('address') as string) ?? '-'} {(row.getValue('address') as string) ?? '-'}
</span> </span>
), ),

View File

@ -1,16 +1,16 @@
import { router } from '@inertiajs/react';
import type { ColumnDef } from '@tanstack/react-table';
import { ChevronRight, Pencil, Trash2 } from 'lucide-react';
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 { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { formatNumber } from '@/lib/format'; import { formatNumber } from '@/lib/format';
import { router } from '@inertiajs/react'; import { formatCurrency } from '@/lib/utils';
import type { ColumnDef } from '@tanstack/react-table';
import { ChevronRight, Pencil, Trash2 } from 'lucide-react';
export type ProductVariant = { export type ProductVariant = {
id: number; id: number;
name: string; name: string;
stock: number; stock: number;
formatted_stock: string
reject_stock: number; reject_stock: number;
retail_stock: number; retail_stock: number;
photo_urls: string[]; photo_urls: string[];

View File

@ -1,4 +1,4 @@
import { Link, router } from '@inertiajs/react'; import { router } from '@inertiajs/react';
import { CardTable } from '@/components/card-table'; import { CardTable } from '@/components/card-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FilterPopover } from '@/components/filter-popover'; import { FilterPopover } from '@/components/filter-popover';
@ -34,7 +34,7 @@ import {
edit as variantEdit, edit as variantEdit,
} from '@/routes/admin/master/products/variants'; } from '@/routes/admin/master/products/variants';
import { Head } from '@inertiajs/react'; import { Head } from '@inertiajs/react';
import { Plus } from 'lucide-react'; import { Link, Plus } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { Product, ProductVariant } from './columns'; import type { Product, ProductVariant } from './columns';
import { ProductCardRow } from './product-card'; import { ProductCardRow } from './product-card';
@ -52,7 +52,6 @@ type Props = {
id: number; id: number;
name: string; name: string;
}[]; }[];
productNames: string[];
filters: { filters: {
status?: string; status?: string;
name?: string; name?: string;
@ -61,7 +60,7 @@ type Props = {
}; };
}; };
export default function ProductIndex({ products, categories, productNames, filters }: Props) { export default function ProductIndex({ products, categories, filters }: Props) {
const { can } = useCan(); const { can } = useCan();
const [deleting, setDeleting] = useState<Product | null>(null); const [deleting, setDeleting] = useState<Product | null>(null);
const [deletingVariant, setDeletingVariant] = useState<{ const [deletingVariant, setDeletingVariant] = useState<{
@ -93,10 +92,11 @@ export default function ProductIndex({ products, categories, productNames, filte
filterWithParams: false, filterWithParams: false,
}); });
const sortedProductNames = useMemo( const productNames = useMemo(() => {
() => [...productNames].sort(), const names = products.data.map((p) => p.name);
[productNames],
); return [...new Set(names)].sort();
}, [products.data]);
const selectedCategory = useMemo( const selectedCategory = useMemo(
() => categories.find((c) => String(c.id) === filters.category) ?? null, () => categories.find((c) => String(c.id) === filters.category) ?? null,
@ -147,7 +147,7 @@ export default function ProductIndex({ products, categories, productNames, filte
Nama Produk Nama Produk
</label> </label>
<Combobox <Combobox
items={sortedProductNames} items={productNames}
value={filters.name ?? ''} value={filters.name ?? ''}
onValueChange={(value) => onValueChange={(value) =>
applyFilter('name', (value as string) ?? '') applyFilter('name', (value as string) ?? '')

View File

@ -67,7 +67,7 @@ export function TransferStockDialog({
Stok Bagus Tersedia Stok Bagus Tersedia
</Label> </Label>
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{variant.formatted_stock} {variant.stock}
</p> </p>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">

View File

@ -41,7 +41,7 @@ export function createSupplierColumns(
accessorKey: 'address', accessorKey: 'address',
header: () => <span>Alamat</span>, header: () => <span>Alamat</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="block max-w-[200px]"> <span className="block max-w-[200px] truncate">
{(row.getValue('address') as string) ?? '-'} {(row.getValue('address') as string) ?? '-'}
</span> </span>
), ),

View File

@ -65,7 +65,6 @@
Route::resource('cuttings', CuttingController::class)->except(['show'])->middleware('permission:cuttings.view|cuttings.create|cuttings.update|cuttings.delete'); Route::resource('cuttings', CuttingController::class)->except(['show'])->middleware('permission:cuttings.view|cuttings.create|cuttings.update|cuttings.delete');
Route::resource('transactions', TransactionController::class)->except(['show'])->middleware('permission:orders.view|orders.create|orders.update|orders.delete'); Route::resource('transactions', TransactionController::class)->except(['show'])->middleware('permission:orders.view|orders.create|orders.update|orders.delete');
Route::patch('transactions/{transaction}/status', [TransactionController::class, 'updateStatus'])->name('transactions.updateStatus')->middleware('permission:orders.update');
Route::resource('restocks', RestockController::class)->except(['show'])->middleware('permission:restocks.view|restocks.create|restocks.update|restocks.delete'); Route::resource('restocks', RestockController::class)->except(['show'])->middleware('permission:restocks.view|restocks.create|restocks.update|restocks.delete');
}); });