331 lines
14 KiB
TypeScript
331 lines
14 KiB
TypeScript
import { Head, Link, router } from '@inertiajs/react';
|
||
import { useState } from 'react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Card, CardContent } from '@/components/ui/card';
|
||
import { Separator } from '@/components/ui/separator';
|
||
import AppLayout from '@/layouts/app-layout';
|
||
import { Printer, Building2, CalendarDays, Hash, Pencil, CheckCircle2, Clock } from 'lucide-react';
|
||
|
||
|
||
|
||
interface DetailItem {
|
||
name: string;
|
||
type: 'bonus' | 'deduction';
|
||
amount: number;
|
||
}
|
||
|
||
interface PayrollData {
|
||
id: number;
|
||
period: string;
|
||
basic_salary: number;
|
||
details: DetailItem[];
|
||
net_salary: number;
|
||
status: 'pending' | 'paid';
|
||
created_at: string;
|
||
employee: {
|
||
name: string;
|
||
nip: string;
|
||
position: string;
|
||
department: string;
|
||
};
|
||
}
|
||
|
||
interface PageProps {
|
||
payroll: PayrollData;
|
||
}
|
||
|
||
|
||
|
||
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('-');
|
||
const date = new Date(Number(year), Number(month) - 1, 1);
|
||
return date.toLocaleDateString('id-ID', { month: 'long', year: 'numeric' });
|
||
}
|
||
|
||
|
||
|
||
function InfoCell({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div>
|
||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">{label}</p>
|
||
<p className="mt-0.5 text-sm font-semibold text-foreground">{value}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LineItem({
|
||
label,
|
||
value,
|
||
type = 'neutral',
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
type?: 'neutral' | 'bonus' | 'deduction';
|
||
}) {
|
||
const valueClass =
|
||
type === 'bonus'
|
||
? 'text-emerald-600 font-medium'
|
||
: type === 'deduction'
|
||
? 'text-red-500 font-medium'
|
||
: 'font-medium';
|
||
const prefix = type === 'bonus' ? '+ ' : type === 'deduction' ? '− ' : '';
|
||
|
||
return (
|
||
<div className="flex items-center justify-between py-2">
|
||
<span className="text-sm text-muted-foreground">{label}</span>
|
||
<span className={`text-sm ${valueClass}`}>
|
||
{prefix}{value}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatusBadge({ status }: { status: 'pending' | 'paid' }) {
|
||
return status === 'paid' ? (
|
||
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 border-emerald-200 gap-1">
|
||
<CheckCircle2 className="h-3 w-3" />
|
||
Telah Dikirim
|
||
</Badge>
|
||
) : (
|
||
<Badge className="bg-amber-100 text-amber-700 hover:bg-amber-100 border-amber-200 gap-1">
|
||
<Clock className="h-3 w-3" />
|
||
Menunggu
|
||
</Badge>
|
||
);
|
||
}
|
||
|
||
|
||
|
||
export default function Show({ payroll }: PageProps) {
|
||
const bonuses = payroll.details.filter((d) => d.type === 'bonus');
|
||
const deductions = payroll.details.filter((d) => d.type === 'deduction');
|
||
const totalBonus = bonuses.reduce((s, d) => s + d.amount, 0);
|
||
const totalDeduction = deductions.reduce((s, d) => s + d.amount, 0);
|
||
const [toggling, setToggling] = useState(false);
|
||
|
||
const toggleStatus = () => {
|
||
const newStatus = payroll.status === 'paid' ? 'pending' : 'paid';
|
||
setToggling(true);
|
||
router.patch(
|
||
`/admin/payrolls/${payroll.id}/status`,
|
||
{ status: newStatus },
|
||
{ onFinish: () => setToggling(false) },
|
||
);
|
||
};
|
||
|
||
return (
|
||
<AppLayout>
|
||
<Head title={`Slip Gaji — ${payroll.employee.name}`} />
|
||
|
||
<div className="mx-auto max-w-2xl p-4 md:p-8 print:p-0 print:max-w-none">
|
||
|
||
{/* Toolbar — tersembunyi saat print */}
|
||
<div className="mb-5 flex items-center justify-between gap-2 print:hidden">
|
||
<Button variant="outline" size="sm" asChild>
|
||
<Link href="/admin/payrolls">← Kembali</Link>
|
||
</Button>
|
||
<div className="flex items-center gap-2">
|
||
{payroll.status !== 'paid' && (
|
||
<>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={toggleStatus}
|
||
disabled={toggling}
|
||
className="gap-1.5"
|
||
>
|
||
<CheckCircle2 className="h-3.5 w-3.5" /> Tandai Terkirim
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="gap-1.5"
|
||
asChild
|
||
>
|
||
<Link href={`/admin/payrolls/${payroll.id}/edit`}>
|
||
<Pencil className="h-3.5 w-3.5" />
|
||
Edit
|
||
</Link>
|
||
</Button>
|
||
</>
|
||
)}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="gap-1.5"
|
||
onClick={() => window.print()}
|
||
>
|
||
<Printer className="h-3.5 w-3.5" />
|
||
Cetak
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Slip Card ── */}
|
||
<Card className="overflow-hidden border shadow-sm print:shadow-none print:border-none print:rounded-none">
|
||
|
||
{/* ── HEADER ── */}
|
||
<div className="border-b bg-zinc-50 px-6 py-5 print:break-inside-avoid">
|
||
<div className="flex items-start justify-between">
|
||
{/* Nama Perusahaan */}
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||
<Building2 className="h-5 w-5 text-primary" />
|
||
</div>
|
||
<div>
|
||
<p className="text-sm font-bold leading-none text-foreground">
|
||
PT. HRIS Nusantara
|
||
</p>
|
||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||
Slip Gaji Karyawan
|
||
</p>
|
||
</div>
|
||
</div>
|
||
{/* Meta slip */}
|
||
<div className="text-right">
|
||
<div className="flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
|
||
<Hash className="h-3 w-3" />
|
||
<span className="font-mono font-medium">
|
||
{String(payroll.id).padStart(5, '0')}
|
||
</span>
|
||
</div>
|
||
<div className="mt-1 flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
|
||
<CalendarDays className="h-3 w-3" />
|
||
<span>{payroll.created_at}</span>
|
||
</div>
|
||
<div className="mt-2">
|
||
<StatusBadge status={payroll.status} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<CardContent className="p-0">
|
||
|
||
{/* ── INFO KARYAWAN ── */}
|
||
<div className="border-b px-6 py-5 print:break-inside-avoid">
|
||
<p className="mb-3 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||
Informasi Karyawan
|
||
</p>
|
||
<div className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-4">
|
||
<InfoCell label="Nama" value={payroll.employee.name} />
|
||
<InfoCell label="NIP" value={payroll.employee.nip} />
|
||
<InfoCell label="Jabatan" value={payroll.employee.position ?? '-'} />
|
||
<InfoCell label="Departemen" value={payroll.employee.department ?? '-'} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── PERIODE ── */}
|
||
<div className="flex items-center justify-between border-b bg-muted/30 px-6 py-3 print:break-inside-avoid">
|
||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||
Periode Penggajian
|
||
</p>
|
||
<p className="text-sm font-bold text-foreground">
|
||
{formatPeriod(payroll.period)}
|
||
</p>
|
||
</div>
|
||
|
||
{/* ── RINCIAN GAJI ── */}
|
||
<div className="px-6 py-5 space-y-1 print:break-inside-avoid">
|
||
|
||
{/* Gaji Pokok */}
|
||
<p className="mb-1 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||
Komponen Gaji
|
||
</p>
|
||
<LineItem
|
||
label="Gaji Pokok"
|
||
value={formatRupiah(payroll.basic_salary)}
|
||
/>
|
||
|
||
{/* Bonus */}
|
||
{bonuses.length > 0 && (
|
||
<>
|
||
<Separator className="my-2" />
|
||
<p className="pb-0.5 text-[11px] font-semibold uppercase tracking-widest text-emerald-600">
|
||
Tambahan
|
||
</p>
|
||
{bonuses.map((item, i) => (
|
||
<LineItem
|
||
key={i}
|
||
label={item.name}
|
||
value={formatRupiah(item.amount)}
|
||
type="bonus"
|
||
/>
|
||
))}
|
||
<div className="flex justify-between pt-1 text-xs">
|
||
<span className="text-muted-foreground">Subtotal tambahan</span>
|
||
<span className="font-semibold text-emerald-600">
|
||
+ {formatRupiah(totalBonus)}
|
||
</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Potongan */}
|
||
{deductions.length > 0 && (
|
||
<>
|
||
<Separator className="my-2" />
|
||
<p className="pb-0.5 text-[11px] font-semibold uppercase tracking-widest text-red-500">
|
||
Potongan
|
||
</p>
|
||
{deductions.map((item, i) => (
|
||
<LineItem
|
||
key={i}
|
||
label={item.name}
|
||
value={formatRupiah(item.amount)}
|
||
type="deduction"
|
||
/>
|
||
))}
|
||
<div className="flex justify-between pt-1 text-xs">
|
||
<span className="text-muted-foreground">Subtotal potongan</span>
|
||
<span className="font-semibold text-red-500">
|
||
− {formatRupiah(totalDeduction)}
|
||
</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── TOTAL GAJI BERSIH ── */}
|
||
<div className="border-t bg-zinc-50 px-6 py-5 print:break-inside-avoid">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-semibold text-foreground">
|
||
Total Gaji Bersih
|
||
</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
Gaji Pokok
|
||
{bonuses.length > 0 && ' + Tambahan'}
|
||
{deductions.length > 0 && ' − Potongan'}
|
||
</p>
|
||
</div>
|
||
<p className="text-2xl font-bold tracking-tight text-foreground">
|
||
{formatRupiah(payroll.net_salary)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── FOOTER ── */}
|
||
<div className="border-t px-6 py-3">
|
||
<p className="text-center text-[11px] text-muted-foreground">
|
||
Dokumen ini diterbitkan secara otomatis oleh sistem HRIS dan sah tanpa tanda tangan basah.
|
||
</p>
|
||
</div>
|
||
|
||
</CardContent>
|
||
</Card>
|
||
|
||
</div>
|
||
</AppLayout>
|
||
);
|
||
}
|