diff --git a/src/App.tsx b/src/App.tsx index 03c63a1..c1da124 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,7 @@ import { Admin } from '@/pages/Admin/Admin'; import { Form16CreditNotes } from '@/pages/Form16/Form16CreditNotes'; import { Form16CreditNoteDetail } from '@/pages/Form16/Form16CreditNoteDetail'; import { Form16DebitNotes } from '@/pages/Form16/Form16DebitNotes'; +import { Form16Transactions } from '@/pages/Form16/Form16Transactions'; import { Form16Submit } from '@/pages/Form16/Form16Submit'; import { Form16SubmissionResult } from '@/pages/Form16/Form16SubmissionResult'; import { Form16_26AS } from '@/pages/Form16/Form16_26AS'; @@ -543,6 +544,16 @@ function AppRoutes({ onLogout }: AppProps) { } /> + {/* Form 16 – Transactions (RE) */} + + + + } + /> + {/* Form 16 – Submit (dealer) */} { @@ -416,32 +416,17 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on - )} diff --git a/src/pages/Form16/Form16CreditNoteDetail.tsx b/src/pages/Form16/Form16CreditNoteDetail.tsx index c5c2c7f..3d32a23 100644 --- a/src/pages/Form16/Form16CreditNoteDetail.tsx +++ b/src/pages/Form16/Form16CreditNoteDetail.tsx @@ -27,7 +27,7 @@ function formatAmount(value: number | null | undefined): string { function mapSapResponseToPreviewRow(sap: Form16SapResponseRecord): Record { return { TRNS_UNIQ_NO: sap.trnsUniqNo || '', - TDS_TRNS_ID: sap.tdsTransId || sap.claimNumber || '', + TDS_TRNS_ID: sap.tdsTransId || '', DOC_NO: sap.sapDocumentNumber || '', MSG_TYP: sap.msgTyp || '', MESSAGE: sap.message || '', diff --git a/src/pages/Form16/Form16CreditNotes.tsx b/src/pages/Form16/Form16CreditNotes.tsx index 0a26a6e..938128a 100644 --- a/src/pages/Form16/Form16CreditNotes.tsx +++ b/src/pages/Form16/Form16CreditNotes.tsx @@ -51,7 +51,7 @@ type SapResponsePreview = { function mapSapResponseToPreviewRow(sap: Form16SapResponseRecord): Record { return { TRNS_UNIQ_NO: sap.trnsUniqNo || '', - TDS_TRNS_ID: sap.tdsTransId || sap.claimNumber || '', + TDS_TRNS_ID: sap.tdsTransId || '', DOC_NO: sap.sapDocumentNumber || '', MSG_TYP: sap.msgTyp || '', MESSAGE: sap.message || '', diff --git a/src/pages/Form16/Form16DebitNotes.tsx b/src/pages/Form16/Form16DebitNotes.tsx index d9c840a..7ea6f5f 100644 --- a/src/pages/Form16/Form16DebitNotes.tsx +++ b/src/pages/Form16/Form16DebitNotes.tsx @@ -47,7 +47,7 @@ function formatIssuedAt(value: string | null | undefined): string { function mapSapResponseToPreviewRow(sap: Form16SapResponseRecord): Record { return { TRNS_UNIQ_NO: sap.trnsUniqNo || '', - TDS_TRNS_ID: sap.tdsTransId || sap.claimNumber || '', + TDS_TRNS_ID: sap.tdsTransId || '', DOC_NO: sap.sapDocumentNumber || '', MSG_TYP: sap.msgTyp || '', MESSAGE: sap.message || '', diff --git a/src/pages/Form16/Form16NonSubmittedDealers.tsx b/src/pages/Form16/Form16NonSubmittedDealers.tsx index e66c234..ece2ea1 100644 --- a/src/pages/Form16/Form16NonSubmittedDealers.tsx +++ b/src/pages/Form16/Form16NonSubmittedDealers.tsx @@ -71,7 +71,12 @@ export function Form16NonSubmittedDealers() { const handleNotifyDealer = async (dealer: NonSubmittedDealerItem) => { setNotifyingDealer(dealer.id); try { - await notifyNonSubmittedDealer(dealer.dealerCode, financialYearFilter || undefined); + await notifyNonSubmittedDealer({ + dealerCode: dealer.dealerCode, + dealerId: dealer.id, + email: dealer.email, + financialYear: financialYearFilter || undefined, + }); toast.success(`Notification sent to ${dealer.dealerName}`, { description: `Reminder sent for missing quarters: ${dealer.missingQuarters.join(', ')}. Last notified column updated.`, }); diff --git a/src/pages/Form16/Form16Transactions.tsx b/src/pages/Form16/Form16Transactions.tsx new file mode 100644 index 0000000..4dab9e2 --- /dev/null +++ b/src/pages/Form16/Form16Transactions.tsx @@ -0,0 +1,394 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { ArrowDownCircle, ArrowUpCircle, Eye, Loader2, Receipt, RefreshCw, Search, FileText, IndianRupee, CalendarClock } from 'lucide-react'; +import { toast } from 'sonner'; +import apiClient from '@/services/authApi'; +import { + getCreditNoteSapResponse, + getDebitNoteSapResponse, + listCreditNotes, + listDebitNotes, + type Form16CreditNoteItem, + type Form16DebitNoteListItem, + type Form16SapResponseRecord, +} from '@/services/form16Api'; + +type TransactionType = 'credit' | 'debit'; + +type TransactionRow = { + id: number; + type: TransactionType; + referenceNumber: string; + dealer: string; + amount: number | null; + issueDate: string | null; + sapResponseAvailable: boolean; + searchText: string; +}; + +type SapPreview = { + type: TransactionType; + referenceNumber: string; + amountText: string; + issuedAtText: string; + row: Record; +}; + +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 formatDateTime(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; + } +} + +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); +} + +function mapSapResponseToPreviewRow(sap: Form16SapResponseRecord): Record { + return { + TRNS_UNIQ_NO: sap.trnsUniqNo || '', + TDS_TRNS_ID: sap.tdsTransId || '', + DOC_NO: sap.sapDocumentNumber || '', + MSG_TYP: sap.msgTyp || '', + MESSAGE: sap.message || '', + }; +} + +function toCreditTransaction(note: Form16CreditNoteItem): TransactionRow { + return { + id: Number(note.id), + type: 'credit', + referenceNumber: note.creditNoteNumber || '–', + dealer: note.dealerName || note.dealerCode || '–', + amount: note.amount, + issueDate: note.issueDate, + sapResponseAvailable: !!note.sapResponseAvailable, + searchText: [ + note.creditNoteNumber, + note.dealerName, + note.dealerCode, + note.submission?.form16aNumber, + 'credit', + 'cr', + ] + .filter(Boolean) + .join(' ') + .toLowerCase(), + }; +} + +function toDebitTransaction(note: Form16DebitNoteListItem): TransactionRow { + return { + id: Number(note.id), + type: 'debit', + referenceNumber: note.debitNoteNumber || '–', + dealer: note.dealerName || note.dealerCode || '–', + amount: note.amount, + issueDate: note.issueDate, + sapResponseAvailable: !!note.sapResponseAvailable, + searchText: [ + note.debitNoteNumber, + note.dealerName, + note.dealerCode, + note.form16aNumber, + 'debit', + 'db', + ] + .filter(Boolean) + .join(' ') + .toLowerCase(), + }; +} + +export function Form16Transactions() { + const [loading, setLoading] = useState(true); + const [pulling, setPulling] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [transactions, setTransactions] = useState([]); + + const [previewOpen, setPreviewOpen] = useState(false); + const [previewLoading, setPreviewLoading] = useState(false); + const [preview, setPreview] = useState(null); + + const fetchTransactions = useCallback(async () => { + try { + setLoading(true); + const [creditResult, debitResult] = await Promise.all([listCreditNotes(), listDebitNotes()]); + + const merged = [ + ...creditResult.creditNotes.map(toCreditTransaction), + ...debitResult.debitNotes.map(toDebitTransaction), + ].sort((a, b) => { + const aTime = a.issueDate ? new Date(a.issueDate).getTime() : 0; + const bTime = b.issueDate ? new Date(b.issueDate).getTime() : 0; + return bTime - aTime; + }); + + setTransactions(merged); + } catch (error: unknown) { + console.error('[Form16Transactions] Failed to fetch:', error); + toast.error(error instanceof Error ? error.message : 'Failed to load transactions'); + setTransactions([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchTransactions(); + }, [fetchTransactions]); + + const filteredTransactions = useMemo(() => { + if (!searchQuery.trim()) return transactions; + const q = searchQuery.trim().toLowerCase(); + return transactions.filter((t) => t.searchText.includes(q)); + }, [transactions, searchQuery]); + + const openSapPreview = useCallback(async (tx: TransactionRow) => { + setPreviewOpen(true); + setPreviewLoading(true); + setPreview(null); + try { + const payload = tx.type === 'credit' + ? await getCreditNoteSapResponse(tx.id) + : await getDebitNoteSapResponse(tx.id); + + setPreview({ + type: tx.type, + referenceNumber: tx.referenceNumber, + amountText: formatAmount(tx.amount), + issuedAtText: formatDateTime(tx.issueDate), + row: mapSapResponseToPreviewRow(payload.sapResponse), + }); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Failed to load SAP response'; + toast.error(msg); + setPreviewOpen(false); + } finally { + setPreviewLoading(false); + } + }, []); + + const pullNow = useCallback(async () => { + setPulling(true); + toast.loading('Pulling SAP responses...', { id: 'pull-transactions' }); + try { + await apiClient.post('/form16/sap/pull'); + await fetchTransactions(); + toast.success('Pulled and refreshed transactions.', { id: 'pull-transactions' }); + } catch { + toast.error('Failed to pull SAP responses', { id: 'pull-transactions' }); + } finally { + setPulling(false); + } + }, [fetchTransactions]); + + return ( +
+ { + setPreviewOpen(open); + if (!open) setPreview(null); + }} + > + + + SAP Response + + {previewLoading ? 'Loading SAP response CSV...' : 'Review SAP response details.'} + + + + {previewLoading ? ( +
+ + Loading SAP response... +
+ ) : preview ? ( +
+
+
+ +

SAP Response Identifiers

+
+
+
+

TRNS_UNIQ_NO

+

{preview.row.TRNS_UNIQ_NO || '–'}

+
+
+

CLAIM_NUMBER

+

{preview.row.TDS_TRNS_ID || '–'}

+
+
+
+ +
+
+ +

+ {preview.type === 'credit' ? 'Credit Note Details' : 'Debit Note Details'} +

+
+ +
+
+

+ Amount +

+

{preview.amountText}

+
+
+

+ Issued Date +

+

{preview.issuedAtText}

+
+
+

DOC_NO

+

{preview.row.DOC_NO || '–'}

+
+
+

MSG_TYP

+

{preview.row.MSG_TYP || '–'}

+
+
+ +
+

MESSAGE

+

{preview.row.MESSAGE || '–'}

+
+
+
+ ) : ( +
No preview available.
+ )} + + + + +
+
+ +
+
+

Transactions

+

Credit and debit notes in one place

+
+ + + + All Transactions + CR/DB reference number, dealer, amount, issue date, and SAP status +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ +
+
+ +
+ + + + TDS_TRANS_ID + Dealer + Transaction Amount + Issued Date + Status + Actions + + + + {loading ? ( + + + +

Loading transactions...

+
+
+ ) : filteredTransactions.length === 0 ? ( + + + +

No transactions found

+
+
+ ) : ( + filteredTransactions.map((tx) => ( + + {tx.referenceNumber} + {tx.dealer} + +
+ {tx.type === 'credit' ? : } + {tx.type === 'credit' ? '+ ' : '- '}{formatAmount(tx.amount)} +
+
+ {formatDate(tx.issueDate)} + + {tx.sapResponseAvailable ? ( + Completed + ) : ( + OUTGOING + )} + + + {tx.sapResponseAvailable ? ( + + ) : ( + + )} + +
+ )) + )} +
+
+
+
+
+
+
+ ); +} diff --git a/src/services/form16Api.ts b/src/services/form16Api.ts index 96013c6..7e42472 100644 --- a/src/services/form16Api.ts +++ b/src/services/form16Api.ts @@ -65,11 +65,13 @@ export interface Form16CreditNoteItem { } export async function getCreditNoteDownloadUrl(id: number): Promise { + const fallbackCsvApiUrl = `/api/v1/form16/credit-notes/${id}/sap-response/csv`; const { data } = await apiClient.get<{ data?: { url?: string }; url?: string }>(`/form16/credit-notes/${id}/download`); const payload = data?.data ?? data; const url = payload?.url; - if (!url) throw new Error('Download link not available'); - return url; + // Hard-stop on legacy local-file links; View/Download must use API-backed DB data. + if (!url || String(url).startsWith('/uploads/')) return fallbackCsvApiUrl; + return String(url); } export interface ListCreditNotesSummary { @@ -128,7 +130,6 @@ export interface Form16SapResponseRecord { fileName: string | null; trnsUniqNo: string | null; tdsTransId: string | null; - claimNumber: string | null; sapDocumentNumber: string | null; msgTyp: string | null; message: string | null; @@ -199,25 +200,34 @@ export async function listDebitNotes(params?: ListDebitNotesParams): Promise { + const fallbackCsvApiUrl = `/api/v1/form16/debit-notes/${id}/sap-response/csv`; const { data } = await apiClient.get<{ data?: { url?: string }; url?: string }>(`/form16/debit-notes/${id}/sap-response`); const payload = data?.data ?? data; const url = payload?.url; - if (!url) throw new Error('SAP response link not available'); - return url; + if (!url || String(url).startsWith('/uploads/')) return fallbackCsvApiUrl; + return String(url); } export async function getDebitNoteSapResponse(id: number): Promise { const { data } = await apiClient.get<{ data?: Form16SapResponsePayload } | Form16SapResponsePayload>(`/form16/debit-notes/${id}/sap-response`); const payload = (data && typeof data === 'object' && 'data' in data ? data.data : data) as Form16SapResponsePayload | undefined; if (!payload?.sapResponse) throw new Error('SAP response not available'); - return payload; + return { + ...payload, + // Force API-backed CSV endpoint to avoid any /uploads path leakage. + url: `/api/v1/form16/debit-notes/${id}/sap-response/csv`, + }; } export async function getCreditNoteSapResponse(id: number): Promise { const { data } = await apiClient.get<{ data?: Form16SapResponsePayload } | Form16SapResponsePayload>(`/form16/credit-notes/${id}/sap-response`); const payload = (data && typeof data === 'object' && 'data' in data ? data.data : data) as Form16SapResponsePayload | undefined; if (!payload?.sapResponse) throw new Error('SAP response not available'); - return payload; + return { + ...payload, + // Force API-backed CSV endpoint to avoid any /uploads path leakage. + url: `/api/v1/form16/credit-notes/${id}/sap-response/csv`, + }; } /** Get credit note linked to a Form 16 request (for workflow tab). */ @@ -725,10 +735,20 @@ export async function listNonSubmittedDealers(financialYear?: string): Promise { +export async function notifyNonSubmittedDealer( + params: { dealerCode?: string | null; dealerId?: string | null; email?: string | null; financialYear?: string } +): Promise { + const dealerCode = String(params.dealerCode ?? '').trim(); + const dealerId = String(params.dealerId ?? '').trim(); + const email = String(params.email ?? '').trim(); + if (!dealerCode && !dealerId && !email) { + throw new Error('Dealer identifier missing'); + } const { data } = await apiClient.post<{ data?: { dealer: NonSubmittedDealerItem } }>('/form16/non-submitted-dealers/notify', { - dealerCode, - financialYear: financialYear || undefined, + dealerCode: dealerCode || undefined, + dealerId: dealerId || undefined, + email: email || undefined, + financialYear: params.financialYear || undefined, }); const dealer = data?.data?.dealer; if (!dealer) throw new Error('No dealer returned');