694 lines
25 KiB
TypeScript
694 lines
25 KiB
TypeScript
/**
|
||
* Form 16 API – credit notes and (later) submissions.
|
||
* Uses apiClient for JSON; uses fetch for FormData (so browser sets multipart boundary).
|
||
*/
|
||
|
||
import apiClient from './authApi';
|
||
import { TokenManager } from '@/utils/tokenManager';
|
||
|
||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api/v1';
|
||
|
||
/**
|
||
* REform16 pattern: authenticated request; do NOT set Content-Type for FormData (browser sets multipart/form-data + boundary).
|
||
*/
|
||
async function fetchWithAuth(url: string, options: RequestInit = {}): Promise<{ data?: unknown; message?: string; success?: boolean }> {
|
||
const token = TokenManager.getAccessToken();
|
||
const headers: HeadersInit = {
|
||
...(options.headers || {}),
|
||
};
|
||
if (!(options.body instanceof FormData)) {
|
||
(headers as Record<string, string>)['Content-Type'] = 'application/json';
|
||
}
|
||
if (token) {
|
||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||
}
|
||
const response = await fetch(`${API_BASE_URL}${url}`, {
|
||
...options,
|
||
headers,
|
||
credentials: 'include',
|
||
});
|
||
const contentType = response.headers.get('content-type');
|
||
const isJson = contentType?.includes('application/json');
|
||
const data = isJson ? await response.json() : { message: await response.text() || 'Request failed' };
|
||
if (!response.ok) {
|
||
const err = new Error((data as { message?: string }).message || `Request failed ${response.status}`) as Error & { response?: { status: number; data?: unknown } };
|
||
err.response = { status: response.status, data };
|
||
throw err;
|
||
}
|
||
return data as { data?: unknown; message?: string; success?: boolean };
|
||
}
|
||
|
||
export interface Form16CreditNoteSubmission {
|
||
requestId: string;
|
||
form16aNumber: string | null;
|
||
financialYear: string | null;
|
||
quarter: string | null;
|
||
status: string | null;
|
||
}
|
||
|
||
export interface Form16CreditNoteItem {
|
||
id: string;
|
||
creditNoteNumber: string | null;
|
||
sapDocumentNumber: string | null;
|
||
amount: number | null;
|
||
issueDate: string | null;
|
||
financialYear: string | null;
|
||
quarter: string | null;
|
||
status: string | null;
|
||
remarks: string | null;
|
||
submission: Form16CreditNoteSubmission | null;
|
||
/** RE view: dealer code (when listing all credit notes) */
|
||
dealerCode?: string | null;
|
||
dealerName?: string | null;
|
||
}
|
||
|
||
export interface ListCreditNotesSummary {
|
||
totalCreditNotes: number;
|
||
totalAmount: number;
|
||
activeDealersCount: number;
|
||
}
|
||
|
||
export interface ListCreditNotesResponse {
|
||
creditNotes: Form16CreditNoteItem[];
|
||
total: number;
|
||
summary?: ListCreditNotesSummary;
|
||
}
|
||
|
||
export interface ListCreditNotesParams {
|
||
financialYear?: string;
|
||
quarter?: string;
|
||
}
|
||
|
||
export interface Form16Permissions {
|
||
canViewForm16Submission: boolean;
|
||
canView26AS: boolean;
|
||
}
|
||
|
||
/**
|
||
* Get Form 16 permissions for the current user (API-driven from admin config).
|
||
* Use to show/hide Form 16 menu, 26AS, and submission data in the UI.
|
||
*/
|
||
export async function getForm16Permissions(): Promise<Form16Permissions> {
|
||
const res = await apiClient.get<{ data: Form16Permissions }>('/form16/permissions');
|
||
const data = res.data?.data ?? res.data;
|
||
return {
|
||
canViewForm16Submission: !!data?.canViewForm16Submission,
|
||
canView26AS: !!data?.canView26AS,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* List credit notes for the authenticated dealer (resolved by backend from user email).
|
||
*/
|
||
export async function listCreditNotes(params?: ListCreditNotesParams): Promise<ListCreditNotesResponse> {
|
||
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/credit-notes?${query}` : '/form16/credit-notes';
|
||
const { data } = await apiClient.get<{ data?: ListCreditNotesResponse; creditNotes?: Form16CreditNoteItem[]; total?: number; summary?: ListCreditNotesSummary }>(url);
|
||
const payload = data?.data ?? data;
|
||
return {
|
||
creditNotes: payload?.creditNotes ?? [],
|
||
total: payload?.total ?? 0,
|
||
summary: payload?.summary,
|
||
};
|
||
}
|
||
|
||
/** Get credit note linked to a Form 16 request (for workflow tab). */
|
||
export async function getCreditNoteByRequestId(requestId: string): Promise<Form16CreditNoteItem | null> {
|
||
const { data } = await apiClient.get<{ data?: { creditNote?: Form16CreditNoteItem | null }; creditNote?: Form16CreditNoteItem | null }>(`/form16/requests/${encodeURIComponent(requestId)}/credit-note`);
|
||
const payload = data?.data ?? data;
|
||
return payload?.creditNote ?? null;
|
||
}
|
||
|
||
/** RE only. Cancel Form 16 submission and mark workflow rejected. */
|
||
export async function cancelForm16Submission(requestId: string): Promise<void> {
|
||
await apiClient.post(`/form16/requests/${encodeURIComponent(requestId)}/cancel-submission`);
|
||
}
|
||
|
||
/** RE only. Mark Form 16 submission as resubmission needed. */
|
||
export async function setForm16ResubmissionNeeded(requestId: string): Promise<void> {
|
||
await apiClient.post(`/form16/requests/${encodeURIComponent(requestId)}/resubmission-needed`);
|
||
}
|
||
|
||
/** RE only. Manually generate credit note for Form 16 request. Body: { amount: number }. */
|
||
export async function generateForm16CreditNoteManually(requestId: string, amount: number): Promise<{ creditNote: Form16CreditNoteItem }> {
|
||
const { data } = await apiClient.post<{ data?: { creditNote?: Form16CreditNoteItem }; creditNote?: Form16CreditNoteItem }>(
|
||
`/form16/requests/${encodeURIComponent(requestId)}/generate-credit-note`,
|
||
{ amount }
|
||
);
|
||
const payload = data?.data ?? data;
|
||
const creditNote = payload?.creditNote;
|
||
if (!creditNote) throw new Error('Credit note not returned');
|
||
return { creditNote };
|
||
}
|
||
|
||
/** Get a single credit note by id with dealer info and dealer transaction history. */
|
||
export interface CreditNoteDetailResponse {
|
||
creditNote: Form16CreditNoteItem & { submission?: { submittedDate?: string | null } };
|
||
dealerName: string;
|
||
dealerEmail: string;
|
||
dealerContact: string;
|
||
/** Present when a debit note was issued for this credit note */
|
||
debitNote?: Form16DebitNoteItem | null;
|
||
dealerCreditNotes: Array<{
|
||
id: number;
|
||
creditNoteNumber: string | null;
|
||
amount: number | null;
|
||
issueDate: string | null;
|
||
status: string | null;
|
||
form16aNumber?: string | null;
|
||
submittedDate?: string | null;
|
||
}>;
|
||
}
|
||
|
||
export async function getCreditNoteById(id: number): Promise<CreditNoteDetailResponse> {
|
||
const { data } = await apiClient.get<{ data?: CreditNoteDetailResponse } | CreditNoteDetailResponse>(`/form16/credit-notes/${id}`);
|
||
const payload = (data && typeof data === 'object' && 'data' in data ? data.data : data) as CreditNoteDetailResponse | undefined;
|
||
if (!payload?.creditNote) throw new Error('Credit note not found');
|
||
return payload;
|
||
}
|
||
|
||
// ---------- Form 16 SAP simulation (replace with real SAP when integrating) ----------
|
||
|
||
/** Dealer details for SAP credit note simulation */
|
||
export interface SapCreditNoteDealerDetails {
|
||
dealerCode: string;
|
||
dealerName?: string | null;
|
||
dealerEmail?: string | null;
|
||
dealerContact?: string | null;
|
||
}
|
||
|
||
/** Simulated SAP credit note response (JSON from SAP) */
|
||
export interface SapCreditNoteResponse {
|
||
success: boolean;
|
||
creditNoteNumber: string;
|
||
sapDocumentNumber: string;
|
||
amount: number;
|
||
issueDate: string;
|
||
financialYear?: string;
|
||
quarter?: string;
|
||
status: string;
|
||
message?: string;
|
||
}
|
||
|
||
/** Dealer info for SAP debit note simulation */
|
||
export interface SapDebitNoteDealerInfo {
|
||
dealerCode: string;
|
||
dealerName?: string | null;
|
||
dealerEmail?: string | null;
|
||
dealerContact?: string | null;
|
||
}
|
||
|
||
/** Simulated SAP debit note response (JSON from SAP) */
|
||
export interface SapDebitNoteResponse {
|
||
success: boolean;
|
||
debitNoteNumber: string;
|
||
sapDocumentNumber: string;
|
||
amount: number;
|
||
issueDate: string;
|
||
status: string;
|
||
creditNoteNumber: string;
|
||
message?: string;
|
||
}
|
||
|
||
/** Simulate SAP credit note generation (Form 16). Returns simulated SAP JSON. */
|
||
export async function sapSimulateCreditNote(
|
||
dealerDetails: SapCreditNoteDealerDetails,
|
||
amount: number
|
||
): Promise<SapCreditNoteResponse> {
|
||
const { data } = await apiClient.post<{ data?: SapCreditNoteResponse } | SapCreditNoteResponse>(
|
||
'/form16/sap-simulate/credit-note',
|
||
{ dealerDetails, amount }
|
||
);
|
||
const payload = (data && typeof data === 'object' && 'data' in data ? data.data : data) as SapCreditNoteResponse | undefined;
|
||
if (!payload?.creditNoteNumber) throw new Error('Simulated credit note not returned');
|
||
return payload;
|
||
}
|
||
|
||
/** Simulate SAP debit note generation (Form 16). Returns simulated SAP JSON. */
|
||
export async function sapSimulateDebitNote(params: {
|
||
dealerCode: string;
|
||
dealerInfo?: SapDebitNoteDealerInfo;
|
||
creditNoteNumber: string;
|
||
amount: number;
|
||
}): Promise<SapDebitNoteResponse> {
|
||
const { data } = await apiClient.post<{ data?: SapDebitNoteResponse } | SapDebitNoteResponse>(
|
||
'/form16/sap-simulate/debit-note',
|
||
params
|
||
);
|
||
const payload = (data && typeof data === 'object' && 'data' in data ? data.data : data) as SapDebitNoteResponse | undefined;
|
||
if (!payload?.debitNoteNumber) throw new Error('Simulated debit note not returned');
|
||
return payload;
|
||
}
|
||
|
||
/** Debit note item (from generate-debit-note or credit note detail). */
|
||
export interface Form16DebitNoteItem {
|
||
id: number;
|
||
debitNoteNumber: string | null;
|
||
sapDocumentNumber?: string | null;
|
||
amount: number | null;
|
||
issueDate: string | null;
|
||
status: string | null;
|
||
reason?: string | null;
|
||
}
|
||
|
||
/** RE only. Generate debit note for a credit note (Form 16). Calls SAP simulation then saves. */
|
||
export async function generateForm16DebitNote(
|
||
creditNoteId: number,
|
||
amount: number
|
||
): Promise<{ debitNote: Form16DebitNoteItem; creditNote: Form16CreditNoteItem }> {
|
||
const { data } = await apiClient.post<{
|
||
data?: { debitNote?: Form16DebitNoteItem; creditNote?: Form16CreditNoteItem };
|
||
debitNote?: Form16DebitNoteItem;
|
||
creditNote?: Form16CreditNoteItem;
|
||
}>(`/form16/credit-notes/${creditNoteId}/generate-debit-note`, { amount });
|
||
const payload = data?.data ?? data;
|
||
const debitNote = payload?.debitNote;
|
||
const creditNote = payload?.creditNote;
|
||
if (!debitNote) throw new Error('Debit note not returned');
|
||
return { debitNote, creditNote: creditNote! };
|
||
}
|
||
|
||
/** Extracted Form 16A data from OCR (Gemini or regex fallback) */
|
||
export interface Form16AExtractedData {
|
||
nameAndAddressOfDeductor?: string | null;
|
||
deductorName?: string | null;
|
||
deductorAddress?: string | null;
|
||
deductorPhone?: string | null;
|
||
deductorEmail?: string | null;
|
||
totalAmountPaid?: number | null;
|
||
totalTaxDeducted?: number | null;
|
||
totalTdsDeposited?: number | null;
|
||
tanOfDeductor?: string | null;
|
||
natureOfPayment?: string | null;
|
||
transactionDate?: string | null;
|
||
statusOfMatchingOltas?: string | null;
|
||
dateOfBooking?: string | null;
|
||
assessmentYear?: string | null;
|
||
quarter?: string | null;
|
||
form16aNumber?: string | null;
|
||
financialYear?: string | null;
|
||
certificateDate?: string | null;
|
||
tanNumber?: string | null;
|
||
tdsAmount?: number | null;
|
||
totalAmount?: number | null;
|
||
}
|
||
|
||
export interface ExtractOcrResponse {
|
||
extractedData: Form16AExtractedData;
|
||
ocrProvider?: string;
|
||
}
|
||
|
||
/**
|
||
* Extract Form 16A data from PDF – exact REform16: FormData with 'document', fetchWithAuth (no Content-Type for FormData).
|
||
*/
|
||
export async function extractOcr(file: File): Promise<ExtractOcrResponse> {
|
||
const formData = new FormData();
|
||
formData.append('document', file);
|
||
const result = await fetchWithAuth('/form16/extract', {
|
||
method: 'POST',
|
||
body: formData,
|
||
});
|
||
const raw = result as { data?: { extractedData?: Form16AExtractedData; ocrProvider?: string }; extractedData?: Form16AExtractedData; ocrProvider?: string };
|
||
const payload = raw?.data ?? raw;
|
||
const extracted = payload?.extractedData;
|
||
const ocrProvider = payload?.ocrProvider;
|
||
if (!extracted) {
|
||
throw new Error((result as { message?: string }).message || 'No extracted data returned');
|
||
}
|
||
return { extractedData: extracted, ocrProvider };
|
||
}
|
||
|
||
export interface CreateForm16SubmissionPayload {
|
||
financialYear: string;
|
||
quarter: string;
|
||
form16aNumber: string;
|
||
tdsAmount: number;
|
||
totalAmount: number;
|
||
tanNumber: string;
|
||
deductorName: string;
|
||
version?: number;
|
||
file: File;
|
||
/** Optional: raw OCR extracted data for audit (stored in form16a_submissions.ocr_extracted_data). */
|
||
extractedData?: Form16AExtractedData | null;
|
||
}
|
||
|
||
export interface CreateForm16SubmissionResponse {
|
||
requestId: string;
|
||
requestNumber: string;
|
||
submissionId: number;
|
||
/** Set when 26AS matching runs: 'success' | 'failed' | 'resubmission_needed' */
|
||
validationStatus?: string;
|
||
/** Credit note number when validationStatus === 'success' */
|
||
creditNoteNumber?: string | null;
|
||
}
|
||
|
||
/**
|
||
* Create Form 16 submission (workflow request + form16a_submissions + document upload).
|
||
* Uses fetch + FormData so browser sets Content-Type with boundary (avoids 400).
|
||
*/
|
||
export async function createForm16Submission(payload: CreateForm16SubmissionPayload): Promise<CreateForm16SubmissionResponse> {
|
||
const formData = new FormData();
|
||
formData.append('document', payload.file);
|
||
formData.append('financialYear', payload.financialYear);
|
||
formData.append('quarter', payload.quarter);
|
||
formData.append('form16aNumber', payload.form16aNumber);
|
||
formData.append('tdsAmount', String(payload.tdsAmount));
|
||
formData.append('totalAmount', String(payload.totalAmount));
|
||
formData.append('tanNumber', payload.tanNumber);
|
||
formData.append('deductorName', payload.deductorName);
|
||
if (payload.version != null) formData.append('version', String(payload.version));
|
||
if (payload.extractedData != null) {
|
||
formData.append('ocrExtractedData', JSON.stringify(payload.extractedData));
|
||
}
|
||
|
||
const body = await fetchWithAuth('/form16/submissions', { method: 'POST', body: formData });
|
||
const result = (body as { data?: CreateForm16SubmissionResponse }).data ?? body;
|
||
const req = result as CreateForm16SubmissionResponse;
|
||
if (!req?.requestNumber) {
|
||
throw new Error((body as { message?: string }).message || 'Invalid response from server');
|
||
}
|
||
return req;
|
||
}
|
||
|
||
// ---------- RE: 26AS ----------
|
||
export interface Tds26asEntryItem {
|
||
id: number;
|
||
tanNumber: string;
|
||
deductorName?: string | null;
|
||
quarter: string;
|
||
assessmentYear?: string | null;
|
||
financialYear: string;
|
||
sectionCode?: string | null;
|
||
amountPaid?: number | null;
|
||
taxDeducted: number;
|
||
totalTdsDeposited?: number | null;
|
||
natureOfPayment?: string | null;
|
||
transactionDate?: string | null;
|
||
dateOfBooking?: string | null;
|
||
statusOltas?: string | null;
|
||
remarks?: string | null;
|
||
createdAt?: string;
|
||
updatedAt?: string;
|
||
}
|
||
|
||
export interface List26asParams {
|
||
financialYear?: string;
|
||
quarter?: string;
|
||
tanNumber?: string;
|
||
search?: string;
|
||
status?: string;
|
||
assessmentYear?: string;
|
||
sectionCode?: string;
|
||
limit?: number;
|
||
offset?: number;
|
||
}
|
||
|
||
export interface List26asSummary {
|
||
totalRecords: number;
|
||
booked: number;
|
||
notBooked: number;
|
||
pending: number;
|
||
totalTaxDeducted: number;
|
||
}
|
||
|
||
export async function list26asEntries(params?: List26asParams): Promise<{
|
||
entries: Tds26asEntryItem[];
|
||
total: number;
|
||
summary: List26asSummary;
|
||
}> {
|
||
const searchParams = new URLSearchParams();
|
||
if (params?.financialYear) searchParams.set('financialYear', params.financialYear);
|
||
if (params?.quarter) searchParams.set('quarter', params.quarter);
|
||
if (params?.tanNumber) searchParams.set('tanNumber', params.tanNumber);
|
||
if (params?.search) searchParams.set('search', params.search);
|
||
if (params?.status) searchParams.set('status', params.status);
|
||
if (params?.assessmentYear) searchParams.set('assessmentYear', params.assessmentYear);
|
||
if (params?.sectionCode) searchParams.set('sectionCode', params.sectionCode);
|
||
if (params?.limit != null) searchParams.set('limit', String(params.limit));
|
||
if (params?.offset != null) searchParams.set('offset', String(params.offset));
|
||
const query = searchParams.toString();
|
||
const url = query ? `/form16/26as?${query}` : '/form16/26as';
|
||
const { data } = await apiClient.get<{
|
||
data?: { entries?: Tds26asEntryItem[]; total?: number; summary?: List26asSummary };
|
||
entries?: Tds26asEntryItem[];
|
||
total?: number;
|
||
summary?: List26asSummary;
|
||
}>(url);
|
||
const payload = data?.data ?? data;
|
||
return {
|
||
entries: payload?.entries ?? [],
|
||
total: payload?.total ?? 0,
|
||
summary: payload?.summary ?? {
|
||
totalRecords: 0,
|
||
booked: 0,
|
||
notBooked: 0,
|
||
pending: 0,
|
||
totalTaxDeducted: 0,
|
||
},
|
||
};
|
||
}
|
||
|
||
export async function create26asEntry(body: Partial<Tds26asEntryItem>): Promise<{ entry: Tds26asEntryItem }> {
|
||
const { data } = await apiClient.post<{ data?: { entry?: Tds26asEntryItem }; entry?: Tds26asEntryItem }>('/form16/26as', body);
|
||
const payload = data?.data ?? data;
|
||
const entry = (payload?.entry ?? payload) as Tds26asEntryItem | undefined;
|
||
if (!entry || typeof entry !== 'object') throw new Error('No entry returned');
|
||
return { entry };
|
||
}
|
||
|
||
export async function update26asEntry(id: number, body: Partial<Tds26asEntryItem>): Promise<{ entry: Tds26asEntryItem }> {
|
||
const { data } = await apiClient.put<{ data?: { entry?: Tds26asEntryItem }; entry?: Tds26asEntryItem }>(`/form16/26as/${id}`, body);
|
||
const payload = data?.data ?? data;
|
||
const entry = (payload?.entry ?? payload) as Tds26asEntryItem | undefined;
|
||
if (!entry || typeof entry !== 'object') throw new Error('No entry returned');
|
||
return { entry };
|
||
}
|
||
|
||
export async function delete26asEntry(id: number): Promise<void> {
|
||
await apiClient.delete(`/form16/26as/${id}`);
|
||
}
|
||
|
||
/** 26AS upload audit log entry (who uploaded, when, records imported). */
|
||
export interface Form1626asUploadHistoryItem {
|
||
id: number;
|
||
uploadedAt: string;
|
||
uploadedBy: string;
|
||
uploadedByEmail?: string | null;
|
||
uploadedByDisplayName?: string | null;
|
||
fileName?: string | null;
|
||
recordsImported: number;
|
||
errorsCount: number;
|
||
}
|
||
|
||
export async function get26asUploadHistory(limit?: number): Promise<Form1626asUploadHistoryItem[]> {
|
||
const params = new URLSearchParams();
|
||
if (limit != null) params.set('limit', String(limit));
|
||
const query = params.toString();
|
||
const url = query ? `/form16/26as/upload-history?${query}` : '/form16/26as/upload-history';
|
||
const { data } = await apiClient.get<{ data?: { history?: Form1626asUploadHistoryItem[] }; history?: Form1626asUploadHistoryItem[] }>(url);
|
||
const payload = data?.data ?? data;
|
||
return payload?.history ?? [];
|
||
}
|
||
|
||
/** Timeout for 26AS upload (large files can take 1–2 min). */
|
||
const UPLOAD_26AS_TIMEOUT_MS = 5 * 60 * 1000;
|
||
|
||
export interface Upload26asResult {
|
||
imported: number;
|
||
errors: string[];
|
||
}
|
||
|
||
/**
|
||
* Upload 26AS TXT file with optional progress callback (0–100%).
|
||
* Uses XHR so we can report upload progress; after 100% the server may still be processing.
|
||
*/
|
||
export function upload26asFile(
|
||
file: File,
|
||
onProgress?: (percent: number) => void
|
||
): Promise<Upload26asResult> {
|
||
return new Promise((resolve, reject) => {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
const xhr = new XMLHttpRequest();
|
||
const url = `${API_BASE_URL}/form16/26as/upload`;
|
||
const token = TokenManager.getAccessToken();
|
||
|
||
const timeoutId = setTimeout(() => {
|
||
xhr.abort();
|
||
reject(new Error('Upload timed out. Try a smaller file or try again.'));
|
||
}, UPLOAD_26AS_TIMEOUT_MS);
|
||
|
||
xhr.upload.addEventListener('progress', (e) => {
|
||
if (e.lengthComputable && e.total > 0) {
|
||
const percent = Math.min(100, Math.round((e.loaded / e.total) * 100));
|
||
onProgress?.(percent);
|
||
} else {
|
||
onProgress?.(0);
|
||
}
|
||
});
|
||
|
||
xhr.addEventListener('load', () => {
|
||
clearTimeout(timeoutId);
|
||
onProgress?.(100);
|
||
const contentType = xhr.getResponseHeader('content-type');
|
||
const isJson = contentType?.includes('application/json');
|
||
const raw = xhr.responseText;
|
||
let data: { data?: { imported?: number; errors?: string[] }; message?: string };
|
||
try {
|
||
data = isJson ? JSON.parse(raw) : { message: raw || 'Request failed' };
|
||
} catch {
|
||
data = { message: raw || 'Invalid response' };
|
||
}
|
||
if (xhr.status >= 200 && xhr.status < 300) {
|
||
const payload = (data.data ?? data) as { imported?: number; errors?: string[] } | undefined;
|
||
resolve({
|
||
imported: payload?.imported ?? 0,
|
||
errors: Array.isArray(payload?.errors) ? payload.errors : [],
|
||
});
|
||
} else {
|
||
reject(new Error((data as { message?: string }).message || `Request failed ${xhr.status}`));
|
||
}
|
||
});
|
||
|
||
xhr.addEventListener('error', () => {
|
||
clearTimeout(timeoutId);
|
||
reject(new Error('Network error during upload'));
|
||
});
|
||
|
||
xhr.addEventListener('abort', () => {
|
||
clearTimeout(timeoutId);
|
||
reject(new Error('Upload was cancelled or timed out'));
|
||
});
|
||
|
||
xhr.open('POST', url);
|
||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||
xhr.withCredentials = true;
|
||
xhr.send(formData);
|
||
});
|
||
}
|
||
|
||
// ---------- RE: Non-submitted dealers ----------
|
||
export interface NotificationHistoryItem {
|
||
date: string;
|
||
notifiedBy: string;
|
||
method: string;
|
||
}
|
||
|
||
export interface NonSubmittedDealerItem {
|
||
id: string;
|
||
dealerName: string;
|
||
dealerCode: string;
|
||
email: string;
|
||
phone: string;
|
||
location: string;
|
||
missingQuarters: string[];
|
||
lastSubmissionDate: string | null;
|
||
daysSinceLastSubmission: number | null;
|
||
lastNotifiedDate: string | null;
|
||
lastNotifiedBy: string | null;
|
||
notificationCount: number;
|
||
notificationHistory: NotificationHistoryItem[];
|
||
}
|
||
|
||
export interface NonSubmittedDealersSummary {
|
||
totalDealers: number;
|
||
nonSubmittedCount: number;
|
||
neverSubmittedCount: number;
|
||
overdue90Count: number;
|
||
}
|
||
|
||
export interface ListNonSubmittedDealersResponse {
|
||
summary: NonSubmittedDealersSummary;
|
||
dealers: NonSubmittedDealerItem[];
|
||
}
|
||
|
||
export async function listNonSubmittedDealers(financialYear?: string): Promise<ListNonSubmittedDealersResponse> {
|
||
const params = new URLSearchParams();
|
||
if (financialYear) params.set('financialYear', financialYear);
|
||
const query = params.toString();
|
||
const url = query ? `/form16/non-submitted-dealers?${query}` : '/form16/non-submitted-dealers';
|
||
const { data } = await apiClient.get<{ data?: ListNonSubmittedDealersResponse }>(url);
|
||
const payload = (data?.data ?? data) as ListNonSubmittedDealersResponse | undefined;
|
||
const s = payload?.summary;
|
||
return {
|
||
summary: s ?? {
|
||
totalDealers: 0,
|
||
nonSubmittedCount: 0,
|
||
neverSubmittedCount: 0,
|
||
overdue90Count: 0,
|
||
},
|
||
dealers: payload?.dealers ?? [],
|
||
};
|
||
}
|
||
|
||
/** Send "submit Form 16" notification to one non-submitted dealer. Returns updated dealer (with lastNotifiedDate set). */
|
||
export async function notifyNonSubmittedDealer(dealerCode: string, financialYear?: string): Promise<NonSubmittedDealerItem> {
|
||
const { data } = await apiClient.post<{ data?: { dealer: NonSubmittedDealerItem } }>('/form16/non-submitted-dealers/notify', {
|
||
dealerCode,
|
||
financialYear: financialYear || undefined,
|
||
});
|
||
const dealer = data?.data?.dealer;
|
||
if (!dealer) throw new Error('No dealer returned');
|
||
return dealer;
|
||
}
|
||
|
||
// ---------- Dealer: Pending Submissions page ----------
|
||
export interface DealerPendingSubmissionItem {
|
||
id: number;
|
||
requestId: string;
|
||
form16a_number: string;
|
||
financial_year: string;
|
||
quarter: string;
|
||
version: number;
|
||
version_number?: number;
|
||
status: string;
|
||
/** Display label: Completed | Resubmission needed | Duplicate submission | Failed | Under review (never Pending) */
|
||
display_status?: string;
|
||
validation_status: string | null;
|
||
submitted_date: string | null;
|
||
total_amount?: number | null;
|
||
credit_note_number?: string | null;
|
||
document_url?: string | null;
|
||
}
|
||
|
||
export interface DealerPendingQuarterItem {
|
||
financial_year: string;
|
||
quarter: string;
|
||
dealer_name?: string | null;
|
||
has_submission: boolean;
|
||
latest_submission_status: string | null;
|
||
latest_submission_id: number | null;
|
||
audit_start_date: string | null;
|
||
twenty_six_as_start_date?: string | null;
|
||
days_remaining: number | null;
|
||
days_overdue: number | null;
|
||
days_since_26as_uploaded?: number | null;
|
||
}
|
||
|
||
/** List dealer's Form 16 submissions (pending/failed). Dealer only. */
|
||
export async function listDealerSubmissions(params?: {
|
||
status?: string;
|
||
financialYear?: string;
|
||
quarter?: string;
|
||
}): Promise<DealerPendingSubmissionItem[]> {
|
||
const searchParams = new URLSearchParams();
|
||
if (params?.status) searchParams.set('status', params.status);
|
||
if (params?.financialYear) searchParams.set('financialYear', params.financialYear);
|
||
if (params?.quarter) searchParams.set('quarter', params.quarter);
|
||
const query = searchParams.toString();
|
||
const url = query ? `/form16/dealer/submissions?${query}` : '/form16/dealer/submissions';
|
||
const { data } = await apiClient.get<{ data?: DealerPendingSubmissionItem[] }>(url);
|
||
const payload = data?.data ?? data;
|
||
return Array.isArray(payload) ? payload : [];
|
||
}
|
||
|
||
/** List dealer's pending quarters (no completed Form 16A). Dealer only. */
|
||
export async function listDealerPendingQuarters(): Promise<DealerPendingQuarterItem[]> {
|
||
const { data } = await apiClient.get<{ data?: DealerPendingQuarterItem[] }>('/form16/dealer/pending-quarters');
|
||
const payload = data?.data ?? data;
|
||
return Array.isArray(payload) ? payload : [];
|
||
}
|