399 lines
17 KiB
TypeScript
399 lines
17 KiB
TypeScript
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>
|
||
);
|
||
}
|