Compare commits
15 Commits
1f39565754
...
6ebcbc24c0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ebcbc24c0 | ||
|
|
508a0e3fa8 | ||
|
|
1a5ac1dd85 | ||
|
|
1d866d1b94 | ||
|
|
8399de9d2e | ||
|
|
0a6a441ec4 | ||
|
|
1fc058f974 | ||
|
|
fe0c565196 | ||
|
|
f008e46f54 | ||
|
|
58d45f8363 | ||
|
|
8eb3e565ec | ||
|
|
54ad2edaf7 | ||
|
|
81b5db77e2 | ||
|
|
175decfa22 | ||
|
|
3fe74bf166 |
@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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']),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -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.']);
|
||||
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
|
||||
54
app/Models/EmployeeAdvancePayment.php
Normal file
54
app/Models/EmployeeAdvancePayment.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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');
|
||||
},
|
||||
]);
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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];
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -268,7 +268,6 @@ public function run(): void
|
||||
'employee_advances.create',
|
||||
'employee_advances.update',
|
||||
'employee_advances.delete',
|
||||
'employee_advances.pay',
|
||||
|
||||
'activity_logs.view',
|
||||
|
||||
|
||||
246
resources/js/components/ui/field.tsx
Normal file
246
resources/js/components/ui/field.tsx
Normal 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,
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
),
|
||||
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 ·
|
||||
Status:{' '}
|
||||
{payrollPeriod.status === 'open'
|
||||
? 'Terbuka'
|
||||
: 'Ditutup'}
|
||||
</p>
|
||||
{canViewAll && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{payrollPeriod.payrolls.length} karyawan ·
|
||||
Status:{' '}
|
||||
{payrollPeriod.status === 'open'
|
||||
? 'Terbuka'
|
||||
: 'Ditutup'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={payrollPeriodsIndex.url()}>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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),
|
||||
},
|
||||
]}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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) => (
|
||||
|
||||
@ -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" />,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
),
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -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) ?? '')
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
),
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user