feat: implement Employee Advance management functionality

- Add EmployeeAdvanceService for handling employee advance operations including create, update, delete, approve, and pay.
- Create new components for date picking and calendar UI.
- Develop Employee Advance columns for data table representation.
- Implement Employee Advance index page with CRUD operations and dialogs for creating, editing, approving, and paying advances.
- Update routes to include employee advances resource and specific actions for approval and payment.
- Add necessary dependencies for date handling and UI components.
This commit is contained in:
Yoga Pangestu 2026-07-29 21:07:21 +07:00
parent db58fbe049
commit 912f576c74
12 changed files with 1328 additions and 1 deletions

View File

@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers\Admin\Finance;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Finance\EmployeeAdvanceRequest;
use App\Models\EmployeeAdvance;
use App\Services\Admin\Finance\EmployeeAdvanceService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class EmployeeAdvanceController extends Controller
{
public function __construct(
private EmployeeAdvanceService $service
) {}
public function index(): Response
{
return Inertia::render('admin/finance/employee-advance/index', [
'employeeAdvances' => $this->service->getAll(),
]);
}
public function store(EmployeeAdvanceRequest $request): RedirectResponse
{
try {
$this->service->create($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kasbon berhasil ditambahkan.']);
} catch (ValidationException $e) {
$firstError = collect($e->errors())->flatten()->first();
Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']);
} catch (\Exception $e) {
Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]);
}
return to_route('admin.finance.employee-advances.index');
}
public function update(EmployeeAdvanceRequest $request, EmployeeAdvance $employeeAdvance): RedirectResponse
{
try {
$this->service->update($employeeAdvance, $request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kasbon berhasil diperbarui.']);
} catch (ValidationException $e) {
$firstError = collect($e->errors())->flatten()->first();
Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']);
} catch (\Exception $e) {
Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]);
}
return to_route('admin.finance.employee-advances.index');
}
public function destroy(EmployeeAdvance $employeeAdvance): RedirectResponse
{
try {
$this->service->delete($employeeAdvance);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kasbon berhasil dihapus.']);
} catch (\Exception $e) {
Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]);
}
return to_route('admin.finance.employee-advances.index');
}
public function approve(EmployeeAdvance $employeeAdvance): RedirectResponse
{
try {
$this->service->approve($employeeAdvance);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kasbon berhasil disetujui.']);
} catch (\Exception $e) {
Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]);
}
return to_route('admin.finance.employee-advances.index');
}
public function pay(EmployeeAdvance $employeeAdvance): RedirectResponse
{
try {
$this->service->pay($employeeAdvance);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kasbon berhasil dibayar.']);
} catch (\Exception $e) {
Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]);
}
return to_route('admin.finance.employee-advances.index');
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests\Admin\Finance;
use Illuminate\Foundation\Http\FormRequest;
use Override;
class EmployeeAdvanceRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
#[Override]
public function prepareForValidation()
{
if ($this->has('amount')) {
$this->merge([
'amount' => str_replace('.', '', $this->amount),
]);
}
}
public function rules(): array
{
return [
'amount' => ['required', 'integer', 'min:1'],
'description' => ['required', 'string', 'max:100'],
'due_date' => ['required', 'date', 'after_or_equal:today'],
];
}
public function attributes(): array
{
return [
'amount' => 'jumlah',
'description' => 'keterangan',
'due_date' => 'jatuh tempo',
];
}
}

View File

