- Implemented routes for managing payroll periods, including current, close, and reopen functionalities. - Added payroll payment and cancellation routes. - Introduced payroll adjustments with store and delete functionalities. - Created comprehensive feature tests for payroll management, covering authentication, CRUD operations, and business logic. - Ensured proper handling of payroll adjustments and their impact on payroll totals. - Developed tests for generating payrolls and managing payroll periods, ensuring accurate status transitions and data integrity.
287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
import type { ColumnDef } from '@tanstack/react-table';
|
|
import { ArrowUpDown, Eye, Lock, Unlock } 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';
|
|
import { Link } from '@inertiajs/react';
|
|
|
|
export type PayrollPeriod = {
|
|
id: number;
|
|
year: number;
|
|
month: number;
|
|
status: 'open' | 'closed';
|
|
closed_at: string | null;
|
|
created_at: string;
|
|
payrolls_count: number;
|
|
paid_count: number;
|
|
cancelled_count: number;
|
|
payrolls_sum_total_amount: number | null;
|
|
payrolls_sum_bonus_amount: number | null;
|
|
payrolls_sum_deduction_amount: number | null;
|
|
};
|
|
|
|
const MONTH_NAMES = [
|
|
'', 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
|
|
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember',
|
|
];
|
|
|
|
function formatPeriod(period: PayrollPeriod): string {
|
|
return `${MONTH_NAMES[period.month]} ${period.year}`;
|
|
}
|
|
|
|
function getStatusBadge(status: string) {
|
|
const statusConfig: Record<string, { label: string; className: string }> = {
|
|
open: {
|
|
label: 'Terbuka',
|
|
className: 'bg-green-100 text-green-800 hover:bg-green-100',
|
|
},
|
|
closed: {
|
|
label: 'Ditutup',
|
|
className: 'bg-gray-100 text-gray-800 hover:bg-gray-100',
|
|
},
|
|
};
|
|
|
|
const config = statusConfig[status] ?? statusConfig.open;
|
|
|
|
return (
|
|
<Badge variant="secondary" className={config.className}>
|
|
{config.label}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
type CreateColumnsParams = {
|
|
showUrl: (id: number) => string;
|
|
handleClose: (period: PayrollPeriod) => void;
|
|
handleReopen: (period: PayrollPeriod) => void;
|
|
};
|
|
|
|
export function createPayrollPeriodColumns(
|
|
params: CreateColumnsParams,
|
|
): ColumnDef<PayrollPeriod>[] {
|
|
const { showUrl, handleClose, handleReopen } = 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: 'year',
|
|
header: ({ column }) => (
|
|
<Button
|
|
variant="ghost"
|
|
className="-ml-3 h-8"
|
|
onClick={() =>
|
|
column.toggleSorting(
|
|
column.getIsSorted() === 'asc',
|
|
)
|
|
}
|
|
>
|
|
<span>Periode</span>
|
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
),
|
|
cell: ({ row }) => (
|
|
<span className="font-medium">{formatPeriod(row.original)}</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'payrolls_count',
|
|
header: () => <span>Jumlah Karyawan</span>,
|
|
cell: ({ row }) => (
|
|
<span className="text-center">{row.getValue('payrolls_count') as number}</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'payrolls_sum_total_amount',
|
|
header: ({ column }) => (
|
|
<Button
|
|
variant="ghost"
|
|
className="-ml-3 h-8"
|
|
onClick={() =>
|
|
column.toggleSorting(
|
|
column.getIsSorted() === 'asc',
|
|
)
|
|
}
|
|
>
|
|
<span>Total Gaji</span>
|
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
),
|
|
cell: ({ row }) => (
|
|
<span className="font-medium">
|
|
{formatCurrency((row.getValue('payrolls_sum_total_amount') as number) ?? 0)}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'payrolls_sum_bonus_amount',
|
|
header: ({ column }) => (
|
|
<Button
|
|
variant="ghost"
|
|
className="-ml-3 h-8"
|
|
onClick={() =>
|
|
column.toggleSorting(
|
|
column.getIsSorted() === 'asc',
|
|
)
|
|
}
|
|
>
|
|
<span>Bonus</span>
|
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
),
|
|
cell: ({ row }) => {
|
|
const value = (row.getValue('payrolls_sum_bonus_amount') as number) ?? 0;
|
|
|
|
return (
|
|
<span className={value > 0 ? 'text-green-600 font-medium' : 'text-muted-foreground'}>
|
|
{value > 0 ? '+ ' : ''}{formatCurrency(value)}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
accessorKey: 'payrolls_sum_deduction_amount',
|
|
header: ({ column }) => (
|
|
<Button
|
|
variant="ghost"
|
|
className="-ml-3 h-8"
|
|
onClick={() =>
|
|
column.toggleSorting(
|
|
column.getIsSorted() === 'asc',
|
|
)
|
|
}
|
|
>
|
|
<span>Potongan</span>
|
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
),
|
|
cell: ({ row }) => {
|
|
const value = (row.getValue('payrolls_sum_deduction_amount') as number) ?? 0;
|
|
|
|
return (
|
|
<span className={value > 0 ? 'text-red-600 font-medium' : 'text-muted-foreground'}>
|
|
{value > 0 ? '- ' : ''}{formatCurrency(value)}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: 'payment_status',
|
|
header: () => <span>Status Bayar</span>,
|
|
cell: ({ row }) => {
|
|
const period = row.original;
|
|
const paid = period.paid_count ?? 0;
|
|
const cancelled = period.cancelled_count ?? 0;
|
|
const total = period.payrolls_count ?? 0;
|
|
const unpaid = total - paid - cancelled;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-0.5 text-xs">
|
|
{paid > 0 && (
|
|
<span className="text-green-600">{paid} dibayar</span>
|
|
)}
|
|
{unpaid > 0 && (
|
|
<span className="text-yellow-600">{unpaid} menunggu</span>
|
|
)}
|
|
{cancelled > 0 && (
|
|
<span className="text-red-600">{cancelled} dibatalkan</span>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
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-[100px] text-center',
|
|
headerClassName: 'w-[100px] text-center',
|
|
},
|
|
cell: ({ row }) => {
|
|
const period = row.original;
|
|
|
|
return (
|
|
<TooltipProvider>
|
|
<div className="flex items-center justify-center gap-1">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
asChild
|
|
>
|
|
<Link href={showUrl(period.id)}>
|
|
<Eye className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">
|
|
Lihat Detail
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
{period.status === 'open' && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleClose(period)}
|
|
>
|
|
<Lock className="h-4 w-4 text-orange-600" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">
|
|
Tutup Periode
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
|
|
{period.status === 'closed' && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleReopen(period)}
|
|
>
|
|
<Unlock className="h-4 w-4 text-blue-600" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">
|
|
Buka Periode
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
</TooltipProvider>
|
|
);
|
|
},
|
|
},
|
|
];
|
|
}
|