Compare commits

...

15 Commits

Author SHA1 Message Date
Yoga Pangestu
6ebcbc24c0 feat: add update status functionality for transactions with corresponding UI actions 2026-08-06 14:59:48 +07:00
Yoga Pangestu
508a0e3fa8 feat: update label and separator components for improved styling; refactor transaction create/edit pages and add field components 2026-08-06 14:53:21 +07:00
Yoga Pangestu
1a5ac1dd85 feat: add date range filters and enhance transaction summary display 2026-08-06 14:41:10 +07:00
Yoga Pangestu
1d866d1b94 feat: add transaction summary feature with detailed metrics display 2026-08-06 14:25:44 +07:00
Yoga Pangestu
8399de9d2e feat: improve code readability by standardizing spacing and formatting in TransactionService 2026-08-06 14:17:44 +07:00
Yoga Pangestu
0a6a441ec4 feat: add formatted stock display and enhance product variant attributes 2026-08-06 14:16:29 +07:00
Yoga Pangestu
1fc058f974 feat: streamline lambda function syntax and enhance readability in RestockService 2026-08-06 14:01:37 +07:00
Yoga Pangestu
fe0c565196 feat: add product name filtering and retrieval in product management 2026-08-06 13:50:16 +07:00
Yoga Pangestu
f008e46f54 feat: restrict edit and delete actions to pending leave requests 2026-08-06 13:41:31 +07:00
Yoga Pangestu
58d45f8363 feat: add role-based visibility for employee management features 2026-08-06 13:41:26 +07:00
Yoga Pangestu
8eb3e565ec feat: enhance payroll period functionality with role-based visibility and improved data handling 2026-08-06 13:30:04 +07:00
Yoga Pangestu
54ad2edaf7 feat: implement employee advance payment functionality with history tracking 2026-08-06 12:09:53 +07:00
Yoga Pangestu
81b5db77e2 feat: add filtering options for employee advances by status in index view 2026-08-06 11:53:41 +07:00
Yoga Pangestu
175decfa22 fix: remove truncation from description display in various components 2026-08-06 11:42:22 +07:00
Yoga Pangestu
3fe74bf166 fix: remove 'employee_advances.pay' permission from RolePermissionSeeder 2026-08-06 11:40:57 +07:00
45 changed files with 1329 additions and 338 deletions

View File

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

View File

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

View File