@ -0,0 +1,150 @@
<?php
namespace App\Services\Admin\Finance;
use App\Enums\CashTransactionType;
use App\Enums\EmployeeAdvanceStatus;
use App\Models\CashAccount;
use App\Models\CashTransaction;
use App\Models\EmployeeAdvance;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class EmployeeAdvanceService
{
public function getAll(): Collection
{
return EmployeeAdvance::with(['employee.user.userProfile'])
->latest()
->get();
}
public function create(array $data): EmployeeAdvance
{
return DB::transaction(function () use ($data) {
$cashAccount = CashAccount::firstOrFail();
$employee = auth()->user()->employee;
if (!$employee) {
throw new \Exception('Anda tidak terdaftar sebagai karyawan.');
}
if ($cashAccount->balance < $data['amount']) {
throw ValidationException::withMessages([
'amount' => 'Saldo tidak mencukupi.',
]);
}
$newBalance = $cashAccount->balance - $data['amount'];
$cashAccount->update(['balance' => $newBalance]);
$cashTransaction = CashTransaction::create([
'cash_account_id' => $cashAccount->id,
'created_by_id' => auth()->id(),
'amount' => $data['amount'],
'balance_after' => $newBalance,
'type' => CashTransactionType::EXPENSE,
'description' => 'Kasbon: ' . $data['description'],
]);
return EmployeeAdvance::create([
'cash_transaction_id' => $cashTransaction->id,
'employee_id' => $employee->id,
'amount' => $data['amount'],
'description' => $data['description'],
'due_date' => $data['due_date'],
'status' => EmployeeAdvanceStatus::PENDING,
]);
});
}
public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeAdvance
{
return DB::transaction(function () use ($employeeAdvance, $data) {
$cashAccount = CashAccount::firstOrFail();
$cashTransaction = $employeeAdvance->cashTransaction;
$oldAmount = $employeeAdvance->amount;
$newAmount = $data['amount'];
$difference = $newAmount - $oldAmount;
if ($difference > 0 && $cashAccount->balance < $difference) {
throw ValidationException::withMessages([
'amount' => 'Saldo tidak mencukupi.',
]);
}
$newBalance = $cashAccount->balance - $difference;
$cashAccount->update(['balance' => $newBalance]);
$cashTransaction->update([
'amount' => $newAmount,
'balance_after' => $newBalance,
'description' => 'Kasbon: ' . $data['description'],
]);
$employeeAdvance->update([
'amount' => $newAmount,
'description' => $data['description'],
'due_date' => $data['due_date'],
]);
return $employeeAdvance;
});
}
public function delete(EmployeeAdvance $employeeAdvance): bool
{
return DB::transaction(function () use ($employeeAdvance) {
$cashAccount = CashAccount::firstOrFail();
if ($employeeAdvance->status === EmployeeAdvanceStatus::PAID) {
$newBalance = $cashAccount->balance + $employeeAdvance->amount;
$cashAccount->update(['balance' => $newBalance]);
}
return $employeeAdvance->delete();
});
}
public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
{
$employeeAdvance->update([
'status' => EmployeeAdvanceStatus::APPROVED,
'verified_by_id' => auth()->id(),
'verified_at' => now(),
]);
return $employeeAdvance;
}
public function pay(EmployeeAdvance $employeeAdvance): EmployeeAdvance
{
return DB::transaction(function () use ($employeeAdvance) {
$cashAccount = CashAccount::firstOrFail();
$newBalance = $cashAccount->balance + $employeeAdvance->amount;
$cashAccount->update(['balance' => $newBalance]);
$cashTransaction = CashTransaction::create([
'cash_account_id' => $cashAccount->id,
'created_by_id' => auth()->id(),
'amount' => $employeeAdvance->amount,
'balance_after' => $newBalance,
'type' => CashTransactionType::DEPOSIT,
'description' => 'Pembayaran kasbon: ' . $employeeAdvance->description,
]);
$employeeAdvance->update([
'status' => EmployeeAdvanceStatus::PAID,
'paid_by_id' => auth()->id(),
'paid_amount' => $employeeAdvance->amount,
'paid_at' => now(),
'repayment_cash_transaction_id' => $cashTransaction->id,
]);
return $employeeAdvance;
});
}
}

View File

@ -53,6 +53,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"date-fns": "^4.4.0",
"globals": "^15.14.0", "globals": "^15.14.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"laravel-vite-plugin": "^3.0.0", "laravel-vite-plugin": "^3.0.0",
@ -60,6 +61,7 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"radix-ui": "^1.6.7", "radix-ui": "^1.6.7",
"react": "^19.2.0", "react": "^19.2.0",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"shadcn": "^4.16.0", "shadcn": "^4.16.0",
"sonner": "^2.0.0", "sonner": "^2.0.0",

34
pnpm-lock.yaml generated
View File

