206 lines
7.8 KiB
TypeScript
206 lines
7.8 KiB
TypeScript
/**
|
||
* Form 16 Quick Actions – RE actions for the Quick Actions sidebar.
|
||
* Shown only for Form 16 requests when RE user and no credit note yet.
|
||
* Location: custom (Form 16 only); does not modify shared workflow components.
|
||
*/
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog';
|
||
import { Loader2, Receipt, X, RotateCcw } from 'lucide-react';
|
||
import {
|
||
getCreditNoteByRequestId,
|
||
cancelForm16Submission,
|
||
setForm16ResubmissionNeeded,
|
||
generateForm16CreditNoteManually,
|
||
} from '@/services/form16Api';
|
||
import { toast } from 'sonner';
|
||
|
||
interface Form16QuickActionsProps {
|
||
requestId: string;
|
||
request: any;
|
||
onRefresh?: () => void;
|
||
}
|
||
|
||
export function Form16QuickActions({ requestId, request, onRefresh }: Form16QuickActionsProps) {
|
||
const [creditNote, setCreditNote] = useState<{ id: number; status: string } | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [actionLoading, setActionLoading] = useState<'cancel' | 'resubmit' | 'credit' | null>(null);
|
||
const [generateCnOpen, setGenerateCnOpen] = useState(false);
|
||
const [generateCnAmount, setGenerateCnAmount] = useState('');
|
||
|
||
const form16 = request?.form16Submission;
|
||
const hasSubmission = !!form16;
|
||
const hasCreditNote = !!creditNote && creditNote.status !== 'withdrawn';
|
||
const suggestedAmount = form16?.tdsAmount != null ? Number(form16.tdsAmount) : undefined;
|
||
|
||
useEffect(() => {
|
||
if (!requestId) {
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
const note = await getCreditNoteByRequestId(requestId);
|
||
if (!cancelled) setCreditNote(note ? { id: typeof note.id === 'number' ? note.id : Number(note.id), status: note.status || '' } : null);
|
||
} catch {
|
||
if (!cancelled) setCreditNote(null);
|
||
} finally {
|
||
if (!cancelled) setLoading(false);
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [requestId]);
|
||
|
||
const handleCancelSubmission = async () => {
|
||
if (!requestId || !window.confirm('Cancel this Form 16 submission? The request will be marked as rejected.')) return;
|
||
setActionLoading('cancel');
|
||
try {
|
||
await cancelForm16Submission(requestId);
|
||
toast.success('Submission cancelled');
|
||
onRefresh?.();
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : 'Failed to cancel submission');
|
||
} finally {
|
||
setActionLoading(null);
|
||
}
|
||
};
|
||
|
||
const handleResubmissionNeeded = async () => {
|
||
if (!requestId || !window.confirm('Mark this submission as resubmission needed? The dealer will need to resubmit Form 16.')) return;
|
||
setActionLoading('resubmit');
|
||
try {
|
||
await setForm16ResubmissionNeeded(requestId);
|
||
toast.success('Marked as resubmission needed');
|
||
onRefresh?.();
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : 'Failed to update');
|
||
} finally {
|
||
setActionLoading(null);
|
||
}
|
||
};
|
||
|
||
const handleGenerateCreditNote = async () => {
|
||
const amount = parseFloat(generateCnAmount);
|
||
if (!requestId || Number.isNaN(amount) || amount <= 0) {
|
||
toast.error('Enter a valid amount to generate credit note');
|
||
return;
|
||
}
|
||
setActionLoading('credit');
|
||
try {
|
||
await generateForm16CreditNoteManually(requestId, amount);
|
||
setGenerateCnOpen(false);
|
||
setGenerateCnAmount('');
|
||
toast.success('Credit note generated (manually approved)');
|
||
const note = await getCreditNoteByRequestId(requestId);
|
||
setCreditNote(note ? { id: typeof note.id === 'number' ? note.id : Number(note.id), status: note.status || '' } : null);
|
||
onRefresh?.();
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : 'Failed to generate credit note');
|
||
} finally {
|
||
setActionLoading(null);
|
||
}
|
||
};
|
||
|
||
if (loading || !hasSubmission || hasCreditNote) return null;
|
||
|
||
return (
|
||
<>
|
||
<Card className="border-blue-200 bg-blue-50/30" data-testid="form16-quick-actions-card">
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-sm flex items-center gap-2 text-blue-800">
|
||
<Receipt className="w-4 h-4" />
|
||
Form 16 actions
|
||
</CardTitle>
|
||
<CardDescription className="text-xs text-gray-600">
|
||
View the document in the Documents tab. Cancel submission, mark resubmission needed, or generate credit note (e.g. when OCR was partial).
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="w-full justify-start border-red-300 text-red-700 hover:bg-red-50"
|
||
onClick={handleCancelSubmission}
|
||
disabled={!!actionLoading}
|
||
>
|
||
{actionLoading === 'cancel' ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : <X className="w-3 h-3 mr-1" />}
|
||
Cancel submission
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="w-full justify-start border-amber-300 text-amber-700 hover:bg-amber-50"
|
||
onClick={handleResubmissionNeeded}
|
||
disabled={!!actionLoading}
|
||
>
|
||
{actionLoading === 'resubmit' ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : <RotateCcw className="w-3 h-3 mr-1" />}
|
||
Resubmission needed
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="w-full justify-start border-emerald-300 text-emerald-700 hover:bg-emerald-50"
|
||
onClick={() => {
|
||
setGenerateCnAmount(suggestedAmount != null ? String(suggestedAmount) : '');
|
||
setGenerateCnOpen(true);
|
||
}}
|
||
disabled={!!actionLoading}
|
||
>
|
||
{actionLoading === 'credit' ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : <Receipt className="w-3 h-3 mr-1" />}
|
||
Generate credit note
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Dialog open={generateCnOpen} onOpenChange={setGenerateCnOpen}>
|
||
<DialogContent className="sm:max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>Generate credit note (manual)</DialogTitle>
|
||
<DialogDescription>
|
||
Enter the amount for the credit note. This will mark the Form 16 as manually approved.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="grid gap-4 py-4">
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="form16-cn-amount">Amount (₹)</Label>
|
||
<Input
|
||
id="form16-cn-amount"
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
placeholder={suggestedAmount != null ? String(suggestedAmount) : '0'}
|
||
value={generateCnAmount}
|
||
onChange={(e) => setGenerateCnAmount(e.target.value)}
|
||
/>
|
||
{suggestedAmount != null && (
|
||
<p className="text-xs text-gray-500">Suggested from submission TDS amount: ₹{suggestedAmount.toLocaleString('en-IN')}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setGenerateCnOpen(false)} disabled={!!actionLoading}>
|
||
Cancel
|
||
</Button>
|
||
<Button onClick={handleGenerateCreditNote} disabled={!!actionLoading || !generateCnAmount.trim()}>
|
||
{actionLoading === 'credit' ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : null}
|
||
Generate credit note
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
}
|