579 lines
30 KiB
TypeScript
579 lines
30 KiB
TypeScript
import InputError from '@/components/input-error';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Calendar } from '@/components/ui/calendar';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import AppLayout from '@/layouts/app-layout';
|
|
import { cn } from '@/lib/utils';
|
|
import { type BreadcrumbItem } from '@/types';
|
|
import { Head, useForm } from '@inertiajs/react';
|
|
import { format } from 'date-fns';
|
|
import { CalendarIcon } from 'lucide-react';
|
|
import { useEffect, useState } from 'react';
|
|
|
|
interface TransactionFormProps {
|
|
mode: 'create' | 'edit';
|
|
patients: Array<{ id: string; name: string }>;
|
|
registrations: Array<{ id: string; registration_number: string; patient_name: string }>;
|
|
insurances: Array<{ id: string; name: string }>;
|
|
procedures: Array<{
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
base_price?: number;
|
|
tax_percentage?: number;
|
|
is_taxable?: boolean;
|
|
}>;
|
|
employees: Array<{ id: string; name: string }>;
|
|
paymentMethods: Record<string, string>;
|
|
statusOptions: Record<string, string>;
|
|
transaction?: {
|
|
id?: string;
|
|
invoice_number: string;
|
|
registration_id: string;
|
|
patient_id: string;
|
|
insurance_id?: string;
|
|
procedure_id?: string;
|
|
employee_id?: string;
|
|
service_name: string;
|
|
transaction_datetime: string;
|
|
payment_datetime?: string;
|
|
due_date?: string;
|
|
subtotal: number;
|
|
tax_amount: number;
|
|
discount_amount: number;
|
|
grand_total: number;
|
|
paid_amount: number;
|
|
insurance_covered_amount: number;
|
|
patient_responsibility: number;
|
|
payment_method: string;
|
|
payment_reference?: string;
|
|
status: string;
|
|
notes?: string;
|
|
};
|
|
}
|
|
|
|
const breadcrumbs: BreadcrumbItem[] = [
|
|
{ title: 'Dashboard', href: '/dashboard' },
|
|
{ title: 'Transaksi', href: '/transactions' },
|
|
{ title: 'Form', href: '#' },
|
|
];
|
|
|
|
const toLocalISOString = (date: Date) => {
|
|
const offset = date.getTimezoneOffset();
|
|
const localDate = new Date(date.getTime() - offset * 60 * 1000);
|
|
return localDate.toISOString().slice(0, 16);
|
|
};
|
|
|
|
export default function TransactionForm({
|
|
mode,
|
|
patients,
|
|
registrations,
|
|
insurances,
|
|
procedures,
|
|
employees,
|
|
paymentMethods,
|
|
statusOptions,
|
|
transaction,
|
|
}: TransactionFormProps) {
|
|
const { data, setData, post, put, processing, errors, reset } = useForm({
|
|
registration_id: transaction?.registration_id || '',
|
|
patient_id: transaction?.patient_id || '',
|
|
insurance_id: transaction?.insurance_id || '',
|
|
procedure_id: transaction?.procedure_id || '',
|
|
employee_id: transaction?.employee_id || '',
|
|
service_name: transaction?.service_name || '',
|
|
transaction_datetime: transaction?.transaction_datetime || toLocalISOString(new Date()),
|
|
payment_datetime: transaction?.payment_datetime || '',
|
|
due_date: transaction?.due_date || '',
|
|
subtotal: transaction?.subtotal || 0,
|
|
tax_amount: transaction?.tax_amount || 0,
|
|
discount_amount: transaction?.discount_amount || 0,
|
|
grand_total: transaction?.grand_total || 0,
|
|
paid_amount: transaction?.paid_amount || 0,
|
|
insurance_covered_amount: transaction?.insurance_covered_amount || 0,
|
|
patient_responsibility: transaction?.patient_responsibility || 0,
|
|
payment_method: transaction?.payment_method || 'cash',
|
|
payment_reference: transaction?.payment_reference || '',
|
|
status: transaction?.status || 'pending',
|
|
notes: transaction?.notes || '',
|
|
});
|
|
|
|
const [registrationSelected, setRegistrationSelected] = useState<boolean>(false);
|
|
|
|
// update pasien ketika memilih register
|
|
useEffect(() => {
|
|
if (data.registration_id && data.registration_id !== 'none') {
|
|
const selectedRegistration = registrations.find((r) => r.id === data.registration_id);
|
|
if (selectedRegistration) {
|
|
setRegistrationSelected(true);
|
|
const patient = patients.find((p) => p.name === selectedRegistration.patient_name);
|
|
if (patient) {
|
|
setData('patient_id', patient.id);
|
|
}
|
|
}
|
|
} else {
|
|
setRegistrationSelected(false);
|
|
}
|
|
}, [data.registration_id, registrations, patients]);
|
|
|
|
// Update ruangan, subtotal dan pajak ketika prosedur di select
|
|
useEffect(() => {
|
|
if (data.procedure_id && data.procedure_id !== 'manual') {
|
|
const selectedProcedure = procedures.find((p) => p.id === data.procedure_id);
|
|
if (selectedProcedure) {
|
|
const basePrice = selectedProcedure.base_price || 0;
|
|
const taxAmount =
|
|
selectedProcedure.is_taxable && selectedProcedure.tax_percentage ? basePrice * (selectedProcedure.tax_percentage / 100) : 0;
|
|
|
|
setData({
|
|
...data,
|
|
service_name: selectedProcedure.name,
|
|
subtotal: basePrice,
|
|
tax_amount: taxAmount,
|
|
});
|
|
}
|
|
}
|
|
}, [data.procedure_id, procedures]);
|
|
|
|
useEffect(() => {
|
|
// Pastikan semua nilai numerik valid (gunakan 0 jika NaN)
|
|
const subtotal = Number(data.subtotal) || 0;
|
|
const taxAmount = Number(data.tax_amount) || 0;
|
|
const discountAmount = Number(data.discount_amount) || 0;
|
|
|
|
const calculatedGrandTotal = subtotal + taxAmount - discountAmount;
|
|
|
|
let insuranceCovered = 0;
|
|
if (data.payment_method === 'insurance' && data.insurance_id) {
|
|
// Pastikan insurance covered tidak melebihi grand total
|
|
insuranceCovered = Math.min(calculatedGrandTotal, calculatedGrandTotal * 0.8);
|
|
}
|
|
|
|
const patientResponsibility = calculatedGrandTotal - insuranceCovered;
|
|
|
|
setData({
|
|
...data,
|
|
subtotal,
|
|
tax_amount: taxAmount,
|
|
discount_amount: discountAmount,
|
|
grand_total: calculatedGrandTotal,
|
|
insurance_covered_amount: insuranceCovered,
|
|
patient_responsibility: patientResponsibility,
|
|
// Update paid_amount hanya jika belum diisi atau jika nilainya tidak valid
|
|
paid_amount: Number(data.paid_amount) || patientResponsibility,
|
|
});
|
|
}, [data.subtotal, data.tax_amount, data.discount_amount, data.payment_method, data.insurance_id]);
|
|
|
|
// Calculate grand_total, insurance_covered_amount and patient_responsibility
|
|
useEffect(() => {
|
|
const calculatedGrandTotal = data.subtotal + data.tax_amount - data.discount_amount;
|
|
|
|
let insuranceCovered = 0;
|
|
if (data.payment_method === 'insurance' && data.insurance_id) {
|
|
// Example: insurance covers 80% of the total
|
|
insuranceCovered = calculatedGrandTotal * 0.8;
|
|
}
|
|
|
|
const patientResponsibility = calculatedGrandTotal - insuranceCovered;
|
|
|
|
setData({
|
|
...data,
|
|
grand_total: calculatedGrandTotal,
|
|
insurance_covered_amount: insuranceCovered,
|
|
patient_responsibility: patientResponsibility,
|
|
// Default paid amount to what patient is responsible for
|
|
paid_amount: data.paid_amount || patientResponsibility,
|
|
});
|
|
}, [data.subtotal, data.tax_amount, data.discount_amount, data.payment_method, data.insurance_id]);
|
|
|
|
const onSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (mode === 'create') {
|
|
post(route('transactions.store'), {
|
|
onSuccess: () => reset(),
|
|
});
|
|
} else {
|
|
put(route('transactions.update', transaction?.id), {
|
|
preserveScroll: true,
|
|
});
|
|
}
|
|
};
|
|
|
|
const formatCurrency = (value: number) => {
|
|
return new Intl.NumberFormat('id-ID', {
|
|
style: 'currency',
|
|
currency: 'IDR',
|
|
minimumFractionDigits: 0,
|
|
}).format(value);
|
|
};
|
|
|
|
return (
|
|
<AppLayout breadcrumbs={breadcrumbs}>
|
|
<Head title={`${mode === 'create' ? 'Tambah' : 'Edit'} Transaksi`} />
|
|
|
|
<div className="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-bold">{mode === 'create' ? 'Tambah Transaksi Baru' : 'Edit Data Transaksi'}</h1>
|
|
{mode === 'edit' && transaction?.invoice_number && <span className="text-xl">No. Invoice: {transaction.invoice_number}</span>}
|
|
</div>
|
|
|
|
<div>
|
|
<form onSubmit={onSubmit} className="space-y-6">
|
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
|
{/* Registration Section */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="registration_id">No. Registrasi</Label>
|
|
<Select value={data.registration_id} onValueChange={(value) => setData('registration_id', value)}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Pilih nomor registrasi" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">Tanpa registrasi</SelectItem>
|
|
{registrations.map((registration) => (
|
|
<SelectItem key={registration.id} value={registration.id}>
|
|
{registration.registration_number} - {registration.patient_name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<InputError message={errors.registration_id} />
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="patient_id">Pasien *</Label>
|
|
<Select
|
|
value={data.patient_id}
|
|
onValueChange={(value) => setData('patient_id', value)}
|
|
disabled={registrationSelected}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Pilih pasien" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{patients.map((patient) => (
|
|
<SelectItem key={patient.id} value={patient.id}>
|
|
{patient.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<InputError message={errors.patient_id} />
|
|
</div>
|
|
|
|
{/* Procedure Section */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="procedure_id">Prosedur</Label>
|
|
<Select value={data.procedure_id} onValueChange={(value) => setData('procedure_id', value)}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Pilih prosedur" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="manual">Input Manual</SelectItem>
|
|
{procedures.map((procedure) => (
|
|
<SelectItem key={procedure.id} value={procedure.id}>
|
|
{procedure.code} - {procedure.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<InputError message={errors.procedure_id} />
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="service_name">Nama Layanan *</Label>
|
|
<Input
|
|
id="service_name"
|
|
value={data.service_name}
|
|
onChange={(e) => setData('service_name', e.target.value)}
|
|
placeholder="Nama layanan"
|
|
/>
|
|
<InputError message={errors.service_name} />
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="employee_id">Pelaksana Layanan</Label>
|
|
<Select value={data.employee_id} onValueChange={(value) => setData('employee_id', value)}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Pilih pelaksana" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{employees.map((employee) => (
|
|
<SelectItem key={employee.id} value={employee.id}>
|
|
{employee.name} - {employee.specialization}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<InputError message={errors.employee_id} />
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="transaction_datetime">Tanggal/Waktu Transaksi *</Label>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant={'outline'}
|
|
className={cn(
|
|
'w-full justify-start text-left font-normal',
|
|
!data.transaction_datetime && 'text-muted-foreground',
|
|
)}
|
|
>
|
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
{data.transaction_datetime ? (
|
|
format(new Date(data.transaction_datetime), 'dd/MM/yyyy HH:mm')
|
|
) : (
|
|
<span>Pilih tanggal/waktu</span>
|
|
)}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-0">
|
|
<Calendar
|
|
mode="single"
|
|
selected={data.transaction_datetime ? new Date(data.transaction_datetime) : undefined}
|
|
onSelect={(date) => date && setData('transaction_datetime', toLocalISOString(date))}
|
|
initialFocus
|
|
/>
|
|
<div className="border-t p-3">
|
|
<Input
|
|
type="time"
|
|
value={data.transaction_datetime ? data.transaction_datetime.split('T')[1] || '' : ''}
|
|
onChange={(e) => {
|
|
const date =
|
|
data.transaction_datetime?.split('T')[0] || toLocalISOString(new Date()).split('T')[0];
|
|
setData('transaction_datetime', `${date}T${e.target.value}`);
|
|
}}
|
|
/>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<InputError message={errors.transaction_datetime} />
|
|
</div>
|
|
|
|
{/* Financial Section */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="subtotal">Subtotal *</Label>
|
|
<Input
|
|
id="subtotal"
|
|
type="number"
|
|
value={isNaN(data.subtotal) ? '' : data.subtotal}
|
|
onChange={(e) => {
|
|
const value = parseFloat(e.target.value);
|
|
setData('subtotal', isNaN(value) ? 0 : value);
|
|
}}
|
|
placeholder="Subtotal"
|
|
/>
|
|
<InputError message={errors.subtotal} />
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tax_amount">Pajak *</Label>
|
|
<Input
|
|
id="tax_amount"
|
|
type="number"
|
|
value={data.tax_amount}
|
|
onChange={(e) => setData('tax_amount', Math.max(0, parseFloat(e.target.value) || 0))}
|
|
placeholder="Jumlah pajak"
|
|
/>
|
|
<InputError message={errors.tax_amount} />
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="discount_amount">Diskon</Label>
|
|
<Input
|
|
id="discount_amount"
|
|
type="number"
|
|
value={data.discount_amount}
|
|
onChange={(e) => setData('discount_amount', Math.max(0, parseFloat(e.target.value) || 0))}
|
|
placeholder="Jumlah diskon"
|
|
/>
|
|
<InputError message={errors.discount_amount} />
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Grand Total</Label>
|
|
<div className="flex h-9 items-center rounded border p-2 text-xl font-semibold">
|
|
{formatCurrency(isNaN(data.grand_total) ? 0 : data.grand_total)}
|
|
</div>
|
|
<InputError message={errors.grand_total} />
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="payment_method">Metode Pembayaran *</Label>
|
|
<Select value={data.payment_method} onValueChange={(value) => setData('payment_method', value)}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Pilih metode pembayaran" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(paymentMethods).map(([key, value]) => (
|
|
<SelectItem key={key} value={key}>
|
|
{value}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<InputError message={errors.payment_method} />
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="status">Status Pembayaran *</Label>
|
|
<Select value={data.status} onValueChange={(value) => setData('status', value)}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Pilih status pembayaran" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(statusOptions).map(([key, value]) => (
|
|
<SelectItem key={key} value={key}>
|
|
{value}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<InputError message={errors.status} />
|
|
</div>
|
|
|
|
{data.payment_method === 'insurance' && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="insurance_id">Asuransi *</Label>
|
|
<Select value={data.insurance_id} onValueChange={(value) => setData('insurance_id', value)}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Pilih asuransi" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{insurances.map((insurance) => (
|
|
<SelectItem key={insurance.id} value={insurance.id}>
|
|
{insurance.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<InputError message={errors.insurance_id} />
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="insurance_covered_amount">Dibayar Asuransi *</Label>
|
|
<Input
|
|
id="insurance_covered_amount"
|
|
type="number"
|
|
value={data.insurance_covered_amount}
|
|
onChange={(e) => {
|
|
const insuranceCovered = Math.max(0, parseFloat(e.target.value) || 0);
|
|
setData({
|
|
...data,
|
|
insurance_covered_amount: insuranceCovered,
|
|
patient_responsibility: data.grand_total - insuranceCovered,
|
|
});
|
|
}}
|
|
placeholder="Jumlah dibayar asuransi"
|
|
/>
|
|
<InputError message={errors.insurance_covered_amount} />
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="patient_responsibility">Tanggung Jawab Pasien</Label>
|
|
<div className="flex h-9 items-center rounded border p-2 font-semibold">
|
|
{formatCurrency(data.patient_responsibility)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="paid_amount">Jumlah Pembayaran *</Label>
|
|
<Input
|
|
id="paid_amount"
|
|
type="text"
|
|
value={formatCurrency(data.paid_amount)}
|
|
onChange={(e) => {
|
|
const numericValue = e.target.value.replace(/[^0-9]/g, '');
|
|
const paidAmount = parseFloat(numericValue) || 0;
|
|
setData('paid_amount', paidAmount);
|
|
}}
|
|
placeholder="Jumlah pembayaran"
|
|
/>
|
|
<InputError message={errors.paid_amount} />
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="payment_reference">Referensi Pembayaran</Label>
|
|
<Input
|
|
id="payment_reference"
|
|
value={data.payment_reference}
|
|
onChange={(e) => setData('payment_reference', e.target.value)}
|
|
placeholder="No. Kartu/No. Referensi/dll"
|
|
/>
|
|
<InputError message={errors.payment_reference} />
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="payment_datetime">Tanggal/Waktu Pembayaran</Label>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant={'outline'}
|
|
className={cn(
|
|
'w-full justify-start text-left font-normal',
|
|
!data.payment_datetime && 'text-muted-foreground',
|
|
)}
|
|
>
|
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
{data.payment_datetime ? (
|
|
format(new Date(data.payment_datetime), 'dd/MM/yyyy HH:mm')
|
|
) : (
|
|
<span>Pilih tanggal/waktu pembayaran</span>
|
|
)}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-0">
|
|
<Calendar
|
|
mode="single"
|
|
selected={data.payment_datetime ? new Date(data.payment_datetime) : undefined}
|
|
onSelect={(date) => date && setData('payment_datetime', toLocalISOString(date))}
|
|
initialFocus
|
|
/>
|
|
<div className="border-t p-3">
|
|
<Input
|
|
type="time"
|
|
value={data.payment_datetime ? data.payment_datetime.split('T')[1] || '' : ''}
|
|
onChange={(e) => {
|
|
const date = data.payment_datetime?.split('T')[0] || toLocalISOString(new Date()).split('T')[0];
|
|
setData('payment_datetime', `${date}T${e.target.value}`);
|
|
}}
|
|
/>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<InputError message={errors.payment_datetime} />
|
|
</div>
|
|
|
|
<div className="col-span-full space-y-2">
|
|
<Label htmlFor="notes">Catatan</Label>
|
|
<Textarea
|
|
id="notes"
|
|
value={data.notes}
|
|
onChange={(e) => setData('notes', e.target.value)}
|
|
placeholder="Catatan tambahan"
|
|
/>
|
|
<InputError message={errors.notes} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" type="button" onClick={() => window.history.back()}>
|
|
Batal
|
|
</Button>
|
|
<Button type="submit" disabled={processing}>
|
|
{mode === 'create' ? 'Simpan' : 'Perbarui'} Transaksi
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</AppLayout>
|
|
);
|
|
}
|