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:
parent
db58fbe049
commit
912f576c74
@ -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');
|
||||
}
|
||||
}
|
||||
42
app/Http/Requests/Admin/Finance/EmployeeAdvanceRequest.php
Normal file
42
app/Http/Requests/Admin/Finance/EmployeeAdvanceRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
150
app/Services/Admin/Finance/EmployeeAdvanceService.php
Normal file
150
app/Services/Admin/Finance/EmployeeAdvanceService.php
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -53,6 +53,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^9.0.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"globals": "^15.14.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"laravel-vite-plugin": "^3.0.0",
|
||||
@ -60,6 +61,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "^19.2.0",
|
||||
"shadcn": "^4.16.0",
|
||||
"sonner": "^2.0.0",
|
||||
|
||||
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
@ -80,6 +80,9 @@ importers:
|
||||
concurrently:
|
||||
specifier: ^9.0.1
|
||||
version: 9.2.4
|
||||
date-fns:
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0
|
||||
globals:
|
||||
specifier: ^15.14.0
|
||||
version: 15.15.0
|
||||
@ -101,6 +104,9 @@ importers:
|
||||
react:
|
||||
specifier: ^19.2.0
|
||||
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:
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.8(react@19.2.8)
|
||||
@ -331,6 +337,9 @@ packages:
|
||||
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
|
||||
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':
|
||||
resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==}
|
||||
hasBin: true
|
||||
@ -1950,6 +1959,9 @@ packages:
|
||||
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
date-fns@4.4.0:
|
||||
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
|
||||
|
||||
debounce-fn@4.0.0:
|
||||
resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==}
|
||||
engines: {node: '>=10'}
|
||||
@ -3378,6 +3390,16 @@ packages:
|
||||
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==}
|
||||
peerDependencies:
|
||||
@ -4161,6 +4183,8 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@date-fns/tz@1.5.0': {}
|
||||
|
||||
'@dotenvx/dotenvx@1.75.1':
|
||||
dependencies:
|
||||
'@dotenvx/primitives': 0.8.0
|
||||
@ -5832,6 +5856,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
is-data-view: 1.0.2
|
||||
|
||||
date-fns@4.4.0: {}
|
||||
|
||||
debounce-fn@4.0.0:
|
||||
dependencies:
|
||||
mimic-fn: 3.1.0
|
||||
@ -7267,6 +7293,14 @@ snapshots:
|
||||
iconv-lite: 0.7.3
|
||||
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):
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
|
||||
@ -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 cashAccountsIndex } from '@/routes/admin/finance/cash-accounts';
|
||||
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 };
|
||||
|
||||
@ -73,7 +74,7 @@ const kelolaItems: NavMenuItem[] = [
|
||||
const keuanganItems: NavMenuItem[] = [
|
||||
{ title: 'Kas Toko', href: cashAccountsIndex.url(), icon: Wallet },
|
||||
{ title: 'Pengeluaran', href: expensesIndex.url(), icon: ArrowUpFromLine },
|
||||
{ title: 'Kasbon', href: '#', icon: HandCoins },
|
||||
{ title: 'Kasbon', href: employeeAdvancesIndex.url(), icon: HandCoins },
|
||||
{ title: 'Gaji', href: '#', icon: DollarSign },
|
||||
];
|
||||
|
||||
|
||||
91
resources/js/components/date-picker.tsx
Normal file
91
resources/js/components/date-picker.tsx
Normal 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 }
|
||||
218
resources/js/components/ui/calendar.tsx
Normal file
218
resources/js/components/ui/calendar.tsx
Normal 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 }
|
||||
87
resources/js/components/ui/popover.tsx
Normal file
87
resources/js/components/ui/popover.tsx
Normal 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,
|
||||
}
|
||||
287
resources/js/pages/admin/finance/employee-advance/columns.tsx
Normal file
287
resources/js/pages/admin/finance/employee-advance/columns.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
313
resources/js/pages/admin/finance/employee-advance/index.tsx
Normal file
313
resources/js/pages/admin/finance/employee-advance/index.tsx
Normal 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(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
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\Master\CategoryController;
|
||||
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::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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user