255 lines
12 KiB
TypeScript
255 lines
12 KiB
TypeScript
import { Head, Link, router, usePage } from '@inertiajs/react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Input } from '@/components/ui/input';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import AppLayout from '@/layouts/app-layout';
|
|
import { PlusCircle, FileText, Search, CheckCircle2, Clock, Pencil, Download } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
import * as XLSX from 'xlsx';
|
|
|
|
|
|
interface Payroll {
|
|
id: number;
|
|
employee_name: string;
|
|
employee_nip: string;
|
|
period: string;
|
|
net_salary: number;
|
|
status: 'pending' | 'paid';
|
|
}
|
|
|
|
interface PageProps {
|
|
payrolls: Payroll[];
|
|
filters: { search?: string; period?: string; status?: string };
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
|
|
|
|
function formatRupiah(value: number): string {
|
|
return new Intl.NumberFormat('id-ID', {
|
|
style: 'currency',
|
|
currency: 'IDR',
|
|
minimumFractionDigits: 0,
|
|
}).format(value);
|
|
}
|
|
|
|
function formatPeriod(period: string): string {
|
|
const [year, month] = period.split('-');
|
|
return new Date(Number(year), Number(month) - 1, 1)
|
|
.toLocaleDateString('id-ID', { month: 'long', year: 'numeric' });
|
|
}
|
|
|
|
function StatusBadge({ status }: { status: 'pending' | 'paid' }) {
|
|
return status === 'paid' ? (
|
|
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 gap-1">
|
|
<CheckCircle2 className="h-3 w-3" />
|
|
Telah Dikirim
|
|
</Badge>
|
|
) : (
|
|
<Badge className="bg-amber-100 text-amber-700 hover:bg-amber-100 gap-1">
|
|
<Clock className="h-3 w-3" />
|
|
Menunggu
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
|
|
|
|
export default function Index({ payrolls, filters }: PageProps) {
|
|
const [search, setSearch] = useState(filters.search ?? '');
|
|
const [period, setPeriod] = useState(filters.period ?? '');
|
|
const [status, setStatus] = useState(filters.status ?? '');
|
|
const [processingId, setProcessingId] = useState<number | null>(null);
|
|
|
|
const applyFilter = () => {
|
|
router.get('/admin/payrolls', { search, period, status }, { preserveScroll: true });
|
|
};
|
|
|
|
const resetFilter = () => {
|
|
setSearch(''); setPeriod(''); setStatus('');
|
|
router.get('/admin/payrolls', {}, { preserveScroll: true });
|
|
};
|
|
|
|
const toggleStatus = (payroll: Payroll) => {
|
|
const newStatus = payroll.status === 'paid' ? 'pending' : 'paid';
|
|
setProcessingId(payroll.id);
|
|
router.patch(
|
|
`/admin/payrolls/${payroll.id}/status`,
|
|
{ status: newStatus },
|
|
{ preserveScroll: true, onFinish: () => setProcessingId(null) },
|
|
);
|
|
};
|
|
|
|
const exportExcel = () => {
|
|
const rows = payrolls.map((p, i) => ({
|
|
No: i + 1,
|
|
Karyawan: p.employee_name,
|
|
NIP: p.employee_nip,
|
|
Periode: formatPeriod(p.period),
|
|
'Gaji Bersih': p.net_salary,
|
|
Status: p.status === 'paid' ? 'Telah Dikirim' : 'Menunggu',
|
|
}));
|
|
const ws = XLSX.utils.json_to_sheet(rows);
|
|
const wb = XLSX.utils.book_new();
|
|
XLSX.utils.book_append_sheet(wb, ws, 'Payroll');
|
|
XLSX.writeFile(wb, `payroll_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
|
};
|
|
|
|
return (
|
|
<AppLayout>
|
|
<Head title="Daftar Payroll" />
|
|
|
|
<div className="p-4 md:p-8 w-full space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-1">
|
|
<h2 className="text-2xl font-bold tracking-tight">Manajemen Payroll</h2>
|
|
<p className="text-muted-foreground">Daftar seluruh payroll yang telah di-generate.</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={exportExcel} className="gap-2">
|
|
<Download className="h-4 w-4" />
|
|
Export Excel
|
|
</Button>
|
|
<Button asChild>
|
|
<Link href="/admin/payrolls/create" className="gap-2">
|
|
<PlusCircle className="h-4 w-4" />
|
|
Generate Payroll
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter */}
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="flex flex-wrap gap-3 items-end">
|
|
<div className="space-y-1 flex-1 min-w-[180px]">
|
|
<label className="text-xs font-medium text-muted-foreground">Cari Karyawan</label>
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
className="pl-8"
|
|
placeholder="Nama / NIP..."
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && applyFilter()}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1 w-[160px]">
|
|
<label className="text-xs font-medium text-muted-foreground">Periode</label>
|
|
<Input
|
|
type="month"
|
|
value={period}
|
|
onChange={e => setPeriod(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1 w-[160px]">
|
|
<label className="text-xs font-medium text-muted-foreground">Status</label>
|
|
<Select value={status || 'all'} onValueChange={v => setStatus(v === 'all' ? '' : v)}>
|
|
<SelectTrigger><SelectValue placeholder="Semua Status" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Semua Status</SelectItem>
|
|
<SelectItem value="pending">Menunggu</SelectItem>
|
|
<SelectItem value="paid">Telah Dikirim</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Button onClick={applyFilter}>Filter</Button>
|
|
<Button variant="ghost" onClick={resetFilter}>Reset</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Tabel */}
|
|
<Card>
|
|
<CardHeader className="pb-4">
|
|
<CardTitle>Daftar Slip Gaji</CardTitle>
|
|
</CardHeader>
|
|
|
|
<CardContent className="p-0">
|
|
{payrolls.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
<FileText className="mb-3 h-10 w-10 text-muted-foreground/50" />
|
|
<p className="text-sm font-medium text-muted-foreground">Belum ada data payroll.</p>
|
|
<Button asChild variant="outline" className="mt-4">
|
|
<Link href="/admin/payrolls/create">Generate Pertama</Link>
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-12">#</TableHead>
|
|
<TableHead>Karyawan</TableHead>
|
|
<TableHead>Periode</TableHead>
|
|
<TableHead className="text-right">Gaji Bersih</TableHead>
|
|
<TableHead className="text-center">Status</TableHead>
|
|
<TableHead className="text-right">Aksi</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{payrolls.map((payroll, index) => (
|
|
<TableRow key={payroll.id}>
|
|
<TableCell className="text-muted-foreground">{index + 1}</TableCell>
|
|
<TableCell>
|
|
<div className="font-medium">{payroll.employee_name}</div>
|
|
<div className="text-xs text-muted-foreground">NIP: {payroll.employee_nip}</div>
|
|
</TableCell>
|
|
<TableCell>{formatPeriod(payroll.period)}</TableCell>
|
|
<TableCell className="text-right font-medium">
|
|
{formatRupiah(payroll.net_salary)}
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<button
|
|
onClick={() => toggleStatus(payroll)}
|
|
disabled={processingId === payroll.id}
|
|
className="cursor-pointer disabled:opacity-50"
|
|
title="Klik untuk ubah status"
|
|
>
|
|
<StatusBadge status={payroll.status} />
|
|
</button>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex justify-end gap-1">
|
|
<Button asChild variant="ghost" size="sm">
|
|
<Link href={`/admin/payrolls/${payroll.id}/edit`}>
|
|
<Pencil className="h-3.5 w-3.5 mr-1" />
|
|
Edit
|
|
</Link>
|
|
</Button>
|
|
<Button asChild variant="ghost" size="sm">
|
|
<Link href={`/admin/payrolls/${payroll.id}`}>
|
|
Lihat Slip
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</AppLayout>
|
|
);
|
|
}
|