@ -80,6 +80,9 @@ importers:
concurrently: concurrently:
specifier: ^9.0.1 specifier: ^9.0.1
version: 9.2.4 version: 9.2.4
date-fns:
specifier: ^4.4.0
version: 4.4.0
globals: globals:
specifier: ^15.14.0 specifier: ^15.14.0
version: 15.15.0 version: 15.15.0
@ -101,6 +104,9 @@ importers:
react: react:
specifier: ^19.2.0 specifier: ^19.2.0
version: 19.2.8 version: 19.2.8
react-day-picker:
specifier: ^10.0.1
version: 10.0.1(@types/react@19.2.17)(react@19.2.8)
react-dom: react-dom:
specifier: ^19.2.0 specifier: ^19.2.0
version: 19.2.8(react@19.2.8) version: 19.2.8(react@19.2.8)
@ -331,6 +337,9 @@ packages:
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@date-fns/tz@1.5.0':
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
'@dotenvx/dotenvx@1.75.1': '@dotenvx/dotenvx@1.75.1':
resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==} resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==}
hasBin: true hasBin: true
@ -1950,6 +1959,9 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
date-fns@4.4.0:
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
debounce-fn@4.0.0: debounce-fn@4.0.0:
resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -3378,6 +3390,16 @@ packages:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
react-day-picker@10.0.1:
resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==}
engines: {node: '>=18'}
peerDependencies:
'@types/react': '>=16.8.0'
react: '>=16.8.0'
peerDependenciesMeta:
'@types/react':
optional: true
react-dom@19.2.8: react-dom@19.2.8:
resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==}
peerDependencies: peerDependencies:
@ -4161,6 +4183,8 @@ snapshots:
'@babel/helper-string-parser': 7.29.7 '@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7 '@babel/helper-validator-identifier': 7.29.7
'@date-fns/tz@1.5.0': {}
'@dotenvx/dotenvx@1.75.1': '@dotenvx/dotenvx@1.75.1':
dependencies: dependencies:
'@dotenvx/primitives': 0.8.0 '@dotenvx/primitives': 0.8.0
@ -5832,6 +5856,8 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
is-data-view: 1.0.2 is-data-view: 1.0.2
date-fns@4.4.0: {}
debounce-fn@4.0.0: debounce-fn@4.0.0:
dependencies: dependencies:
mimic-fn: 3.1.0 mimic-fn: 3.1.0
@ -7267,6 +7293,14 @@ snapshots:
iconv-lite: 0.7.3 iconv-lite: 0.7.3
unpipe: 1.0.0 unpipe: 1.0.0
react-day-picker@10.0.1(@types/react@19.2.17)(react@19.2.8):
dependencies:
'@date-fns/tz': 1.5.0
date-fns: 4.4.0
react: 19.2.8
optionalDependencies:
'@types/react': 19.2.17
react-dom@19.2.8(react@19.2.8): react-dom@19.2.8(react@19.2.8):
dependencies: dependencies:
react: 19.2.8 react: 19.2.8

View File

