multiple device login restricted and in admin hsn sac code cofiguration added and csv file read approach changed to read at interval of 5 minutes with mutiple credit note in single csv file

This commit is contained in:
laxman h 2026-03-25 19:23:44 +05:30
parent 6d8a60581d
commit 1f2c7f7faf
19 changed files with 1145 additions and 147 deletions

View File

@ -454,7 +454,7 @@ export function ActivityTypeManager() {
<SelectValue placeholder="Select Credit Posting Group" /> <SelectValue placeholder="Select Credit Posting Group" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="rounded-lg"> <SelectContent className="rounded-lg">
<SelectItem value="Vehicle" className="p-3">Vehicle</SelectItem> <SelectItem value="Vehicles" className="p-3">Vehicles</SelectItem>
<SelectItem value="Spares" className="p-3">Spares</SelectItem> <SelectItem value="Spares" className="p-3">Spares</SelectItem>
<SelectItem value="GMA" className="p-3">GMA</SelectItem> <SelectItem value="GMA" className="p-3">GMA</SelectItem>
<SelectItem value="Apparel" className="p-3">Apparel</SelectItem> <SelectItem value="Apparel" className="p-3">Apparel</SelectItem>

View File

@ -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<HsnSacCode[]>([]);
const [loading, setLoading] = useState(true);
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingCode, setEditingCode] = useState<HsnSacCode | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-6">
{/* Error Message */}
{error && (
<div className="p-4 bg-gradient-to-r from-red-50 to-rose-50 border border-red-300 rounded-md shadow-sm flex items-center gap-3 animate-in fade-in slide-in-from-top-2 duration-300">
<div className="p-1.5 bg-red-500 rounded-md">
<AlertCircle className="w-4 h-4 text-white shrink-0" />
</div>
<p className="text-sm font-medium text-red-900">{error}</p>
<Button
size="sm"
variant="ghost"
onClick={() => setError(null)}
className="ml-auto hover:bg-red-100"
>
Dismiss
</Button>
</div>
)}
{/* Header */}
<Card className="shadow-lg border-0 rounded-md">
<CardHeader className="border-b border-slate-100 py-4 sm:py-5">
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md">
<Hash className="w-5 h-5 text-white" />
</div>
<div>
<CardTitle className="text-lg sm:text-xl font-semibold text-slate-900">HSN/SAC Codes</CardTitle>
<CardDescription className="text-xs sm:text-sm">
Manage HSN/SAC codes and associated GST rates ({totalRecords} total)
</CardDescription>
</div>
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 w-full lg:w-auto">
{/* Search Bar */}
<div className="relative flex-1 sm:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search code or description..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 bg-slate-50 border-slate-200 focus:bg-white transition-all text-sm"
/>
</div>
<Button
onClick={handleAdd}
className="gap-2 bg-re-green hover:bg-re-green/90 text-white shadow-sm whitespace-nowrap"
>
<Plus className="w-4 h-4" />
<span>Add New Code</span>
</Button>
</div>
</div>
</CardHeader>
</Card>
{/* List */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
</div>
) : codes.length === 0 ? (
<Card className="shadow-lg border-0 rounded-md">
<CardContent className="p-12 text-center">
<div className="p-4 bg-slate-100 rounded-full w-20 h-20 flex items-center justify-center mx-auto mb-4">
{debouncedSearch ? <SearchX className="w-10 h-10 text-slate-400" /> : <Hash className="w-10 h-10 text-slate-400" />}
</div>
<p className="text-slate-700 font-medium text-lg">
{debouncedSearch ? `No results for "${debouncedSearch}"` : 'No HSN/SAC codes found'}
</p>
<p className="text-sm text-slate-500 mt-2 mb-6">
{debouncedSearch ? 'Try a different search term' : 'Add codes to use in dealer claims'}
</p>
{debouncedSearch ? (
<Button onClick={() => setSearchQuery('')} variant="outline">Clear Search</Button>
) : (
<Button
onClick={handleAdd}
variant="outline"
className="gap-2 border-slate-300 hover:bg-slate-50"
>
<Plus className="w-4 h-4" />
Add First Code
</Button>
)}
</CardContent>
</Card>
) : (
<div className="space-y-6">
{/* Results Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{codes.map(code => (
<CodeCard
key={code.id}
code={code}
onEdit={() => handleEdit(code)}
onDelete={() => handleDelete(code)}
onToggle={() => handleToggle(code.id)}
/>
))}
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 pt-4 border-t border-slate-100">
<p className="text-sm text-slate-500 order-2 sm:order-1">
Showing <span className="font-medium">{(currentPage - 1) * limit + 1}</span> to{' '}
<span className="font-medium">
{Math.min(currentPage * limit, totalRecords)}
</span>{' '}
of <span className="font-medium">{totalRecords}</span> results
</p>
<div className="flex items-center gap-2 order-1 sm:order-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="gap-1 h-9"
>
<ChevronLeft className="w-4 h-4" />
<span>Previous</span>
</Button>
<div className="flex items-center gap-1">
{[...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 (
<Button
key={pageNum}
variant={currentPage === pageNum ? 'default' : 'outline'}
size="sm"
onClick={() => setCurrentPage(pageNum)}
className={`w-9 h-9 p-0 ${currentPage === pageNum ? 'bg-slate-800' : 'text-slate-600'}`}
>
{pageNum}
</Button>
);
} else if (
(pageNum === currentPage - 2 && pageNum > 1) ||
(pageNum === currentPage + 2 && pageNum < totalPages)
) {
return <span key={pageNum} className="px-1 text-slate-400">...</span>;
}
return null;
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="gap-1 h-9"
>
<span>Next</span>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
)}
{/* Dialog */}
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{editingCode ? 'Edit Code' : 'Add New HSN/SAC Code'}</DialogTitle>
<DialogDescription>
Enter the details for recovery or claim code
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="type">Type <span className="text-red-500">*</span></Label>
<Select
value={formData.type}
onValueChange={(val: CodeType) => setFormData({ ...formData, type: val })}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="HSN">HSN</SelectItem>
<SelectItem value="SAC">SAC</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2 relative pb-5">
<Label htmlFor="code">Code <span className="text-red-500">*</span></Label>
<Input
id="code"
placeholder="e.g., 998311"
value={formData.code}
onChange={e => 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 && (
<p className="text-[10px] text-red-500 absolute left-0 bottom-0 leading-tight">
{validateHSNSAC(formData.code, formData.type === 'SAC').message}
</p>
)}
</div>
</div>
{/* Hide GST rate input for now
<div className="space-y-2">
<Label htmlFor="gstRate">GST Rate (%)</Label>
<Input
id="gstRate"
type="number"
step="0.01"
placeholder="e.g., 18.00"
value={formData.gstRate}
onChange={e => setFormData({ ...formData, gstRate: e.target.value })}
/>
</div>
*/}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
placeholder="Brief description of the code"
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAddDialog(false)}>Cancel</Button>
<Button onClick={handleSave} className="bg-re-green hover:bg-re-green/90 text-white">
{editingCode ? 'Update Code' : 'Create Code'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function CodeCard({ code, onEdit, onDelete, onToggle }: {
code: HsnSacCode,
onEdit: () => void,
onDelete: () => void,
onToggle: () => void
}) {
return (
<div className={`p-4 rounded-lg border flex flex-col justify-between gap-4 transition-all hover:shadow-md ${code.isActive ? 'bg-white border-slate-200' : 'bg-slate-50/50 border-slate-100 grayscale-[0.5]'}`}>
<div className="flex justify-between items-start">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-md ${code.type === 'HSN' ? 'bg-blue-50 text-blue-600' : 'bg-purple-50 text-purple-600'}`}>
<Tag className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">{code.type}</span>
<h4 className="font-bold text-slate-900">{code.code}</h4>
</div>
{code.description && <p className="text-xs text-slate-500 mt-0.5 line-clamp-1">{code.description}</p>}
</div>
</div>
<div className="flex items-center gap-1">
{/* GST rate badge hidden for now */}
</div>
</div>
<div className="flex items-center justify-between border-t border-slate-50 pt-3 mt-1">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-slate-400 hover:text-blue-600 hover:bg-blue-50"
onClick={onEdit}
>
<Edit2 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-slate-400 hover:text-red-600 hover:bg-red-50"
onClick={onDelete}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
<Button
variant="ghost"
size="sm"
className={`text-[10px] font-bold uppercase tracking-widest ${code.isActive ? 'text-amber-600' : 'text-green-600'}`}
onClick={onToggle}
>
{code.isActive ? 'Deactivate' : 'Activate'}
</Button>
</div>
</div>
);
}

View File

@ -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<HsnSacCode[]>([]);
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("w-full justify-between bg-white font-normal h-9 px-3 text-xs sm:text-sm border-slate-200 hover:bg-slate-50", className)}
>
<span className="font-medium text-slate-900">
{value
? allCodes.find((code) => code.code === value)?.code || value
: placeholder || `Select ${type}...`}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0 shadow-2xl border-slate-200" align="start">
<Command className="rounded-lg border-0">
<div className="bg-slate-50/80 p-2.5 flex items-center gap-2">
<Search className="w-4 h-4 text-slate-400" />
<CommandInput
placeholder={`Search ${type} code...`}
wrapperClassName="border-none p-0 h-auto flex-1"
className="h-8 border-none bg-transparent shadow-none ring-0 focus-visible:ring-0 placeholder:text-slate-400 text-sm"
/>
</div>
<CommandList className="max-h-[350px] scrollbar-thin scrollbar-thumb-slate-200">
<CommandEmpty className="py-8 text-center">
<div className="flex flex-col items-center gap-2">
<Search className="h-8 w-8 text-slate-200" />
<p className="text-sm font-medium text-slate-500">No {type} code found</p>
<p className="text-xs text-slate-400">Try a different search term</p>
</div>
</CommandEmpty>
<CommandGroup className="p-1.5">
{filteredCodes.map((code) => (
<CommandItem
key={code.id}
value={code.code}
onSelect={(currentValue) => {
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"
>
<div className="flex items-center w-full justify-between">
<span className="font-bold text-sm text-slate-900 leading-none">{code.code}</span>
<div className="flex items-center gap-2">
<Check
className={cn(
"h-4 w-4 text-re-green transition-all",
value === code.code ? "opacity-100 scale-100" : "opacity-0 scale-75"
)}
/>
</div>
</div>
{code.description && (
<span className="text-[11px] text-slate-500 line-clamp-2 leading-tight pr-4">
{code.description}
</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -55,12 +55,15 @@ function CommandDialog({
function CommandInput({ function CommandInput({
className, className,
wrapperClassName,
...props ...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) { }: React.ComponentProps<typeof CommandPrimitive.Input> & {
wrapperClassName?: string;
}) {
return ( return (
<div <div
data-slot="command-input-wrapper" data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3" className={cn("flex h-9 items-center gap-2 border-b px-3", wrapperClassName)}
> >
<SearchIcon className="size-4 shrink-0 opacity-50" /> <SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input <CommandPrimitive.Input

View File

@ -8,8 +8,10 @@
import { createContext, useContext, useEffect, useState, ReactNode, useRef } from 'react'; import { createContext, useContext, useEffect, useState, ReactNode, useRef } from 'react';
import { Auth0Provider, useAuth0 as useAuth0Hook } from '@auth0/auth0-react'; import { Auth0Provider, useAuth0 as useAuth0Hook } from '@auth0/auth0-react';
import { TokenManager, isTokenExpired } from '../utils/tokenManager'; import { TokenManager, isTokenExpired } from '../utils/tokenManager';
import { exchangeCodeForTokens, refreshAccessToken, getCurrentUser, logout as logoutApi, passwordLogin } from '../services/authApi'; import { exchangeCodeForTokens, refreshAccessToken, getCurrentUser, logout as logoutApi, passwordLogin, oktaLogout } from '../services/authApi';
import { tanflowLogout } from '../services/tanflowAuth'; import { tanflowLogout } from '../services/tanflowAuth';
import { getSocket, joinUserRoom } from '../utils/socket';
import { toast } from 'sonner';
interface User { interface User {
userId?: string; userId?: string;
@ -105,10 +107,16 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
// Do NOT treat as logout when we have an OAuth code - we're in the middle of login callback // Do NOT treat as logout when we have an OAuth code - we're in the middle of login callback
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const hasOAuthCode = urlParams.has('code'); const hasOAuthCode = urlParams.has('code');
const hasLogoutParam = urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out'); const storedLogoutType = sessionStorage.getItem('__logout_type__');
const hasLogoutParam = urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out') || storedLogoutType;
if (hasLogoutParam && !hasOAuthCode) { if (hasLogoutParam && !hasOAuthCode) {
console.log('🚪 Logout parameter detected in URL, clearing all tokens'); console.log('🚪 Logout parameter detected in URL, clearing all tokens and backend session');
// Perform final backend logout cleanup when returning from IdP
logoutApi().catch(err => console.warn('🚪 Final backend logout cleanup failed:', err));
TokenManager.clearAll(); TokenManager.clearAll();
sessionStorage.removeItem('__logout_type__');
sessionStorage.removeItem('auth_provider'); sessionStorage.removeItem('auth_provider');
sessionStorage.removeItem('tanflow_auth_state'); sessionStorage.removeItem('tanflow_auth_state');
sessionStorage.removeItem('__logout_in_progress__'); sessionStorage.removeItem('__logout_in_progress__');
@ -210,6 +218,42 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [isAuthenticated]); }, [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 // Handle callback from OAuth redirect
// Use ref to prevent duplicate calls (React StrictMode runs effects twice in dev) // Use ref to prevent duplicate calls (React StrictMode runs effects twice in dev)
const callbackProcessedRef = useRef(false); const callbackProcessedRef = useRef(false);
@ -230,16 +274,19 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
return; return;
} }
// Check if this is a logout redirect (from Tanflow post-logout redirect) // Check if this is a logout redirect (from Tanflow/Okta post-logout redirect)
// If it has logout parameters but no code, it's a logout redirect, not a login callback // We check for URL params OR sessionStorage flags (needed for clean redirect URIs)
if ((urlParams.has('logout') || urlParams.has('tanflow_logged_out') || urlParams.has('okta_logged_out')) && !urlParams.get('code')) { 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 // This is a logout redirect, not a login callback
// Redirect to home page - the mount useEffect will handle logout cleanup // Redirect to home page - the mount useEffect will handle logout cleanup
console.log('🚪 Logout redirect detected in callback, redirecting to home'); console.log('🚪 Logout redirect detected in callback, redirecting to home');
// Extract the logout flags from current URL // Extract the logout flags from current URL
const logoutFlags = new URLSearchParams(); const logoutFlags = new URLSearchParams();
if (urlParams.has('tanflow_logged_out')) logoutFlags.set('tanflow_logged_out', 'true'); if (urlParams.has('tanflow_logged_out') || storedLogoutType === 'tanflow') logoutFlags.set('tanflow_logged_out', 'true');
if (urlParams.has('okta_logged_out')) logoutFlags.set('okta_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()); if (urlParams.has('logout')) logoutFlags.set('logout', urlParams.get('logout') || Date.now().toString());
const redirectUrl = logoutFlags.toString() ? `/?${logoutFlags.toString()}` : '/?logout=' + Date.now(); const redirectUrl = logoutFlags.toString() ? `/?${logoutFlags.toString()}` : '/?logout=' + Date.now();
window.location.replace(redirectUrl); window.location.replace(redirectUrl);
@ -532,16 +579,8 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
setError(null); setError(null);
setIsLoading(true); // Set loading to prevent checkAuthStatus from running setIsLoading(true); // Set loading to prevent checkAuthStatus from running
// Call backend logout API to clear server-side session and httpOnly cookies // NOTE: Backend logout API is now deferred to the AuthContext mount effect
// IMPORTANT: This MUST be called before clearing local storage to clear httpOnly cookies // which handles the redirect return from Okta/Tanflow to ensure IdP-first logout.
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
}
// Preserve logout flags, id_token, and auth_provider BEFORE clearing tokens // Preserve logout flags, id_token, and auth_provider BEFORE clearing tokens
const logoutInProgress = sessionStorage.getItem('__logout_in_progress__'); 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 // OKTA logout or fallback: Clear auth_provider and redirect to login page with flags
console.log('🚪 Using OKTA logout flow or fallback'); console.log('🚪 Using OKTA logout flow or fallback');
sessionStorage.removeItem('auth_provider'); sessionStorage.removeItem('auth_provider');
// Clear id_token now since we're not using provider logout // Initiate full Okta logout (OIDC redirect)
if (idToken) { oktaLogout(idToken || undefined);
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);
} catch (error) { } catch (error) {
console.error('🚪 Logout error:', error); console.error('🚪 Logout error:', error);
// Force redirect even on error - clear everything and redirect to login // Force redirect even on error - clear everything and redirect to login

View File

@ -250,7 +250,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
// If we have a dealer group, validate it // If we have a dealer group, validate it
if (dealerGroup) { if (dealerGroup) {
let isMatch = false; 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 === 'spares' && dealerGroup === 'spares') isMatch = true;
else if (activityCategory === 'gma' && dealerGroup === 'gma') isMatch = true; else if (activityCategory === 'gma' && dealerGroup === 'gma') isMatch = true;
else if (activityCategory === 'apparel' && dealerGroup === 'apparel') isMatch = true; else if (activityCategory === 'apparel' && dealerGroup === 'apparel') isMatch = true;

View File

@ -2729,6 +2729,10 @@ export function DealerClaimWorkflowTab({
Number((request as any)?.claimDetails?.creditNoteAmount) : Number((request as any)?.claimDetails?.creditNoteAmount) :
((request as any)?.claimDetails?.credit_note_amount ? ((request as any)?.claimDetails?.credit_note_amount ?
Number((request as any)?.claimDetails?.credit_note_amount) : undefined)))), 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 || status: (request as any)?.creditNote?.status ||
(request as any)?.claimDetails?.creditNote?.status || (request as any)?.claimDetails?.creditNote?.status ||
((request as any)?.creditNote?.creditNoteNumber ? 'CONFIRMED' : 'PENDING'), ((request as any)?.creditNote?.creditNoteNumber ? 'CONFIRMED' : 'PENDING'),

View File

@ -247,7 +247,7 @@ export function ProcessDetailsCard({
{cn.MSG_TYP === 'E' ? 'Error' : (cn.MSG_TYP === 'S' ? 'Success' : cn.MSG_TYP || 'Unknown')} {cn.MSG_TYP === 'E' ? 'Error' : (cn.MSG_TYP === 'S' ? 'Success' : cn.MSG_TYP || 'Unknown')}
</span> </span>
</div> </div>
<div className="text-gray-500 mb-0.5">Txn: {cn.TRNS_UNIQ_NO || 'N/A'}</div> <div className="text-gray-500 mb-0.5">Txn No: {cn.TRNS_UNIQ_NO || 'N/A'}</div>
<div className="text-gray-700 break-words font-medium">{cn.MESSAGE || 'No Message'}</div> <div className="text-gray-700 break-words font-medium">{cn.MESSAGE || 'No Message'}</div>
</div> </div>
))} ))}

View File

@ -29,7 +29,20 @@ interface CreditNoteSAPModalProps {
creditNoteNumber?: string; creditNoteNumber?: string;
creditNoteDate?: string; creditNoteDate?: string;
creditNoteAmount?: number; creditNoteAmount?: number;
transactionNo?: string;
tdsAmount?: number;
creditAmount?: number;
status?: 'PENDING' | 'APPROVED' | 'ISSUED' | 'SENT' | 'CONFIRMED'; 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?: { dealerInfo?: {
dealerName?: string; dealerName?: string;
@ -67,7 +80,11 @@ export function CreditNoteSAPModal({
? formatDateTime(creditNoteData.creditNoteDate, { includeTime: false, format: 'short' }) ? formatDateTime(creditNoteData.creditNoteDate, { includeTime: false, format: 'short' })
: ''; : '';
const creditNoteAmount = creditNoteData?.creditNoteAmount || 0; 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 status = creditNoteData?.status || 'PENDING';
const items = creditNoteData?.items || [];
const dealerName = dealerInfo?.dealerName || 'Jaipur Royal Enfield'; const dealerName = dealerInfo?.dealerName || 'Jaipur Royal Enfield';
const dealerCode = dealerInfo?.dealerCode || 'RE-JP-009'; const dealerCode = dealerInfo?.dealerCode || 'RE-JP-009';
@ -77,6 +94,19 @@ export function CreditNoteSAPModal({
? formatDateTime(dueDate, { includeTime: false, format: 'short' }) ? formatDateTime(dueDate, { includeTime: false, format: 'short' })
: 'Jan 4, 2026'; : '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 () => { const handleDownload = async () => {
if (onDownload) { if (onDownload) {
try { try {
@ -125,7 +155,7 @@ export function CreditNoteSAPModal({
<DialogTitle className="font-semibold flex items-center gap-2 text-2xl flex-wrap"> <DialogTitle className="font-semibold flex items-center gap-2 text-2xl flex-wrap">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Receipt className="w-6 h-6 text-[--re-green]" /> <Receipt className="w-6 h-6 text-[--re-green]" />
Credit Note from SAP Credit Note Details
</div> </div>
{taxationType && ( {taxationType && (
<Badge className={`ml-2 border-none shadow-sm ${!isNonGst ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-indigo-600 text-white hover:bg-indigo-700'}`}> <Badge className={`ml-2 border-none shadow-sm ${!isNonGst ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-indigo-600 text-white hover:bg-indigo-700'}`}>
@ -150,35 +180,99 @@ export function CreditNoteSAPModal({
</div> </div>
<Badge className="bg-green-600 text-white px-4 py-2 text-base"> <Badge className="bg-green-600 text-white px-4 py-2 text-base">
<CircleCheckBig className="w-4 h-4 mr-2" /> <CircleCheckBig className="w-4 h-4 mr-2" />
{status === 'APPROVED' ? 'Approved' : status === 'ISSUED' ? 'Issued' : status === 'SENT' ? 'Sent' : 'Pending'} {status === 'APPROVED' ? 'Approved' : status === 'ISSUED' ? 'Issued' : status === 'SENT' ? 'Sent' : 'Confirmed'}
</Badge> </Badge>
</div> </div>
<div className="grid grid-cols-2 gap-4 mt-4"> <div className="grid grid-cols-2 md:grid-cols-3 gap-4 mt-4">
<div className="bg-white rounded-lg p-3 border border-green-100"> <div className="bg-white rounded-lg p-3 border border-green-100 shadow-sm">
<Label className="font-medium text-xs text-gray-600 uppercase tracking-wider flex items-center gap-1"> <Label className="font-medium text-[10px] text-gray-500 uppercase tracking-wider flex items-center gap-1">
<Hash className="w-3 h-3" /> <Hash className="w-3 h-3" />
Credit Note Number Credit Note No
</Label> </Label>
<p className="font-bold text-gray-900 mt-1 text-lg">{creditNoteNumber}</p> <p className="font-bold text-gray-900 mt-1">{creditNoteNumber}</p>
</div> </div>
<div className="bg-white rounded-lg p-3 border border-green-100"> <div className="bg-white rounded-lg p-3 border border-green-100 shadow-sm">
<Label className="font-medium text-xs text-gray-600 uppercase tracking-wider flex items-center gap-1"> <Label className="font-medium text-[10px] text-gray-500 uppercase tracking-wider flex items-center gap-1">
<Calendar className="w-3 h-3" /> <Calendar className="w-3 h-3" />
Issue Date Issue Date
</Label> </Label>
<p className="font-semibold text-gray-900 mt-1">{creditNoteDate}</p> <p className="font-semibold text-gray-900 mt-1">{creditNoteDate}</p>
</div> </div>
<div className="bg-white rounded-lg p-3 border border-green-100 shadow-sm col-span-2 md:col-span-1">
<Label className="font-medium text-[10px] text-gray-500 uppercase tracking-wider flex items-center gap-1">
<FileText className="w-3 h-3" />
Transaction ID
</Label>
<p className="font-bold text-emerald-700 mt-1 break-all">{transactionDisplay}</p>
</div>
</div> </div>
</div> </div>
{/* Credit Note Amount */} {/* Financial Summary */}
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-5"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Label className="font-medium text-xs text-gray-600 uppercase tracking-wider flex items-center gap-1 mb-3"> <div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-4">
<DollarSign className="w-4 h-4" /> <Label className="font-medium text-[10px] text-gray-600 uppercase tracking-wider flex items-center gap-1 mb-2">
Credit Note Amount <DollarSign className="w-3 h-3" />
</Label> Gross Amount
<p className="text-4xl font-bold text-blue-700">{formatCurrency(creditNoteAmount)}</p> </Label>
<p className="text-2xl font-bold text-blue-700">{formatCurrency(creditNoteAmount)}</p>
</div>
<div className="bg-orange-50 border-2 border-orange-200 rounded-lg p-4">
<Label className="font-medium text-[10px] text-gray-600 uppercase tracking-wider flex items-center gap-1 mb-2">
<DollarSign className="w-3 h-3" />
TDS Amount
</Label>
<p className="text-2xl font-bold text-orange-700">{formatCurrency(tdsAmount)}</p>
</div>
<div className="bg-emerald-50 border-2 border-emerald-200 rounded-lg p-4">
<Label className="font-medium text-[10px] text-gray-600 uppercase tracking-wider flex items-center gap-1 mb-2">
<DollarSign className="w-3 h-3" />
Net Credit Amount
</Label>
<p className="text-2xl font-bold text-emerald-700">{formatCurrency(creditAmount)}</p>
</div>
</div> </div>
{/* Line Items Section */}
{items && items.length > 0 && (
<div className="border border-slate-200 rounded-lg overflow-hidden mt-2">
<div className="bg-slate-50 px-4 py-2 border-b border-slate-200 flex items-center justify-between">
<h4 className="font-semibold text-slate-900 text-sm flex items-center gap-2">
<FileText className="w-4 h-4 text-slate-500" />
Line Item Breakdown
</h4>
<Badge variant="outline" className="text-[10px] font-medium uppercase tracking-wider">
{items.length} {items.length === 1 ? 'Item' : 'Items'}
</Badge>
</div>
<div className="max-h-60 overflow-y-auto">
<table className="w-full text-sm text-left border-collapse">
<thead className="bg-white sticky top-0 shadow-sm z-10">
<tr className="border-b border-slate-200">
<th className="px-4 py-2.5 font-semibold text-slate-700 text-[11px] uppercase whitespace-nowrap">Sl No</th>
<th className="px-4 py-2.5 font-semibold text-slate-700 text-[11px] uppercase whitespace-nowrap">Transaction Code</th>
<th className="px-4 py-2.5 font-semibold text-slate-700 text-[11px] uppercase whitespace-nowrap">HSN/SAC</th>
<th className="px-4 py-2.5 font-semibold text-slate-700 text-[11px] uppercase whitespace-nowrap text-right">Claim Amount</th>
<th className="px-4 py-2.5 font-semibold text-slate-700 text-[11px] uppercase whitespace-nowrap text-right">TDS</th>
<th className="px-4 py-2.5 font-semibold text-slate-700 text-[11px] uppercase whitespace-nowrap text-right">Net Credit</th>
</tr>
</thead>
<tbody>
{items.map((item, idx) => (
<tr key={idx} className="border-b border-slate-100 hover:bg-slate-50/50 transition-colors">
<td className="px-4 py-2.5 text-slate-600">{item.slNo}</td>
<td className="px-4 py-2.5 font-medium text-slate-900 font-mono text-[11px]">{item.transactionNo || 'N/A'}</td>
<td className="px-4 py-2.5 text-slate-600">{item.hsnCd || 'N/A'}</td>
<td className="px-4 py-2.5 text-right font-medium text-slate-700">{formatCurrency(item.claimAmount || 0)}</td>
<td className="px-4 py-2.5 text-right text-orange-600 font-medium">{formatCurrency(item.tdsAmount || 0)}</td>
<td className="px-4 py-2.5 text-right text-emerald-700 font-bold">{formatCurrency(item.creditAmount || 0)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</> </>
) : ( ) : (
/* No Credit Note Available */ /* No Credit Note Available */

View File

@ -13,6 +13,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { HsnSacSelector } from "@/components/common/HsnSacSelector";
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@ -344,6 +345,10 @@ export function DealerCompletionDocumentsModal({
const calculation = calculateGST(amount, { cgstRate, sgstRate, igstRate, utgstRate }, quantity); const calculation = calculateGST(amount, { cgstRate, sgstRate, igstRate, utgstRate }, quantity);
if (field === 'isService') {
updatedItem.hsnCode = '';
}
return { return {
...updatedItem, ...updatedItem,
amount, amount,
@ -352,6 +357,10 @@ export function DealerCompletionDocumentsModal({
}; };
} }
if (field === 'isService') {
updatedItem.hsnCode = '';
}
return updatedItem; return updatedItem;
} }
return item; return item;
@ -757,18 +766,17 @@ export function DealerCompletionDocumentsModal({
)} )}
{!isNonGst && ( {!isNonGst && (
<> <>
<div className="w-20 sm:w-24 flex-shrink-0"> <div className="w-32 sm:w-36 flex-shrink-0 relative pb-5">
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">HSN/SAC Code</Label> <Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">HSN/SAC Code</Label>
<Input <HsnSacSelector
placeholder="HSN/SAC Code"
value={item.hsnCode || ''} value={item.hsnCode || ''}
onChange={(e) => onChange={(value) => handleExpenseChange(item.id, 'hsnCode', value)}
handleExpenseChange(item.id, 'hsnCode', e.target.value) type={item.isService ? 'SAC' : 'HSN'}
} className={!validateHSNSAC(item.hsnCode, item.isService).isValid ? 'border-red-500' : ''}
className={`w-full bg-white text-sm ${!validateHSNSAC(item.hsnCode, item.isService).isValid ? 'border-red-500 focus-visible:ring-red-500' : ''}`} placeholder={item.isService ? 'SAC' : 'HSN'}
/> />
{!validateHSNSAC(item.hsnCode, item.isService).isValid && ( {!validateHSNSAC(item.hsnCode, item.isService).isValid && (
<span className="text-[9px] text-red-500 mt-1 block leading-tight"> <span className="text-[10px] text-red-500 absolute left-0 bottom-0 block leading-tight">
{validateHSNSAC(item.hsnCode, item.isService).message} {validateHSNSAC(item.hsnCode, item.isService).message}
</span> </span>
)} )}

View File

@ -15,6 +15,7 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { HsnSacSelector } from "@/components/common/HsnSacSelector";
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -1008,14 +1009,21 @@ export function DealerProposalSubmissionModal({
/> />
</div> </div>
</div> </div>
<div className="w-28 sm:w-32"> <div className="w-28 sm:w-32 relative pb-5">
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">HSN/SAC Code</Label> <Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">HSN/SAC Code</Label>
<Input <HsnSacSelector
value={item.hsnCode || ''} value={item.hsnCode || ''}
onChange={(e) => handleCostItemChange(item.id, 'hsnCode', e.target.value)} onChange={(value) => handleCostItemChange(item.id, 'hsnCode', value)}
className={`bg-white shadow-sm ${!validateHSNSAC(item.hsnCode, item.isService).isValid ? 'border-red-500' : ''} ${item.isOriginal ? 'bg-gray-100 cursor-not-allowed' : ''}`} type={item.isService ? 'SAC' : 'HSN'}
disabled={item.isOriginal} disabled={item.isOriginal}
className={!validateHSNSAC(item.hsnCode, item.isService).isValid ? 'border-red-500' : ''}
placeholder={item.isService ? 'Select SAC' : 'Select HSN'}
/> />
{!validateHSNSAC(item.hsnCode, item.isService).isValid && (
<span className="text-[10px] text-red-500 absolute left-0 bottom-0 block leading-tight">
{validateHSNSAC(item.hsnCode, item.isService).message}
</span>
)}
</div> </div>
<div className="w-28 sm:w-32"> <div className="w-28 sm:w-32">
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">Type</Label> <Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">Type</Label>

View File

@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { AuthProvider } from './contexts/AuthContext'; import { AuthProvider } from '@/contexts/AuthContext';
import { AuthenticatedApp } from './pages/Auth'; import { AuthenticatedApp } from '@/pages/Auth';
import { store } from './redux/store'; import { store } from './redux/store';
import './styles/globals.css'; import './styles/globals.css';
import './styles/base-layout.css'; import './styles/base-layout.css';

View File

@ -4,6 +4,7 @@ import { Auth } from './Auth';
import { AuthCallback } from './AuthCallback'; import { AuthCallback } from './AuthCallback';
import { TanflowCallback } from './TanflowCallback'; import { TanflowCallback } from './TanflowCallback';
import { AuthDebugInfo } from '@/components/Auth/AuthDebugInfo'; import { AuthDebugInfo } from '@/components/Auth/AuthDebugInfo';
import { TokenManager } from '@/utils/tokenManager';
import App from '../../App'; import App from '../../App';
export function AuthenticatedApp() { export function AuthenticatedApp() {
@ -40,31 +41,14 @@ export function AuthenticatedApp() {
// Auth state changed // Auth state changed
}, [isAuthenticated, isLoading, error, user]); }, [isAuthenticated, isLoading, error, user]);
// Check for callback parameters
const urlParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
const hasCode = urlParams?.get('code');
const hasError = urlParams?.get('error');
// Always show callback loader when on callback route (after all hooks) // Always show callback loader when on callback route (after all hooks)
// Detect provider from sessionStorage to show appropriate callback component // ONLY if it's a real login callback (has code/error) OR if we are still authenticated
if (isCallbackRoute) { if (isCallbackRoute && (hasCode || hasError || isAuthenticated)) {
// Check if this is a logout redirect (no code, no error)
const urlParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
const hasCode = urlParams?.get('code');
const hasError = urlParams?.get('error');
// If no code and no error, it's a logout redirect - redirect immediately
if (!hasCode && !hasError) {
console.log('🚪 AuthenticatedApp: Logout redirect detected, redirecting to home');
const logoutParams = new URLSearchParams();
logoutParams.set('tanflow_logged_out', 'true');
logoutParams.set('logout', Date.now().toString());
window.location.replace(`/?${logoutParams.toString()}`);
return (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-slate-900 border-t-transparent mx-auto mb-4"></div>
<p className="text-gray-600">Redirecting...</p>
</div>
</div>
);
}
const authProvider = typeof window !== 'undefined' ? sessionStorage.getItem('auth_provider') : null; const authProvider = typeof window !== 'undefined' ? sessionStorage.getItem('auth_provider') : null;
if (authProvider === 'tanflow') { if (authProvider === 'tanflow') {
return <TanflowCallback />; return <TanflowCallback />;
@ -74,7 +58,8 @@ export function AuthenticatedApp() {
} }
// Show loading state while checking authentication // Show loading state while checking authentication
if (isLoading) { // UNLESS a session supersession is in progress (in which case we want the app UI to stay visible while toast is shown)
if (isLoading && TokenManager.getAuthError() !== 'SESSION_SUPERSEDED') {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-slate-50 to-slate-100"> <div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div className="text-center"> <div className="text-center">

View File

@ -18,6 +18,7 @@ import { HolidayManager } from '@/components/admin/HolidayManager';
import { UserRoleManager } from '@/components/admin/UserRoleManager'; import { UserRoleManager } from '@/components/admin/UserRoleManager';
import { ActivityTypeManager } from '@/components/admin/ActivityTypeManager'; import { ActivityTypeManager } from '@/components/admin/ActivityTypeManager';
import { Form16AdminConfig } from '@/components/admin/Form16AdminConfig/Form16AdminConfig'; import { Form16AdminConfig } from '@/components/admin/Form16AdminConfig/Form16AdminConfig';
import { HsnSacCodeManager } from '@/components/admin/HsnSacCodeManager';
import { NotificationStatusModal } from '@/components/settings/NotificationStatusModal'; import { NotificationStatusModal } from '@/components/settings/NotificationStatusModal';
import { NotificationPreferencesModal } from '@/components/settings/NotificationPreferencesModal'; import { NotificationPreferencesModal } from '@/components/settings/NotificationPreferencesModal';
// import { ApiTokenManager } from '@/components/settings/ApiTokenManager'; // Removed: Moved to dedicated page // import { ApiTokenManager } from '@/components/settings/ApiTokenManager'; // Removed: Moved to dedicated page
@ -38,6 +39,7 @@ export function Settings() {
const [checkingSubscription, setCheckingSubscription] = useState(true); const [checkingSubscription, setCheckingSubscription] = useState(true);
const [showActivityTypeManager, setShowActivityTypeManager] = useState(false); const [showActivityTypeManager, setShowActivityTypeManager] = useState(false);
const [showForm16AdminConfig, setShowForm16AdminConfig] = useState(false); const [showForm16AdminConfig, setShowForm16AdminConfig] = useState(false);
const [showHsnSacCodeManager, setShowHsnSacCodeManager] = useState(false);
useEffect(() => { useEffect(() => {
checkSubscriptionStatus(); checkSubscriptionStatus();
@ -425,6 +427,38 @@ export function Settings() {
</Card> </Card>
<ActivityTypeManager /> <ActivityTypeManager />
</div> </div>
) : showHsnSacCodeManager ? (
<div className="space-y-4">
<Card className="shadow-lg border-0 rounded-md">
<CardHeader className="border-b border-slate-100 py-4 sm:py-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => setShowHsnSacCodeManager(false)}
className="gap-2 hover:bg-slate-100"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back
</Button>
<div className="p-2.5 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md">
<FileText className="w-5 h-5 text-white" />
</div>
<div>
<CardTitle className="text-lg sm:text-xl font-semibold text-slate-900">HSN/SAC Code Configuration</CardTitle>
<CardDescription className="text-sm">
Manage HSN/SAC codes and associated GST rates
</CardDescription>
</div>
</div>
</div>
</CardHeader>
</Card>
<HsnSacCodeManager />
</div>
) : ( ) : (
<Card className="shadow-lg border-0 rounded-md"> <Card className="shadow-lg border-0 rounded-md">
<CardHeader className="border-b border-slate-100 py-4 sm:py-5"> <CardHeader className="border-b border-slate-100 py-4 sm:py-5">
@ -498,6 +532,34 @@ export function Settings() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* HSN/SAC Code Configuration Card */}
<Card
className="shadow-md hover:shadow-lg transition-all duration-300 border border-slate-200 rounded-lg cursor-pointer group"
onClick={() => setShowHsnSacCodeManager(true)}
>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 bg-gradient-to-br from-re-green to-teal-600 rounded-lg shadow-md group-hover:shadow-lg transition-shadow">
<FileText className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 group-hover:text-re-green transition-colors">
HSN/SAC Code Configuration
</h3>
<p className="text-sm text-slate-600 mt-1">
Manage HSN/SAC codes for recoveries and claims
</p>
</div>
</div>
<div className="text-slate-400 group-hover:text-re-green transition-colors">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</CardContent>
</Card>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -5,6 +5,34 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import { TokenManager } from '../utils/tokenManager'; import { TokenManager } from '../utils/tokenManager';
import { toast } from 'sonner';
import { tanflowLogout } from './tanflowAuth';
/**
* Perform a formal logout from Okta by redirecting the browser
* This clears the SSO session on the Okta side
*/
export function oktaLogout(idToken?: string): void {
const oktaDomain = import.meta.env.VITE_OKTA_DOMAIN || '{{IDP_DOMAIN}}';
// Use the exact whitelisted login callback URI without query params to avoid mismatch errors
const redirectUri = `${window.location.origin}/login/callback`;
if (idToken) {
// Persist logout flag in sessionStorage before redirecting
// This allows AuthContext to detect the return from logout without query params
sessionStorage.setItem('__logout_type__', 'okta');
// Standard OIDC logout redirect
const logoutUrl = `${oktaDomain}/oauth2/default/v1/logout?id_token_hint=${idToken}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`;
console.log('🚪 Initiating Okta logout redirect to callback (clean URI)');
window.location.href = logoutUrl;
} else {
// Fallback: redirect to callback with logout flag in sessionStorage
sessionStorage.setItem('__logout_type__', 'okta');
console.log('🚪 No id_token for Okta logout, redirecting to callback');
window.location.href = redirectUri;
}
}
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
@ -70,48 +98,74 @@ apiClient.interceptors.response.use(
} }
} }
// If error is 401 and we haven't retried yet // If error is 401
if (error.response?.status === 401 && !originalRequest._retry) { if (error.response?.status === 401) {
originalRequest._retry = true; // Check for concurrent session logout specifically
if (error.response?.data?.errorCode === 'SESSION_SUPERSEDED') {
const idToken = TokenManager.getIdToken();
const authProvider = sessionStorage.getItem('auth_provider') || (idToken?.includes('tanflow') ? 'tanflow' : 'okta');
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production'; // Set error state in TokenManager to stop background refreshes
TokenManager.setAuthError('SESSION_SUPERSEDED');
try { // Show the toast immediately
// Attempt to refresh token toast.error("You have been logged out because an active session was detected from another device.", {
// In production: Cookie is sent automatically via withCredentials duration: 2000,
// In development: Send refresh token from localStorage id: 'session-superseded-toast'
const refreshToken = TokenManager.getRefreshToken(); });
// In production, refreshToken will be null but cookie will be sent // Delay sets flags and redirect so user can read the toast before UI state clears
// In development, we need the token in body setTimeout(async () => {
if (!isProduction && !refreshToken) { // Set flags JUST BEFORE redirect to ensure AuthContext only picks them up on return/reload
throw new Error('No refresh token available'); sessionStorage.setItem('__logout_in_progress__', 'true');
sessionStorage.setItem('__force_logout__', 'true');
// IdP Logout FIRST as requested (clears SSO session)
// Note: Backend logout will be handled by AuthContext on return redirect
if (authProvider === 'tanflow' && idToken) {
tanflowLogout(idToken);
} else {
oktaLogout(idToken || undefined);
}
}, 1000);
return Promise.reject(error);
}
// Handle token refresh if not already retrying
if (!originalRequest._retry) {
originalRequest._retry = true;
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
try {
// Attempt to refresh token
const refreshToken = TokenManager.getRefreshToken();
if (!isProduction && !refreshToken) {
throw new Error('No refresh token available');
}
const response = await axios.post(
`${API_BASE_URL}/auth/refresh`,
isProduction ? {} : { refreshToken },
{ withCredentials: true }
);
const responseData = response.data.data || response.data;
const accessToken = responseData.accessToken;
if (!isProduction && accessToken) {
TokenManager.setAccessToken(accessToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
}
// Retry the original request
return apiClient(originalRequest);
} catch (refreshError) {
// Refresh failed, clear tokens and redirect to login
TokenManager.clearAll();
window.location.href = '/';
return Promise.reject(refreshError);
} }
const response = await axios.post(
`${API_BASE_URL}/auth/refresh`,
isProduction ? {} : { refreshToken }, // Empty body in production, cookie is used
{ withCredentials: true }
);
const responseData = response.data.data || response.data;
const accessToken = responseData.accessToken;
// In production: Backend sets new httpOnly cookie, no token in response
// In development: Token is in response, store it and add to header
if (!isProduction && accessToken) {
TokenManager.setAccessToken(accessToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
}
// Retry the original request
// In production: Cookie will be sent automatically
return apiClient(originalRequest);
} catch (refreshError) {
// Refresh failed, clear tokens and redirect to login
TokenManager.clearAll();
window.location.href = '/';
return Promise.reject(refreshError);
} }
} }
@ -287,8 +341,8 @@ export async function getCurrentUser() {
*/ */
export async function logout(): Promise<void> { export async function logout(): Promise<void> {
try { try {
// Use withCredentials to ensure cookies are sent // Use axios directly to avoid interceptor recursion
await apiClient.post('/auth/logout', {}, { await axios.post(`${API_BASE_URL}/auth/logout`, {}, {
withCredentials: true, // Ensure cookies are sent with request withCredentials: true, // Ensure cookies are sent with request
}); });
} catch (error: any) { } catch (error: any) {

View File

@ -487,7 +487,6 @@ class DashboardService {
params.slaCompliance = slaCompliance; params.slaCompliance = slaCompliance;
} }
console.log('[Dashboard Service] Fetching approver performance with params:', params);
const response = await apiClient.get('/dashboard/stats/approver-performance', { params }); const response = await apiClient.get('/dashboard/stats/approver-performance', { params });
return { return {

View File

@ -0,0 +1,103 @@
import apiClient from './authApi';
export type CodeType = 'HSN' | 'SAC';
export interface HsnSacCode {
id: string;
code: string;
type: CodeType;
gstRate?: number;
description?: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateHsnSacCodeDTO {
code: string;
type: CodeType;
gstRate?: number;
description?: string;
isActive?: boolean;
}
export interface HsnSacCodeResponse {
codes: HsnSacCode[];
pagination: {
totalRecords: number;
totalPages: number;
currentPage: number;
limit: number;
};
}
/**
* Get all HSN/SAC codes with pagination and search
*/
export async function getAllHsnSacCodes(
onlyActive: boolean = false,
page: number = 1,
limit: number = 10,
search?: string
): Promise<HsnSacCodeResponse> {
const params: any = { active: onlyActive, page, limit };
if (search) params.search = search;
const response = await apiClient.get('/hsn-sac', { params });
// Handle the standardized ResponseHandler format
const data = response.data?.data;
const pagination = response.data?.pagination;
if (Array.isArray(data) && pagination) {
return {
codes: data,
pagination
};
}
// Fallback for unexpected formats
return {
codes: Array.isArray(data) ? data : [],
pagination: pagination || { totalRecords: 0, totalPages: 0, currentPage: 1, limit: 10 }
};
}
/**
* Get code by ID
*/
export async function getHsnSacCodeById(id: string): Promise<HsnSacCode> {
const response = await apiClient.get(`/hsn-sac/${id}`);
return response.data?.data || response.data;
}
/**
* Create new HSN/SAC code
*/
export async function createHsnSacCode(data: CreateHsnSacCodeDTO): Promise<HsnSacCode> {
const response = await apiClient.post('/hsn-sac', data);
return response.data?.data || response.data;
}
/**
* Update HSN/SAC code
*/
export async function updateHsnSacCode(id: string, data: Partial<CreateHsnSacCodeDTO>): Promise<HsnSacCode> {
const response = await apiClient.patch(`/hsn-sac/${id}`, data);
return response.data?.data || response.data;
}
/**
* Delete HSN/SAC code
*/
export async function deleteHsnSacCode(id: string): Promise<void> {
await apiClient.delete(`/hsn-sac/${id}`);
}
/**
* Toggle active status
*/
export async function toggleHsnSacCodeActive(id: string): Promise<HsnSacCode> {
const response = await apiClient.patch(`/hsn-sac/${id}/toggle-active`);
return response.data?.data || response.data;
}

View File

@ -24,7 +24,6 @@ export function initiateTanflowLogin(): void {
sessionStorage.removeItem('tanflow_logged_out'); sessionStorage.removeItem('tanflow_logged_out');
sessionStorage.removeItem('__logout_in_progress__'); sessionStorage.removeItem('__logout_in_progress__');
sessionStorage.removeItem('__force_logout__'); sessionStorage.removeItem('__force_logout__');
console.log('🚪 Cleared logout flags before initiating Tanflow login');
} }
const state = Math.random().toString(36).substring(7); const state = Math.random().toString(36).substring(7);
@ -43,10 +42,8 @@ export function initiateTanflowLogin(): void {
// This ensures Tanflow requires login even if a session still exists // This ensures Tanflow requires login even if a session still exists
if (isAfterLogout) { if (isAfterLogout) {
authUrl += `&prompt=login`; authUrl += `&prompt=login`;
console.log('🚪 Adding prompt=login to force re-authentication after logout');
} }
console.log('🚪 Initiating Tanflow login', { isAfterLogout, hasPrompt: isAfterLogout });
window.location.href = authUrl; window.location.href = authUrl;
} }
@ -161,29 +158,22 @@ export function tanflowLogout(idToken: string): void {
return; return;
} }
// Build Tanflow logout URL with redirect back to login callback
// IMPORTANT: Use the base redirect URI (without query params) to match registered URIs
// Tanflow requires exact match with registered "Valid Post Logout Redirect URIs"
// The same URI used for login should be registered for logout
// Using the base URI ensures it matches what's registered in Tanflow client config
const postLogoutRedirectUri = TANFLOW_REDIRECT_URI; // Use base URI without query params
// Construct logout URL - ensure all parameters are properly encoded // Construct logout URL - ensure all parameters are properly encoded
// Keycloak/Tanflow expects: client_id, id_token_hint, post_logout_redirect_uri
const logoutUrl = new URL(`${TANFLOW_BASE_URL}/protocol/openid-connect/logout`); const logoutUrl = new URL(`${TANFLOW_BASE_URL}/protocol/openid-connect/logout`);
logoutUrl.searchParams.set('client_id', TANFLOW_CLIENT_ID); logoutUrl.searchParams.set('client_id', TANFLOW_CLIENT_ID);
logoutUrl.searchParams.set('id_token_hint', idToken); logoutUrl.searchParams.set('id_token_hint', idToken);
logoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
// Use the exact whitelisted login callback URI without query params to avoid mismatch errors
const redirectUri = `${window.location.origin}/login/callback`;
// Persist logout flag in sessionStorage before redirecting
// This allows AuthContext to detect the return from logout without query params
sessionStorage.setItem('__logout_type__', 'tanflow');
logoutUrl.searchParams.set('post_logout_redirect_uri', redirectUri);
const finalLogoutUrl = logoutUrl.toString(); const finalLogoutUrl = logoutUrl.toString();
console.log('🚪 Tanflow logout initiated', {
hasIdToken: !!idToken,
idTokenPrefix: idToken ? idToken.substring(0, 20) + '...' : 'none',
postLogoutRedirectUri,
logoutUrlBase: `${TANFLOW_BASE_URL}/protocol/openid-connect/logout`,
finalLogoutUrl: finalLogoutUrl.replace(idToken.substring(0, 20), '***'),
});
// DO NOT clear auth_provider here - we need it to detect Tanflow callback // DO NOT clear auth_provider here - we need it to detect Tanflow callback
// The logout flags should already be set by AuthContext // The logout flags should already be set by AuthContext
@ -195,7 +185,6 @@ export function tanflowLogout(idToken: string): void {
// Redirect to Tanflow logout endpoint // Redirect to Tanflow logout endpoint
// Tanflow will clear the session and redirect back to post_logout_redirect_uri // Tanflow will clear the session and redirect back to post_logout_redirect_uri
// The redirect will include tanflow_logged_out=true in the query params // The redirect will include tanflow_logged_out=true in the query params
console.log('🚪 Redirecting to Tanflow logout endpoint...');
window.location.href = finalLogoutUrl; window.location.href = finalLogoutUrl;
} }

View File

@ -288,6 +288,25 @@ export class TokenManager {
static isProduction(): boolean { static isProduction(): boolean {
return isProduction(); return isProduction();
} }
/**
* Set authentication error state (e.g. "SESSION_SUPERSEDED")
* Used to flag specific fatal errors without immediately clearing all state
*/
static setAuthError(error: string | null): void {
if (error) {
sessionStorage.setItem('__auth_error__', error);
} else {
sessionStorage.removeItem('__auth_error__');
}
}
/**
* Get authentication error state
*/
static getAuthError(): string | null {
return sessionStorage.getItem('__auth_error__');
}
} }
/** /**