291 lines
14 KiB
TypeScript
291 lines
14 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
|
import { useForm } from '@inertiajs/react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/Components/ui/dialog';
|
|
import { Button } from '@/Components/ui/button';
|
|
import { Input } from '@/Components/ui/input';
|
|
import { Label } from '@/Components/ui/label';
|
|
import { Textarea } from '@/Components/ui/textarea';
|
|
import { Badge } from '@/Components/ui/badge';
|
|
import { AlertCircle, Calculator, ShieldAlert, Sparkles, Clock } from 'lucide-react';
|
|
import { formatCurrency } from '@/lib/utils';
|
|
|
|
export interface OverdueInvoiceItem {
|
|
id: number;
|
|
ulid?: string;
|
|
invoice_number: string;
|
|
project: {
|
|
id: number;
|
|
ulid: string;
|
|
name: string;
|
|
code: string;
|
|
} | null;
|
|
status: string;
|
|
invoice_date: string;
|
|
approved_at?: string;
|
|
subtotal: number;
|
|
retention_amount: number;
|
|
retention_rate: number;
|
|
total_amount: number;
|
|
paid_amount: number;
|
|
days_overdue: number;
|
|
penalty_rate: number | null;
|
|
penalty_amount: number;
|
|
penalty_reason: string | null;
|
|
penalty_applied_by: string | null;
|
|
penalty_applied_at: string | null;
|
|
}
|
|
|
|
interface RetentionPenaltyModalProps {
|
|
invoice: OverdueInvoiceItem | null;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
isExecutive: boolean;
|
|
}
|
|
|
|
const PRESET_RATES = [2, 5, 10, 15, 20];
|
|
|
|
export default function RetentionPenaltyModal({
|
|
invoice,
|
|
isOpen,
|
|
onClose,
|
|
isExecutive,
|
|
}: RetentionPenaltyModalProps) {
|
|
if (!invoice) return null;
|
|
|
|
const [customRate, setCustomRate] = useState<string>(
|
|
invoice.penalty_rate ? String(invoice.penalty_rate) : '5'
|
|
);
|
|
|
|
const { data, setData, post, processing, errors, reset } = useForm({
|
|
penalty_rate: invoice.penalty_rate ? String(invoice.penalty_rate) : '5',
|
|
reason: invoice.penalty_reason || '',
|
|
});
|
|
|
|
const numericRate = parseFloat(data.penalty_rate) || 0;
|
|
const baseRetention = Number(invoice.retention_amount) > 0 ? Number(invoice.retention_amount) : Number(invoice.subtotal);
|
|
const computedPenalty = useMemo(() => {
|
|
return Math.round(baseRetention * (numericRate / 100) * 100) / 100;
|
|
}, [baseRetention, numericRate]);
|
|
|
|
const handlePresetSelect = (rate: number) => {
|
|
setData('penalty_rate', String(rate));
|
|
setCustomRate(String(rate));
|
|
};
|
|
|
|
const handleRateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const val = e.target.value;
|
|
setCustomRate(val);
|
|
setData('penalty_rate', val);
|
|
};
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const targetId = invoice.ulid || invoice.id;
|
|
post(route('finance.apply-penalty', targetId), {
|
|
preserveScroll: true,
|
|
onSuccess: () => {
|
|
onClose();
|
|
reset();
|
|
},
|
|
});
|
|
};
|
|
|
|
const isAlreadyPenalized = invoice.penalty_rate !== null && Number(invoice.penalty_amount) > 0;
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
|
|
<DialogContent className="max-w-xl p-0 overflow-hidden border border-slate-200 shadow-2xl">
|
|
<div className="bg-gradient-to-r from-red-600 via-rose-600 to-amber-600 p-6 text-white">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2.5 bg-white/15 backdrop-blur-xs rounded-xl">
|
|
<ShieldAlert className="w-6 h-6 text-white" />
|
|
</div>
|
|
<div>
|
|
<DialogTitle className="text-xl font-bold text-white tracking-tight">
|
|
{isAlreadyPenalized ? 'Update Retention Penalty' : 'Assess Retention Penalty'}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-rose-100 text-xs mt-0.5">
|
|
Overdue 10% retention remittance penalty configuration
|
|
</DialogDescription>
|
|
</div>
|
|
</div>
|
|
<Badge className="bg-white/20 hover:bg-white/30 text-white border-0 font-mono text-xs px-2.5 py-1">
|
|
<Clock className="w-3.5 h-3.5 mr-1 text-amber-200" />
|
|
{invoice.days_overdue} Days Overdue
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
|
{/* Invoice & Project Context Card */}
|
|
<div className="bg-slate-50 border border-slate-200/80 rounded-xl p-4 space-y-3">
|
|
<div className="flex justify-between items-start">
|
|
<div>
|
|
<span className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Project</span>
|
|
<h4 className="text-sm font-bold text-slate-800">{invoice.project?.name || 'N/A'}</h4>
|
|
<span className="text-xs text-slate-500 font-mono">Invoice #{invoice.invoice_number}</span>
|
|
</div>
|
|
<Badge variant="outline" className="border-red-200 bg-red-50 text-red-700 font-semibold text-xs">
|
|
Overdue {'>'} 2 Months
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-2 pt-2 border-t border-slate-200/60 text-xs">
|
|
<div>
|
|
<span className="text-slate-400 text-[10px] block">Invoice Subtotal</span>
|
|
<span className="font-semibold text-slate-700 font-mono">{formatCurrency(invoice.subtotal)}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-400 text-[10px] block">10% Retention</span>
|
|
<span className="font-bold text-rose-600 font-mono">{formatCurrency(invoice.retention_amount)}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-400 text-[10px] block">Invoice Date</span>
|
|
<span className="font-medium text-slate-700">{invoice.invoice_date}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{isAlreadyPenalized && (
|
|
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-800 space-y-1">
|
|
<div className="flex items-center gap-1.5 font-bold">
|
|
<AlertCircle className="w-4 h-4 text-amber-600" />
|
|
Current Penalty Status
|
|
</div>
|
|
<p>
|
|
A penalty of <strong>{invoice.penalty_rate}%</strong> ({formatCurrency(invoice.penalty_amount)}) was previously assessed
|
|
{invoice.penalty_applied_by ? ` by ${invoice.penalty_applied_by}` : ''}
|
|
{invoice.penalty_applied_at ? ` on ${invoice.penalty_applied_at}` : ''}.
|
|
</p>
|
|
{invoice.penalty_reason && (
|
|
<p className="text-slate-600 italic">"{invoice.penalty_reason}"</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Penalty Rate Configuration */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="penalty_rate" className="text-xs font-bold uppercase tracking-wider text-slate-600 flex items-center gap-1.5">
|
|
<Calculator className="w-3.5 h-3.5 text-slate-500" /> Penalty Percentage Rate (%)
|
|
</Label>
|
|
<span className="text-xs font-semibold text-slate-500">
|
|
Presets:
|
|
</span>
|
|
</div>
|
|
|
|
{/* Quick Presets */}
|
|
<div className="flex items-center gap-2">
|
|
{PRESET_RATES.map((rate) => (
|
|
<button
|
|
key={rate}
|
|
type="button"
|
|
onClick={() => handlePresetSelect(rate)}
|
|
className={`flex-1 py-1.5 text-xs font-bold rounded-lg border transition-all ${
|
|
numericRate === rate
|
|
? 'bg-rose-600 text-white border-rose-600 shadow-xs'
|
|
: 'bg-white text-slate-700 border-slate-200 hover:bg-slate-50 hover:border-slate-300'
|
|
}`}
|
|
>
|
|
{rate}%
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Custom Percentage Input */}
|
|
<div className="relative">
|
|
<Input
|
|
id="penalty_rate"
|
|
type="number"
|
|
step="0.01"
|
|
min="0.01"
|
|
max="100"
|
|
value={data.penalty_rate}
|
|
onChange={handleRateChange}
|
|
placeholder="Enter custom percentage (e.g. 5)"
|
|
className="font-mono pr-8 font-semibold text-slate-800"
|
|
disabled={!isExecutive}
|
|
/>
|
|
<span className="absolute right-3 top-2.5 text-xs font-bold text-slate-400 pointer-events-none">
|
|
%
|
|
</span>
|
|
</div>
|
|
{errors.penalty_rate && (
|
|
<p className="text-xs text-red-600 font-medium">{errors.penalty_rate}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Computed Live Cost Summary Banner */}
|
|
<div className="bg-gradient-to-r from-red-50 to-rose-50 border border-rose-200 rounded-xl p-4 flex items-center justify-between">
|
|
<div>
|
|
<span className="text-[11px] font-bold text-rose-800 uppercase tracking-wider block">
|
|
Computed Penalty Amount ({numericRate}%)
|
|
</span>
|
|
<span className="text-xs text-rose-600">
|
|
Calculated on 10% retention base ({formatCurrency(baseRetention)})
|
|
</span>
|
|
</div>
|
|
<div className="text-right">
|
|
<span className="text-2xl font-black text-rose-700 font-mono tracking-tight">
|
|
{formatCurrency(computedPenalty)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Penalty Reason Textarea */}
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="reason" className="text-xs font-bold uppercase tracking-wider text-slate-600">
|
|
Reason / Justification <span className="text-red-500">*</span>
|
|
</Label>
|
|
<Textarea
|
|
id="reason"
|
|
rows={3}
|
|
value={data.reason}
|
|
onChange={(e) => setData('reason', e.target.value)}
|
|
placeholder="e.g. Invoice overdue beyond 60-day remittance grace period. Applying standard 5% overdue penalty."
|
|
className="text-xs text-slate-800"
|
|
disabled={!isExecutive}
|
|
/>
|
|
{errors.reason && (
|
|
<p className="text-xs text-red-600 font-medium">{errors.reason}</p>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter className="pt-2 border-t border-slate-100 flex items-center justify-between gap-3">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={onClose}
|
|
className="border-slate-200 text-slate-700"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
{isExecutive ? (
|
|
<Button
|
|
type="submit"
|
|
disabled={processing || numericRate <= 0 || !data.reason.trim()}
|
|
className="bg-rose-600 hover:bg-rose-700 text-white font-bold shadow-xs px-5"
|
|
>
|
|
<Sparkles className="w-4 h-4 mr-1.5" />
|
|
{processing ? 'Applying...' : isAlreadyPenalized ? 'Update Penalty' : 'Assess & Apply Penalty'}
|
|
</Button>
|
|
) : (
|
|
<span className="text-xs text-slate-400 italic">
|
|
Read-only (Super Admin / Admin / PM required to apply penalty)
|
|
</span>
|
|
)}
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|