From def22be1b171632def3259fc274b7927d79e54d4 Mon Sep 17 00:00:00 2001 From: Aaditya Jaiswal Date: Wed, 18 Mar 2026 11:32:48 +0530 Subject: [PATCH] SAP response from credit note and debit note --- src/App.tsx | 11 + .../Form16AdminConfig/Form16AdminConfig.tsx | 179 +++++--- .../layout/PageLayout/PageLayout.tsx | 17 +- src/pages/Form16/Form16CreditNoteDetail.tsx | 232 ++++++++++- src/pages/Form16/Form16CreditNotes.tsx | 274 +++++++++++-- src/pages/Form16/Form16DebitNotes.tsx | 384 ++++++++++++++++++ src/pages/Form16/Form16Submit.tsx | 6 +- .../components/RequestSubmissionSuccess.tsx | 29 ++ src/services/adminApi.ts | 6 + src/services/form16Api.ts | 78 ++++ 10 files changed, 1120 insertions(+), 96 deletions(-) create mode 100644 src/pages/Form16/Form16DebitNotes.tsx diff --git a/src/App.tsx b/src/App.tsx index 61bf503..03c63a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,6 +28,7 @@ import { CreateAdminRequest } from '@/pages/CreateAdminRequest/CreateAdminReques 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 { Form16Submit } from '@/pages/Form16/Form16Submit'; import { Form16SubmissionResult } from '@/pages/Form16/Form16SubmissionResult'; import { Form16_26AS } from '@/pages/Form16/Form16_26AS'; @@ -532,6 +533,16 @@ function AppRoutes({ onLogout }: AppProps) { } /> + {/* Form 16 – Debit Notes (RE) */} + + + + } + /> + {/* Form 16 – Submit (dealer) */} (default26AsNotif()); + const [reminder26AsUploadEnabled, setReminder26AsUploadEnabled] = useState(true); + const [reminder26AsUploadAfterQuarterEndDays, setReminder26AsUploadAfterQuarterEndDays] = useState(0); const [notificationForm16SuccessCreditNote, setNotificationForm16SuccessCreditNote] = useState(defaultNotif(true, 'Form 16 submitted successfully. Credit note: [CreditNoteRef].')); const [notificationForm16Unsuccessful, setNotificationForm16Unsuccessful] = useState(defaultNotif(true, 'Form 16 submission was unsuccessful. Issue: [Issue]. Please review.')); const [alertSubmitForm16Enabled, setAlertSubmitForm16Enabled] = useState(true); + const [alertSubmitForm16AfterQuarterEndDays, setAlertSubmitForm16AfterQuarterEndDays] = useState(0); + const [alertSubmitForm16EveryDays, setAlertSubmitForm16EveryDays] = useState(7); const [alertSubmitForm16FrequencyDays, setAlertSubmitForm16FrequencyDays] = useState(0); const [alertSubmitForm16FrequencyHours, setAlertSubmitForm16FrequencyHours] = useState(24); const [alertSubmitForm16RunAtTime, setAlertSubmitForm16RunAtTime] = useState('09:00'); @@ -152,9 +156,13 @@ export function Form16AdminConfig() { templateDealers: n.templateDealers ?? default26AsNotif().templateDealers, }); } + setReminder26AsUploadEnabled(config.reminder26AsUploadEnabled ?? true); + setReminder26AsUploadAfterQuarterEndDays(typeof config.reminder26AsUploadAfterQuarterEndDays === 'number' ? config.reminder26AsUploadAfterQuarterEndDays : 0); if (config.notificationForm16SuccessCreditNote) setNotificationForm16SuccessCreditNote(config.notificationForm16SuccessCreditNote); if (config.notificationForm16Unsuccessful) setNotificationForm16Unsuccessful(config.notificationForm16Unsuccessful); setAlertSubmitForm16Enabled(config.alertSubmitForm16Enabled ?? true); + setAlertSubmitForm16AfterQuarterEndDays(typeof config.alertSubmitForm16AfterQuarterEndDays === 'number' ? config.alertSubmitForm16AfterQuarterEndDays : 0); + setAlertSubmitForm16EveryDays(typeof config.alertSubmitForm16EveryDays === 'number' ? config.alertSubmitForm16EveryDays : 7); setAlertSubmitForm16FrequencyDays(config.alertSubmitForm16FrequencyDays ?? 0); setAlertSubmitForm16FrequencyHours(config.alertSubmitForm16FrequencyHours ?? 24); setAlertSubmitForm16RunAtTime(config.alertSubmitForm16RunAtTime !== undefined && config.alertSubmitForm16RunAtTime !== null ? config.alertSubmitForm16RunAtTime : '09:00'); @@ -185,9 +193,13 @@ export function Form16AdminConfig() { reminderEnabled, reminderDays: Math.max(1, Math.min(365, reminderDays)) || 7, notification26AsDataAdded, + reminder26AsUploadEnabled, + reminder26AsUploadAfterQuarterEndDays: Math.max(0, Math.min(365, reminder26AsUploadAfterQuarterEndDays)), notificationForm16SuccessCreditNote, notificationForm16Unsuccessful, alertSubmitForm16Enabled, + alertSubmitForm16AfterQuarterEndDays: Math.max(0, Math.min(365, alertSubmitForm16AfterQuarterEndDays)), + alertSubmitForm16EveryDays: Math.max(1, Math.min(365, alertSubmitForm16EveryDays)), alertSubmitForm16FrequencyDays: Math.max(0, Math.min(365, alertSubmitForm16FrequencyDays)), alertSubmitForm16FrequencyHours: Math.max(0, Math.min(168, alertSubmitForm16FrequencyHours)), alertSubmitForm16RunAtTime: alertSubmitForm16RunAtTime ?? '', @@ -257,6 +269,39 @@ export function Form16AdminConfig() { + {/* Quarter calendar – explain quarter-based reminders */} + + + + + Quarter calendar (how reminders/alerts work) + + + Form 16 schedules are quarter-based. Reminders/alerts are intended to be sent after the quarter end date (quarter end + N days) if required data is missing. + + + +
+
+

