Files
GSB-Construction/Modules/FinancialManagement/resources/js/Components/RetentionPenaltyModal.tsx

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>
);
}