Re_Figma_Code/src/pages/Form16/Form16CreditNotes.tsx

399 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Search, Loader2, Receipt, Download, RefreshCw, FileText, IndianRupee, CalendarClock } from 'lucide-react';
import {
listCreditNotes,
getCreditNoteSapResponse,
type Form16CreditNoteItem,
type Form16SapResponseRecord,
type ListCreditNotesParams,
type ListCreditNotesSummary,
} from '@/services/form16Api';
import { toast } from 'sonner';
import apiClient from '@/services/authApi';
function formatDate(value: string | null | undefined): string {
if (!value) return '';
try {
const d = new Date(value);
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
} catch {
return value;
}
}
function formatAmount(value: number | null | undefined): string {
if (value == null) return '';
return new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', maximumFractionDigits: 0 }).format(value);
}
const DEFAULT_SUMMARY: ListCreditNotesSummary = {
totalCreditNotes: 0,
totalAmount: 0,
activeDealersCount: 0,
};
type SapResponsePreview = {
fileName: string;
downloadUrl?: string | null;
row: Record<string, string>;
meta: {
amountText: string;
issuedAtText: string;
};
};
function mapSapResponseToPreviewRow(sap: Form16SapResponseRecord): Record<string, string> {
return {
TRNS_UNIQ_NO: sap.trnsUniqNo || '',
TDS_TRNS_ID: sap.tdsTransId || sap.claimNumber || '',
DOC_NO: sap.sapDocumentNumber || '',
MSG_TYP: sap.msgTyp || '',
MESSAGE: sap.message || '',
};
}
function formatIssuedAt(value: string | null | undefined): string {
if (!value) return '';
try {
const d = new Date(value);
return Number.isNaN(d.getTime())
? value
: d.toLocaleString('en-IN', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
} catch {
return value;
}
}
export function Form16CreditNotes() {
const [creditNotes, setCreditNotes] = useState<Form16CreditNoteItem[]>([]);
const [summary, setSummary] = useState<ListCreditNotesSummary>(DEFAULT_SUMMARY);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [previewOpen, setPreviewOpen] = useState(false);
const [previewLoading, setPreviewLoading] = useState(false);
const [preview, setPreview] = useState<SapResponsePreview | null>(null);
const fetchNotes = useCallback(async (params?: ListCreditNotesParams) => {
try {
setLoading(true);
const result = await listCreditNotes(params);
setCreditNotes(result.creditNotes);
setSummary(result.summary ?? DEFAULT_SUMMARY);
} catch (error: unknown) {
console.error('[Form16CreditNotes] Failed to fetch:', error);
toast.error(error instanceof Error ? error.message : 'Failed to load credit notes');
setCreditNotes([]);
setSummary(DEFAULT_SUMMARY);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchNotes();
}, [fetchNotes]);
const openSapPreview = useCallback(async (noteId: number, creditNoteNumber?: string | null) => {
setPreviewOpen(true);
setPreviewLoading(true);
setPreview(null);
try {
const payload = await getCreditNoteSapResponse(noteId);
const fileName = (creditNoteNumber || `credit-note-${noteId}`).trim() + '.csv';
const row = mapSapResponseToPreviewRow(payload.sapResponse);
// Enrich with UI meta (amount/date from listing row when available)
const note = creditNotes.find((n) => Number(n.id) === Number(noteId));
const amountText = formatAmount(note?.amount ?? null);
const issuedAtText = formatIssuedAt(note?.issueDate ?? null);
setPreview({ fileName, row, meta: { amountText, issuedAtText }, downloadUrl: payload.url || null });
} catch (e: any) {
const msg =
e?.response?.status === 409
? 'The credit note is being generated, wait.'
: (e instanceof Error ? e.message : 'Failed to load SAP response');
toast.error(String(msg));
setPreviewOpen(false);
} finally {
setPreviewLoading(false);
}
}, [creditNotes]);
const downloadPreviewCsv = useCallback(async () => {
if (!preview?.downloadUrl) {
toast.error('CSV download link not available');
return;
}
window.open(preview.downloadUrl, '_blank');
}, [preview]);
const filteredNotes = useMemo(() => {
if (!searchQuery.trim()) return creditNotes;
const q = searchQuery.trim().toLowerCase();
return creditNotes.filter(
(n) =>
(n.creditNoteNumber && n.creditNoteNumber.toLowerCase().includes(q)) ||
(n.dealerName && n.dealerName.toLowerCase().includes(q)) ||
(n.dealerCode && n.dealerCode.toLowerCase().includes(q)) ||
(n.submission?.form16aNumber && n.submission.form16aNumber.toLowerCase().includes(q))
);
}, [creditNotes, searchQuery]);
return (
<div className="space-y-6 min-h-screen bg-gray-50 p-4 md:p-6 w-full">
<Dialog
open={previewOpen}
onOpenChange={(open) => {
setPreviewOpen(open);
if (!open) setPreview(null);
}}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>SAP Response</DialogTitle>
<DialogDescription>
{previewLoading ? 'Loading SAP response CSV…' : 'Review SAP response details and download the CSV.'}
</DialogDescription>
</DialogHeader>
{previewLoading ? (
<div className="py-8 flex items-center justify-center gap-2 text-gray-600">
<Loader2 className="w-5 h-5 animate-spin" />
Loading SAP response
</div>
) : preview ? (
<div className="space-y-4">
{/* Card 1 (GREEN): SAP identifiers only */}
<div className="rounded-xl border border-emerald-200 bg-emerald-50/70 p-4">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-emerald-700" />
<p className="text-xs font-semibold text-emerald-800 tracking-wide uppercase">SAP Response Identifiers</p>
</div>
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="rounded-lg border border-emerald-200 bg-white/70 p-3">
<p className="text-xs text-emerald-700 uppercase tracking-wide">TRNS_UNIQ_NO</p>
<p className="text-sm font-semibold text-emerald-900 break-all mt-1">{preview.row['TRNS_UNIQ_NO'] || ''}</p>
</div>
<div className="rounded-lg border border-emerald-200 bg-white/70 p-3">
<p className="text-xs text-emerald-700 uppercase tracking-wide">TDS_TRNS_ID</p>
<p className="text-sm font-semibold text-emerald-900 break-all mt-1">{preview.row['TDS_TRNS_ID'] || ''}</p>
</div>
</div>
</div>
{/* Card 2: Amount + date + ids */}
<div className="rounded-xl border border-amber-200 bg-amber-50/70 p-4">
<div className="flex items-center gap-2 mb-3">
<IndianRupee className="w-4 h-4 text-amber-700" />
<p className="text-xs font-semibold text-amber-800 tracking-wide uppercase">Credit Note Details</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="rounded-lg border border-amber-200 bg-white/70 p-3">
<p className="text-xs text-amber-700 uppercase tracking-wide flex items-center gap-2">
<IndianRupee className="w-4 h-4" /> Amount
</p>
<p className="text-base font-semibold text-amber-900 mt-1">{preview.meta.amountText}</p>
</div>
<div className="rounded-lg border border-amber-200 bg-white/70 p-3">
<p className="text-xs text-amber-700 uppercase tracking-wide flex items-center gap-2">
<CalendarClock className="w-4 h-4" /> Issued Date
</p>
<p className="text-base font-semibold text-amber-900 mt-1">{preview.meta.issuedAtText}</p>
</div>
<div className="rounded-lg border border-amber-200 bg-white/70 p-3">
<p className="text-xs text-amber-700 uppercase tracking-wide">DOC_NO</p>
<p className="text-sm font-semibold text-amber-900 break-all mt-1">{preview.row['DOC_NO'] || ''}</p>
</div>
<div className="rounded-lg border border-amber-200 bg-white/70 p-3">
<p className="text-xs text-amber-700 uppercase tracking-wide">MSG_TYP</p>
<p className="text-sm font-semibold text-amber-900 break-all mt-1">{preview.row['MSG_TYP'] || ''}</p>
</div>
</div>
<div className="rounded-lg border border-amber-200 bg-white/70 p-3 mt-3">
<p className="text-xs text-amber-700 uppercase tracking-wide">MESSAGE</p>
<p className="text-sm font-medium text-amber-900 break-words mt-1">{preview.row['MESSAGE'] || ''}</p>
</div>
</div>
</div>
) : (
<div className="py-8 text-sm text-gray-600">No preview available.</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setPreviewOpen(false)}>
Close
</Button>
<Button onClick={downloadPreviewCsv} disabled={!preview || previewLoading || !preview?.downloadUrl}>
<Download className="w-4 h-4 mr-2" />
Download CSV
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="w-full min-w-0">
<div className="mb-6">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 mb-2">Credit Notes</h1>
<p className="text-sm text-gray-600">View and manage all issued credit notes</p>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<Card>
<CardHeader className="pb-3">
<CardDescription>Total Credit Notes Issued</CardDescription>
<CardTitle className="text-3xl">
{loading ? '...' : summary.totalCreditNotes}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Total Credit Amount</CardDescription>
<CardTitle className="text-3xl text-green-600">
{loading ? '...' : formatAmount(summary.totalAmount)}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Active Dealers</CardDescription>
<CardTitle className="text-3xl">
{loading ? '...' : summary.activeDealersCount}
</CardTitle>
</CardHeader>
</Card>
</div>
{/* All Credit Notes */}
<Card>
<CardHeader>
<CardTitle>All Credit Notes</CardTitle>
<CardDescription>Search by credit note number, dealer name, or Form 16A number</CardDescription>
<div className="relative max-w-md mt-2">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
type="search"
placeholder="Search credit notes..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
</CardHeader>
<CardContent>
<div className="rounded-lg border overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Credit Note No.</TableHead>
<TableHead>Dealer Name</TableHead>
<TableHead>Form 16A No.</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Issue Date</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-12">
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-teal-600" />
<p className="text-gray-500">Loading credit notes...</p>
</TableCell>
</TableRow>
) : filteredNotes.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-gray-500">
<Receipt className="w-12 h-12 mx-auto mb-3 text-gray-400" />
<p>No credit notes found</p>
<p className="text-sm mt-1">
{searchQuery.trim()
? 'Try a different search.'
: 'Credit notes will appear here once issued.'}
</p>
</TableCell>
</TableRow>
) : (
filteredNotes.map((note) => (
<TableRow key={note.id} className="hover:bg-gray-50 transition-colors">
<TableCell className="font-medium">
{note.creditNoteNumber
? note.creditNoteNumber.startsWith('CN')
? note.creditNoteNumber
: `CN #${note.creditNoteNumber}`
: ''}
</TableCell>
<TableCell>{note.dealerName ?? note.dealerCode ?? ''}</TableCell>
<TableCell>{note.submission?.form16aNumber ?? ''}</TableCell>
<TableCell>{formatAmount(note.amount)}</TableCell>
<TableCell>{formatDate(note.issueDate)}</TableCell>
<TableCell>
<Badge
className={
note.status?.toLowerCase() === 'issued' || note.status?.toLowerCase() === 'completed'
? 'bg-green-100 text-green-800 border-green-200'
: 'bg-gray-100 text-gray-700 border-gray-200'
}
>
{note.status ?? ''}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
{note.sapResponseAvailable ? (
<Button
variant="outline"
size="sm"
onClick={async () => {
await openSapPreview(Number(note.id), note.creditNoteNumber);
}}
>
<Download className="w-4 h-4 mr-2" />
View
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={async () => {
toast.loading('Pulling credit note...', { id: `pull-cn-${note.id}` });
try {
await apiClient.post('/form16/sap/pull');
await fetchNotes();
toast.success('Refreshed.', { id: `pull-cn-${note.id}` });
} catch {
toast.error('Failed to pull credit note', { id: `pull-cn-${note.id}` });
}
}}
>
<RefreshCw className="w-4 h-4 mr-2" />
Pull
</Button>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
</div>
);
}