Q1

+

1 Apr – 30 Jun

+
+
+

Q2

+

1 Jul – 30 Sep

+
+
+

Q3

+

1 Oct – 31 Dec

+
+
+

Q4

+

1 Jan – 31 Mar

+
+
+
+
+ {/* Submission data viewers */} @@ -345,6 +390,36 @@ export function Form16AdminConfig() {

Form 16 notifications – recipient and trigger

+ {/* 26AS upload reminder (RE) */} +
+
+

Reminder – upload 26AS (RE)

+

+ Sent to: RE users listed in 26AS viewers (RE).{' '} + When: Quarter end + N days if 26AS is missing for the most recently ended quarter. +

+
+
+ + setReminder26AsUploadAfterQuarterEndDays(parseInt(e.target.value || '0', 10) || 0)} + className="w-24" + /> +
+ Stops automatically once 26AS is uploaded for that quarter. +
+
+ +
+ {/* 26AS data added – separate message for RE users and for dealers */}
@@ -362,6 +437,47 @@ export function Form16AdminConfig() { />
+ {/* Dealer reminder to submit Form 16A (quarter-ended based) */} +
+
+

Reminder – submit Form 16A (Dealers)

+

+ Sent to: Dealers who have not submitted Form 16A for the most recently ended quarter.{' '} + When: Quarter end + N days, then repeat every X days (until submitted). +

+
+
+ + setAlertSubmitForm16AfterQuarterEndDays(parseInt(e.target.value || '0', 10) || 0)} + className="w-24" + /> +
+
+ + setAlertSubmitForm16EveryDays(parseInt(e.target.value || '7', 10) || 7)} + className="w-24" + /> +
+
+
+ +
+ {/* Successful Form 16 with credit note */}
@@ -396,69 +512,6 @@ export function Form16AdminConfig() { />
- {/* Alert to submit Form 16 (auto, configurable) */} -
-
-

Alert – submit Form 16 (to dealers who haven’t submitted)

-

- Sent to: Dealers who have not yet submitted Form 16 for the current FY. When: Daily at the time below (server timezone). All settings are API-driven from this config. -

-
-
-
- - setAlertSubmitForm16RunAtTime(e.target.value)} - className="w-28" - /> - {alertSubmitForm16RunAtTime ? ( - - ) : null} -
- 24h, server TZ. Leave empty to disable daily run. -
-
-
- - setAlertSubmitForm16FrequencyDays(Math.max(0, parseInt(e.target.value, 10) || 0))} - className="w-20" - /> -
-
- - setAlertSubmitForm16FrequencyHours(Math.max(0, parseInt(e.target.value, 10) || 0))} - className="w-20" - /> -
-
-