@ -40,6 +40,7 @@ import { index as customersIndex } from '@/routes/admin/master/customers';
import { index as suppliersIndex } from '@/routes/admin/master/suppliers'; import { index as suppliersIndex } from '@/routes/admin/master/suppliers';
import { index as cashAccountsIndex } from '@/routes/admin/finance/cash-accounts'; import { index as cashAccountsIndex } from '@/routes/admin/finance/cash-accounts';
import { index as expensesIndex } from '@/routes/admin/finance/expenses'; import { index as expensesIndex } from '@/routes/admin/finance/expenses';
import { index as employeeAdvancesIndex } from '@/routes/admin/finance/employee-advances';
type NavMenuItem = { title: string; href: string; icon: LucideIcon }; type NavMenuItem = { title: string; href: string; icon: LucideIcon };
@ -73,7 +74,7 @@ const kelolaItems: NavMenuItem[] = [
const keuanganItems: NavMenuItem[] = [ const keuanganItems: NavMenuItem[] = [
{ title: 'Kas Toko', href: cashAccountsIndex.url(), icon: Wallet }, { title: 'Kas Toko', href: cashAccountsIndex.url(), icon: Wallet },
{ title: 'Pengeluaran', href: expensesIndex.url(), icon: ArrowUpFromLine }, { title: 'Pengeluaran', href: expensesIndex.url(), icon: ArrowUpFromLine },
{ title: 'Kasbon', href: '#', icon: HandCoins }, { title: 'Kasbon', href: employeeAdvancesIndex.url(), icon: HandCoins },
{ title: 'Gaji', href: '#', icon: DollarSign }, { title: 'Gaji', href: '#', icon: DollarSign },
]; ];

View File

@ -0,0 +1,91 @@
import * as React from "react"
import { format } from "date-fns"
import { id } from "date-fns/locale"
import { CalendarIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
interface DatePickerProps {
value?: Date | string | null
onChange?: (date: Date | undefined) => void
placeholder?: string
disabled?: boolean
className?: string
name?: string
id?: string
min?: Date
max?: Date
}
function DatePicker({
value,
onChange,
placeholder = "Pilih tanggal",
disabled = false,
className,
name,
id,
min,
max,
}: DatePickerProps) {
const [open, setOpen] = React.useState(false)
const date = React.useMemo(() => {
if (!value) return undefined
if (value instanceof Date) return value
return new Date(value)
}, [value])
const formattedDate = React.useMemo(() => {
if (!date) return ""
return format(date, "dd MMM yyyy", { locale: id })
}, [date])
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={id}
variant="outline"
disabled={disabled}
className={cn(
"w-full justify-start text-left font-normal",
!date && "text-muted-foreground",
className
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? formattedDate : placeholder}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={(selectedDate) => {
onChange?.(selectedDate)
setOpen(false)
}}
disabled={(date) => {
if (min && date < min) return true
if (max && date > max) return true
return false
}}
initialFocus
/>
</PopoverContent>
{name && (
<input type="hidden" name={name} value={date ? format(date, "yyyy-MM-dd") : ""} />
)}
</Popover>
)
}
export { DatePicker }

View File

@ -0,0 +1,218 @@
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-md",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@ -0,0 +1,87 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverHeader,
PopoverTitle,
PopoverDescription,
}

View File

@ -0,0 +1,287 @@
import type { ColumnDef } from '@tanstack/react-table';
import { ArrowUpDown, CheckCircle, CircleDollarSign, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { formatCurrency } from '@/lib/utils';
export type EmployeeAdvance = {
id: number;
amount: number;
paid_amount: number;
description: string;
due_date: string;
status: 'pending' | 'approved' | 'paid' | 'rejected' | 'cancelled';
created_at: string;
employee: {
user: {
user_profile: {
full_name: string;
};
};
};
};
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
}) + ' ' + date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
});
}
function formatShortDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
function getStatusBadge(status: string) {
const statusConfig: Record<string, { label: string; className: string }> = {
pending: {
label: 'Menunggu',
className: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100',
},
approved: {
label: 'Disetujui',
className: 'bg-green-100 text-green-800 hover:bg-green-100',
},
paid: {
label: 'Dibayar',
className: 'bg-blue-100 text-blue-800 hover:bg-blue-100',
},
rejected: {
label: 'Ditolak',
className: 'bg-red-100 text-red-800 hover:bg-red-100',
},
cancelled: {
label: 'Dibatalkan',
className: 'bg-gray-100 text-gray-800 hover:bg-gray-100',
},
};
const config = statusConfig[status] ?? statusConfig.pending;
return (
<Badge variant="secondary" className={config.className}>
{config.label}
</Badge>
);
}
type CreateColumnsParams = {
handleEdit: (employeeAdvance: EmployeeAdvance) => void;
handleDeleteClick: (employeeAdvance: EmployeeAdvance) => void;
handleApprove: (employeeAdvance: EmployeeAdvance) => void;
handlePay: (employeeAdvance: EmployeeAdvance) => void;
};
export function createEmployeeAdvanceColumns(
params: CreateColumnsParams,
): ColumnDef<EmployeeAdvance>[] {
const { handleEdit, handleDeleteClick, handleApprove, handlePay } = params;
return [
{
id: 'no',
header: () => <span className="block text-center">No</span>,
cell: ({ row }) => (
<span className="block text-center">
{row.index + 1}
</span>
),
meta: {
className: 'w-[50px] text-center',
headerClassName: 'w-[50px] text-center',
},
},
{
accessorKey: 'created_at',
header: ({ column }) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
}
>
<span>Tanggal</span>
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span>{formatDate(row.getValue('created_at') as string)}</span>
),
},
{
id: 'employee_name',
header: () => <span>Oleh</span>,
cell: ({ row }) => {
const employee = row.original.employee;
return <span>{employee?.user?.user_profile?.full_name ?? '-'}</span>;
},
},
{
accessorKey: 'amount',
header: ({ column }) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
}
>
<span>Jumlah</span>
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="text-red-600 font-medium">
- {formatCurrency(row.getValue('amount') as number)}
</span>
),
},
{
accessorKey: 'description',
header: () => <span>Keterangan</span>,
cell: ({ row }) => (
<span className="max-w-[200px] truncate block">{row.getValue('description') as string}</span>
),
},
{
accessorKey: 'due_date',
header: ({ column }) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
}
>
<span>Jatuh Tempo</span>
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span>{formatShortDate(row.getValue('due_date') as string)}</span>
),
},
{
accessorKey: 'status',
header: () => <span>Status</span>,
cell: ({ row }) => (
<span>{getStatusBadge(row.getValue('status') as string)}</span>
),
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[150px] text-center',
headerClassName: 'w-[150px] text-center',
},
cell: ({ row }) => {
const employeeAdvance = row.original;
return (
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
{employeeAdvance.status === 'pending' && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleApprove(employeeAdvance)
}
>
<CheckCircle className="h-4 w-4 text-green-600" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Setujui
</TooltipContent>
</Tooltip>
)}
{employeeAdvance.status === 'approved' && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handlePay(employeeAdvance)
}
>
<CircleDollarSign className="h-4 w-4 text-blue-600" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Bayar
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleEdit(employeeAdvance)
}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleDeleteClick(employeeAdvance)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
},
},
];
}

