370 lines
16 KiB
TypeScript
370 lines
16 KiB
TypeScript
import { Form, Head, router } from '@inertiajs/react';
|
|
import { ArrowLeft } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
import { ConfirmDialog } from '@/components/confirm-dialog';
|
|
import { DataTable } from '@/components/data-table';
|
|
import InputError from '@/components/input-error';
|
|
import { RupiahInput } from '@/components/rupiah-input';
|
|
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 { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
|
import { MONTH_NAMES } from '@/lib/constants';
|
|
import { formatCurrency } from '@/lib/utils';
|
|
import { destroy as adjustmentDestroy } from '@/routes/admin/finance/payroll-adjustments';
|
|
import { index as payrollPeriodsIndex } from '@/routes/admin/finance/payroll-periods';
|
|
import {
|
|
cancel as payrollCancel,
|
|
pay as payrollPay,
|
|
} from '@/routes/admin/finance/payrolls';
|
|
import { store as adjustmentStore } from '@/routes/admin/finance/payrolls/adjustments';
|
|
import type { Payroll, PayrollAdjustment } from './show-columns';
|
|
import { createPayrollColumns } from './show-columns';
|
|
|
|
type Props = {
|
|
payrollPeriod: {
|
|
id: number;
|
|
year: number;
|
|
month: number;
|
|
status: string;
|
|
payrolls: Payroll[];
|
|
};
|
|
};
|
|
|
|
export default function PayrollPeriodShow({ payrollPeriod }: Props) {
|
|
const [paying, setPaying] = useState<Payroll | null>(null);
|
|
const [cancelling, setCancelling] = useState<Payroll | null>(null);
|
|
const [addingAdjustment, setAddingAdjustment] = useState<Payroll | null>(
|
|
null,
|
|
);
|
|
const [deletingAdjustment, setDeletingAdjustment] = useState<{
|
|
adjustment: PayrollAdjustment;
|
|
payrollId: number;
|
|
} | null>(null);
|
|
const [adjustmentType, setAdjustmentType] = useState<string>('bonus');
|
|
|
|
function handlePay() {
|
|
if (!paying) {
|
|
return;
|
|
}
|
|
|
|
router.post(
|
|
payrollPay(paying.id),
|
|
{},
|
|
{
|
|
onSuccess: () => setPaying(null),
|
|
},
|
|
);
|
|
}
|
|
|
|
function handleCancel() {
|
|
if (!cancelling) {
|
|
return;
|
|
}
|
|
|
|
router.post(
|
|
payrollCancel(cancelling.id),
|
|
{},
|
|
{
|
|
onSuccess: () => setCancelling(null),
|
|
},
|
|
);
|
|
}
|
|
|
|
function handleDeleteAdjustment() {
|
|
if (!deletingAdjustment) {
|
|
return;
|
|
}
|
|
|
|
router.delete(adjustmentDestroy(deletingAdjustment.adjustment.id), {
|
|
onSuccess: () => setDeletingAdjustment(null),
|
|
});
|
|
}
|
|
|
|
const columns = createPayrollColumns({
|
|
handlePay: (payroll) => setPaying(payroll),
|
|
handleCancel: (payroll) => setCancelling(payroll),
|
|
handleAddAdjustment: (payroll) => {
|
|
setAddingAdjustment(payroll);
|
|
setAdjustmentType('bonus');
|
|
},
|
|
handleDeleteAdjustment: (adjustment, payrollId) => {
|
|
setDeletingAdjustment({ adjustment, payrollId });
|
|
},
|
|
});
|
|
|
|
const totalBaseSalary = payrollPeriod.payrolls.reduce(
|
|
(sum, p) => sum + p.base_salary,
|
|
0,
|
|
);
|
|
const totalBonus = payrollPeriod.payrolls.reduce(
|
|
(sum, p) => sum + p.bonus_amount,
|
|
0,
|
|
);
|
|
const totalDeduction = payrollPeriod.payrolls.reduce(
|
|
(sum, p) => sum + p.deduction_amount,
|
|
0,
|
|
);
|
|
const totalAmount = payrollPeriod.payrolls.reduce(
|
|
(sum, p) => sum + p.total_amount,
|
|
0,
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<Head
|
|
title={`Gaji - ${MONTH_NAMES[payrollPeriod.month]} ${payrollPeriod.year}`}
|
|
/>
|
|
|
|
<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">
|
|
Gaji {MONTH_NAMES[payrollPeriod.month]}{' '}
|
|
{payrollPeriod.year}
|
|
</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
{payrollPeriod.payrolls.length} karyawan ·
|
|
Status:{' '}
|
|
{payrollPeriod.status === 'open'
|
|
? 'Terbuka'
|
|
: 'Ditutup'}
|
|
</p>
|
|
</div>
|
|
<Button asChild variant="outline">
|
|
<a href={payrollPeriodsIndex.url()}>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Kembali
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
|
<div className="rounded-lg border p-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Total Gaji Pokok
|
|
</p>
|
|
<p className="text-lg font-semibold">
|
|
{formatCurrency(totalBaseSalary)}
|
|
</p>
|
|
</div>
|
|
<div className="rounded-lg border p-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Total Bonus
|
|
</p>
|
|
<p className="text-lg font-semibold text-green-600">
|
|
{formatCurrency(totalBonus)}
|
|
</p>
|
|
</div>
|
|
<div className="rounded-lg border p-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Total Potongan
|
|
</p>
|
|
<p className="text-lg font-semibold text-red-600">
|
|
{formatCurrency(totalDeduction)}
|
|
</p>
|
|
</div>
|
|
<div className="rounded-lg border p-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Total Gaji
|
|
</p>
|
|
<p className="text-lg font-semibold">
|
|
{formatCurrency(totalAmount)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<DataTable
|
|
columns={columns}
|
|
data={payrollPeriod.payrolls}
|
|
searchKey="employee_name"
|
|
searchPlaceholder="Cari karyawan..."
|
|
emptyText="Belum ada data gaji."
|
|
/>
|
|
|
|
{/* Dialog Tambah Penyesuaian */}
|
|
<Dialog
|
|
open={addingAdjustment !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setAddingAdjustment(null);
|
|
setAdjustmentType('bonus');
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
{addingAdjustment && (
|
|
<Form
|
|
action={adjustmentStore(addingAdjustment.id)}
|
|
resetOnSuccess
|
|
onSuccess={() => {
|
|
setAddingAdjustment(null);
|
|
setAdjustmentType('bonus');
|
|
}}
|
|
>
|
|
{({ errors, processing }) => (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
Tambah Penyesuaian
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="grid gap-4 py-4">
|
|
<div className="grid gap-2">
|
|
<Label>
|
|
Jenis{' '}
|
|
<span className="text-destructive">
|
|
*
|
|
</span>
|
|
</Label>
|
|
<input
|
|
type="hidden"
|
|
name="type"
|
|
value={adjustmentType}
|
|
/>
|
|
<RadioGroup
|
|
value={adjustmentType}
|
|
onValueChange={
|
|
setAdjustmentType
|
|
}
|
|
className="flex gap-4"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<RadioGroupItem
|
|
value="bonus"
|
|
id="bonus"
|
|
/>
|
|
<Label
|
|
htmlFor="bonus"
|
|
className="font-normal"
|
|
>
|
|
Bonus
|
|
</Label>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<RadioGroupItem
|
|
value="deduction"
|
|
id="deduction"
|
|
/>
|
|
<Label
|
|
htmlFor="deduction"
|
|
className="font-normal"
|
|
>
|
|
Potongan
|
|
</Label>
|
|
</div>
|
|
</RadioGroup>
|
|
<InputError
|
|
message={errors.type}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label>
|
|
Jumlah{' '}
|
|
<span className="text-destructive">
|
|
*
|
|
</span>
|
|
</Label>
|
|
<RupiahInput
|
|
name="amount"
|
|
/>
|
|
<InputError
|
|
message={errors.amount}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="adjustment-description">
|
|
Keterangan{' '}
|
|
<span className="text-destructive">
|
|
*
|
|
</span>
|
|
</Label>
|
|
<Input
|
|
id="adjustment-description"
|
|
name="description"
|
|
placeholder="Masukkan keterangan"
|
|
/>
|
|
<InputError
|
|
message={errors.description}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() =>
|
|
setAddingAdjustment(null)
|
|
}
|
|
>
|
|
Batal
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
disabled={processing}
|
|
>
|
|
{processing
|
|
? 'Menyimpan...'
|
|
: 'Simpan'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</>
|
|
)}
|
|
</Form>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Dialog Konfirmasi Bayar */}
|
|
<ConfirmDialog
|
|
open={paying !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setPaying(null);
|
|
}
|
|
}}
|
|
title="Tandai Dibayar"
|
|
description={`Apakah Anda yakin ingin menandai gaji "${paying?.employee?.user?.user_profile?.full_name}" sebesar ${formatCurrency(paying?.total_amount ?? 0)} sebagai sudah dibayar? Penyesuaian tidak dapat ditambahkan setelah dibayar.`}
|
|
confirmLabel="Bayar"
|
|
onConfirm={handlePay}
|
|
/>
|
|
|
|
{/* Dialog Konfirmasi Batalkan */}
|
|
<ConfirmDialog
|
|
open={cancelling !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setCancelling(null);
|
|
}
|
|
}}
|
|
title="Batalkan Gaji"
|
|
description={`Apakah Anda yakin ingin membatalkan gaji "${cancelling?.employee?.user?.user_profile?.full_name}"?`}
|
|
confirmLabel="Batalkan"
|
|
onConfirm={handleCancel}
|
|
/>
|
|
|
|
{/* Dialog Konfirmasi Hapus Penyesuaian */}
|
|
<ConfirmDialog
|
|
open={deletingAdjustment !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setDeletingAdjustment(null);
|
|
}
|
|
}}
|
|
title="Hapus Penyesuaian"
|
|
description={`Apakah Anda yakin ingin menghapus penyesuaian "${deletingAdjustment?.adjustment.description}" sebesar ${formatCurrency(deletingAdjustment?.adjustment.amount ?? 0)}?`}
|
|
confirmLabel="Hapus"
|
|
onConfirm={handleDeleteAdjustment}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|