388 lines
19 KiB
TypeScript
388 lines
19 KiB
TypeScript
import { Head, useForm, Link } from '@inertiajs/react';
|
||
import React, { useState } from 'react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from '@/components/ui/select';
|
||
import { Separator } from '@/components/ui/separator';
|
||
import AppLayout from '@/layouts/app-layout';
|
||
import { PlusCircle, Trash2 } from 'lucide-react';
|
||
|
||
|
||
|
||
interface Position {
|
||
id: number;
|
||
name: string;
|
||
basic_salary: number;
|
||
}
|
||
|
||
interface Department {
|
||
name: string;
|
||
}
|
||
|
||
interface Employee {
|
||
id: number;
|
||
name: string;
|
||
nip: string;
|
||
position: Position;
|
||
department: Department;
|
||
}
|
||
|
||
interface DetailItem {
|
||
name: string;
|
||
type: 'bonus' | 'deduction';
|
||
amount: number;
|
||
}
|
||
|
||
interface PageProps {
|
||
employees: Employee[];
|
||
}
|
||
|
||
|
||
|
||
function formatRupiah(value: number): string {
|
||
return new Intl.NumberFormat('id-ID', {
|
||
style: 'currency',
|
||
currency: 'IDR',
|
||
minimumFractionDigits: 0,
|
||
}).format(value);
|
||
}
|
||
|
||
|
||
|
||
export default function Create({ employees }: PageProps) {
|
||
const { data, setData, post, processing, errors } = useForm<{
|
||
employee_id: string;
|
||
period: string;
|
||
basic_salary: number;
|
||
details: DetailItem[];
|
||
}>({
|
||
employee_id: '',
|
||
period: '',
|
||
basic_salary: 0,
|
||
details: [],
|
||
});
|
||
|
||
const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(null);
|
||
|
||
// Kalkulasi net salary
|
||
const netSalary = data.details.reduce((acc, item) => {
|
||
return item.type === 'bonus'
|
||
? acc + (item.amount || 0)
|
||
: acc - (item.amount || 0);
|
||
}, data.basic_salary);
|
||
|
||
// Set basic_salary otomatis
|
||
const handleEmployeeChange = (value: string) => {
|
||
const emp = employees.find((e) => String(e.id) === value) ?? null;
|
||
setSelectedEmployee(emp);
|
||
setData((prev) => ({
|
||
...prev,
|
||
employee_id: value,
|
||
basic_salary: emp?.position?.basic_salary ?? 0,
|
||
}));
|
||
};
|
||
|
||
// Tambah baris
|
||
const addDetail = () => {
|
||
setData('details', [
|
||
...data.details,
|
||
{ name: '', type: 'bonus', amount: 0 },
|
||
]);
|
||
};
|
||
|
||
// Update baris
|
||
const updateDetail = (index: number, field: keyof DetailItem, value: string | number) => {
|
||
const updated = [...data.details];
|
||
// @ts-expect-error – dynamic field assignment
|
||
updated[index][field] = field === 'amount' ? Number(value) : value;
|
||
setData('details', updated);
|
||
};
|
||
|
||
// Hapus baris
|
||
const removeDetail = (index: number) => {
|
||
setData('details', data.details.filter((_, i) => i !== index));
|
||
};
|
||
|
||
const submit = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
post('/admin/payrolls');
|
||
};
|
||
|
||
return (
|
||
<AppLayout>
|
||
<Head title="Generate Payroll" />
|
||
|
||
<div className="mx-auto max-w-4xl p-4 md:p-8">
|
||
{/* Back button */}
|
||
<Button variant="outline" asChild className="mb-6">
|
||
<Link href="/admin/payrolls">← Kembali</Link>
|
||
</Button>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Form Generate Payroll</CardTitle>
|
||
<p className="text-sm text-muted-foreground">
|
||
Pilih karyawan dan periode, lalu tambahkan komponen bonus/potongan jika diperlukan.
|
||
</p>
|
||
</CardHeader>
|
||
|
||
<Separator />
|
||
|
||
<CardContent className="pt-6">
|
||
<form onSubmit={submit} className="space-y-8">
|
||
|
||
{/* ── SEKSI 1: Informasi Utama ── */}
|
||
<div className="space-y-4">
|
||
<h3 className="flex items-center gap-2 text-lg font-medium">
|
||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-xs text-primary">
|
||
1
|
||
</span>
|
||
Informasi Utama
|
||
</h3>
|
||
|
||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||
{/* Pilih Karyawan */}
|
||
<div className="space-y-2">
|
||
<Label>Karyawan</Label>
|
||
<Select onValueChange={handleEmployeeChange}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Pilih Karyawan..." />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{employees.map((emp) => (
|
||
<SelectItem key={emp.id} value={String(emp.id)}>
|
||
{emp.name}{' '}
|
||
<span className="text-muted-foreground">
|
||
({emp.nip})
|
||
</span>
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
{errors.employee_id && (
|
||
<p className="text-xs text-red-500">{errors.employee_id}</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Periode */}
|
||
<div className="space-y-2">
|
||
<Label>Periode Gaji</Label>
|
||
<Input
|
||
type="month"
|
||
value={data.period}
|
||
onChange={(e) => setData('period', e.target.value)}
|
||
/>
|
||
{errors.period && (
|
||
<p className="text-xs text-red-500">{errors.period}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Info Karyawan Terpilih */}
|
||
{selectedEmployee && (
|
||
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<span className="text-muted-foreground">Departemen:</span>{' '}
|
||
<span className="font-medium">
|
||
{selectedEmployee.department.name}
|
||
</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-muted-foreground">Jabatan:</span>{' '}
|
||
<span className="font-medium">
|
||
{selectedEmployee.position.name}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
{/* ── SEKSI 2: Gaji Pokok (Auto-fill) ── */}
|
||
<div className="space-y-4">
|
||
<h3 className="flex items-center gap-2 text-lg font-medium">
|
||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-xs text-primary">
|
||
2
|
||
</span>
|
||
Gaji Pokok
|
||
</h3>
|
||
|
||
<div className="max-w-xs space-y-2">
|
||
<Label>
|
||
Gaji Pokok{' '}
|
||
<span className="text-xs text-muted-foreground">
|
||
(otomatis dari jabatan)
|
||
</span>
|
||
</Label>
|
||
<Input
|
||
type="number"
|
||
min={0}
|
||
value={data.basic_salary}
|
||
onChange={(e) =>
|
||
setData('basic_salary', Number(e.target.value))
|
||
}
|
||
className="font-medium"
|
||
/>
|
||
{errors.basic_salary && (
|
||
<p className="text-xs text-red-500">{errors.basic_salary}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
{/* ── SEKSI 3: Komponen Bonus / Potongan ── */}
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="flex items-center gap-2 text-lg font-medium">
|
||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-xs text-primary">
|
||
3
|
||
</span>
|
||
Komponen Gaji
|
||
</h3>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={addDetail}
|
||
className="gap-1.5"
|
||
>
|
||
<PlusCircle className="h-4 w-4" />
|
||
Tambah Komponen
|
||
</Button>
|
||
</div>
|
||
|
||
{data.details.length === 0 ? (
|
||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||
Belum ada komponen tambahan. Klik "Tambah Komponen" untuk menambahkan bonus atau potongan.
|
||
</p>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{/* Header kolom */}
|
||
<div className="grid grid-cols-[1fr_140px_160px_40px] gap-3 px-1">
|
||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Nama Komponen</span>
|
||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Tipe</span>
|
||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Nominal (Rp)</span>
|
||
<span />
|
||
</div>
|
||
|
||
{data.details.map((item, index) => (
|
||
<div key={index} className="space-y-1">
|
||
<div
|
||
className="grid grid-cols-[1fr_140px_160px_40px] items-center gap-3"
|
||
>
|
||
{/* Nama Komponen */}
|
||
<Input
|
||
placeholder="cth: Bonus Kinerja"
|
||
value={item.name}
|
||
onChange={(e) =>
|
||
updateDetail(index, 'name', e.target.value)
|
||
}
|
||
/>
|
||
|
||
{/* Tipe */}
|
||
<Select
|
||
value={item.type}
|
||
onValueChange={(val) =>
|
||
updateDetail(index, 'type', val)
|
||
}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="bonus">Bonus</SelectItem>
|
||
<SelectItem value="deduction">Potongan</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
{/* Nominal */}
|
||
<Input
|
||
type="number"
|
||
min={0}
|
||
placeholder="0"
|
||
value={item.amount || ''}
|
||
onChange={(e) =>
|
||
updateDetail(index, 'amount', e.target.value)
|
||
}
|
||
/>
|
||
|
||
{/* Hapus */}
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => removeDetail(index)}
|
||
className="text-muted-foreground hover:text-red-500"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
{/* Error per baris detail */}
|
||
<div className="px-1">
|
||
{errors[`details.${index}.name`] && (
|
||
<p className="text-xs text-red-500">{errors[`details.${index}.name`]}</p>
|
||
)}
|
||
{errors[`details.${index}.type`] && (
|
||
<p className="text-xs text-red-500">{errors[`details.${index}.type`]}</p>
|
||
)}
|
||
{errors[`details.${index}.amount`] && (
|
||
<p className="text-xs text-red-500">{errors[`details.${index}.amount`]}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
{/* ── RINGKASAN GAJI BERSIH ── */}
|
||
<div className="rounded-lg border bg-primary/5 p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">Total Gaji Bersih (Estimasi)</p>
|
||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||
Gaji Pokok + Bonus − Potongan
|
||
</p>
|
||
</div>
|
||
<p className="text-2xl font-semibold text-primary">
|
||
{formatRupiah(Math.max(0, netSalary))}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── TOMBOL AKSI ── */}
|
||
<div className="flex justify-end gap-4 pt-2">
|
||
<Button type="button" variant="ghost" asChild>
|
||
<Link href="/admin/payrolls">Batal</Link>
|
||
</Button>
|
||
<Button
|
||
type="submit"
|
||
disabled={processing || !data.employee_id || !data.period}
|
||
className="px-8"
|
||
>
|
||
{processing ? 'Menyimpan...' : 'Generate Payroll'}
|
||
</Button>
|
||
</div>
|
||
|
||
</form>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</AppLayout>
|
||
);
|
||
}
|