View File

@ -0,0 +1,313 @@
import { Form, Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useEffect, useState } from 'react';
import InputError from '@/components/input-error';
import { RupiahInput } from '@/components/rupiah-input';
import { DatePicker } from '@/components/date-picker';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { destroy, index as employeeAdvanceIndex, store, update, approve, pay } from '@/routes/admin/finance/employee-advances';
import { createEmployeeAdvanceColumns } from './columns';
import type { EmployeeAdvance } from './columns';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { DataTable } from '@/components/data-table';
type Props = {
employeeAdvances: EmployeeAdvance[];
};
export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<EmployeeAdvance | null>(null);
const [deleting, setDeleting] = useState<EmployeeAdvance | null>(null);
const [approving, setApproving] = useState<EmployeeAdvance | null>(null);
const [paying, setPaying] = useState<EmployeeAdvance | null>(null);
const [dueDate, setDueDate] = useState<Date | undefined>(undefined);
const [editingDueDate, setEditingDueDate] = useState<Date | undefined>(undefined);
useEffect(() => {
if (editing) {
setEditingDueDate(new Date(editing.due_date));
} else {
setEditingDueDate(undefined);
}
}, [editing]);
function handleDelete() {
if (!deleting) {
return;
}
router.delete(destroy(deleting.id), {
onSuccess: () => setDeleting(null),
});
}
function handleApprove() {
if (!approving) {
return;
}
router.post(approve(approving.id), {}, {
onSuccess: () => setApproving(null),
});
}
function handlePay() {
if (!paying) {
return;
}
router.post(pay(paying.id), {}, {
onSuccess: () => setPaying(null),
});
}
const columns = createEmployeeAdvanceColumns({
handleEdit: (employeeAdvance) => setEditing(employeeAdvance),
handleDeleteClick: (employeeAdvance) => setDeleting(employeeAdvance),
handleApprove: (employeeAdvance) => setApproving(employeeAdvance),
handlePay: (employeeAdvance) => setPaying(employeeAdvance),
});
return (
<>
<Head title="Kasbon" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Kasbon
</h2>
</div>
<Dialog open={createOpen} onOpenChange={(open) => {
setCreateOpen(open);
if (!open) {
setDueDate(undefined);
}
}}>
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
<DialogContent>
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>Tambah Kasbon</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>
Jumlah{' '} <span className="text-destructive">*</span>
</Label>
<RupiahInput name="amount" min={1} />
<InputError message={errors.amount} />
</div>
<div className="grid gap-2">
<Label htmlFor="description">
Keterangan{' '} <span className="text-destructive">*</span>
</Label>
<Input
id="description"
name="description"
placeholder="Masukkan keterangan"
/>
<InputError message={errors.description} />
</div>
<div className="grid gap-2">
<Label htmlFor="due_date">
Jatuh Tempo{' '} <span className="text-destructive">*</span>
</Label>
<input type="hidden" name="due_date" value={dueDate ? dueDate.toISOString().split('T')[0] : ''} />
<DatePicker
value={dueDate}
onChange={setDueDate}
placeholder="Pilih jatuh tempo"
min={new Date()}
/>
<InputError message={errors.due_date} />
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setCreateOpen(false)}
>
Batal
</Button>
<Button
type='submit'
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
);
}}
</Form>
</DialogContent>
</Dialog>
</div>
<DataTable
columns={columns}
data={employeeAdvances}
searchKey="description"
searchPlaceholder="Cari kasbon..."
emptyText="Belum ada data kasbon."
/>
<Dialog
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
setEditing(null);
setEditingDueDate(undefined);
}
}}
>
<DialogContent>
{editing && (
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => {
setEditing(null);
setEditingDueDate(undefined);
}}>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>Edit Kasbon</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Jumlah{' '} <span className="text-destructive">*</span></Label>
<RupiahInput name="amount" defaultValue={editing.amount} min={1} />
<InputError message={errors.amount} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-description">Keterangan{' '} <span className="text-destructive">*</span></Label>
<Input
id="edit-description"
name="description"
placeholder="Masukkan keterangan"
defaultValue={editing.description}
/>
<InputError message={errors.description} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-due_date">Jatuh Tempo{' '} <span className="text-destructive">*</span></Label>
<input type="hidden" name="due_date" value={editingDueDate ? editingDueDate.toISOString().split('T')[0] : ''} />
<DatePicker
value={editingDueDate}
onChange={setEditingDueDate}
placeholder="Pilih jatuh tempo"
min={new Date()}
/>
<InputError message={errors.due_date} />
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setEditing(null);
}}
>
Batal
</Button>
<Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
);
}}
</Form>
)}
</DialogContent>
</Dialog>
<ConfirmDialog
open={deleting !== null}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Kasbon"
description={`Apakah Anda yakin ingin menghapus kasbon "${deleting?.description}"? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
onConfirm={handleDelete}
/>
<ConfirmDialog
open={approving !== null}
onOpenChange={(open) => {
if (!open) {
setApproving(null);
}
}}
title="Setujui Kasbon"
description={`Apakah Anda yakin ingin menyetujui kasbon "${approving?.description}"?`}
confirmLabel="Setujui"
onConfirm={handleApprove}
/>
<ConfirmDialog
open={paying !== null}
onOpenChange={(open) => {
if (!open) {
setPaying(null);
}
}}
title="Bayar Kasbon"
description={`Apakah Anda yakin ingin membayar kasbon "${paying?.description}" sebesar ${paying?.amount}? Saldo kas akan dikembalikan.`}
confirmLabel="Bayar"
onConfirm={handlePay}
/>
</div>
</>
);
}
EmployeeAdvanceIndex.layout = {
breadcrumbs: [
{
title: 'Keuangan',
href: employeeAdvanceIndex(),
},
{
title: 'Kasbon',
href: employeeAdvanceIndex(),
},
],
};

View File

@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\Admin\Finance\CashAccountController; use App\Http\Controllers\Admin\Finance\CashAccountController;
use App\Http\Controllers\Admin\Finance\EmployeeAdvanceController;
use App\Http\Controllers\Admin\Finance\ExpenseController; use App\Http\Controllers\Admin\Finance\ExpenseController;
use App\Http\Controllers\Admin\Master\CategoryController; use App\Http\Controllers\Admin\Master\CategoryController;
use App\Http\Controllers\Admin\Master\CustomerController; use App\Http\Controllers\Admin\Master\CustomerController;
@ -26,6 +27,10 @@
Route::delete('cash-accounts/transactions/{transaction}', [CashAccountController::class, 'destroy'])->name('cash-accounts.transactions.destroy'); Route::delete('cash-accounts/transactions/{transaction}', [CashAccountController::class, 'destroy'])->name('cash-accounts.transactions.destroy');
Route::resource('expenses', ExpenseController::class)->except(['show', 'create', 'edit']); Route::resource('expenses', ExpenseController::class)->except(['show', 'create', 'edit']);
Route::resource('employee-advances', EmployeeAdvanceController::class)->except(['show', 'create', 'edit']);
Route::post('employee-advances/{employeeAdvance}/approve', [EmployeeAdvanceController::class, 'approve'])->name('employee-advances.approve');
Route::post('employee-advances/{employeeAdvance}/pay', [EmployeeAdvanceController::class, 'pay'])->name('employee-advances.pay');
}); });
}); });