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;
|
namespace App\Http\Controllers\Admin\Finance;
|
||||||
|
|
||||||
|
use App\Enums\EmployeeAdvanceStatus;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Admin\Finance\EmployeeAdvancePaymentRequest;
|
||||||
use App\Http\Requests\Admin\Finance\EmployeeAdvanceRequest;
|
use App\Http\Requests\Admin\Finance\EmployeeAdvanceRequest;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\EmployeeAdvance;
|
use App\Models\EmployeeAdvance;
|
||||||
@ -20,7 +22,14 @@ public function __construct(
|
|||||||
public function index(PaginatedRequest $request): Response
|
public function index(PaginatedRequest $request): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('admin/finance/employee-advance/index', [
|
return Inertia::render('admin/finance/employee-advance/index', [
|
||||||
'employeeAdvances' => $this->service->paginated(...$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(
|
return $this->handleAction(
|
||||||
fn () => $this->service->pay($employeeAdvance),
|
fn () => $this->service->pay($employeeAdvance, $request->validated('amount')),
|
||||||
'Kasbon berhasil dibayar.',
|
'Pembayaran kasbon berhasil.',
|
||||||
'admin.finance.employee-advances.index'
|
'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']),
|
||||||
),
|
),
|
||||||
'filters' => $request->only(['employment_status', 'is_active', 'gender']),
|
'filters' => $request->only(['employment_status', 'is_active', 'gender']),
|
||||||
|
'canViewAll' => $this->service->canViewAll(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(): Response
|
public function create(): Response
|
||||||
{
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
return Inertia::render('admin/hr/employee/create', [
|
return Inertia::render('admin/hr/employee/create', [
|
||||||
'roles' => 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
|
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', [
|
return Inertia::render('admin/manage/transaction/index', [
|
||||||
'transactions' => $this->service->paginated(
|
'transactions' => $this->service->paginated(
|
||||||
...$request->validatedWithDefaults(),
|
...$request->validatedWithDefaults(),
|
||||||
filters: $filters,
|
filters: $filters,
|
||||||
),
|
),
|
||||||
|
'summary' => $this->service->getSummary($filters),
|
||||||
'filters' => $filters,
|
'filters' => $filters,
|
||||||
'filterOptions' => $this->service->getFilterOptions(),
|
'filterOptions' => $this->service->getFilterOptions(),
|
||||||
]);
|
]);
|
||||||
@ -75,4 +76,13 @@ public function destroy(Order $transaction): RedirectResponse
|
|||||||
'admin.manage.transactions.index'
|
'admin.manage.transactions.index'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updateStatus(Order $transaction): RedirectResponse
|
||||||
|
{
|
||||||
|
return $this->handleAction(
|
||||||
|
fn () => $this->service->updateStatus($transaction, request('status')),
|
||||||
|
'Status transaksi berhasil diperbarui.',
|
||||||
|
'admin.manage.transactions.index'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,10 +24,11 @@ public function index(PaginatedRequest $request): Response
|
|||||||
return Inertia::render('admin/master/product/index', [
|
return Inertia::render('admin/master/product/index', [
|
||||||
'products' => $this->service->paginated(
|
'products' => $this->service->paginated(
|
||||||
...$request->validatedWithDefaults(),
|
...$request->validatedWithDefaults(),
|
||||||
filters: $request->only(['status', 'stock', 'category']),
|
filters: $request->only(['status', 'stock', 'category', 'name']),
|
||||||
),
|
),
|
||||||
'categories' => $this->categoryService->getAll(),
|
'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();
|
$action();
|
||||||
Inertia::flash('toast', ['type' => 'success', 'message' => $successMessage]);
|
Inertia::flash('toast', ['type' => 'success', 'message' => $successMessage]);
|
||||||
|
|
||||||
return to_route($redirectRoute);
|
return to_route($redirectRoute, $parameters);
|
||||||
} catch (ValidationException $e) {
|
} catch (ValidationException $e) {
|
||||||
$firstError = collect($e->errors())->flatten()->first();
|
$firstError = collect($e->errors())->flatten()->first();
|
||||||
Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']);
|
Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']);
|
||||||
|
|||||||
@ -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\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
#[Appends(['formatted_amount', 'formatted_created_at', 'formatted_due_date', 'formatted_paid_amount', 'status_label'])]
|
#[Appends(['formatted_amount', 'formatted_created_at', 'formatted_due_date', 'formatted_paid_amount', 'formatted_remaining_amount', 'status_label'])]
|
||||||
#[Guarded(['id'])]
|
#[Guarded(['id'])]
|
||||||
class EmployeeAdvance extends Model
|
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
|
protected function statusLabel(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
@ -106,6 +114,11 @@ public function employee(): BelongsTo
|
|||||||
return $this->belongsTo(Employee::class);
|
return $this->belongsTo(Employee::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function payments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(EmployeeAdvancePayment::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function paidBy(): BelongsTo
|
public function paidBy(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'paid_by_id');
|
return $this->belongsTo(User::class, 'paid_by_id');
|
||||||
|
|||||||
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\HasMedia;
|
||||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||||
|
|
||||||
#[Appends(['formatted_name'])]
|
#[Appends(['formatted_name', 'formatted_stock'])]
|
||||||
#[Guarded(['id'])]
|
#[Guarded(['id'])]
|
||||||
#[ScopedBy([ProductVariantScope::class])]
|
#[ScopedBy([ProductVariantScope::class])]
|
||||||
class ProductVariant extends Model implements HasMedia
|
class ProductVariant extends Model implements HasMedia
|
||||||
@ -34,7 +34,14 @@ protected function casts(): array
|
|||||||
protected function formattedName(): Attribute
|
protected function formattedName(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
get: fn () => ucfirst($this->name),
|
get: fn() => ucfirst($this->name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function formattedStock(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn() => number_format($this->stock, 0, ',', '.'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,8 +3,8 @@
|
|||||||
namespace App\Services\Admin\Finance;
|
namespace App\Services\Admin\Finance;
|
||||||
|
|
||||||
use App\Enums\EmployeeAdvanceStatus;
|
use App\Enums\EmployeeAdvanceStatus;
|
||||||
use App\Models\CashAccount;
|
|
||||||
use App\Models\EmployeeAdvance;
|
use App\Models\EmployeeAdvance;
|
||||||
|
use App\Models\EmployeeAdvancePayment;
|
||||||
use App\Services\Concerns\HandlesCashTransactions;
|
use App\Services\Concerns\HandlesCashTransactions;
|
||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
@ -16,10 +16,16 @@ class EmployeeAdvanceService
|
|||||||
{
|
{
|
||||||
use HandlesCashTransactions;
|
use HandlesCashTransactions;
|
||||||
|
|
||||||
|
private function canViewAll(): bool
|
||||||
|
{
|
||||||
|
return auth()->user()->hasAnyRole(['developer', 'owner', 'direktur', 'admin-toko']);
|
||||||
|
}
|
||||||
|
|
||||||
public function getAll(array $filters = []): Collection
|
public function getAll(array $filters = []): Collection
|
||||||
{
|
{
|
||||||
return EmployeeAdvance::select(['id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at'])
|
return EmployeeAdvance::select(['id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at'])
|
||||||
->with(['employee.user.userProfile'])
|
->with(['employee.user.userProfile', 'payments.paidBy.userProfile'])
|
||||||
|
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
|
||||||
->latest()
|
->latest()
|
||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
@ -28,7 +34,9 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
|||||||
{
|
{
|
||||||
return EmployeeAdvance::query()
|
return EmployeeAdvance::query()
|
||||||
->select(['id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at'])
|
->select(['id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at'])
|
||||||
->with(['employee.user.userProfile'])
|
->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}%"))
|
->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%"))
|
||||||
->orderBy($sort, $direction)
|
->orderBy($sort, $direction)
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
@ -36,27 +44,19 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
|||||||
|
|
||||||
public function create(array $data): EmployeeAdvance
|
public function create(array $data): EmployeeAdvance
|
||||||
{
|
{
|
||||||
$employeeAdvance = DB::transaction(function () use ($data) {
|
$employee = auth()->user()->employee;
|
||||||
$employee = auth()->user()->employee;
|
|
||||||
|
|
||||||
if (! $employee) {
|
if (! $employee) {
|
||||||
throw new \Exception('Anda tidak terdaftar sebagai karyawan.');
|
throw new \Exception('Anda tidak terdaftar sebagai karyawan.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$cashTransaction = $this->debitCash(
|
$employeeAdvance = EmployeeAdvance::create([
|
||||||
amount: $data['amount'],
|
'employee_id' => $employee->id,
|
||||||
description: 'Kasbon: '.$data['description'],
|
'amount' => $data['amount'],
|
||||||
);
|
'description' => $data['description'],
|
||||||
|
'due_date' => $data['due_date'],
|
||||||
return EmployeeAdvance::create([
|
'status' => EmployeeAdvanceStatus::PENDING,
|
||||||
'cash_transaction_id' => $cashTransaction->id,
|
]);
|
||||||
'employee_id' => $employee->id,
|
|
||||||
'amount' => $data['amount'],
|
|
||||||
'description' => $data['description'],
|
|
||||||
'due_date' => $data['due_date'],
|
|
||||||
'status' => EmployeeAdvanceStatus::PENDING,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
$employeeAdvance->load('employee.user');
|
$employeeAdvance->load('employee.user');
|
||||||
|
|
||||||
@ -73,56 +73,36 @@ public function create(array $data): EmployeeAdvance
|
|||||||
|
|
||||||
public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeAdvance
|
public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeAdvance
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($employeeAdvance, $data) {
|
$employeeAdvance->update([
|
||||||
$cashAccount = CashAccount::firstOrFail();
|
'amount' => $data['amount'],
|
||||||
$cashTransaction = $employeeAdvance->cashTransaction;
|
'description' => $data['description'],
|
||||||
|
'due_date' => $data['due_date'],
|
||||||
|
]);
|
||||||
|
|
||||||
$oldAmount = $employeeAdvance->amount;
|
return $employeeAdvance;
|
||||||
$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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(EmployeeAdvance $employeeAdvance): bool
|
public function delete(EmployeeAdvance $employeeAdvance): bool
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($employeeAdvance) {
|
if ($employeeAdvance->status === EmployeeAdvanceStatus::APPROVED && $employeeAdvance->cash_transaction_id) {
|
||||||
$cashAccount = CashAccount::firstOrFail();
|
$cashAccount = $this->getCashAccount();
|
||||||
|
$newBalance = $cashAccount->balance + $employeeAdvance->amount;
|
||||||
|
$cashAccount->update(['balance' => $newBalance]);
|
||||||
|
$employeeAdvance->cashTransaction()->delete();
|
||||||
|
}
|
||||||
|
|
||||||
if ($employeeAdvance->status === EmployeeAdvanceStatus::PAID) {
|
return $employeeAdvance->delete();
|
||||||
$newBalance = $cashAccount->balance + $employeeAdvance->amount;
|
|
||||||
$cashAccount->update(['balance' => $newBalance]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $employeeAdvance->delete();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
|
public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
|
||||||
{
|
{
|
||||||
|
$cashTransaction = $this->debitCash(
|
||||||
|
amount: $employeeAdvance->amount,
|
||||||
|
description: 'Kasbon: '.$employeeAdvance->description,
|
||||||
|
);
|
||||||
|
|
||||||
$employeeAdvance->update([
|
$employeeAdvance->update([
|
||||||
|
'cash_transaction_id' => $cashTransaction->id,
|
||||||
'status' => EmployeeAdvanceStatus::APPROVED,
|
'status' => EmployeeAdvanceStatus::APPROVED,
|
||||||
'verified_by_id' => auth()->id(),
|
'verified_by_id' => auth()->id(),
|
||||||
'verified_at' => now(),
|
'verified_at' => now(),
|
||||||
@ -139,29 +119,51 @@ public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
|
|||||||
return $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(
|
$cashTransaction = $this->creditCash(
|
||||||
amount: $employeeAdvance->amount,
|
amount: $amount,
|
||||||
description: 'Pembayaran kasbon: '.$employeeAdvance->description,
|
description: 'Pembayaran kasbon: '.$employeeAdvance->description,
|
||||||
);
|
);
|
||||||
|
|
||||||
$employeeAdvance->update([
|
EmployeeAdvancePayment::create([
|
||||||
'status' => EmployeeAdvanceStatus::PAID,
|
'employee_advance_id' => $employeeAdvance->id,
|
||||||
'paid_by_id' => auth()->id(),
|
'paid_by_id' => auth()->id(),
|
||||||
'paid_amount' => $employeeAdvance->amount,
|
'cash_transaction_id' => $cashTransaction->id,
|
||||||
|
'amount' => $amount,
|
||||||
'paid_at' => now(),
|
'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;
|
return $employeeAdvance;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$notificationBody = $employeeAdvance->status === EmployeeAdvanceStatus::PAID
|
||||||
|
? 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah dibayar lunas'.' oleh '.auth()->user()->full_name.'.'
|
||||||
|
: 'Pembayaran kasbon sebesar Rp '.number_format($amount, 0, ',', '.').' oleh '.auth()->user()->full_name.'. Sisa: Rp '.number_format($employeeAdvance->amount - $employeeAdvance->paid_amount, 0, ',', '.').'.';
|
||||||
|
|
||||||
NotificationService::notify(
|
NotificationService::notify(
|
||||||
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
|
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
|
||||||
title: 'Kasbon Dibayar',
|
title: $employeeAdvance->status === EmployeeAdvanceStatus::PAID ? 'Kasbon Dibayar' : 'Pembayaran Kasbon',
|
||||||
body: 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah dibayar lunas'.' oleh '.auth()->user()->full_name.'.',
|
body: $notificationBody,
|
||||||
url: route('admin.finance.employee-advances.index'),
|
url: route('admin.finance.employee-advances.index'),
|
||||||
additionalUser: $employeeAdvance->employee->user ?? null,
|
additionalUser: $employeeAdvance->employee->user ?? null,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -17,19 +17,30 @@ class PayrollPeriodService
|
|||||||
{
|
{
|
||||||
use HandlesCashTransactions;
|
use HandlesCashTransactions;
|
||||||
|
|
||||||
|
private function canViewAll(): bool
|
||||||
|
{
|
||||||
|
return auth()->user()->hasAnyRole(['developer', 'owner', 'direktur', 'admin-toko']);
|
||||||
|
}
|
||||||
|
|
||||||
public function getAll(array $filters = []): Collection
|
public function getAll(array $filters = []): Collection
|
||||||
{
|
{
|
||||||
return PayrollPeriod::select(['id', 'year', 'month', 'status', 'closed_at', 'created_at'])
|
return PayrollPeriod::select(['id', 'year', 'month', 'status', 'closed_at', 'created_at'])
|
||||||
->withCount('payrolls')
|
->when(! $this->canViewAll(), function ($query) {
|
||||||
->withSum('payrolls', 'total_amount')
|
$query->withCount(['payrolls as payrolls_count' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))])
|
||||||
->withSum('payrolls', 'bonus_amount')
|
->withSum(['payrolls as payrolls_sum_total_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'total_amount')
|
||||||
->withSum('payrolls', 'deduction_amount')
|
->withSum(['payrolls as payrolls_sum_bonus_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'bonus_amount')
|
||||||
->withCount(['payrolls as paid_count' => function ($q) {
|
->withSum(['payrolls as payrolls_sum_deduction_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'deduction_amount')
|
||||||
$q->paid();
|
->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()))]);
|
||||||
->withCount(['payrolls as cancelled_count' => function ($q) {
|
})
|
||||||
$q->cancelled();
|
->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('year')
|
||||||
->latest('month')
|
->latest('month')
|
||||||
->get();
|
->get();
|
||||||
@ -39,16 +50,22 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
|||||||
{
|
{
|
||||||
return PayrollPeriod::query()
|
return PayrollPeriod::query()
|
||||||
->select(['id', 'year', 'month', 'status', 'closed_at', 'created_at'])
|
->select(['id', 'year', 'month', 'status', 'closed_at', 'created_at'])
|
||||||
->withCount('payrolls')
|
->when(! $this->canViewAll(), function ($query) {
|
||||||
->withSum('payrolls', 'total_amount')
|
$query->withCount(['payrolls as payrolls_count' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))])
|
||||||
->withSum('payrolls', 'bonus_amount')
|
->withSum(['payrolls as payrolls_sum_total_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'total_amount')
|
||||||
->withSum('payrolls', 'deduction_amount')
|
->withSum(['payrolls as payrolls_sum_bonus_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'bonus_amount')
|
||||||
->withCount(['payrolls as paid_count' => function ($q) {
|
->withSum(['payrolls as payrolls_sum_deduction_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'deduction_amount')
|
||||||
$q->paid();
|
->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()))]);
|
||||||
->withCount(['payrolls as cancelled_count' => function ($q) {
|
})
|
||||||
$q->cancelled();
|
->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}%"))
|
->when($search, fn ($q) => $q->where('year', 'like', "%{$search}%"))
|
||||||
->orderBy($sort, $direction)
|
->orderBy($sort, $direction)
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
@ -59,6 +76,7 @@ public function getDetail(PayrollPeriod $period): PayrollPeriod
|
|||||||
return $period->load([
|
return $period->load([
|
||||||
'payrolls' => function ($query) {
|
'payrolls' => function ($query) {
|
||||||
$query->with(['employee.user.userProfile', 'payrollAdjustments'])
|
$query->with(['employee.user.userProfile', 'payrollAdjustments'])
|
||||||
|
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
|
||||||
->orderBy('id');
|
->orderBy('id');
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -9,6 +9,13 @@
|
|||||||
|
|
||||||
class EmployeeService
|
class EmployeeService
|
||||||
{
|
{
|
||||||
|
private const ADMIN_ROLES = ['developer', 'owner', 'direktur', 'admin-toko'];
|
||||||
|
|
||||||
|
public function canViewAll(): bool
|
||||||
|
{
|
||||||
|
return auth()->user()->hasAnyRole(self::ADMIN_ROLES);
|
||||||
|
}
|
||||||
|
|
||||||
public function getAll(array $filters = []): Collection
|
public function getAll(array $filters = []): Collection
|
||||||
{
|
{
|
||||||
return User::select(['id', 'email', 'username', 'is_active'])
|
return User::select(['id', 'email', 'username', 'is_active'])
|
||||||
@ -18,6 +25,10 @@ public function getAll(array $filters = []): Collection
|
|||||||
'employee' => fn ($q) => $q->select(['id', 'user_id', 'join_date', 'employment_status', 'base_salary']),
|
'employee' => fn ($q) => $q->select(['id', 'user_id', 'join_date', 'employment_status', 'base_salary']),
|
||||||
'roles' => fn ($q) => $q->select(['id', 'name']),
|
'roles' => fn ($q) => $q->select(['id', 'name']),
|
||||||
])
|
])
|
||||||
|
->when(! $this->canViewAll(), function ($q) {
|
||||||
|
$userRoles = auth()->user()->roles->pluck('name');
|
||||||
|
$q->whereHas('roles', fn ($rq) => $rq->whereIn('name', $userRoles));
|
||||||
|
})
|
||||||
->when($filters['employment_status'] ?? null, fn ($q, $status) => $q->whereHas('employee', fn ($eq) => $eq->where('employment_status', $status)))
|
->when($filters['employment_status'] ?? null, fn ($q, $status) => $q->whereHas('employee', fn ($eq) => $eq->where('employment_status', $status)))
|
||||||
->when(isset($filters['is_active']) && $filters['is_active'] !== '', fn ($q) => $q->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN)))
|
->when(isset($filters['is_active']) && $filters['is_active'] !== '', fn ($q) => $q->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN)))
|
||||||
->when($filters['gender'] ?? null, fn ($q, $gender) => $q->whereHas('userProfile', fn ($uq) => $uq->where('gender', $gender)))
|
->when($filters['gender'] ?? null, fn ($q, $gender) => $q->whereHas('userProfile', fn ($uq) => $uq->where('gender', $gender)))
|
||||||
@ -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']),
|
'employee' => fn ($q) => $q->select(['id', 'user_id', 'join_date', 'employment_status', 'base_salary']),
|
||||||
'roles' => fn ($q) => $q->select(['id', 'name']),
|
'roles' => fn ($q) => $q->select(['id', 'name']),
|
||||||
])
|
])
|
||||||
|
->when(! $this->canViewAll(), function ($q) {
|
||||||
|
$userRoles = auth()->user()->roles->pluck('name');
|
||||||
|
$q->whereHas('roles', fn ($rq) => $rq->whereIn('name', $userRoles));
|
||||||
|
})
|
||||||
->when($search, fn ($q) => $q->whereHas('userProfile', fn ($uq) => $uq->where('full_name', 'like', "%{$search}%")))
|
->when($search, fn ($q) => $q->whereHas('userProfile', fn ($uq) => $uq->where('full_name', 'like', "%{$search}%")))
|
||||||
->when($filters['employment_status'] ?? null, fn ($q, $status) => $q->whereHas('employee', fn ($eq) => $eq->where('employment_status', $status)))
|
->when($filters['employment_status'] ?? null, fn ($q, $status) => $q->whereHas('employee', fn ($eq) => $eq->where('employment_status', $status)))
|
||||||
->when(isset($filters['is_active']) && $filters['is_active'] !== '', fn ($q) => $q->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN)))
|
->when(isset($filters['is_active']) && $filters['is_active'] !== '', fn ($q) => $q->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN)))
|
||||||
|
|||||||
@ -30,14 +30,14 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
|||||||
->with([
|
->with([
|
||||||
'createdBy:id',
|
'createdBy:id',
|
||||||
'createdBy.userProfile:id,user_id,full_name',
|
'createdBy.userProfile:id,user_id,full_name',
|
||||||
'restockItems' => fn ($q) => $q
|
'restockItems' => fn($q) => $q
|
||||||
->select(['id', 'restock_id', 'product_variant_id', 'quantity', 'unit_price', 'subtotal'])
|
->select(['id', 'restock_id', 'product_variant_id', 'quantity', 'unit_price', 'subtotal'])
|
||||||
->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = restock_items.product_variant_id)'),
|
->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = restock_items.product_variant_id)'),
|
||||||
'restockItems.productVariant:id,product_id,name,stock,reject_stock,retail_stock',
|
'restockItems.productVariant:id,product_id,name,stock,reject_stock,retail_stock',
|
||||||
'restockItems.productVariant.product:id,name',
|
'restockItems.productVariant.product:id,name',
|
||||||
])
|
])
|
||||||
->when($search, function ($q) use ($search) {
|
->when($search, function ($q) use ($search) {
|
||||||
$q->whereHas('restockItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%"))
|
$q->whereHas('restockItems.productVariant.product', fn($sq) => $sq->where('name', 'like', "%{$search}%"))
|
||||||
->orWhere('notes', 'like', "%{$search}%");
|
->orWhere('notes', 'like', "%{$search}%");
|
||||||
})
|
})
|
||||||
->orderBy($sort, $direction)
|
->orderBy($sort, $direction)
|
||||||
@ -68,6 +68,7 @@ public function getForCreate(): array
|
|||||||
'productVariants:id,product_id,name,stock,reject_stock',
|
'productVariants:id,product_id,name,stock,reject_stock',
|
||||||
'productVariants.productPrices:id,variant_id,type,price',
|
'productVariants.productPrices:id,variant_id,type,price',
|
||||||
])
|
])
|
||||||
|
->active()
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get()
|
->get()
|
||||||
->each(function (Product $product) {
|
->each(function (Product $product) {
|
||||||
@ -78,11 +79,11 @@ public function getForCreate(): array
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
$capitalPrice = $variant->productPrices
|
$capitalPrice = $variant->productPrices
|
||||||
->first(fn ($price) => $price->type === PriceType::CAPITAL);
|
->first(fn($price) => $price->type === PriceType::CAPITAL);
|
||||||
$variant->capital_price = $capitalPrice?->price ?? 0;
|
$variant->capital_price = $capitalPrice?->price ?? 0;
|
||||||
|
|
||||||
$rejectPrice = $variant->productPrices
|
$rejectPrice = $variant->productPrices
|
||||||
->first(fn ($price) => $price->type === PriceType::REJECT);
|
->first(fn($price) => $price->type === PriceType::REJECT);
|
||||||
$variant->reject_price = $rejectPrice?->price ?? 0;
|
$variant->reject_price = $rejectPrice?->price ?? 0;
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@ -92,7 +93,7 @@ public function getForCreate(): array
|
|||||||
public function getForEdit(Restock $restock): array
|
public function getForEdit(Restock $restock): array
|
||||||
{
|
{
|
||||||
$restock->load([
|
$restock->load([
|
||||||
'restockItems' => fn ($q) => $q
|
'restockItems' => fn($q) => $q
|
||||||
->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = restock_items.product_variant_id)'),
|
->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = restock_items.product_variant_id)'),
|
||||||
'restockItems.productVariant.product',
|
'restockItems.productVariant.product',
|
||||||
]);
|
]);
|
||||||
@ -107,7 +108,7 @@ public function getForEdit(Restock $restock): array
|
|||||||
'photo_url' => $media
|
'photo_url' => $media
|
||||||
? $this->s3Service->getTemporaryUrl($media->file_name)
|
? $this->s3Service->getTemporaryUrl($media->file_name)
|
||||||
: null,
|
: null,
|
||||||
'items' => $restock->restockItems->map(fn (RestockItem $item) => [
|
'items' => $restock->restockItems->map(fn(RestockItem $item) => [
|
||||||
'id' => $item->id,
|
'id' => $item->id,
|
||||||
'product_variant_id' => $item->product_variant_id,
|
'product_variant_id' => $item->product_variant_id,
|
||||||
'quantity' => $item->quantity,
|
'quantity' => $item->quantity,
|
||||||
@ -144,7 +145,7 @@ public function create(array $data): Restock
|
|||||||
NotificationService::notify(
|
NotificationService::notify(
|
||||||
roles: ['Owner', 'Developer', 'Admin Toko'],
|
roles: ['Owner', 'Developer', 'Admin Toko'],
|
||||||
title: 'Restock Baru',
|
title: 'Restock Baru',
|
||||||
body: 'Restock '.($stockType === ProductStockQuality::GOOD->value ? 'produk' : 'reject').' sebesar Rp '.number_format($subtotal, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
|
body: 'Restock ' . ($stockType === ProductStockQuality::GOOD->value ? 'produk' : 'reject') . ' sebesar Rp ' . number_format($subtotal, 0, ',', '.') . ' berhasil dicatat oleh ' . auth()->user()->full_name . '.',
|
||||||
url: route('admin.manage.restocks.index'),
|
url: route('admin.manage.restocks.index'),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -218,7 +219,7 @@ private function buildItemRows(array $items, string $stockType, $now, int &$subt
|
|||||||
->get()
|
->get()
|
||||||
->mapWithKeys(function (ProductVariant $variant) use ($priceType) {
|
->mapWithKeys(function (ProductVariant $variant) use ($priceType) {
|
||||||
$price = $variant->productPrices
|
$price = $variant->productPrices
|
||||||
->first(fn ($p) => $p->type === $priceType);
|
->first(fn($p) => $p->type === $priceType);
|
||||||
|
|
||||||
return [$variant->id => $price?->price ?? 0];
|
return [$variant->id => $price?->price ?? 0];
|
||||||
});
|
});
|
||||||
|
|||||||
@ -53,16 +53,18 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
|||||||
'orderItems.productVariant.product:id,name',
|
'orderItems.productVariant.product:id,name',
|
||||||
])
|
])
|
||||||
->when($search, function ($q) use ($search) {
|
->when($search, function ($q) use ($search) {
|
||||||
$q->whereHas('orderItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%"))
|
$q->whereHas('orderItems.productVariant.product', fn($sq) => $sq->where('name', 'like', "%{$search}%"))
|
||||||
->orWhere('order_number', 'like', "%{$search}%")
|
->orWhere('order_number', 'like', "%{$search}%")
|
||||||
->orWhere('notes', 'like', "%{$search}%");
|
->orWhere('notes', 'like', "%{$search}%");
|
||||||
})
|
})
|
||||||
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
|
->when($filters['status'] ?? null, fn($q, $status) => $q->where('status', $status))
|
||||||
->when($filters['channel'] ?? null, fn ($q, $channel) => $q->where('channel', $channel))
|
->when($filters['channel'] ?? null, fn($q, $channel) => $q->where('channel', $channel))
|
||||||
->when($filters['payment_type'] ?? null, fn ($q, $paymentType) => $q->where('payment_type', $paymentType))
|
->when($filters['payment_type'] ?? null, fn($q, $paymentType) => $q->where('payment_type', $paymentType))
|
||||||
->when($filters['customer_id'] ?? null, fn ($q, $customerId) => $q->where('customer_id', $customerId))
|
->when($filters['customer_id'] ?? null, fn($q, $customerId) => $q->where('customer_id', $customerId))
|
||||||
->when($filters['marketing_id'] ?? null, fn ($q, $marketingId) => $q->where('marketing_id', $marketingId))
|
->when($filters['marketing_id'] ?? null, fn($q, $marketingId) => $q->where('marketing_id', $marketingId))
|
||||||
->when($filters['created_by_id'] ?? null, fn ($q, $createdById) => $q->where('created_by_id', $createdById))
|
->when($filters['created_by_id'] ?? null, fn($q, $createdById) => $q->where('created_by_id', $createdById))
|
||||||
|
->when($filters['date_from'] ?? null, fn($q, $dateFrom) => $q->whereDate('created_at', '>=', $dateFrom))
|
||||||
|
->when($filters['date_to'] ?? null, fn($q, $dateTo) => $q->whereDate('created_at', '<=', $dateTo))
|
||||||
->orderBy($sort, $direction)
|
->orderBy($sort, $direction)
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
|
|
||||||
@ -89,6 +91,33 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
|||||||
return $paginator;
|
return $paginator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getSummary(array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = Order::query()
|
||||||
|
->selectRaw('COUNT(*) as total_orders')
|
||||||
|
->selectRaw('COALESCE(SUM(subtotal), 0) as total_subtotal')
|
||||||
|
->selectRaw('COALESCE(SUM(subtotal) - SUM(COALESCE(nego_price, subtotal)), 0) as total_discount')
|
||||||
|
->selectRaw('COALESCE(SUM(total_amount), 0) as total_amount')
|
||||||
|
->selectRaw('COALESCE(SUM(cogs), 0) as total_cogs')
|
||||||
|
->when($filters['status'] ?? null, fn($q, $status) => $q->where('status', $status))
|
||||||
|
->when($filters['channel'] ?? null, fn($q, $channel) => $q->where('channel', $channel))
|
||||||
|
->when($filters['payment_type'] ?? null, fn($q, $paymentType) => $q->where('payment_type', $paymentType))
|
||||||
|
->when($filters['customer_id'] ?? null, fn($q, $customerId) => $q->where('customer_id', $customerId))
|
||||||
|
->when($filters['marketing_id'] ?? null, fn($q, $marketingId) => $q->where('marketing_id', $marketingId))
|
||||||
|
->when($filters['created_by_id'] ?? null, fn($q, $createdById) => $q->where('created_by_id', $createdById))
|
||||||
|
->when($filters['date_from'] ?? null, fn($q, $dateFrom) => $q->whereDate('created_at', '>=', $dateFrom))
|
||||||
|
->when($filters['date_to'] ?? null, fn($q, $dateTo) => $q->whereDate('created_at', '<=', $dateTo))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_orders' => $query->total_orders,
|
||||||
|
'total_subtotal' => $query->total_subtotal,
|
||||||
|
'total_discount' => $query->total_discount,
|
||||||
|
'total_amount' => $query->total_amount,
|
||||||
|
'net_total' => $query->total_amount - $query->total_cogs,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function getFilterOptions(): array
|
public function getFilterOptions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@ -105,9 +134,9 @@ public function getFilterOptions(): array
|
|||||||
->with('userProfile:id,user_id,full_name')
|
->with('userProfile:id,user_id,full_name')
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->get()
|
->get()
|
||||||
->filter(fn (User $user) => $user->userProfile?->full_name)
|
->filter(fn(User $user) => $user->userProfile?->full_name)
|
||||||
->values()
|
->values()
|
||||||
->map(fn (User $user) => [
|
->map(fn(User $user) => [
|
||||||
'id' => $user->id,
|
'id' => $user->id,
|
||||||
'name' => $user->userProfile->full_name,
|
'name' => $user->userProfile->full_name,
|
||||||
]),
|
]),
|
||||||
@ -123,6 +152,7 @@ public function getForCreate(): array
|
|||||||
'productVariants:id,product_id,name,stock,reject_stock',
|
'productVariants:id,product_id,name,stock,reject_stock',
|
||||||
'productVariants.productPrices:id,variant_id,type,price',
|
'productVariants.productPrices:id,variant_id,type,price',
|
||||||
])
|
])
|
||||||
|
->active()
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get()
|
->get()
|
||||||
->each(function (Product $product) {
|
->each(function (Product $product) {
|
||||||
@ -132,7 +162,7 @@ public function getForCreate(): array
|
|||||||
? $this->s3Service->getTemporaryUrl($media->file_name)
|
? $this->s3Service->getTemporaryUrl($media->file_name)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
$prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]);
|
$prices = $variant->productPrices->mapWithKeys(fn($p) => [$p->type->value => $p->price]);
|
||||||
$variant->prices = $prices;
|
$variant->prices = $prices;
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@ -146,11 +176,11 @@ public function getForCreate(): array
|
|||||||
->with('userProfile:id,user_id,full_name')
|
->with('userProfile:id,user_id,full_name')
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->get()
|
->get()
|
||||||
->filter(fn (User $user) => $user->userProfile?->full_name)
|
->filter(fn(User $user) => $user->userProfile?->full_name)
|
||||||
->values(),
|
->values(),
|
||||||
'channelOptions' => OrderChannel::toSelect(),
|
'channelOptions' => OrderChannel::toSelect(),
|
||||||
'paymentTypeOptions' => PaymentType::toSelect(),
|
'paymentTypeOptions' => PaymentType::toSelect(),
|
||||||
'priceTypeOptions' => PriceType::toSelect()->filter(fn ($p) => $p['value'] !== PriceType::CAPITAL->value)->values(),
|
'priceTypeOptions' => PriceType::toSelect()->filter(fn($p) => $p['value'] !== PriceType::CAPITAL->value)->values(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,7 +210,7 @@ public function getForEdit(Order $order): array
|
|||||||
'photo_url' => $media
|
'photo_url' => $media
|
||||||
? $this->s3Service->getTemporaryUrl($media->file_name)
|
? $this->s3Service->getTemporaryUrl($media->file_name)
|
||||||
: null,
|
: null,
|
||||||
'items' => $order->orderItems->map(fn (OrderItem $item) => [
|
'items' => $order->orderItems->map(fn(OrderItem $item) => [
|
||||||
'id' => $item->id,
|
'id' => $item->id,
|
||||||
'product_variant_id' => $item->product_variant_id,
|
'product_variant_id' => $item->product_variant_id,
|
||||||
'quantity' => $item->quantity,
|
'quantity' => $item->quantity,
|
||||||
@ -240,7 +270,7 @@ public function create(array $data): Order
|
|||||||
NotificationService::notify(
|
NotificationService::notify(
|
||||||
roles: ['Owner', 'Developer', 'Admin Toko'],
|
roles: ['Owner', 'Developer', 'Admin Toko'],
|
||||||
title: 'Transaksi Baru',
|
title: 'Transaksi Baru',
|
||||||
body: 'Transaksi '.$order->order_number.' sebesar Rp '.number_format($totalAmount, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
|
body: 'Transaksi ' . $order->order_number . ' sebesar Rp ' . number_format($totalAmount, 0, ',', '.') . ' berhasil dicatat oleh ' . auth()->user()->full_name . '.',
|
||||||
url: route('admin.manage.transactions.index'),
|
url: route('admin.manage.transactions.index'),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -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
|
private function buildItemRows(array $items, string $stockType, string $priceType, $now, int &$subtotal, int &$totalCost): array
|
||||||
{
|
{
|
||||||
$resolvedPriceType = $stockType === ProductStockQuality::REJECT->value
|
$resolvedPriceType = $stockType === ProductStockQuality::REJECT->value
|
||||||
@ -342,14 +379,14 @@ private function buildItemRows(array $items, string $stockType, string $priceTyp
|
|||||||
|
|
||||||
$prices = $variants->mapWithKeys(function (ProductVariant $variant) use ($resolvedPriceType) {
|
$prices = $variants->mapWithKeys(function (ProductVariant $variant) use ($resolvedPriceType) {
|
||||||
$price = $variant->productPrices
|
$price = $variant->productPrices
|
||||||
->first(fn ($p) => $p->type === $resolvedPriceType);
|
->first(fn($p) => $p->type === $resolvedPriceType);
|
||||||
|
|
||||||
return [$variant->id => $price?->price ?? 0];
|
return [$variant->id => $price?->price ?? 0];
|
||||||
});
|
});
|
||||||
|
|
||||||
$capitalPrices = $variants->mapWithKeys(function (ProductVariant $variant) {
|
$capitalPrices = $variants->mapWithKeys(function (ProductVariant $variant) {
|
||||||
$price = $variant->productPrices
|
$price = $variant->productPrices
|
||||||
->first(fn ($p) => $p->type === PriceType::CAPITAL);
|
->first(fn($p) => $p->type === PriceType::CAPITAL);
|
||||||
|
|
||||||
return [$variant->id => $price?->price ?? 0];
|
return [$variant->id => $price?->price ?? 0];
|
||||||
});
|
});
|
||||||
@ -392,6 +429,6 @@ private function generateOrderNumber(): string
|
|||||||
$sequence = 1;
|
$sequence = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $prefix.$date.str_pad($sequence, 4, '0', STR_PAD_LEFT);
|
return $prefix . $date . str_pad($sequence, 4, '0', STR_PAD_LEFT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,16 @@ public function __construct(
|
|||||||
private StockMutationService $stockMutationService,
|
private StockMutationService $stockMutationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public function getNames(): array
|
||||||
|
{
|
||||||
|
return Product::where('status', '!=', 'deleted')
|
||||||
|
->orderBy('name')
|
||||||
|
->pluck('name')
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
public function getAll(array $filters = []): Collection
|
public function getAll(array $filters = []): Collection
|
||||||
{
|
{
|
||||||
$products = Product::select(['id', 'name', 'slug', 'description', 'status'])
|
$products = Product::select(['id', 'name', 'slug', 'description', 'status'])
|
||||||
@ -53,6 +63,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
|||||||
'productVariants.productPrices:id,variant_id,type,price',
|
'productVariants.productPrices:id,variant_id,type,price',
|
||||||
])
|
])
|
||||||
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
|
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
|
||||||
|
->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%"))
|
||||||
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
|
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
|
||||||
->when($filters['category'] ?? null, fn ($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) {
|
->when($filters['category'] ?? null, fn ($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) {
|
||||||
$cq->where('categories.id', $categoryId);
|
$cq->where('categories.id', $categoryId);
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Enums\ProductStockQuality;
|
use App\Enums\ProductStockQuality;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
trait HasStockAdjustment
|
trait HasStockAdjustment
|
||||||
{
|
{
|
||||||
@ -18,6 +19,17 @@ private function adjustStock(Model $model, string $field, int $quantity, int $si
|
|||||||
if ($sign > 0) {
|
if ($sign > 0) {
|
||||||
$model->increment($field, $quantity);
|
$model->increment($field, $quantity);
|
||||||
} else {
|
} else {
|
||||||
|
if ($model->{$field} < $quantity) {
|
||||||
|
$label = match ($field) {
|
||||||
|
'stock' => 'stok bagus',
|
||||||
|
'reject_stock' => 'stok reject',
|
||||||
|
'retail_stock' => 'stok ecer',
|
||||||
|
default => $field,
|
||||||
|
};
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'stock' => "Stok {$label} tidak mencukupi. Tersedia: {$model->{$field}}, dibutuhkan: {$quantity}.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
$model->decrement($field, $quantity);
|
$model->decrement($field, $quantity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -268,7 +268,6 @@ public function run(): void
|
|||||||
'employee_advances.create',
|
'employee_advances.create',
|
||||||
'employee_advances.update',
|
'employee_advances.update',
|
||||||
'employee_advances.delete',
|
'employee_advances.delete',
|
||||||
'employee_advances.pay',
|
|
||||||
|
|
||||||
'activity_logs.view',
|
'activity_logs.view',
|
||||||
|
|
||||||
|
|||||||
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 * as React from "react"
|
||||||
import { Label as LabelPrimitive } from "radix-ui"
|
import { Label as LabelPrimitive } from "radix-ui"
|
||||||
|
|
||||||
@ -13,7 +11,7 @@ function Label({
|
|||||||
<LabelPrimitive.Root
|
<LabelPrimitive.Root
|
||||||
data-slot="label"
|
data-slot="label"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||||
|
|
||||||
@ -15,7 +17,7 @@ function Separator({
|
|||||||
decorative={decorative}
|
decorative={decorative}
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -120,7 +120,7 @@ export function createTransactionColumns(
|
|||||||
accessorKey: 'description',
|
accessorKey: 'description',
|
||||||
header: () => <span>Keterangan</span>,
|
header: () => <span>Keterangan</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="block max-w-[200px] truncate">
|
<span className="block max-w-[200px]">
|
||||||
{row.getValue('description') as string}
|
{row.getValue('description') as string}
|
||||||
</span>
|
</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 { RowActions } from '@/components/row-actions';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { CheckCircle, CircleDollarSign, History, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export type EmployeeAdvancePayment = {
|
||||||
|
id: number;
|
||||||
|
amount: number;
|
||||||
|
formatted_amount: string;
|
||||||
|
description: string | null;
|
||||||
|
paid_at: string;
|
||||||
|
formatted_paid_at: string;
|
||||||
|
paid_by: {
|
||||||
|
user_profile: {
|
||||||
|
full_name: string;
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type EmployeeAdvance = {
|
export type EmployeeAdvance = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -9,6 +23,8 @@ export type EmployeeAdvance = {
|
|||||||
formatted_amount: string;
|
formatted_amount: string;
|
||||||
paid_amount: number;
|
paid_amount: number;
|
||||||
formatted_paid_amount: string;
|
formatted_paid_amount: string;
|
||||||
|
remaining_amount: number;
|
||||||
|
formatted_remaining_amount: string;
|
||||||
description: string;
|
description: string;
|
||||||
due_date: string;
|
due_date: string;
|
||||||
formatted_due_date: string;
|
formatted_due_date: string;
|
||||||
@ -17,11 +33,13 @@ export type EmployeeAdvance = {
|
|||||||
formatted_created_at: string;
|
formatted_created_at: string;
|
||||||
employee: {
|
employee: {
|
||||||
user: {
|
user: {
|
||||||
|
id: number;
|
||||||
user_profile: {
|
user_profile: {
|
||||||
full_name: string;
|
full_name: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
payments: EmployeeAdvancePayment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function getStatusBadge(status: string) {
|
function getStatusBadge(status: string) {
|
||||||
@ -62,13 +80,15 @@ type CreateColumnsParams = {
|
|||||||
handleDeleteClick: (employeeAdvance: EmployeeAdvance) => void;
|
handleDeleteClick: (employeeAdvance: EmployeeAdvance) => void;
|
||||||
handleApprove: (employeeAdvance: EmployeeAdvance) => void;
|
handleApprove: (employeeAdvance: EmployeeAdvance) => void;
|
||||||
handlePay: (employeeAdvance: EmployeeAdvance) => void;
|
handlePay: (employeeAdvance: EmployeeAdvance) => void;
|
||||||
|
handleShowPayments: (employeeAdvance: EmployeeAdvance) => void;
|
||||||
can: (permission: string) => boolean;
|
can: (permission: string) => boolean;
|
||||||
|
authUserId: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createEmployeeAdvanceColumns(
|
export function createEmployeeAdvanceColumns(
|
||||||
params: CreateColumnsParams,
|
params: CreateColumnsParams,
|
||||||
): ColumnDef<EmployeeAdvance>[] {
|
): ColumnDef<EmployeeAdvance>[] {
|
||||||
const { handleEdit, handleDeleteClick, handleApprove, handlePay, can } =
|
const { handleEdit, handleDeleteClick, handleApprove, handlePay, handleShowPayments, can, authUserId } =
|
||||||
params;
|
params;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -96,11 +116,27 @@ export function createEmployeeAdvanceColumns(
|
|||||||
accessorKey: 'formatted_amount',
|
accessorKey: 'formatted_amount',
|
||||||
header: () => <span>Jumlah</span>,
|
header: () => <span>Jumlah</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-medium text-red-600">
|
<span className="font-medium">
|
||||||
- {row.getValue('formatted_amount') as string}
|
{row.getValue('formatted_amount') as string}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'formatted_remaining_amount',
|
||||||
|
header: () => <span>Sisa</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const employeeAdvance = row.original;
|
||||||
|
if (employeeAdvance.status === 'paid') {
|
||||||
|
return <span className="text-green-600">Lunas</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="font-medium text-orange-600">
|
||||||
|
{row.getValue('formatted_remaining_amount') as string}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'description',
|
accessorKey: 'description',
|
||||||
header: () => <span>Keterangan</span>,
|
header: () => <span>Keterangan</span>,
|
||||||
@ -159,10 +195,19 @@ export function createEmployeeAdvanceColumns(
|
|||||||
employeeAdvance.status === 'approved',
|
employeeAdvance.status === 'approved',
|
||||||
onClick: () => handlePay(employeeAdvance),
|
onClick: () => handlePay(employeeAdvance),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Riwayat',
|
||||||
|
icon: <History className="h-4 w-4" />,
|
||||||
|
show: employeeAdvance.payments.length > 0,
|
||||||
|
onClick: () => handleShowPayments(employeeAdvance),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
icon: <Pencil className="h-4 w-4" />,
|
icon: <Pencil className="h-4 w-4" />,
|
||||||
show: can('employee_advances.update'),
|
show:
|
||||||
|
can('employee_advances.update') &&
|
||||||
|
employeeAdvance.status === 'pending' &&
|
||||||
|
employeeAdvance.employee?.user?.id === authUserId,
|
||||||
onClick: () => handleEdit(employeeAdvance),
|
onClick: () => handleEdit(employeeAdvance),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -170,7 +215,10 @@ export function createEmployeeAdvanceColumns(
|
|||||||
icon: (
|
icon: (
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
),
|
),
|
||||||
show: can('employee_advances.delete'),
|
show:
|
||||||
|
can('employee_advances.delete') &&
|
||||||
|
employeeAdvance.status === 'pending' &&
|
||||||
|
employeeAdvance.employee?.user?.id === authUserId,
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
handleDeleteClick(employeeAdvance),
|
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 type { PaginationState } from '@/components/data-table';
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import { DatePicker } from '@/components/date-picker';
|
import { DatePicker } from '@/components/date-picker';
|
||||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||||
|
import { FilterPopover } from '@/components/filter-popover';
|
||||||
import { FormDialog } from '@/components/form-dialog';
|
import { FormDialog } from '@/components/form-dialog';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { RupiahInput } from '@/components/rupiah-input';
|
import { RupiahInput } from '@/components/rupiah-input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import { useCan } from '@/hooks/use-can';
|
import { useCan } from '@/hooks/use-can';
|
||||||
import { useServerTable } from '@/hooks/use-server-table';
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
import {
|
import {
|
||||||
|
approve,
|
||||||
destroy,
|
destroy,
|
||||||
index as employeeAdvanceIndex,
|
index as employeeAdvanceIndex,
|
||||||
|
pay,
|
||||||
store,
|
store,
|
||||||
update,
|
update,
|
||||||
approve,
|
|
||||||
pay,
|
|
||||||
} from '@/routes/admin/finance/employee-advances';
|
} from '@/routes/admin/finance/employee-advances';
|
||||||
import { 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 type { EmployeeAdvance } from './columns';
|
||||||
|
import { createEmployeeAdvanceColumns } from './columns';
|
||||||
|
|
||||||
|
type TypeOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
employeeAdvances: {
|
employeeAdvances: {
|
||||||
@ -33,15 +54,27 @@ type Props = {
|
|||||||
per_page: number;
|
per_page: number;
|
||||||
total: 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 { can } = useCan();
|
||||||
|
const { auth } = usePage().props as { auth: { user: { id: number } } };
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<EmployeeAdvance | null>(null);
|
const [editing, setEditing] = useState<EmployeeAdvance | null>(null);
|
||||||
const [deleting, setDeleting] = useState<EmployeeAdvance | null>(null);
|
const [deleting, setDeleting] = useState<EmployeeAdvance | null>(null);
|
||||||
const [approving, setApproving] = useState<EmployeeAdvance | null>(null);
|
const [approving, setApproving] = useState<EmployeeAdvance | null>(null);
|
||||||
const [paying, setPaying] = useState<EmployeeAdvance | null>(null);
|
const [paying, setPaying] = useState<EmployeeAdvance | null>(null);
|
||||||
|
const [viewingPayments, setViewingPayments] = useState<EmployeeAdvance | null>(null);
|
||||||
const [dueDate, setDueDate] = useState<Date | undefined>(undefined);
|
const [dueDate, setDueDate] = useState<Date | undefined>(undefined);
|
||||||
const [editingDueDate, setEditingDueDate] = useState<Date | undefined>(
|
const [editingDueDate, setEditingDueDate] = useState<Date | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
@ -56,12 +89,17 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
search,
|
search,
|
||||||
|
filterOpen,
|
||||||
|
setFilterOpen,
|
||||||
handlePageChange,
|
handlePageChange,
|
||||||
handlePerPageChange,
|
handlePerPageChange,
|
||||||
handleSearchChange,
|
handleSearchChange,
|
||||||
|
applyFilter,
|
||||||
|
clearFilters,
|
||||||
} = useServerTable({
|
} = useServerTable({
|
||||||
route: () => employeeAdvanceIndex.url(),
|
route: () => employeeAdvanceIndex.url(),
|
||||||
pagination,
|
pagination,
|
||||||
|
filters,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -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({
|
const columns = createEmployeeAdvanceColumns({
|
||||||
handleEdit: (employeeAdvance) => setEditing(employeeAdvance),
|
handleEdit: (employeeAdvance) => setEditing(employeeAdvance),
|
||||||
handleDeleteClick: (employeeAdvance) => setDeleting(employeeAdvance),
|
handleDeleteClick: (employeeAdvance) => setDeleting(employeeAdvance),
|
||||||
handleApprove: (employeeAdvance) => setApproving(employeeAdvance),
|
handleApprove: (employeeAdvance) => setApproving(employeeAdvance),
|
||||||
handlePay: (employeeAdvance) => setPaying(employeeAdvance),
|
handlePay: (employeeAdvance) => setPaying(employeeAdvance),
|
||||||
|
handleShowPayments: (employeeAdvance) => setViewingPayments(employeeAdvance),
|
||||||
can,
|
can,
|
||||||
|
authUserId: auth.user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const filterToolbar = (
|
||||||
|
<FilterPopover
|
||||||
|
open={filterOpen}
|
||||||
|
onOpenChange={setFilterOpen}
|
||||||
|
filters={filters}
|
||||||
|
hasActiveFilters={Boolean(filters.status)}
|
||||||
|
onClear={clearFilters}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-xs text-muted-foreground">Status</label>
|
||||||
|
<Select
|
||||||
|
value={filters.status ?? 'all'}
|
||||||
|
onValueChange={(value) => applyFilter('status', value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Semua Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Semua Status</SelectItem>
|
||||||
|
{filterOptions.statusOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</FilterPopover>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Kasbon" />
|
<Head title="Kasbon" />
|
||||||
@ -187,8 +243,8 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
|||||||
value={
|
value={
|
||||||
dueDate
|
dueDate
|
||||||
? dueDate
|
? dueDate
|
||||||
.toISOString()
|
.toISOString()
|
||||||
.split('T')[0]
|
.split('T')[0]
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -214,6 +270,7 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
|||||||
onPerPageChange={handlePerPageChange}
|
onPerPageChange={handlePerPageChange}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
searchValue={search}
|
searchValue={search}
|
||||||
|
toolbar={filterToolbar}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormDialog
|
<FormDialog
|
||||||
@ -276,8 +333,8 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
|||||||
value={
|
value={
|
||||||
editingDueDate
|
editingDueDate
|
||||||
? editingDueDate
|
? editingDueDate
|
||||||
.toISOString()
|
.toISOString()
|
||||||
.split('T')[0]
|
.split('T')[0]
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -322,20 +379,128 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
|||||||
onConfirm={handleApprove}
|
onConfirm={handleApprove}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DeleteConfirmDialog
|
<FormDialog
|
||||||
target={paying}
|
open={paying !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setPaying(null);
|
setPaying(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Bayar Kasbon"
|
title="Bayar Kasbon"
|
||||||
description={(advance) =>
|
action={paying ? pay(paying.id) : ''}
|
||||||
`Apakah Anda yakin ingin membayar kasbon "${advance.description}" sebesar ${advance.amount}? Saldo kas akan dikembalikan.`
|
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"
|
</FormDialog>
|
||||||
onConfirm={handlePay}
|
|
||||||
/>
|
<Dialog
|
||||||
|
open={viewingPayments !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setViewingPayments(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Riwayat Pembayaran</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{viewingPayments?.description}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{viewingPayments && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Total:
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{viewingPayments.formatted_amount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Terbayar:
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-green-600">
|
||||||
|
{viewingPayments.formatted_paid_amount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Sisa:
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-orange-600">
|
||||||
|
{viewingPayments.formatted_remaining_amount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{viewingPayments.payments.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||||
|
Belum ada pembayaran.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{viewingPayments.payments.map(
|
||||||
|
(payment) => (
|
||||||
|
<div
|
||||||
|
key={payment.id}
|
||||||
|
className="rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-green-600">
|
||||||
|
{payment.formatted_amount}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{payment.formatted_paid_at}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{payment.paid_by && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
oleh{' '}
|
||||||
|
{payment.paid_by
|
||||||
|
.user_profile
|
||||||
|
.full_name ?? '-'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -42,7 +42,7 @@ export function createExpenseColumns(
|
|||||||
accessorKey: 'description',
|
accessorKey: 'description',
|
||||||
header: () => <span>Keterangan</span>,
|
header: () => <span>Keterangan</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="block max-w-[200px] truncate">
|
<span className="block max-w-[200px]">
|
||||||
{row.getValue('description') as string}
|
{row.getValue('description') as string}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -50,14 +50,15 @@ type CreateColumnsParams = {
|
|||||||
handleClose: (period: PayrollPeriod) => void;
|
handleClose: (period: PayrollPeriod) => void;
|
||||||
handleReopen: (period: PayrollPeriod) => void;
|
handleReopen: (period: PayrollPeriod) => void;
|
||||||
can: (permission: string) => boolean;
|
can: (permission: string) => boolean;
|
||||||
|
canViewAll: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createPayrollPeriodColumns(
|
export function createPayrollPeriodColumns(
|
||||||
params: CreateColumnsParams,
|
params: CreateColumnsParams,
|
||||||
): ColumnDef<PayrollPeriod>[] {
|
): ColumnDef<PayrollPeriod>[] {
|
||||||
const { showUrl, handleClose, handleReopen, can } = params;
|
const { showUrl, handleClose, handleReopen, can, canViewAll } = params;
|
||||||
|
|
||||||
return [
|
const columns: ColumnDef<PayrollPeriod>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: 'year',
|
accessorKey: 'year',
|
||||||
header: () => <span>Periode</span>,
|
header: () => <span>Periode</span>,
|
||||||
@ -67,7 +68,10 @@ export function createPayrollPeriodColumns(
|
|||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
];
|
||||||
|
|
||||||
|
if (canViewAll) {
|
||||||
|
columns.push({
|
||||||
accessorKey: 'payrolls_count',
|
accessorKey: 'payrolls_count',
|
||||||
header: () => <span>Jumlah Karyawan</span>,
|
header: () => <span>Jumlah Karyawan</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
@ -75,7 +79,10 @@ export function createPayrollPeriodColumns(
|
|||||||
{row.getValue('payrolls_count') as number}
|
{row.getValue('payrolls_count') as number}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
columns.push(
|
||||||
{
|
{
|
||||||
accessorKey: 'payrolls_sum_total_amount',
|
accessorKey: 'payrolls_sum_total_amount',
|
||||||
header: () => <span>Total Gaji</span>,
|
header: () => <span>Total Gaji</span>,
|
||||||
@ -131,7 +138,10 @@ export function createPayrollPeriodColumns(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
);
|
||||||
|
|
||||||
|
if (canViewAll) {
|
||||||
|
columns.push({
|
||||||
id: 'payment_status',
|
id: 'payment_status',
|
||||||
header: () => <span>Status Bayar</span>,
|
header: () => <span>Status Bayar</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@ -161,7 +171,10 @@ export function createPayrollPeriodColumns(
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
columns.push(
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
header: () => <span>Status</span>,
|
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) {
|
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 [closing, setClosing] = useState<PayrollPeriod | null>(null);
|
||||||
const [reopening, setReopening] = 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),
|
handleClose: (period) => setClosing(period),
|
||||||
handleReopen: (period) => setReopening(period),
|
handleReopen: (period) => setReopening(period),
|
||||||
can,
|
can,
|
||||||
|
canViewAll,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -81,7 +81,9 @@ export function createPayrollColumns(
|
|||||||
can,
|
can,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
return [
|
const hasAnyPayrollAction = can('payroll.adjust') || can('payroll.pay') || can('payroll.cancel');
|
||||||
|
|
||||||
|
const columns: ColumnDef<Payroll>[] = [
|
||||||
{
|
{
|
||||||
id: 'employee_name',
|
id: 'employee_name',
|
||||||
header: () => <span>Nama Karyawan</span>,
|
header: () => <span>Nama Karyawan</span>,
|
||||||
@ -182,10 +184,10 @@ export function createPayrollColumns(
|
|||||||
{adj.type === 'bonus' ? '+' : '-'}{' '}
|
{adj.type === 'bonus' ? '+' : '-'}{' '}
|
||||||
{formatCurrency(adj.amount)}
|
{formatCurrency(adj.amount)}
|
||||||
</span>
|
</span>
|
||||||
<span className="max-w-[100px] truncate text-muted-foreground">
|
<span className="max-w-[100px] text-muted-foreground">
|
||||||
{adj.description}
|
{adj.description}
|
||||||
</span>
|
</span>
|
||||||
{payroll.status === 'unpaid' && (
|
{can('payroll.adjust') && payroll.status === 'unpaid' && (
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleDeleteAdjustment(
|
handleDeleteAdjustment(
|
||||||
@ -204,7 +206,10 @@ export function createPayrollColumns(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
];
|
||||||
|
|
||||||
|
if (hasAnyPayrollAction) {
|
||||||
|
columns.push({
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: () => <span className="block text-center">Aksi</span>,
|
header: () => <span className="block text-center">Aksi</span>,
|
||||||
meta: {
|
meta: {
|
||||||
@ -249,6 +254,8 @@ export function createPayrollColumns(
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
];
|
}
|
||||||
|
|
||||||
|
return columns;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,7 +40,8 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function PayrollPeriodShow({ payrollPeriod }: 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 [paying, setPaying] = useState<Payroll | null>(null);
|
||||||
const [cancelling, setCancelling] = useState<Payroll | null>(null);
|
const [cancelling, setCancelling] = useState<Payroll | null>(null);
|
||||||
const [addingAdjustment, setAddingAdjustment] = useState<Payroll | null>(
|
const [addingAdjustment, setAddingAdjustment] = useState<Payroll | null>(
|
||||||
@ -103,19 +104,23 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
|
|||||||
can,
|
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,
|
(sum, p) => sum + p.base_salary,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const totalBonus = payrollPeriod.payrolls.reduce(
|
const totalBonus = activePayrolls.reduce(
|
||||||
(sum, p) => sum + p.bonus_amount,
|
(sum, p) => sum + p.bonus_amount,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const totalDeduction = payrollPeriod.payrolls.reduce(
|
const totalDeduction = activePayrolls.reduce(
|
||||||
(sum, p) => sum + p.deduction_amount,
|
(sum, p) => sum + p.deduction_amount,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const totalAmount = payrollPeriod.payrolls.reduce(
|
const totalAmount = activePayrolls.reduce(
|
||||||
(sum, p) => sum + p.total_amount,
|
(sum, p) => sum + p.total_amount,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
@ -133,13 +138,15 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
|
|||||||
Gaji {MONTH_NAMES[payrollPeriod.month]}{' '}
|
Gaji {MONTH_NAMES[payrollPeriod.month]}{' '}
|
||||||
{payrollPeriod.year}
|
{payrollPeriod.year}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
{canViewAll && (
|
||||||
{payrollPeriod.payrolls.length} karyawan ·
|
<p className="text-sm text-muted-foreground">
|
||||||
Status:{' '}
|
{payrollPeriod.payrolls.length} karyawan ·
|
||||||
{payrollPeriod.status === 'open'
|
Status:{' '}
|
||||||
? 'Terbuka'
|
{payrollPeriod.status === 'open'
|
||||||
: 'Ditutup'}
|
? 'Terbuka'
|
||||||
</p>
|
: 'Ditutup'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href={payrollPeriodsIndex.url()}>
|
<Link href={payrollPeriodsIndex.url()}>
|
||||||
|
|||||||
@ -39,6 +39,7 @@ type CreateColumnsParams = {
|
|||||||
handleResetPassword: (employee: Employee) => void;
|
handleResetPassword: (employee: Employee) => void;
|
||||||
toggleActiveUrl: (id: number) => string;
|
toggleActiveUrl: (id: number) => string;
|
||||||
can: (permission: string) => boolean;
|
can: (permission: string) => boolean;
|
||||||
|
canViewAll: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createEmployeeColumns(
|
export function createEmployeeColumns(
|
||||||
@ -50,6 +51,7 @@ export function createEmployeeColumns(
|
|||||||
handleResetPassword,
|
handleResetPassword,
|
||||||
toggleActiveUrl,
|
toggleActiveUrl,
|
||||||
can,
|
can,
|
||||||
|
canViewAll,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -93,20 +95,25 @@ export function createEmployeeColumns(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
...(canViewAll
|
||||||
id: 'role',
|
? [
|
||||||
header: () => <span>Role</span>,
|
{
|
||||||
cell: ({ row }) => {
|
id: 'role',
|
||||||
const employee = row.original;
|
header: () => <span>Role</span>,
|
||||||
const roleName = employee.roles?.[0]?.name ?? '-';
|
cell: ({ row }) => {
|
||||||
|
const employee = row.original;
|
||||||
|
const roleName =
|
||||||
|
employee.roles?.[0]?.name ?? '-';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium">
|
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium">
|
||||||
{roleName}
|
{roleName}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
accessorKey: 'employee.employment_status',
|
accessorKey: 'employee.employment_status',
|
||||||
id: 'employment_status',
|
id: 'employment_status',
|
||||||
|
|||||||
@ -36,12 +36,15 @@ type Role = {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
roles: Role[];
|
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 [joinDate, setJoinDate] = useState<Date | undefined>(undefined);
|
||||||
const [resignDate, setResignDate] = useState<Date | undefined>(undefined);
|
const [resignDate, setResignDate] = useState<Date | undefined>(undefined);
|
||||||
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
|
const [selectedRole, setSelectedRole] = useState<Role | null>(
|
||||||
|
!canViewAll && roles.length === 1 ? roles[0] : null,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -117,46 +120,61 @@ export default function EmployeeCreate({ roles }: Props) {
|
|||||||
message={errors.username}
|
message={errors.username}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
{canViewAll ? (
|
||||||
<Label>
|
<div className="grid gap-2">
|
||||||
Role{' '}
|
<Label>
|
||||||
<span className="text-destructive">
|
Role{' '}
|
||||||
*
|
<span className="text-destructive">
|
||||||
</span>
|
*
|
||||||
</Label>
|
</span>
|
||||||
<Combobox
|
</Label>
|
||||||
items={roles}
|
<Combobox
|
||||||
itemToStringLabel={(r) => r.name}
|
items={roles}
|
||||||
value={selectedRole}
|
itemToStringLabel={(r) =>
|
||||||
onValueChange={(value) =>
|
r.name
|
||||||
setSelectedRole(value)
|
}
|
||||||
}
|
value={selectedRole}
|
||||||
>
|
onValueChange={(value) =>
|
||||||
<ComboboxInput
|
setSelectedRole(value)
|
||||||
placeholder="Cari role..."
|
}
|
||||||
className="w-full"
|
>
|
||||||
|
<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>
|
</div>
|
||||||
<ComboboxEmpty>
|
) : (
|
||||||
Tidak ada role
|
<div className="grid gap-2">
|
||||||
ditemukan.
|
<Label>Role</Label>
|
||||||
</ComboboxEmpty>
|
<div className="flex h-10 w-full items-center rounded-md border border-input bg-muted px-3 py-2 text-sm">
|
||||||
<ComboboxList>
|
{selectedRole?.name ?? '-'}
|
||||||
{(role) => (
|
</div>
|
||||||
<ComboboxItem
|
</div>
|
||||||
key={role.id}
|
)}
|
||||||
value={role}
|
|
||||||
>
|
|
||||||
{role.name}
|
|
||||||
</ComboboxItem>
|
|
||||||
)}
|
|
||||||
</ComboboxList>
|
|
||||||
</ComboboxContent>
|
|
||||||
</Combobox>
|
|
||||||
<InputError
|
|
||||||
message={errors.role}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@ -56,9 +56,10 @@ type EmployeeData = {
|
|||||||
type Props = {
|
type Props = {
|
||||||
employee: EmployeeData;
|
employee: EmployeeData;
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
|
canViewAll: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function EmployeeEdit({ employee, roles }: Props) {
|
export default function EmployeeEdit({ employee, roles, canViewAll }: Props) {
|
||||||
const currentRole = useMemo(
|
const currentRole = useMemo(
|
||||||
() =>
|
() =>
|
||||||
roles.find((r) => r.name === employee.roles?.[0]?.name) ?? null,
|
roles.find((r) => r.name === employee.roles?.[0]?.name) ?? null,
|
||||||
|
|||||||
@ -40,9 +40,10 @@ type Props = {
|
|||||||
is_active?: string;
|
is_active?: string;
|
||||||
gender?: string;
|
gender?: string;
|
||||||
};
|
};
|
||||||
|
canViewAll: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function EmployeeIndex({ employees, filters }: Props) {
|
export default function EmployeeIndex({ employees, filters, canViewAll }: Props) {
|
||||||
const { can } = useCan();
|
const { can } = useCan();
|
||||||
const [deleting, setDeleting] = useState<Employee | null>(null);
|
const [deleting, setDeleting] = useState<Employee | null>(null);
|
||||||
const [resetPasswordTarget, setResetPasswordTarget] =
|
const [resetPasswordTarget, setResetPasswordTarget] =
|
||||||
@ -102,6 +103,7 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
|||||||
handleResetPassword: (employee) => setResetPasswordTarget(employee),
|
handleResetPassword: (employee) => setResetPasswordTarget(employee),
|
||||||
toggleActiveUrl: (id) => toggleActive.url(id),
|
toggleActiveUrl: (id) => toggleActive.url(id),
|
||||||
can,
|
can,
|
||||||
|
canViewAll,
|
||||||
});
|
});
|
||||||
|
|
||||||
const filterToolbar = (
|
const filterToolbar = (
|
||||||
|
|||||||
@ -148,7 +148,9 @@ export function createLeaveRequestColumns(
|
|||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
icon: <Pencil className="h-4 w-4" />,
|
icon: <Pencil className="h-4 w-4" />,
|
||||||
show: can('leave_requests.update'),
|
show:
|
||||||
|
can('leave_requests.update') &&
|
||||||
|
leaveRequest.status === 'pending',
|
||||||
onClick: () => handleEdit(leaveRequest),
|
onClick: () => handleEdit(leaveRequest),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -156,7 +158,9 @@ export function createLeaveRequestColumns(
|
|||||||
icon: (
|
icon: (
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
),
|
),
|
||||||
show: can('leave_requests.delete'),
|
show:
|
||||||
|
can('leave_requests.delete') &&
|
||||||
|
leaveRequest.status === 'pending',
|
||||||
onClick: () => handleDeleteClick(leaveRequest),
|
onClick: () => handleDeleteClick(leaveRequest),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
|
import { Fragment } from 'react';
|
||||||
import { formatNumber } from '@/lib/format';
|
import { formatNumber } from '@/lib/format';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import type { Restock } from './columns';
|
import type { Restock } from './columns';
|
||||||
@ -14,8 +15,20 @@ import type { Restock } from './columns';
|
|||||||
export function RestockItemSubRow({ restock }: { restock: Restock }) {
|
export function RestockItemSubRow({ restock }: { restock: Restock }) {
|
||||||
const items = restock.restock_items ?? [];
|
const items = restock.restock_items ?? [];
|
||||||
|
|
||||||
|
const groupedByProduct = items.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
const name = item.product_variant?.product?.name ?? 'Tanpa Produk';
|
||||||
|
if (!acc[name]) acc[name] = [];
|
||||||
|
acc[name].push(item);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, typeof items>,
|
||||||
|
);
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@ -23,7 +36,6 @@ export function RestockItemSubRow({ restock }: { restock: Restock }) {
|
|||||||
No
|
No
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[60px]">Foto</TableHead>
|
<TableHead className="w-[60px]">Foto</TableHead>
|
||||||
<TableHead>Produk</TableHead>
|
|
||||||
<TableHead>Varian</TableHead>
|
<TableHead>Varian</TableHead>
|
||||||
<TableHead className="text-right">
|
<TableHead className="text-right">
|
||||||
Harga Modal
|
Harga Modal
|
||||||
@ -36,49 +48,94 @@ export function RestockItemSubRow({ restock }: { restock: Restock }) {
|
|||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={7}
|
colSpan={6}
|
||||||
className="text-center text-muted-foreground"
|
className="text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
Tidak ada item.
|
Tidak ada item.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
items.map((item, index) => (
|
Object.entries(groupedByProduct).map(
|
||||||
<TableRow key={item.id}>
|
([productName, groupItems]) => {
|
||||||
<TableCell className="text-center">
|
const totalQty = groupItems.reduce(
|
||||||
{index + 1}
|
(sum, item) => sum + (item.quantity ?? 0),
|
||||||
</TableCell>
|
0,
|
||||||
<TableCell>
|
);
|
||||||
{item.product_variant?.photo_url ? (
|
const totalSubtotal = groupItems.reduce(
|
||||||
<ImagePreviewButton
|
(sum, item) => sum + (item.subtotal ?? 0),
|
||||||
srcs={[
|
0,
|
||||||
item.product_variant.photo_url,
|
);
|
||||||
]}
|
|
||||||
title={item.product_variant.name}
|
return (
|
||||||
/>
|
<Fragment key={productName}>
|
||||||
) : (
|
<TableRow>
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
|
<TableCell
|
||||||
N/A
|
colSpan={4}
|
||||||
</div>
|
className="font-medium text-muted-foreground bg-muted/20"
|
||||||
)}
|
>
|
||||||
</TableCell>
|
{productName}
|
||||||
<TableCell>
|
</TableCell>
|
||||||
{item.product_variant?.product?.name ?? '-'}
|
<TableCell className="text-center font-medium text-muted-foreground bg-muted/20">
|
||||||
</TableCell>
|
{formatNumber(totalQty)}
|
||||||
<TableCell>
|
</TableCell>
|
||||||
{item.product_variant?.name ?? '-'}
|
<TableCell className="text-right font-medium text-muted-foreground bg-muted/20">
|
||||||
</TableCell>
|
{formatCurrency(totalSubtotal)}
|
||||||
<TableCell className="text-right">
|
</TableCell>
|
||||||
{formatCurrency(item.unit_price)}
|
</TableRow>
|
||||||
</TableCell>
|
{groupItems.map((item) => {
|
||||||
<TableCell className="text-center">
|
counter++;
|
||||||
{formatNumber(item.quantity)}
|
return (
|
||||||
</TableCell>
|
<TableRow key={item.id}>
|
||||||
<TableCell className="text-right font-medium">
|
<TableCell className="text-center">
|
||||||
{formatCurrency(item.subtotal)}
|
{counter}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
<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>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
'use no memo';
|
'use no memo';
|
||||||
|
|
||||||
import { Form, Head, Link, usePage } from '@inertiajs/react';
|
|
||||||
import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react';
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||||
import { FileUpload } from '@/components/file-upload';
|
import { FileUpload } from '@/components/file-upload';
|
||||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||||
@ -42,8 +39,12 @@ import { formatNumber } from '@/lib/format';
|
|||||||
import { loadTransactionDraft } from '@/lib/transaction-draft';
|
import { loadTransactionDraft } from '@/lib/transaction-draft';
|
||||||
import { getTemporaryUrl } from '@/lib/upload';
|
import { getTemporaryUrl } from '@/lib/upload';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import { 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 type { ProductForTransaction, TransactionCreateData } from './columns';
|
||||||
|
import { FieldDescription } from '@/components/ui/field';
|
||||||
|
|
||||||
type CartLine = {
|
type CartLine = {
|
||||||
key: string;
|
key: string;
|
||||||
@ -437,8 +438,8 @@ export default function TransactionCreate({ data }: Props) {
|
|||||||
disabled={
|
disabled={
|
||||||
!(
|
!(
|
||||||
quantities[
|
quantities[
|
||||||
variant
|
variant
|
||||||
.id
|
.id
|
||||||
] ??
|
] ??
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
@ -456,8 +457,8 @@ export default function TransactionCreate({ data }: Props) {
|
|||||||
className="w-24 text-center"
|
className="w-24 text-center"
|
||||||
value={
|
value={
|
||||||
quantities[
|
quantities[
|
||||||
variant
|
variant
|
||||||
.id
|
.id
|
||||||
] ??
|
] ??
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
@ -507,7 +508,7 @@ export default function TransactionCreate({ data }: Props) {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setStockType(
|
setStockType(
|
||||||
value as
|
value as
|
||||||
'good' | 'reject',
|
'good' | 'reject',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="flex flex-wrap gap-4"
|
className="flex flex-wrap gap-4"
|
||||||
@ -673,8 +674,8 @@ export default function TransactionCreate({ data }: Props) {
|
|||||||
setPhotoUrl(
|
setPhotoUrl(
|
||||||
key
|
key
|
||||||
? getTemporaryUrl(
|
? getTemporaryUrl(
|
||||||
key,
|
key,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@ -797,6 +798,9 @@ export default function TransactionCreate({ data }: Props) {
|
|||||||
}
|
}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
/>
|
/>
|
||||||
|
<FieldDescription>
|
||||||
|
Jika diisi, harga nego menjadi total akhir.
|
||||||
|
</FieldDescription>
|
||||||
<InputError
|
<InputError
|
||||||
message={errors.nego_price}
|
message={errors.nego_price}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
'use no memo';
|
'use no memo';
|
||||||
|
|
||||||
import { Form, Head, Link } from '@inertiajs/react';
|
|
||||||
import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react';
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||||
import { FileUpload } from '@/components/file-upload';
|
import { FileUpload } from '@/components/file-upload';
|
||||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||||
@ -41,7 +38,11 @@ import { formatNumber } from '@/lib/format';
|
|||||||
import { getTemporaryUrl } from '@/lib/upload';
|
import { getTemporaryUrl } from '@/lib/upload';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import { index as transactionIndex, update } from '@/routes/admin/manage/transactions';
|
import { index as transactionIndex, update } from '@/routes/admin/manage/transactions';
|
||||||
import 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 = {
|
type CartLine = {
|
||||||
key: string;
|
key: string;
|
||||||
@ -368,8 +369,8 @@ export default function TransactionEdit({ transaction, data }: Props) {
|
|||||||
disabled={
|
disabled={
|
||||||
!(
|
!(
|
||||||
quantities[
|
quantities[
|
||||||
variant
|
variant
|
||||||
.id
|
.id
|
||||||
] ??
|
] ??
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
@ -387,8 +388,8 @@ export default function TransactionEdit({ transaction, data }: Props) {
|
|||||||
className="w-24 text-center"
|
className="w-24 text-center"
|
||||||
value={
|
value={
|
||||||
quantities[
|
quantities[
|
||||||
variant
|
variant
|
||||||
.id
|
.id
|
||||||
] ??
|
] ??
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
@ -443,7 +444,7 @@ export default function TransactionEdit({ transaction, data }: Props) {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setStockType(
|
setStockType(
|
||||||
value as
|
value as
|
||||||
'good' | 'reject',
|
'good' | 'reject',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="flex flex-wrap gap-4"
|
className="flex flex-wrap gap-4"
|
||||||
@ -609,8 +610,8 @@ export default function TransactionEdit({ transaction, data }: Props) {
|
|||||||
setPhotoUrl(
|
setPhotoUrl(
|
||||||
key
|
key
|
||||||
? getTemporaryUrl(
|
? getTemporaryUrl(
|
||||||
key,
|
key,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@ -733,6 +734,9 @@ export default function TransactionEdit({ transaction, data }: Props) {
|
|||||||
}
|
}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
/>
|
/>
|
||||||
|
<FieldDescription>
|
||||||
|
Jika diisi, harga nego menjadi total akhir.
|
||||||
|
</FieldDescription>
|
||||||
<InputError
|
<InputError
|
||||||
message={errors.nego_price}
|
message={errors.nego_price}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { Head, Link, router } from '@inertiajs/react';
|
import { Head, Link, router } from '@inertiajs/react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { CardTable } from '@/components/card-table';
|
import { CardTable } from '@/components/card-table';
|
||||||
|
import { DatePicker } from '@/components/date-picker';
|
||||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||||
import { FilterPopover } from '@/components/filter-popover';
|
import { FilterPopover } from '@/components/filter-popover';
|
||||||
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
|
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
|
||||||
@ -29,10 +31,12 @@ import {
|
|||||||
create as transactionCreate,
|
create as transactionCreate,
|
||||||
index as transactionIndex,
|
index as transactionIndex,
|
||||||
edit as transactionEdit,
|
edit as transactionEdit,
|
||||||
|
updateStatus as transactionUpdateStatus,
|
||||||
} from '@/routes/admin/manage/transactions';
|
} from '@/routes/admin/manage/transactions';
|
||||||
import type { Transaction } from './columns';
|
import type { Transaction } from './columns';
|
||||||
import { TransactionCardRow } from './transaction-card';
|
import { TransactionCardRow } from './transaction-card';
|
||||||
import { TransactionItemSubRow } from './transaction-sub-row';
|
import { TransactionItemSubRow } from './transaction-sub-row';
|
||||||
|
import { TransactionSummaryCard } from './transaction-summary-card';
|
||||||
|
|
||||||
type FilterOption = {
|
type FilterOption = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -52,6 +56,13 @@ type Props = {
|
|||||||
per_page: number;
|
per_page: number;
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
summary: {
|
||||||
|
total_orders: number;
|
||||||
|
total_subtotal: number;
|
||||||
|
total_discount: number;
|
||||||
|
total_amount: number;
|
||||||
|
net_total: number;
|
||||||
|
};
|
||||||
filters: {
|
filters: {
|
||||||
status?: string;
|
status?: string;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
@ -59,6 +70,8 @@ type Props = {
|
|||||||
customer_id?: string;
|
customer_id?: string;
|
||||||
marketing_id?: string;
|
marketing_id?: string;
|
||||||
created_by_id?: string;
|
created_by_id?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
};
|
};
|
||||||
filterOptions: {
|
filterOptions: {
|
||||||
statusOptions: StatusOption[];
|
statusOptions: StatusOption[];
|
||||||
@ -71,6 +84,7 @@ type Props = {
|
|||||||
|
|
||||||
export default function TransactionIndex({
|
export default function TransactionIndex({
|
||||||
transactions,
|
transactions,
|
||||||
|
summary,
|
||||||
filters,
|
filters,
|
||||||
filterOptions,
|
filterOptions,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@ -134,6 +148,10 @@ export default function TransactionIndex({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleUpdateStatus(transaction: Transaction, status: string) {
|
||||||
|
router.patch(transactionUpdateStatus.url(transaction.id), { status });
|
||||||
|
}
|
||||||
|
|
||||||
const filterToolbar = (
|
const filterToolbar = (
|
||||||
<FilterPopover
|
<FilterPopover
|
||||||
open={filterOpen}
|
open={filterOpen}
|
||||||
@ -145,7 +163,9 @@ export default function TransactionIndex({
|
|||||||
Boolean(filters.payment_type) ||
|
Boolean(filters.payment_type) ||
|
||||||
Boolean(filters.customer_id) ||
|
Boolean(filters.customer_id) ||
|
||||||
Boolean(filters.marketing_id) ||
|
Boolean(filters.marketing_id) ||
|
||||||
Boolean(filters.created_by_id)
|
Boolean(filters.created_by_id) ||
|
||||||
|
Boolean(filters.date_from) ||
|
||||||
|
Boolean(filters.date_to)
|
||||||
}
|
}
|
||||||
onClear={clearFilters}
|
onClear={clearFilters}
|
||||||
>
|
>
|
||||||
@ -311,6 +331,22 @@ export default function TransactionIndex({
|
|||||||
</ComboboxContent>
|
</ComboboxContent>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-xs text-muted-foreground">Dari Tanggal</label>
|
||||||
|
<DatePicker
|
||||||
|
value={filters.date_from ?? null}
|
||||||
|
onChange={(date) => applyFilter('date_from', date ? format(date, 'yyyy-MM-dd') : '')}
|
||||||
|
placeholder="Pilih tanggal mulai"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-xs text-muted-foreground">Sampai Tanggal</label>
|
||||||
|
<DatePicker
|
||||||
|
value={filters.date_to ?? null}
|
||||||
|
onChange={(date) => applyFilter('date_to', date ? format(date, 'yyyy-MM-dd') : '')}
|
||||||
|
placeholder="Pilih tanggal akhir"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</FilterPopover>
|
</FilterPopover>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -333,6 +369,8 @@ export default function TransactionIndex({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TransactionSummaryCard summary={summary} />
|
||||||
|
|
||||||
<CardTable
|
<CardTable
|
||||||
data={transactions.data}
|
data={transactions.data}
|
||||||
getItemKey={(t) => t.id}
|
getItemKey={(t) => t.id}
|
||||||
@ -365,6 +403,7 @@ export default function TransactionIndex({
|
|||||||
router.visit(transactionEdit.url(t.id));
|
router.visit(transactionEdit.url(t.id));
|
||||||
}}
|
}}
|
||||||
onDelete={(t) => setDeleting(t)}
|
onDelete={(t) => setDeleting(t)}
|
||||||
|
onUpdateStatus={(t, status) => handleUpdateStatus(t, status)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderSubContent={(transaction) => (
|
renderSubContent={(transaction) => (
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
|
||||||
import { RowActions } from '@/components/row-actions';
|
import { RowActions } from '@/components/row-actions';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -6,6 +5,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
|||||||
import { useCan } from '@/hooks/use-can';
|
import { useCan } from '@/hooks/use-can';
|
||||||
import { formatDateTime, formatNumber } from '@/lib/format';
|
import { formatDateTime, formatNumber } from '@/lib/format';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
|
import { CheckCircle, ChevronDown, Pencil, Send, Trash2, XCircle } from 'lucide-react';
|
||||||
import type { Transaction } from './columns';
|
import type { Transaction } from './columns';
|
||||||
|
|
||||||
const STATUS_BADGE_CLASSES: Record<string, string> = {
|
const STATUS_BADGE_CLASSES: Record<string, string> = {
|
||||||
@ -23,6 +23,7 @@ export type TransactionCardRowParams = {
|
|||||||
onToggleExpand: () => void;
|
onToggleExpand: () => void;
|
||||||
onEdit: (transaction: Transaction) => void;
|
onEdit: (transaction: Transaction) => void;
|
||||||
onDelete: (transaction: Transaction) => void;
|
onDelete: (transaction: Transaction) => void;
|
||||||
|
onUpdateStatus: (transaction: Transaction, status: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TransactionCardRow({
|
export function TransactionCardRow({
|
||||||
@ -32,6 +33,7 @@ export function TransactionCardRow({
|
|||||||
onToggleExpand,
|
onToggleExpand,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onUpdateStatus,
|
||||||
}: TransactionCardRowParams) {
|
}: TransactionCardRowParams) {
|
||||||
const { can } = useCan();
|
const { can } = useCan();
|
||||||
const items = transaction.order_items ?? [];
|
const items = transaction.order_items ?? [];
|
||||||
@ -70,7 +72,7 @@ export function TransactionCardRow({
|
|||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{index}.
|
{index}.
|
||||||
</span>
|
</span>
|
||||||
<h3 className="truncate font-medium">
|
<h3 className="font-medium">
|
||||||
{transaction.order_number}
|
{transaction.order_number}
|
||||||
</h3>
|
</h3>
|
||||||
<Badge
|
<Badge
|
||||||
@ -113,7 +115,7 @@ export function TransactionCardRow({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{transaction.notes && (
|
{transaction.notes && (
|
||||||
<span className="max-w-[200px] truncate">
|
<span className="max-w-[200px]">
|
||||||
{transaction.notes}
|
{transaction.notes}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -152,6 +154,14 @@ export function TransactionCardRow({
|
|||||||
{formatCurrency(transaction.discount)}
|
{formatCurrency(transaction.discount)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{transaction.nego_price != null && transaction.nego_price > 0 && (
|
||||||
|
<span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Nego:{' '}
|
||||||
|
</span>
|
||||||
|
{formatCurrency(transaction.nego_price)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
<span className="font-normal text-muted-foreground">
|
<span className="font-normal text-muted-foreground">
|
||||||
Total:{' '}
|
Total:{' '}
|
||||||
@ -175,6 +185,38 @@ export function TransactionCardRow({
|
|||||||
|
|
||||||
<RowActions
|
<RowActions
|
||||||
actions={[
|
actions={[
|
||||||
|
...(transaction.status === 'pending' && can('orders.update')
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: 'Kirim',
|
||||||
|
icon: <Send className="h-4 w-4" />,
|
||||||
|
onClick: () => onUpdateStatus(transaction, 'processing'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...((transaction.status === 'pending' || transaction.status === 'processing') && can('orders.update')
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: 'Selesai',
|
||||||
|
icon: <CheckCircle className="h-4 w-4" />,
|
||||||
|
onClick: () => onUpdateStatus(transaction, 'completed'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Dibatalkan',
|
||||||
|
icon: <XCircle className="h-4 w-4 text-destructive" />,
|
||||||
|
onClick: () => onUpdateStatus(transaction, 'cancelled'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(transaction.status === 'completed' && can('orders.update')
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: 'Refund',
|
||||||
|
icon: <XCircle className="h-4 w-4 text-destructive" />,
|
||||||
|
onClick: () => onUpdateStatus(transaction, 'refunded'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
icon: <Pencil className="h-4 w-4" />,
|
icon: <Pencil className="h-4 w-4" />,
|
||||||
|
|||||||
@ -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',
|
accessorKey: 'address',
|
||||||
header: () => <span>Alamat</span>,
|
header: () => <span>Alamat</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="block max-w-[200px] truncate">
|
<span className="block max-w-[200px]">
|
||||||
{(row.getValue('address') as string) ?? '-'}
|
{(row.getValue('address') as string) ?? '-'}
|
||||||
</span>
|
</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 { RowActions } from '@/components/row-actions';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { formatNumber } from '@/lib/format';
|
import { formatNumber } from '@/lib/format';
|
||||||
import { 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 = {
|
export type ProductVariant = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
stock: number;
|
stock: number;
|
||||||
|
formatted_stock: string
|
||||||
reject_stock: number;
|
reject_stock: number;
|
||||||
retail_stock: number;
|
retail_stock: number;
|
||||||
photo_urls: string[];
|
photo_urls: string[];
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { router } from '@inertiajs/react';
|
import { Link, router } from '@inertiajs/react';
|
||||||
import { CardTable } from '@/components/card-table';
|
import { CardTable } from '@/components/card-table';
|
||||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||||
import { FilterPopover } from '@/components/filter-popover';
|
import { FilterPopover } from '@/components/filter-popover';
|
||||||
@ -34,7 +34,7 @@ import {
|
|||||||
edit as variantEdit,
|
edit as variantEdit,
|
||||||
} from '@/routes/admin/master/products/variants';
|
} from '@/routes/admin/master/products/variants';
|
||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
import { Link, Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { Product, ProductVariant } from './columns';
|
import type { Product, ProductVariant } from './columns';
|
||||||
import { ProductCardRow } from './product-card';
|
import { ProductCardRow } from './product-card';
|
||||||
@ -52,6 +52,7 @@ type Props = {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
}[];
|
}[];
|
||||||
|
productNames: string[];
|
||||||
filters: {
|
filters: {
|
||||||
status?: string;
|
status?: string;
|
||||||
name?: 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 { can } = useCan();
|
||||||
const [deleting, setDeleting] = useState<Product | null>(null);
|
const [deleting, setDeleting] = useState<Product | null>(null);
|
||||||
const [deletingVariant, setDeletingVariant] = useState<{
|
const [deletingVariant, setDeletingVariant] = useState<{
|
||||||
@ -92,11 +93,10 @@ export default function ProductIndex({ products, categories, filters }: Props) {
|
|||||||
filterWithParams: false,
|
filterWithParams: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const productNames = useMemo(() => {
|
const sortedProductNames = useMemo(
|
||||||
const names = products.data.map((p) => p.name);
|
() => [...productNames].sort(),
|
||||||
|
[productNames],
|
||||||
return [...new Set(names)].sort();
|
);
|
||||||
}, [products.data]);
|
|
||||||
|
|
||||||
const selectedCategory = useMemo(
|
const selectedCategory = useMemo(
|
||||||
() => categories.find((c) => String(c.id) === filters.category) ?? null,
|
() => categories.find((c) => String(c.id) === filters.category) ?? null,
|
||||||
@ -147,7 +147,7 @@ export default function ProductIndex({ products, categories, filters }: Props) {
|
|||||||
Nama Produk
|
Nama Produk
|
||||||
</label>
|
</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
items={productNames}
|
items={sortedProductNames}
|
||||||
value={filters.name ?? ''}
|
value={filters.name ?? ''}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
applyFilter('name', (value as string) ?? '')
|
applyFilter('name', (value as string) ?? '')
|
||||||
|
|||||||
@ -67,7 +67,7 @@ export function TransferStockDialog({
|
|||||||
Stok Bagus Tersedia
|
Stok Bagus Tersedia
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
{variant.stock}
|
{variant.formatted_stock}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
|
|||||||
@ -41,7 +41,7 @@ export function createSupplierColumns(
|
|||||||
accessorKey: 'address',
|
accessorKey: 'address',
|
||||||
header: () => <span>Alamat</span>,
|
header: () => <span>Alamat</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="block max-w-[200px] truncate">
|
<span className="block max-w-[200px]">
|
||||||
{(row.getValue('address') as string) ?? '-'}
|
{(row.getValue('address') as string) ?? '-'}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -65,6 +65,7 @@
|
|||||||
Route::resource('cuttings', CuttingController::class)->except(['show'])->middleware('permission:cuttings.view|cuttings.create|cuttings.update|cuttings.delete');
|
Route::resource('cuttings', CuttingController::class)->except(['show'])->middleware('permission:cuttings.view|cuttings.create|cuttings.update|cuttings.delete');
|
||||||
|
|
||||||
Route::resource('transactions', TransactionController::class)->except(['show'])->middleware('permission:orders.view|orders.create|orders.update|orders.delete');
|
Route::resource('transactions', TransactionController::class)->except(['show'])->middleware('permission:orders.view|orders.create|orders.update|orders.delete');
|
||||||
|
Route::patch('transactions/{transaction}/status', [TransactionController::class, 'updateStatus'])->name('transactions.updateStatus')->middleware('permission:orders.update');
|
||||||
|
|
||||||
Route::resource('restocks', RestockController::class)->except(['show'])->middleware('permission:restocks.view|restocks.create|restocks.update|restocks.delete');
|
Route::resource('restocks', RestockController::class)->except(['show'])->middleware('permission:restocks.view|restocks.create|restocks.update|restocks.delete');
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user