- Alert message content is fixed and managed by the system. -

-
-
- -
- {/* Reminder notification (pending Form 16) */}
diff --git a/src/components/layout/PageLayout/PageLayout.tsx b/src/components/layout/PageLayout/PageLayout.tsx index 5f063e7..0d0efb5 100644 --- a/src/components/layout/PageLayout/PageLayout.tsx +++ b/src/components/layout/PageLayout/PageLayout.tsx @@ -126,7 +126,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on const canView26AS = !!form16Permissions?.canView26AS; // Keep Form 16 expanded when on a Form 16 page (dealer or RE) - const isForm16Page = currentPage === 'form16-credit-notes' || currentPage === 'form16-submit' || currentPage === 'form16-pending-submissions' || currentPage === 'form16-26as' || currentPage === 'form16-non-submitted-dealers'; + const isForm16Page = currentPage === 'form16-credit-notes' || currentPage === 'form16-debit-notes' || currentPage === 'form16-submit' || currentPage === 'form16-pending-submissions' || currentPage === 'form16-26as' || currentPage === 'form16-non-submitted-dealers'; const form16ExpandedOrActive = form16Expanded || isForm16Page; const toggleSidebar = () => { @@ -413,6 +413,21 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on Non-submitted Dealers + + + + + +
-
@@ -128,6 +330,20 @@ export function Form16CreditNoteDetail() {

+ +
+ {creditNote.sapResponseAvailable ? ( + + ) : ( + + )} +

Dealer Name

diff --git a/src/pages/Form16/Form16CreditNotes.tsx b/src/pages/Form16/Form16CreditNotes.tsx index ae7ab7f..64ce192 100644 --- a/src/pages/Form16/Form16CreditNotes.tsx +++ b/src/pages/Form16/Form16CreditNotes.tsx @@ -1,18 +1,20 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; -import { useNavigate } from 'react-router-dom'; 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 { Search, Loader2, Receipt } from 'lucide-react'; +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, + getCreditNoteDownloadUrl, type Form16CreditNoteItem, 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 '–'; @@ -35,12 +37,76 @@ const DEFAULT_SUMMARY: ListCreditNotesSummary = { activeDealersCount: 0, }; +type SapResponsePreview = { + fileUrl: string; + fileName: string; + rawCsv: string; + row: Record; + meta: { + amountText: string; + issuedAtText: string; + }; +}; + +function buildAbsoluteBackendUrl(url: string): string { + if (!url) return url; + if (/^https?:\/\//i.test(url)) return url; + const apiBase = ((import.meta as any).env?.VITE_API_BASE_URL as string | undefined) || 'http://localhost:5000/api/v1'; + const origin = apiBase.replace(/\/api\/v1\/?$/i, ''); + return `${origin}${url.startsWith('/') ? '' : '/'}${url}`; +} + +function parseSapResponseCsv(rawCsv: string): Record | null { + const lines = rawCsv + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + if (lines.length < 2) return null; + + const header = lines[0]!.split('|').map((h) => h.trim()); + const isUsefulRow = (values: string[]) => { + const obj: Record = {}; + header.forEach((h, idx) => (obj[h] = (values[idx] || '').trim())); + const trns = (obj.TRNS_UNIQ_NO || '').trim(); + const docNo = (obj.DOC_NO || '').trim(); + const msgTyp = (obj.MSG_TYP || '').trim(); + const tdsId = (obj.TDS_TRNS_ID || '').trim(); + if (trns) return true; + if (tdsId && (docNo || msgTyp) && tdsId.toUpperCase() !== 'MSG_TYP' && tdsId.toUpperCase() !== 'MESSAGE') return true; + return false; + }; + + for (let i = lines.length - 1; i >= 1; i--) { + const values = lines[i]!.split('|'); + if (isUsefulRow(values)) { + const obj: Record = {}; + header.forEach((h, idx) => (obj[h] = (values[idx] || '').trim())); + return obj; + } + } + return null; +} + +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 navigate = useNavigate(); const [creditNotes, setCreditNotes] = useState([]); const [summary, setSummary] = useState(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(null); const fetchNotes = useCallback(async (params?: ListCreditNotesParams) => { try { @@ -62,6 +128,52 @@ export function Form16CreditNotes() { fetchNotes(); }, [fetchNotes]); + const openSapPreview = useCallback(async (noteId: number, creditNoteNumber?: string | null) => { + setPreviewOpen(true); + setPreviewLoading(true); + setPreview(null); + try { + const url = await getCreditNoteDownloadUrl(noteId); + const absUrl = buildAbsoluteBackendUrl(url); + const fileName = (creditNoteNumber || `credit-note-${noteId}`).trim() + '.csv'; + + const res = await apiClient.get(absUrl, { responseType: 'blob' }); + const blob = res.data; + const rawCsv = await blob.text(); + const row = parseSapResponseCsv(rawCsv); + if (!row) throw new Error('Could not parse SAP response CSV'); + + // 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({ fileUrl: absUrl, fileName, rawCsv, row, meta: { amountText, issuedAtText } }); + } 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) return; + const blob = new Blob([preview.rawCsv], { type: 'text/csv;charset=utf-8' }); + const href = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = href; + a.download = preview.fileName; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(href); + }, [preview]); + const filteredNotes = useMemo(() => { if (!searchQuery.trim()) return creditNotes; const q = searchQuery.trim().toLowerCase(); @@ -76,6 +188,99 @@ export function Form16CreditNotes() { return (
+ { + setPreviewOpen(open); + if (!open) setPreview(null); + }} + > + + + SAP Response + + {previewLoading ? 'Loading SAP response CSV…' : 'Review SAP response details and download the CSV.'} + + + + {previewLoading ? ( +
+ + Loading SAP response… +
+ ) : preview ? ( +
+ {/* Card 1 (GREEN): SAP identifiers only */} +
+
+ +

SAP Response Identifiers

+
+ +
+
+

TRNS_UNIQ_NO

+

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

+
+
+

TDS_TRNS_ID

+

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

+
+
+
+ + {/* Card 2: Amount + date + ids */} +
+
+ +

Credit Note Details

+
+ +
+
+

+ Amount +

+

{preview.meta.amountText}

+
+
+

+ Issued Date +

+

{preview.meta.issuedAtText}

+
+
+

DOC_NO

+

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

+
+
+

MSG_TYP

+

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

+
+
+ +
+

MESSAGE

+

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

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

Credit Notes

@@ -114,19 +319,17 @@ export function Form16CreditNotes() { All Credit Notes - - Search by credit note number, dealer name, or Form 16A number -
- - setSearchQuery(e.target.value)} - className="pl-9" - /> -
-
+ Search by credit note number, dealer name, or Form 16A number +
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
@@ -188,13 +391,38 @@ export function Form16CreditNotes() { - +
+ {note.sapResponseAvailable ? ( + + ) : ( + + )} +
)) diff --git a/src/pages/Form16/Form16DebitNotes.tsx b/src/pages/Form16/Form16DebitNotes.tsx new file mode 100644 index 0000000..db54603 --- /dev/null +++ b/src/pages/Form16/Form16DebitNotes.tsx @@ -0,0 +1,384 @@ +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, RefreshCw, Eye, FileText, IndianRupee, CalendarClock } from 'lucide-react'; +import { toast } from 'sonner'; +import { + listDebitNotes, + getDebitNoteSapResponseUrl, + type Form16DebitNoteListItem, + type ListDebitNotesParams, + type ListDebitNotesSummary, +} from '@/services/form16Api'; +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); +} + +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; + } +} + +function buildAbsoluteBackendUrl(url: string): string { + if (!url) return url; + if (/^https?:\/\//i.test(url)) return url; + const apiBase = ((import.meta as any).env?.VITE_API_BASE_URL as string | undefined) || 'http://localhost:5000/api/v1'; + const origin = apiBase.replace(/\/api\/v1\/?$/i, ''); + return `${origin}${url.startsWith('/') ? '' : '/'}${url}`; +} + +function parseSapResponseCsv(rawCsv: string): Record | null { + const lines = rawCsv + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + if (lines.length < 2) return null; + + const header = lines[0]!.split('|').map((h) => h.trim()); + // Debit response should contain: TRNS_UNIQ_NO, CLAIM_NUMBER, DOC_NO, MSG_TYP, MESSAGE + for (let i = lines.length - 1; i >= 1; i--) { + const values = lines[i]!.split('|'); + const obj: Record = {}; + header.forEach((h, idx) => (obj[h] = (values[idx] || '').trim())); + const trns = (obj.TRNS_UNIQ_NO || '').trim(); + const claim = (obj.CLAIM_NUMBER || '').trim(); + const doc = (obj.DOC_NO || '').trim(); + const typ = (obj.MSG_TYP || '').trim(); + if (trns || (claim && (doc || typ))) return obj; + } + return null; +} + +const DEFAULT_SUMMARY: ListDebitNotesSummary = { + totalDebitNotes: 0, + totalAmount: 0, + impactedDealersCount: 0, +}; + +export function Form16DebitNotes() { + const [debitNotes, setDebitNotes] = useState([]); + const [summary, setSummary] = useState(DEFAULT_SUMMARY); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [previewOpen, setPreviewOpen] = useState(false); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewRow, setPreviewRow] = useState | null>(null); + const [previewMeta, setPreviewMeta] = useState<{ amountText: string; issuedAtText: string } | null>(null); + const [pulling, setPulling] = useState(false); + + const fetchNotes = useCallback(async (params?: ListDebitNotesParams) => { + try { + setLoading(true); + const result = await listDebitNotes(params); + setDebitNotes(result.debitNotes); + setSummary(result.summary ?? DEFAULT_SUMMARY); + } catch (error: unknown) { + console.error('[Form16DebitNotes] Failed to fetch:', error); + toast.error(error instanceof Error ? error.message : 'Failed to load debit notes'); + setDebitNotes([]); + setSummary(DEFAULT_SUMMARY); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchNotes(); + }, [fetchNotes]); + + const filteredNotes = useMemo(() => { + if (!searchQuery.trim()) return debitNotes; + const q = searchQuery.trim().toLowerCase(); + return debitNotes.filter( + (n) => + (n.debitNoteNumber && n.debitNoteNumber.toLowerCase().includes(q)) || + (n.creditNoteNumber && n.creditNoteNumber.toLowerCase().includes(q)) || + (n.dealerName && n.dealerName.toLowerCase().includes(q)) || + (n.dealerCode && n.dealerCode.toLowerCase().includes(q)) || + (n.form16aNumber && n.form16aNumber.toLowerCase().includes(q)) + ); + }, [debitNotes, searchQuery]); + + const openSapPreview = useCallback(async (note: Form16DebitNoteListItem) => { + setPreviewOpen(true); + setPreviewLoading(true); + setPreviewRow(null); + setPreviewMeta({ amountText: formatAmount(note.amount), issuedAtText: formatIssuedAt(note.issueDate) }); + try { + const url = await getDebitNoteSapResponseUrl(Number(note.id)); + const absUrl = buildAbsoluteBackendUrl(url); + const res = await apiClient.get(absUrl, { responseType: 'blob' }); + const rawCsv = await res.data.text(); + const row = parseSapResponseCsv(rawCsv); + if (!row) throw new Error('Could not parse SAP response CSV'); + setPreviewRow(row); + } catch (e: any) { + const msg = + e?.response?.status === 409 + ? 'The debit note is being generated, wait.' + : (e instanceof Error ? e.message : 'Failed to load SAP response'); + toast.error(String(msg)); + setPreviewOpen(false); + } finally { + setPreviewLoading(false); + } + }, []); + + const pullNow = useCallback(async () => { + setPulling(true); + toast.loading('Pulling SAP responses...', { id: 'pull-debit' }); + try { + const res = await apiClient.post<{ data?: { processed?: number; debitProcessed?: number }; message?: string }>('/form16/sap/pull'); + await fetchNotes(); + const data = res.data?.data; + const debitCount = typeof data?.debitProcessed === 'number' ? data.debitProcessed : data?.processed; + if (typeof debitCount === 'number' && debitCount > 0) { + toast.success(`Pulled. ${debitCount} debit response(s) processed. List refreshed.`, { id: 'pull-debit' }); + } else { + toast.success('Pulled. List refreshed. (No new debit responses in folder.)', { id: 'pull-debit' }); + } + } catch { + toast.error('Failed to pull SAP responses', { id: 'pull-debit' }); + } finally { + setPulling(false); + } + }, [fetchNotes]); + + return ( +
+ { + setPreviewOpen(open); + if (!open) { + setPreviewRow(null); + setPreviewMeta(null); + } + }} + > + + + SAP Response + + {previewLoading ? 'Loading SAP response CSV…' : 'Review SAP response details.'} + + + + {previewLoading ? ( +
+ + Loading SAP response… +
+ ) : previewRow ? ( +
+ {/* Block 1 (green): TRNS_UNIQ_NO + CLAIM_NUMBER */} +
+
+ +

SAP Response Identifiers

+
+
+
+

TRNS_UNIQ_NO

+

{previewRow['TRNS_UNIQ_NO'] || '–'}

+
+
+

CLAIM_NUMBER

+

{previewRow['CLAIM_NUMBER'] || '–'}

+
+
+
+ + {/* Block 2 (yellow): AMOUNT, DOC_NO, MSG_TYP, MESSAGE, ISSUED DATE */} +
+
+ +

Debit Note Details

+
+ +
+
+

+ Amount +

+

{previewMeta?.amountText ?? '–'}

+
+
+

+ Issued Date +

+

{previewMeta?.issuedAtText ?? '–'}

+
+
+

DOC_NO

+

{previewRow['DOC_NO'] || '–'}

+
+
+

MSG_TYP

+

{previewRow['MSG_TYP'] || '–'}

+
+
+ +
+

MESSAGE

+

{previewRow['MESSAGE'] || '–'}

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

Debit Notes

+

View all issued debit notes

+
+ +
+ + + Total Debit Notes Issued + {loading ? '...' : summary.totalDebitNotes} + + + + + Total Debit Amount + {loading ? '...' : formatAmount(summary.totalAmount)} + + + + + Impacted Dealers + {loading ? '...' : summary.impactedDealersCount} + + +
+ + + + All Debit Notes + Search by debit note number, credit note number, dealer, or Form 16A number +
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+
+ +
+ + + + Debit Note No. + Credit Note No. + Dealer + Form 16A No. + Amount + Issue Date + Status + Actions + + + + {loading ? ( + + + +

Loading debit notes...

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

No debit notes found

+

{searchQuery.trim() ? 'Try a different search.' : 'Debit notes will appear here once issued.'}

+
+
+ ) : ( + filteredNotes.map((note) => ( + + {note.debitNoteNumber ?? '–'} + {note.creditNoteNumber ?? '–'} + {note.dealerName ?? note.dealerCode ?? '–'} + {note.form16aNumber ?? '–'} + {formatAmount(note.amount)} + {formatDate(note.issueDate)} + + + {note.status ?? '–'} + + + +
+ {note.sapResponseAvailable ? ( + + ) : ( + + )} +
+
+
+ )) + )} +
+
+
+
+
+
+
+ ); +} + diff --git a/src/pages/Form16/Form16Submit.tsx b/src/pages/Form16/Form16Submit.tsx index cadd103..8f4a406 100644 --- a/src/pages/Form16/Form16Submit.tsx +++ b/src/pages/Form16/Form16Submit.tsx @@ -180,6 +180,10 @@ export function Form16Submit() { status === 'duplicate' ? 'A credit note has already been issued for this financial year and quarter. This submission is recorded as a new version.' : undefined; + const mismatchMessage = + status === 'mismatch' + ? (result.validationNotes || 'Form 16A details did not match with 26AS data.') + : undefined; toast.success(status === 'success' ? 'Form 16A matched with 26AS. Credit note generated.' : 'Form 16A submitted successfully'); navigate('/form16/submit/result', { state: { @@ -187,7 +191,7 @@ export function Form16Submit() { requestId: result.requestId, requestNumber: result.requestNumber, creditNoteNumber: result.creditNoteNumber ?? undefined, - message: duplicateMessage, + message: duplicateMessage ?? mismatchMessage, }, }); } catch (err: unknown) { diff --git a/src/pages/Form16/components/RequestSubmissionSuccess.tsx b/src/pages/Form16/components/RequestSubmissionSuccess.tsx index 694ae68..9e37128 100644 --- a/src/pages/Form16/components/RequestSubmissionSuccess.tsx +++ b/src/pages/Form16/components/RequestSubmissionSuccess.tsx @@ -5,6 +5,8 @@ import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { StatusChip } from './StatusChip'; import { TimelineStep } from './TimelineStep'; +import { contactAdminForForm16Mismatch } from '@/services/form16Api'; +import { toast } from 'sonner'; export type SubmissionResultStatus = 'success' | 'mismatch' | 'duplicate' | 'error'; @@ -32,6 +34,18 @@ export function RequestSubmissionSuccess({ }: RequestSubmissionSuccessProps) { const isSuccess = status === 'success'; const isMismatch = status === 'mismatch'; + const isMissing26AsMismatch = isMismatch && (message || '').toLowerCase().includes('26as') && (message || '').toLowerCase().includes('no 26as'); + + const onContactAdmin = async () => { + try { + await contactAdminForForm16Mismatch(requestId); + toast.success('Administrator notified'); + } catch (e: any) { + const msg = e?.response?.data?.message || e?.message || 'Failed to notify administrator'; + toast.error(String(msg)); + } + }; + const isDuplicate = status === 'duplicate'; const isError = status === 'error'; @@ -160,6 +174,16 @@ export function RequestSubmissionSuccess({

{message}

)} + {isMissing26AsMismatch && ( +
+

+ Contact administrator: FORM 26AS does not match FORM 16A. +

+

+ If you have submitted the updated Form 16A but the latest 26AS is not uploaded for this quarter yet, please notify RE so they can upload/update 26AS. +

+
+ )} ) : ( <> @@ -253,6 +277,11 @@ export function RequestSubmissionSuccess({ {isDuplicate ? 'Back to New Submission' : isMismatch ? 'Resubmit Form 16A' : 'Try Again'} )} + {isMissing26AsMismatch && ( + + )} diff --git a/src/services/adminApi.ts b/src/services/adminApi.ts index b327a6f..1016fc0 100644 --- a/src/services/adminApi.ts +++ b/src/services/adminApi.ts @@ -102,9 +102,15 @@ export interface Form16AdminConfig { reminderEnabled: boolean; reminderDays: number; notification26AsDataAdded?: Form16NotificationItem | Form16Notification26AsItem; + /** RE reminder to upload 26AS (quarter end + N days) */ + reminder26AsUploadEnabled?: boolean; + reminder26AsUploadAfterQuarterEndDays?: number; notificationForm16SuccessCreditNote?: Form16NotificationItem; notificationForm16Unsuccessful?: Form16NotificationItem; alertSubmitForm16Enabled?: boolean; + /** Dealer reminder to submit Form 16A (quarter end + N days, repeat every X days) */ + alertSubmitForm16AfterQuarterEndDays?: number; + alertSubmitForm16EveryDays?: number; alertSubmitForm16FrequencyDays?: number; alertSubmitForm16FrequencyHours?: number; /** When to run the alert job daily (HH:mm, 24h, server timezone). */ diff --git a/src/services/form16Api.ts b/src/services/form16Api.ts index f34c4a9..ebb3557 100644 --- a/src/services/form16Api.ts +++ b/src/services/form16Api.ts @@ -50,6 +50,8 @@ export interface Form16CreditNoteItem { id: string; creditNoteNumber: string | null; sapDocumentNumber: string | null; + /** True when SAP response CSV is ingested and downloadable */ + sapResponseAvailable?: boolean; amount: number | null; issueDate: string | null; financialYear: string | null; @@ -62,6 +64,14 @@ export interface Form16CreditNoteItem { dealerName?: string | null; } +export async function getCreditNoteDownloadUrl(id: number): Promise { + 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; +} + export interface ListCreditNotesSummary { totalCreditNotes: number; totalAmount: number; @@ -79,6 +89,41 @@ export interface ListCreditNotesParams { quarter?: string; } +// ---------- Debit notes (RE only) ---------- + +export interface Form16DebitNoteListItem { + id: number; + debitNoteNumber: string | null; + sapDocumentNumber?: string | null; + sapResponseAvailable?: boolean; + amount: number | null; + issueDate: string | null; + status: string | null; + financialYear?: string | null; + quarter?: string | null; + creditNoteNumber?: string | null; + dealerCode?: string | null; + dealerName?: string | null; + form16aNumber?: string | null; +} + +export interface ListDebitNotesSummary { + totalDebitNotes: number; + totalAmount: number; + impactedDealersCount?: number; +} + +export interface ListDebitNotesResponse { + debitNotes: Form16DebitNoteListItem[]; + total: number; + summary?: ListDebitNotesSummary; +} + +export interface ListDebitNotesParams { + financialYear?: string; + quarter?: string; +} + export interface Form16Permissions { canViewForm16Submission: boolean; canView26AS: boolean; @@ -115,6 +160,32 @@ export async function listCreditNotes(params?: ListCreditNotesParams): Promise { + const searchParams = new URLSearchParams(); + if (params?.financialYear) searchParams.set('financialYear', params.financialYear); + if (params?.quarter) searchParams.set('quarter', params.quarter); + const query = searchParams.toString(); + const url = query ? `/form16/debit-notes?${query}` : '/form16/debit-notes'; + const { data } = await apiClient.get<{ data?: ListDebitNotesResponse; debitNotes?: Form16DebitNoteListItem[]; total?: number; summary?: ListDebitNotesSummary }>(url); + const payload = data?.data ?? data; + return { + debitNotes: payload?.debitNotes ?? [], + total: payload?.total ?? 0, + summary: payload?.summary, + }; +} + +export async function getDebitNoteSapResponseUrl(id: number): Promise { + 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; +} + /** Get credit note linked to a Form 16 request (for workflow tab). */ export async function getCreditNoteByRequestId(requestId: string): Promise { const { data } = await apiClient.get<{ data?: { creditNote?: Form16CreditNoteItem | null }; creditNote?: Form16CreditNoteItem | null }>(`/form16/requests/${encodeURIComponent(requestId)}/credit-note`); @@ -329,6 +400,8 @@ export interface CreateForm16SubmissionResponse { submissionId: number; /** Set when 26AS matching runs: 'success' | 'failed' | 'resubmission_needed' */ validationStatus?: string; + /** Reason/details returned by backend (e.g. no 26AS, amount mismatch) */ + validationNotes?: string; /** Credit note number when validationStatus === 'success' */ creditNoteNumber?: string | null; } @@ -361,6 +434,11 @@ export async function createForm16Submission(payload: CreateForm16SubmissionPayl return req; } +/** Dealer: notify RE admins when 26AS is missing/outdated for the quarter (contact admin CTA). */ +export async function contactAdminForForm16Mismatch(requestId: string): Promise { + await apiClient.post(`/form16/requests/${encodeURIComponent(requestId)}/contact-admin`); +} + // ---------- RE: 26AS ---------- export interface Tds26asEntryItem { id: number;