diff --git a/src/components/admin/ActivityTypeManager.tsx b/src/components/admin/ActivityTypeManager.tsx index ca71f29..039374c 100644 --- a/src/components/admin/ActivityTypeManager.tsx +++ b/src/components/admin/ActivityTypeManager.tsx @@ -454,7 +454,7 @@ export function ActivityTypeManager() { - Vehicle + Vehicles Spares GMA Apparel diff --git a/src/components/admin/HsnSacCodeManager.tsx b/src/components/admin/HsnSacCodeManager.tsx new file mode 100644 index 0000000..a0ac6e6 --- /dev/null +++ b/src/components/admin/HsnSacCodeManager.tsx @@ -0,0 +1,507 @@ +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Hash, + Plus, + Trash2, + Edit2, + Loader2, + AlertCircle, + Tag, + Search, + ChevronLeft, + ChevronRight, + SearchX +} from 'lucide-react'; +import { + getAllHsnSacCodes, + createHsnSacCode, + updateHsnSacCode, + deleteHsnSacCode, + toggleHsnSacCodeActive, + HsnSacCode, + CodeType +} from '@/services/hsnSacCodeApi'; +import { toast } from 'sonner'; +import { validateHSNSAC } from '@/utils/validationUtils'; + +export function HsnSacCodeManager() { + const [codes, setCodes] = useState([]); + const [loading, setLoading] = useState(true); + const [showAddDialog, setShowAddDialog] = useState(false); + const [editingCode, setEditingCode] = useState(null); + const [error, setError] = useState(null); + + // Pagination & Search State + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [totalRecords, setTotalRecords] = useState(0); + const [limit] = useState(10); + const [searchQuery, setSearchQuery] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + + const [formData, setFormData] = useState({ + code: '', + type: 'HSN' as CodeType, + gstRate: '', + description: '', + isActive: true + }); + + // Debounce search query + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(searchQuery); + setCurrentPage(1); // Reset to first page on new search + }, 500); + return () => clearTimeout(timer); + }, [searchQuery]); + + useEffect(() => { + loadCodes(); + }, [currentPage, debouncedSearch]); + + const loadCodes = async () => { + try { + setLoading(true); + setError(null); + const response = await getAllHsnSacCodes(false, currentPage, limit, debouncedSearch); + setCodes(response.codes); + setTotalPages(response.pagination.totalPages); + setTotalRecords(response.pagination.totalRecords); + } catch (err: any) { + const errorMsg = err.response?.data?.error || 'Failed to load HSN/SAC codes'; + setError(errorMsg); + toast.error(errorMsg); + } finally { + setLoading(false); + } + }; + + const handleAdd = () => { + setFormData({ + code: '', + type: 'HSN', + gstRate: '', + description: '', + isActive: true + }); + setEditingCode(null); + setShowAddDialog(true); + }; + + const handleEdit = (code: HsnSacCode) => { + setFormData({ + code: code.code, + type: code.type, + gstRate: code.gstRate?.toString() || '', + description: code.description || '', + isActive: code.isActive + }); + setEditingCode(code); + setShowAddDialog(true); + }; + + const handleSave = async () => { + try { + setError(null); + + if (!formData.code.trim() || !formData.type) { + setError('Code and Type are required'); + toast.error('Please fill in required fields'); + return; + } + + const { isValid, message } = validateHSNSAC(formData.code, formData.type === 'SAC'); + if (!isValid) { + setError(message); + toast.error(message); + return; + } + + const payload = { + code: formData.code.trim(), + type: formData.type, + gstRate: formData.gstRate ? parseFloat(formData.gstRate) : undefined, + description: formData.description.trim() || undefined, + isActive: formData.isActive + }; + + if (editingCode) { + await updateHsnSacCode(editingCode.id, payload); + toast.success(`${formData.type} code updated successfully`); + } else { + await createHsnSacCode(payload); + toast.success(`${formData.type} code created successfully`); + } + + await loadCodes(); + setShowAddDialog(false); + } catch (err: any) { + const errorMsg = err.response?.data?.error || 'Failed to save code'; + setError(errorMsg); + toast.error(errorMsg); + } + }; + + const handleDelete = async (code: HsnSacCode) => { + if (!confirm(`Delete ${code.type} code "${code.code}"?`)) { + return; + } + + try { + setError(null); + await deleteHsnSacCode(code.id); + toast.success('Code deleted successfully'); + await loadCodes(); + } catch (err: any) { + const errorMsg = err.response?.data?.error || 'Failed to delete code'; + setError(errorMsg); + toast.error(errorMsg); + } + }; + + const handleToggle = async (id: string) => { + try { + await toggleHsnSacCodeActive(id); + await loadCodes(); + toast.success('Status updated successfully'); + } catch (err: any) { + toast.error('Failed to toggle status'); + } + }; + + + return ( +
+ {/* Error Message */} + {error && ( +
+
+ +
+

{error}

+ +
+ )} + + {/* Header */} + + +
+
+
+ +
+
+ HSN/SAC Codes + + Manage HSN/SAC codes and associated GST rates ({totalRecords} total) + +
+
+ +
+ {/* Search Bar */} +
+ + setSearchQuery(e.target.value)} + className="pl-9 bg-slate-50 border-slate-200 focus:bg-white transition-all text-sm" + /> +
+ + +
+
+
+
+ + {/* List */} + {loading ? ( +
+ +
+ ) : codes.length === 0 ? ( + + +
+ {debouncedSearch ? : } +
+

+ {debouncedSearch ? `No results for "${debouncedSearch}"` : 'No HSN/SAC codes found'} +

+

+ {debouncedSearch ? 'Try a different search term' : 'Add codes to use in dealer claims'} +

+ {debouncedSearch ? ( + + ) : ( + + )} +
+
+ ) : ( +
+ {/* Results Grid */} +
+ {codes.map(code => ( + handleEdit(code)} + onDelete={() => handleDelete(code)} + onToggle={() => handleToggle(code.id)} + /> + ))} +
+ + {/* Pagination Controls */} + {totalPages > 1 && ( +
+

+ Showing {(currentPage - 1) * limit + 1} to{' '} + + {Math.min(currentPage * limit, totalRecords)} + {' '} + of {totalRecords} results +

+ +
+ + +
+ {[...Array(totalPages)].map((_, i) => { + const pageNum = i + 1; + // Show first, last, and current page with neighbors logic if many pages + if ( + totalPages <= 7 || + pageNum === 1 || + pageNum === totalPages || + (pageNum >= currentPage - 1 && pageNum <= currentPage + 1) + ) { + return ( + + ); + } else if ( + (pageNum === currentPage - 2 && pageNum > 1) || + (pageNum === currentPage + 2 && pageNum < totalPages) + ) { + return ...; + } + return null; + })} +
+ + +
+
+ )} +
+ )} + + {/* Dialog */} + + + + {editingCode ? 'Edit Code' : 'Add New HSN/SAC Code'} + + Enter the details for recovery or claim code + + + +
+
+
+ + +
+
+ + setFormData({ ...formData, code: e.target.value })} + className={!validateHSNSAC(formData.code, formData.type === 'SAC').isValid ? 'border-red-500' : ''} + /> + {!validateHSNSAC(formData.code, formData.type === 'SAC').isValid && formData.code && ( +

+ {validateHSNSAC(formData.code, formData.type === 'SAC').message} +

+ )} +
+
+ +{/* Hide GST rate input for now +
+ + setFormData({ ...formData, gstRate: e.target.value })} + /> +
+ */} + +
+ + setFormData({ ...formData, description: e.target.value })} + /> +
+
+ + + + + +
+
+
+ ); +} + +function CodeCard({ code, onEdit, onDelete, onToggle }: { + code: HsnSacCode, + onEdit: () => void, + onDelete: () => void, + onToggle: () => void +}) { + return ( +
+
+
+
+ +
+
+
+ {code.type} +

{code.code}

+
+ {code.description &&

{code.description}

} +
+
+
+ {/* GST rate badge hidden for now */} +
+
+ +
+
+ + +
+ + +
+
+ ); +} diff --git a/src/components/common/HsnSacSelector.tsx b/src/components/common/HsnSacSelector.tsx new file mode 100644 index 0000000..ac75041 --- /dev/null +++ b/src/components/common/HsnSacSelector.tsx @@ -0,0 +1,130 @@ +import * as React from "react"; +import { Check, ChevronsUpDown, Search } from "lucide-react"; +import { cn } from "@/components/ui/utils"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { getAllHsnSacCodes, HsnSacCode } from "@/services/hsnSacCodeApi"; + +interface HsnSacSelectorProps { + value: string; + onChange: (value: string) => void; + type: "HSN" | "SAC"; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +export function HsnSacSelector({ + value, + onChange, + type, + placeholder, + disabled, + className, +}: HsnSacSelectorProps) { + const [open, setOpen] = React.useState(false); + const [allCodes, setAllCodes] = React.useState([]); + + React.useEffect(() => { + const fetchCodes = async () => { + try { + const response = await getAllHsnSacCodes(true, 1, 1000); + setAllCodes(response.codes); + } catch (error) { + console.error("Failed to fetch HSN/SAC codes for selector:", error); + } + }; + + if (open && allCodes.length === 0) { + fetchCodes(); + } + }, [open, allCodes.length]); + + const filteredCodes = React.useMemo(() => { + return allCodes.filter((c) => c.type === type); + }, [allCodes, type]); + + return ( + + + + + + +
+ + +
+ + +
+ +

No {type} code found

+

Try a different search term

+
+
+ + {filteredCodes.map((code) => ( + { + onChange(currentValue); + setOpen(false); + }} + className="flex flex-col items-start gap-1 p-2.5 rounded-md aria-selected:bg-slate-100 transition-colors cursor-pointer mb-1 last:mb-0" + > +
+ {code.code} +
+ +
+
+ {code.description && ( + + {code.description} + + )} +
+ ))} +
+
+
+
+
+ ); +} diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 1705ee0..50d908b 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -55,12 +55,15 @@ function CommandDialog({ function CommandInput({ className, + wrapperClassName, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + wrapperClassName?: string; +}) { return (
console.warn('🚪 Final backend logout cleanup failed:', err)); + TokenManager.clearAll(); + sessionStorage.removeItem('__logout_type__'); sessionStorage.removeItem('auth_provider'); sessionStorage.removeItem('tanflow_auth_state'); sessionStorage.removeItem('__logout_in_progress__'); @@ -210,6 +218,42 @@ function BackendAuthProvider({ children }: { children: ReactNode }) { return () => clearInterval(interval); }, [isAuthenticated]); + // WebSocket for Reactive Logout (Session Supersession) + useEffect(() => { + if (!isAuthenticated || !user?.userId) return; + + const socket = getSocket(); + + // Join the user's personal room for real-time notifications + joinUserRoom(socket, user.userId); + + // Listen for session supersession (concurrent login from another device) + const handleSessionSuperseded = (data: any) => { + console.log('📡 [Socket] Session superseded event received:', data); + + // Stop background refreshes immediately + TokenManager.setAuthError('SESSION_SUPERSEDED'); + + // Notify the user + toast.error("Session Expired", { + description: "You have been logged out because another session was started on a different device.", + duration: 4000, + id: 'session-superseded-socket' + }); + + // Trigger automatic logout after a short delay (1s) for readability + setTimeout(() => { + logout(); + }, 1000); + }; + + socket.on('SESSION_SUPERSEDED', handleSessionSuperseded); + + return () => { + socket.off('SESSION_SUPERSEDED', handleSessionSuperseded); + }; + }, [isAuthenticated, user?.userId]); + // Handle callback from OAuth redirect // Use ref to prevent duplicate calls (React StrictMode runs effects twice in dev) const callbackProcessedRef = useRef(false); @@ -230,16 +274,19 @@ function BackendAuthProvider({ children }: { children: ReactNode }) { return; } - // Check if this is a logout redirect (from Tanflow post-logout redirect) - // If it has logout parameters but no code, it's a logout redirect, not a login callback - if ((urlParams.has('logout') || urlParams.has('tanflow_logged_out') || urlParams.has('okta_logged_out')) && !urlParams.get('code')) { + // Check if this is a logout redirect (from Tanflow/Okta post-logout redirect) + // We check for URL params OR sessionStorage flags (needed for clean redirect URIs) + const storedLogoutType = sessionStorage.getItem('__logout_type__'); + const isLogoutRedirect = (urlParams.has('logout') || urlParams.has('tanflow_logged_out') || urlParams.has('okta_logged_out') || storedLogoutType) && !urlParams.get('code'); + + if (isLogoutRedirect) { // This is a logout redirect, not a login callback // Redirect to home page - the mount useEffect will handle logout cleanup console.log('🚪 Logout redirect detected in callback, redirecting to home'); // Extract the logout flags from current URL const logoutFlags = new URLSearchParams(); - if (urlParams.has('tanflow_logged_out')) logoutFlags.set('tanflow_logged_out', 'true'); - if (urlParams.has('okta_logged_out')) logoutFlags.set('okta_logged_out', 'true'); + if (urlParams.has('tanflow_logged_out') || storedLogoutType === 'tanflow') logoutFlags.set('tanflow_logged_out', 'true'); + if (urlParams.has('okta_logged_out') || storedLogoutType === 'okta') logoutFlags.set('okta_logged_out', 'true'); if (urlParams.has('logout')) logoutFlags.set('logout', urlParams.get('logout') || Date.now().toString()); const redirectUrl = logoutFlags.toString() ? `/?${logoutFlags.toString()}` : '/?logout=' + Date.now(); window.location.replace(redirectUrl); @@ -532,16 +579,8 @@ function BackendAuthProvider({ children }: { children: ReactNode }) { setError(null); setIsLoading(true); // Set loading to prevent checkAuthStatus from running - // Call backend logout API to clear server-side session and httpOnly cookies - // IMPORTANT: This MUST be called before clearing local storage to clear httpOnly cookies - try { - await logoutApi(); - console.log('🚪 Backend logout API called successfully'); - } catch (err) { - console.error('🚪 Logout API error:', err); - console.warn('🚪 Backend logout failed - httpOnly cookies may not be cleared'); - // Continue with logout even if API call fails - } + // NOTE: Backend logout API is now deferred to the AuthContext mount effect + // which handles the redirect return from Okta/Tanflow to ensure IdP-first logout. // Preserve logout flags, id_token, and auth_provider BEFORE clearing tokens const logoutInProgress = sessionStorage.getItem('__logout_in_progress__'); @@ -584,14 +623,8 @@ function BackendAuthProvider({ children }: { children: ReactNode }) { // OKTA logout or fallback: Clear auth_provider and redirect to login page with flags console.log('🚪 Using OKTA logout flow or fallback'); sessionStorage.removeItem('auth_provider'); - // Clear id_token now since we're not using provider logout - if (idToken) { - TokenManager.clearAll(); // Clear id_token too - } - // The okta_logged_out flag will trigger prompt=login in the login() function - // This forces re-authentication even if Okta session still exists - const loginUrl = `${window.location.origin}/?okta_logged_out=true&logout=${Date.now()}`; - window.location.replace(loginUrl); + // Initiate full Okta logout (OIDC redirect) + oktaLogout(idToken || undefined); } catch (error) { console.error('🚪 Logout error:', error); // Force redirect even on error - clear everything and redirect to login diff --git a/src/dealer-claim/components/request-creation/ClaimManagementWizard.tsx b/src/dealer-claim/components/request-creation/ClaimManagementWizard.tsx index fbe7164..df419c4 100644 --- a/src/dealer-claim/components/request-creation/ClaimManagementWizard.tsx +++ b/src/dealer-claim/components/request-creation/ClaimManagementWizard.tsx @@ -250,7 +250,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar // If we have a dealer group, validate it if (dealerGroup) { let isMatch = false; - if (activityCategory === 'vehicle' && dealerGroup === 'vehicle') isMatch = true; + if ((activityCategory === 'vehicle' || activityCategory === 'vehicles') && (dealerGroup === 'vehicle' || dealerGroup === 'vehicles')) isMatch = true; else if (activityCategory === 'spares' && dealerGroup === 'spares') isMatch = true; else if (activityCategory === 'gma' && dealerGroup === 'gma') isMatch = true; else if (activityCategory === 'apparel' && dealerGroup === 'apparel') isMatch = true; diff --git a/src/dealer-claim/components/request-detail/WorkflowTab.tsx b/src/dealer-claim/components/request-detail/WorkflowTab.tsx index 4fd57e3..c020d18 100644 --- a/src/dealer-claim/components/request-detail/WorkflowTab.tsx +++ b/src/dealer-claim/components/request-detail/WorkflowTab.tsx @@ -2729,6 +2729,10 @@ export function DealerClaimWorkflowTab({ Number((request as any)?.claimDetails?.creditNoteAmount) : ((request as any)?.claimDetails?.credit_note_amount ? Number((request as any)?.claimDetails?.credit_note_amount) : undefined)))), + transactionNo: (request as any)?.creditNote?.transactionNo || (request as any)?.creditNote?.transaction_no, + tdsAmount: (request as any)?.creditNote?.tdsAmount || (request as any)?.creditNote?.tds_amount, + creditAmount: (request as any)?.creditNote?.creditAmount || (request as any)?.creditNote?.credit_amount, + items: (request as any)?.creditNote?.items || [], status: (request as any)?.creditNote?.status || (request as any)?.claimDetails?.creditNote?.status || ((request as any)?.creditNote?.creditNoteNumber ? 'CONFIRMED' : 'PENDING'), diff --git a/src/dealer-claim/components/request-detail/claim-cards/ProcessDetailsCard.tsx b/src/dealer-claim/components/request-detail/claim-cards/ProcessDetailsCard.tsx index bd1ce18..1725e05 100644 --- a/src/dealer-claim/components/request-detail/claim-cards/ProcessDetailsCard.tsx +++ b/src/dealer-claim/components/request-detail/claim-cards/ProcessDetailsCard.tsx @@ -247,7 +247,7 @@ export function ProcessDetailsCard({ {cn.MSG_TYP === 'E' ? 'Error' : (cn.MSG_TYP === 'S' ? 'Success' : cn.MSG_TYP || 'Unknown')}
-
Txn: {cn.TRNS_UNIQ_NO || 'N/A'}
+
Txn No: {cn.TRNS_UNIQ_NO || 'N/A'}
{cn.MESSAGE || 'No Message'}
))} diff --git a/src/dealer-claim/components/request-detail/modals/CreditNoteSAPModal.tsx b/src/dealer-claim/components/request-detail/modals/CreditNoteSAPModal.tsx index 1f3af2a..b9cc636 100644 --- a/src/dealer-claim/components/request-detail/modals/CreditNoteSAPModal.tsx +++ b/src/dealer-claim/components/request-detail/modals/CreditNoteSAPModal.tsx @@ -29,7 +29,20 @@ interface CreditNoteSAPModalProps { creditNoteNumber?: string; creditNoteDate?: string; creditNoteAmount?: number; + transactionNo?: string; + tdsAmount?: number; + creditAmount?: number; status?: 'PENDING' | 'APPROVED' | 'ISSUED' | 'SENT' | 'CONFIRMED'; + items?: Array<{ + slNo: number; + transactionNo?: string; + description?: string; + hsnCd?: string; + amount?: number; + claimAmount?: number; + tdsAmount?: number; + creditAmount?: number; + }>; }; dealerInfo?: { dealerName?: string; @@ -67,7 +80,11 @@ export function CreditNoteSAPModal({ ? formatDateTime(creditNoteData.creditNoteDate, { includeTime: false, format: 'short' }) : ''; const creditNoteAmount = creditNoteData?.creditNoteAmount || 0; + const transactionNo = creditNoteData?.transactionNo || ''; + const tdsAmount = creditNoteData?.tdsAmount || 0; + const creditAmount = creditNoteData?.creditAmount || 0; const status = creditNoteData?.status || 'PENDING'; + const items = creditNoteData?.items || []; const dealerName = dealerInfo?.dealerName || 'Jaipur Royal Enfield'; const dealerCode = dealerInfo?.dealerCode || 'RE-JP-009'; @@ -77,6 +94,19 @@ export function CreditNoteSAPModal({ ? formatDateTime(dueDate, { includeTime: false, format: 'short' }) : 'Jan 4, 2026'; + // Calculate Transaction Display (Single or Range) + const transactionDisplay = (() => { + if (items.length > 1) { + const firstCode = items[0]?.transactionNo; + const lastCode = items[items.length - 1]?.transactionNo; + if (firstCode && lastCode && firstCode !== lastCode) { + return `${firstCode} - ${lastCode}`; + } + return firstCode || transactionNo || 'N/A'; + } + return transactionNo || 'N/A'; + })(); + const handleDownload = async () => { if (onDownload) { try { @@ -125,7 +155,7 @@ export function CreditNoteSAPModal({
- Credit Note from SAP + Credit Note Details
{taxationType && ( @@ -150,35 +180,99 @@ export function CreditNoteSAPModal({ - {status === 'APPROVED' ? 'Approved' : status === 'ISSUED' ? 'Issued' : status === 'SENT' ? 'Sent' : 'Pending'} + {status === 'APPROVED' ? 'Approved' : status === 'ISSUED' ? 'Issued' : status === 'SENT' ? 'Sent' : 'Confirmed'} -
-
-