@ -19,13 +19,14 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
$filters = $request->only(['status', 'channel', 'payment_type', 'customer_id', 'marketing_id', 'created_by_id']);
$filters = $request->only(['status', 'channel', 'payment_type', 'customer_id', 'marketing_id', 'created_by_id', 'date_from', 'date_to']);
return Inertia::render('admin/manage/transaction/index', [
'transactions' => $this->service->paginated(
...$request->validatedWithDefaults(),
filters: $filters,
),
'summary' => $this->service->getSummary($filters),
'filters' => $filters,
'filterOptions' => $this->service->getFilterOptions(),
]);
@ -75,4 +76,13 @@ public function destroy(Order $transaction): RedirectResponse
'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,10 +24,11 @@ public function index(PaginatedRequest $request): Response
return Inertia::render('admin/master/product/index', [
'products' => $this->service->paginated(
...$request->validatedWithDefaults(),
filters: $request->only(['status', 'stock', 'category']),
filters: $request->only(['status', 'stock', 'category', 'name']),
),
'categories' => $this->categoryService->getAll(),
'filters' => $request->only(['status', 'stock', 'category']),
'productNames' => $this->service->getNames(),
'filters' => $request->only(['status', 'stock', 'category', 'name']),
]);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,13 @@
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
{
return User::select(['id', 'email', 'username', 'is_active'])
@ -18,6 +25,10 @@ public function getAll(array $filters = []): Collection
'employee' => fn ($q) => $q->select(['id', 'user_id', 'join_date', 'employment_status', 'base_salary']),
'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(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)))
@ -35,6 +46,10 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
'employee' => fn ($q) => $q->select(['id', 'user_id', 'join_date', 'employment_status', 'base_salary']),
'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($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)))

View File

@ -30,14 +30,14 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->with([
'createdBy:id',
'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'])
->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.product:id,name',
])
->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}%");
})
->orderBy($sort, $direction)
@ -68,6 +68,7 @@ public function getForCreate(): array
'productVariants:id,product_id,name,stock,reject_stock',
'productVariants.productPrices:id,variant_id,type,price',
])
->active()
->orderBy('name')
->get()
->each(function (Product $product) {
@ -78,11 +79,11 @@ public function getForCreate(): array
: null;
$capitalPrice = $variant->productPrices
->first(fn ($price) => $price->type === PriceType::CAPITAL);
->first(fn($price) => $price->type === PriceType::CAPITAL);
$variant->capital_price = $capitalPrice?->price ?? 0;
$rejectPrice = $variant->productPrices
->first(fn ($price) => $price->type === PriceType::REJECT);
->first(fn($price) => $price->type === PriceType::REJECT);
$variant->reject_price = $rejectPrice?->price ?? 0;
});
}),
@ -92,7 +93,7 @@ public function getForCreate(): array
public function getForEdit(Restock $restock): array
{
$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)'),
'restockItems.productVariant.product',
]);
@ -107,7 +108,7 @@ public function getForEdit(Restock $restock): array
'photo_url' => $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null,
'items' => $restock->restockItems->map(fn (RestockItem $item) => [
'items' => $restock->restockItems->map(fn(RestockItem $item) => [
'id' => $item->id,
'product_variant_id' => $item->product_variant_id,
'quantity' => $item->quantity,
@ -144,7 +145,7 @@ public function create(array $data): Restock
NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Toko'],
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'),
);
@ -218,7 +219,7 @@ private function buildItemRows(array $items, string $stockType, $now, int &$subt
->get()
->mapWithKeys(function (ProductVariant $variant) use ($priceType) {
$price = $variant->productPrices
->first(fn ($p) => $p->type === $priceType);
->first(fn($p) => $p->type === $priceType);
return [$variant->id => $price?->price ?? 0];
});

View File

@ -53,16 +53,18 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
'orderItems.productVariant.product:id,name',
])
->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('notes', 'like', "%{$search}%");
})
->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['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))
->orderBy($sort, $direction)
->paginate($perPage);
@ -89,6 +91,33 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
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
{
return [
@ -105,9 +134,9 @@ public function getFilterOptions(): array
->with('userProfile:id,user_id,full_name')
->orderBy('id')
->get()
->filter(fn (User $user) => $user->userProfile?->full_name)
->filter(fn(User $user) => $user->userProfile?->full_name)
->values()
->map(fn (User $user) => [
->map(fn(User $user) => [
'id' => $user->id,
'name' => $user->userProfile->full_name,
]),
@ -123,6 +152,7 @@ public function getForCreate(): array
'productVariants:id,product_id,name,stock,reject_stock',
'productVariants.productPrices:id,variant_id,type,price',
])
->active()
->orderBy('name')
->get()
->each(function (Product $product) {
@ -132,7 +162,7 @@ public function getForCreate(): array
? $this->s3Service->getTemporaryUrl($media->file_name)
: 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;
});
}),
@ -146,11 +176,11 @@ public function getForCreate(): array
->with('userProfile:id,user_id,full_name')
->orderBy('id')
->get()
->filter(fn (User $user) => $user->userProfile?->full_name)
->filter(fn(User $user) => $user->userProfile?->full_name)
->values(),
'channelOptions' => OrderChannel::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(),
];
}
@ -180,7 +210,7 @@ public function getForEdit(Order $order): array
'photo_url' => $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null,
'items' => $order->orderItems->map(fn (OrderItem $item) => [
'items' => $order->orderItems->map(fn(OrderItem $item) => [
'id' => $item->id,
'product_variant_id' => $item->product_variant_id,
'quantity' => $item->quantity,
@ -240,7 +270,7 @@ public function create(array $data): Order
NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Toko'],
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'),
);
@ -328,6 +358,13 @@ 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
{
$resolvedPriceType = $stockType === ProductStockQuality::REJECT->value
@ -342,14 +379,14 @@ private function buildItemRows(array $items, string $stockType, string $priceTyp
$prices = $variants->mapWithKeys(function (ProductVariant $variant) use ($resolvedPriceType) {
$price = $variant->productPrices
->first(fn ($p) => $p->type === $resolvedPriceType);
->first(fn($p) => $p->type === $resolvedPriceType);
return [$variant->id => $price?->price ?? 0];
});
$capitalPrices = $variants->mapWithKeys(function (ProductVariant $variant) {
$price = $variant->productPrices
->first(fn ($p) => $p->type === PriceType::CAPITAL);
->first(fn($p) => $p->type === PriceType::CAPITAL);
return [$variant->id => $price?->price ?? 0];
});
@ -392,6 +429,6 @@ private function generateOrderNumber(): string
$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,6 +19,16 @@ public function __construct(
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
{
$products = Product::select(['id', 'name', 'slug', 'description', 'status'])
@ -53,6 +63,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
'productVariants.productPrices:id,variant_id,type,price',
])
->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['category'] ?? null, fn ($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) {
$cq->where('categories.id', $categoryId);

View File

@ -5,6 +5,7 @@
use App\Enums\ProductStockQuality;
use App\Models\ProductVariant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\ValidationException;
trait HasStockAdjustment
{
@ -18,6 +19,17 @@ private function adjustStock(Model $model, string $field, int $quantity, int $si
if ($sign > 0) {
$model->increment($field, $quantity);
} 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);
}
}

View File

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

View File

@ -0,0 +1,246 @@
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,5 +1,3 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
@ -13,7 +11,7 @@ function Label({
<LabelPrimitive.Root
data-slot="label"
className={cn(
"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",
"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",
className
)}
{...props}

View File

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

View File

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

View File

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

View File

@ -1,29 +1,50 @@
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DatePicker } from '@/components/date-picker';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FilterPopover } from '@/components/filter-popover';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table';
import {
approve,
destroy,
index as employeeAdvanceIndex,
pay,
store,
update,
approve,
pay,
} from '@/routes/admin/finance/employee-advances';
import { createEmployeeAdvanceColumns } from './columns';
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';
type TypeOption = {
value: string;
label: string;
};
type Props = {
employeeAdvances: {
@ -33,15 +54,27 @@ type Props = {
per_page: number;
total: number;
};
filters: {
status?: string;
};
filterOptions: {
statusOptions: TypeOption[];
};
};
export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
export default function EmployeeAdvanceIndex({
employeeAdvances,
filters,
filterOptions,
}: Props) {
const { can } = useCan();
const { auth } = usePage().props as { auth: { user: { id: number } } };
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<EmployeeAdvance | null>(null);
const [deleting, setDeleting] = useState<EmployeeAdvance | null>(null);
const [approving, setApproving] = 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 [editingDueDate, setEditingDueDate] = useState<Date | undefined>(
undefined,
@ -56,12 +89,17 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
const {
search,
filterOpen,
setFilterOpen,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilter,
clearFilters,
} = useServerTable({
route: () => employeeAdvanceIndex.url(),
pagination,
filters,
});
useEffect(() => {
@ -96,28 +134,46 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
);
}
function handlePay() {
if (!paying) {
return;
}
router.post(
pay(paying.id),
{},
{
onSuccess: () => setPaying(null),
},
);
}
const columns = createEmployeeAdvanceColumns({
handleEdit: (employeeAdvance) => setEditing(employeeAdvance),
handleDeleteClick: (employeeAdvance) => setDeleting(employeeAdvance),
handleApprove: (employeeAdvance) => setApproving(employeeAdvance),
handlePay: (employeeAdvance) => setPaying(employeeAdvance),
handleShowPayments: (employeeAdvance) => setViewingPayments(employeeAdvance),
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 (
<>
<Head title="Kasbon" />
@ -187,8 +243,8 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
value={
dueDate
? dueDate
.toISOString()
.split('T')[0]
.toISOString()
.split('T')[0]
: ''
}
/>
@ -214,6 +270,7 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
toolbar={filterToolbar}
/>
<FormDialog
@ -276,8 +333,8 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
value={
editingDueDate
? editingDueDate
.toISOString()
.split('T')[0]
.toISOString()
.split('T')[0]
: ''
}
/>
@ -322,20 +379,128 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
onConfirm={handleApprove}
/>
<DeleteConfirmDialog
target={paying}
<FormDialog
open={paying !== null}
onOpenChange={(open) => {
if (!open) {
setPaying(null);
}
}}
title="Bayar Kasbon"
description={(advance) =>
`Apakah Anda yakin ingin membayar kasbon "${advance.description}" sebesar ${advance.amount}? Saldo kas akan dikembalikan.`
action={paying ? pay(paying.id) : ''}
resetOnSuccess
onSuccess={() => setPaying(null)}
>
{({ errors }) =>
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>
</>
)
}
confirmLabel="Bayar"
onConfirm={handlePay}
/>
</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>
</>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -36,12 +36,15 @@ type Role = {
type Props = {
roles: Role[];
canViewAll: boolean;
};
export default function EmployeeCreate({ roles }: Props) {
export default function EmployeeCreate({ roles, canViewAll }: Props) {
const [joinDate, setJoinDate] = useState<Date | undefined>(undefined);
const [resignDate, setResignDate] = useState<Date | undefined>(undefined);
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
const [selectedRole, setSelectedRole] = useState<Role | null>(
!canViewAll && roles.length === 1 ? roles[0] : null,
);
return (
<>
@ -117,46 +120,61 @@ export default function EmployeeCreate({ roles }: Props) {
message={errors.username}
/>
</div>
<div className="grid gap-2">
<Label>
Role{' '}
<span className="text-destructive">
*
</span>
</Label>
<Combobox
items={roles}
itemToStringLabel={(r) => r.name}
value={selectedRole}
onValueChange={(value) =>
setSelectedRole(value)
}
>
<ComboboxInput
placeholder="Cari role..."
className="w-full"
{canViewAll ? (
<div className="grid gap-2">
<Label>
Role{' '}
<span className="text-destructive">
*
</span>
</Label>
<Combobox
items={roles}
itemToStringLabel={(r) =>
r.name
}
value={selectedRole}
onValueChange={(value) =>
setSelectedRole(value)
}
>
<ComboboxInput
placeholder="Cari role..."
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Tidak ada role
ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(role) => (
<ComboboxItem
key={
role.id
}
value={
role
}
>
{role.name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError
message={errors.role}
/>
<ComboboxContent>
<ComboboxEmpty>
Tidak ada role
ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(role) => (
<ComboboxItem
key={role.id}
value={role}
>
{role.name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError
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>
</Card>

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Fragment } from 'react';
import { formatNumber } from '@/lib/format';
import { formatCurrency } from '@/lib/utils';
import type { Restock } from './columns';
@ -14,8 +15,20 @@ import type { Restock } from './columns';
export function RestockItemSubRow({ restock }: { restock: Restock }) {
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 (
<div className="space-y-4 overflow-x-auto">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
@ -23,7 +36,6 @@ export function RestockItemSubRow({ restock }: { restock: Restock }) {
No
</TableHead>
<TableHead className="w-[60px]">Foto</TableHead>
<TableHead>Produk</TableHead>
<TableHead>Varian</TableHead>
<TableHead className="text-right">
Harga Modal
@ -36,49 +48,94 @@ export function RestockItemSubRow({ restock }: { restock: Restock }) {
{items.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
colSpan={6}
className="text-center text-muted-foreground"
>
Tidak ada item.
</TableCell>
</TableRow>
) : (
items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">
{index + 1}
</TableCell>
<TableCell>
{item.product_variant?.photo_url ? (
<ImagePreviewButton
srcs={[
item.product_variant.photo_url,
]}
title={item.product_variant.name}
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
N/A
</div>
)}
</TableCell>
<TableCell>
{item.product_variant?.product?.name ?? '-'}
</TableCell>
<TableCell>
{item.product_variant?.name ?? '-'}
</TableCell>
<TableCell className="text-right">
{formatCurrency(item.unit_price)}
</TableCell>
<TableCell className="text-center">
{formatNumber(item.quantity)}
</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(item.subtotal)}
</TableCell>
</TableRow>
))
Object.entries(groupedByProduct).map(
([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}>
<TableCell className="text-center">
{counter}
</TableCell>
<TableCell>
{item.product_variant
?.photo_url ? (
<ImagePreviewButton
srcs={[
item
.product_variant
.photo_url,
]}
title={
item
.product_variant
.name
}
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
N/A
</div>
)}
</TableCell>
<TableCell>
{item.product_variant
?.name ?? '-'}
</TableCell>
<TableCell className="text-right">
{formatCurrency(
item.unit_price,
)}
</TableCell>
<TableCell className="text-center">
{formatNumber(
item.quantity,
)}
</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(
item.subtotal,
)}
</TableCell>
</TableRow>
);
})}
</Fragment>
);
},
)
)}
</TableBody>
</Table>

View File

@ -1,8 +1,5 @@
'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 { FileUpload } from '@/components/file-upload';
import { ImagePreviewModal } from '@/components/image-preview-modal';
@ -42,8 +39,12 @@ import { formatNumber } from '@/lib/format';
import { loadTransactionDraft } from '@/lib/transaction-draft';
import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils';
import { index as transactionIndex, store } from '@/routes/admin/manage/transactions';
import { store, index as transactionIndex } from '@/routes/admin/manage/transactions';
import { Form, Head, Link, usePage } from '@inertiajs/react';
import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ProductForTransaction, TransactionCreateData } from './columns';
import { FieldDescription } from '@/components/ui/field';
type CartLine = {
key: string;
@ -437,8 +438,8 @@ export default function TransactionCreate({ data }: Props) {
disabled={
!(
quantities[
variant
.id
variant
.id
] ??
0
)
@ -456,8 +457,8 @@ export default function TransactionCreate({ data }: Props) {
className="w-24 text-center"
value={
quantities[
variant
.id
variant
.id
] ??
0
}
@ -507,7 +508,7 @@ export default function TransactionCreate({ data }: Props) {
onValueChange={(value) =>
setStockType(
value as
'good' | 'reject',
'good' | 'reject',
)
}
className="flex flex-wrap gap-4"
@ -673,8 +674,8 @@ export default function TransactionCreate({ data }: Props) {
setPhotoUrl(
key
? getTemporaryUrl(
key,
)
key,
)
: null,
);
}}
@ -797,6 +798,9 @@ export default function TransactionCreate({ data }: Props) {
}
placeholder="0"
/>
<FieldDescription>
Jika diisi, harga nego menjadi total akhir.
</FieldDescription>
<InputError
message={errors.nego_price}
/>

View File

@ -1,8 +1,5 @@
'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 { FileUpload } from '@/components/file-upload';
import { ImagePreviewModal } from '@/components/image-preview-modal';
@ -41,7 +38,11 @@ import { formatNumber } from '@/lib/format';
import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils';
import { index as transactionIndex, update } from '@/routes/admin/manage/transactions';
import type { TransactionCreateData, TransactionForEdit, OptionItem } from './columns';
import { FieldDescription } from '@base-ui/react';
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 = {
key: string;
@ -368,8 +369,8 @@ export default function TransactionEdit({ transaction, data }: Props) {
disabled={
!(
quantities[
variant
.id
variant
.id
] ??
0
)
@ -387,8 +388,8 @@ export default function TransactionEdit({ transaction, data }: Props) {
className="w-24 text-center"
value={
quantities[
variant
.id
variant
.id
] ??
0
}
@ -443,7 +444,7 @@ export default function TransactionEdit({ transaction, data }: Props) {
onValueChange={(value) =>
setStockType(
value as
'good' | 'reject',
'good' | 'reject',
)
}
className="flex flex-wrap gap-4"
@ -609,8 +610,8 @@ export default function TransactionEdit({ transaction, data }: Props) {
setPhotoUrl(
key
? getTemporaryUrl(
key,
)
key,
)
: null,
);
}}
@ -733,6 +734,9 @@ export default function TransactionEdit({ transaction, data }: Props) {
}
placeholder="0"
/>
<FieldDescription>
Jika diisi, harga nego menjadi total akhir.
</FieldDescription>
<InputError
message={errors.nego_price}
/>

View File

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

View File

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

View File

@ -0,0 +1,90 @@
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',
header: () => <span>Alamat</span>,
cell: ({ row }) => (
<span className="block max-w-[200px] truncate">
<span className="block max-w-[200px]">
{(row.getValue('address') as string) ?? '-'}
</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 { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { formatNumber } from '@/lib/format';
import { formatCurrency } from '@/lib/utils';
import { router } from '@inertiajs/react';
import type { ColumnDef } from '@tanstack/react-table';
import { ChevronRight, Pencil, Trash2 } from 'lucide-react';
export type ProductVariant = {
id: number;
name: string;
stock: number;
formatted_stock: string
reject_stock: number;
retail_stock: number;
photo_urls: string[];

View File

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

View File

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

View File

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

View File

@ -65,6 +65,7 @@
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::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');
});