Compare commits
2 Commits
8f3f484dbc
...
f883bd34d8
| Author | SHA1 | Date | |
|---|---|---|---|
| f883bd34d8 | |||
| e3ba29ffcd |
@ -1,5 +1,6 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
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 { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@ -336,7 +337,21 @@ export function UserRoleManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<Card className="shadow-lg border-0 rounded-md">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md">
|
||||||
|
<UserCog className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg font-semibold text-gray-900">User Role Management</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-gray-600">
|
||||||
|
Search for users, assign roles, and manage user permissions across the system
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
{/* Statistics Cards */}
|
{/* Statistics Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
|
||||||
<Card
|
<Card
|
||||||
@ -409,22 +424,17 @@ export function UserRoleManager() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
{/* Assign Role Section */}
|
{/* Assign Role Section */}
|
||||||
<Card className="shadow-lg border">
|
<div className="space-y-5">
|
||||||
<CardHeader className="border-b pb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-lg shadow-md">
|
|
||||||
<UserCog className="w-5 h-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-lg font-semibold">Assign User Role</CardTitle>
|
<h3 className="text-base font-semibold text-gray-900 mb-1">Assign User Role</h3>
|
||||||
<CardDescription className="text-sm">
|
<p className="text-sm text-gray-600">
|
||||||
Search for a user in Okta and assign them a role
|
Search for a user in Okta and assign them a role
|
||||||
</CardDescription>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="space-y-5">
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-5 pt-6">
|
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
<div className="space-y-2" ref={searchContainerRef}>
|
<div className="space-y-2" ref={searchContainerRef}>
|
||||||
<label className="text-sm font-medium text-gray-700">Search User</label>
|
<label className="text-sm font-medium text-gray-700">Search User</label>
|
||||||
@ -435,11 +445,11 @@ export function UserRoleManager() {
|
|||||||
placeholder="Type name or email address..."
|
placeholder="Type name or email address..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
className="pl-10 pr-10 h-12 border rounded-lg border-gray-300 focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all"
|
className="pl-10 pr-10 border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
data-testid="user-search-input"
|
data-testid="user-search-input"
|
||||||
/>
|
/>
|
||||||
{searching && (
|
{searching && (
|
||||||
<Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-purple-500 animate-spin" />
|
<Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-re-green animate-spin" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">Start typing to search across all Okta users</p>
|
<p className="text-xs text-gray-500">Start typing to search across all Okta users</p>
|
||||||
@ -452,18 +462,18 @@ export function UserRoleManager() {
|
|||||||
{searchResults.length} user{searchResults.length > 1 ? 's' : ''} found
|
{searchResults.length} user{searchResults.length > 1 ? 's' : ''} found
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3">
|
<div className="p-2">
|
||||||
{searchResults.map((user) => (
|
{searchResults.map((user) => (
|
||||||
<button
|
<button
|
||||||
key={user.userId}
|
key={user.userId}
|
||||||
onClick={() => handleSelectUser(user)}
|
onClick={() => handleSelectUser(user)}
|
||||||
className="w-full text-left p-3 hover:bg-purple-50 rounded-lg transition-colors mb-1 last:mb-0"
|
className="w-full text-left p-2 hover:bg-purple-50 rounded-lg transition-colors mb-1 last:mb-0"
|
||||||
data-testid={`user-result-${user.email}`}
|
data-testid={`user-result-${user.email}`}
|
||||||
>
|
>
|
||||||
<p className="font-medium text-gray-900">{user.displayName || user.email}</p>
|
<p className="text-sm font-medium text-gray-900">{user.displayName || user.email}</p>
|
||||||
<p className="text-sm text-gray-600">{user.email}</p>
|
<p className="text-xs text-gray-600">{user.email}</p>
|
||||||
{user.department && (
|
{user.department && (
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500">
|
||||||
{user.department}{user.designation ? ` • ${user.designation}` : ''}
|
{user.department}{user.designation ? ` • ${user.designation}` : ''}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -514,25 +524,25 @@ export function UserRoleManager() {
|
|||||||
<label className="text-sm font-medium text-gray-700">Select Role</label>
|
<label className="text-sm font-medium text-gray-700">Select Role</label>
|
||||||
<Select value={selectedRole} onValueChange={(value: any) => setSelectedRole(value)}>
|
<Select value={selectedRole} onValueChange={(value: any) => setSelectedRole(value)}>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className="h-12 border border-gray-300 py-2 rounded-lg focus:border-purple-500 focus:ring-1 focus:ring-purple-200 transition-all"
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
data-testid="role-select"
|
data-testid="role-select"
|
||||||
>
|
>
|
||||||
<SelectValue placeholder="Select role" />
|
<SelectValue placeholder="Select role" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="rounded-lg">
|
<SelectContent>
|
||||||
<SelectItem value="USER" className="p-3 rounded-lg my-1">
|
<SelectItem value="USER">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<UserIcon className="w-4 h-4 text-gray-600" />
|
<UserIcon className="w-4 h-4 text-gray-600" />
|
||||||
<span>User - Regular access</span>
|
<span>User - Regular access</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="MANAGEMENT" className="p-3 rounded-lg my-1">
|
<SelectItem value="MANAGEMENT">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Users className="w-4 h-4 text-blue-600" />
|
<Users className="w-4 h-4 text-blue-600" />
|
||||||
<span>Management - Read all data</span>
|
<span>Management - Read all data</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="ADMIN" className="p-3 rounded-lg my-1">
|
<SelectItem value="ADMIN">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Crown className="w-4 h-4 text-yellow-600" />
|
<Crown className="w-4 h-4 text-yellow-600" />
|
||||||
<span>Administrator - Full access</span>
|
<span>Administrator - Full access</span>
|
||||||
@ -546,7 +556,7 @@ export function UserRoleManager() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleAssignRole}
|
onClick={handleAssignRole}
|
||||||
disabled={!selectedUser || updating}
|
disabled={!selectedUser || updating}
|
||||||
className="w-full h-12 bg-re-green hover:bg-re-green/90 text-white font-medium shadow-md hover:shadow-lg transition-all disabled:opacity-50 rounded-lg"
|
className="w-full bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
data-testid="assign-role-button"
|
data-testid="assign-role-button"
|
||||||
>
|
>
|
||||||
{updating ? (
|
{updating ? (
|
||||||
@ -581,24 +591,19 @@ export function UserRoleManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
{/* Users List with Filter and Pagination */}
|
{/* Users List with Filter and Pagination */}
|
||||||
<div ref={userListRef}>
|
<div ref={userListRef}>
|
||||||
<Card className="shadow-lg border">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
|
||||||
<CardHeader className="border-b pb-4">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-lg shadow-md">
|
|
||||||
<Shield className="w-5 h-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-lg font-semibold">User Management</CardTitle>
|
<h3 className="text-base font-semibold text-gray-900 mb-1">User Management</h3>
|
||||||
<CardDescription className="text-sm">
|
<p className="text-sm text-gray-600">
|
||||||
View and manage user roles ({totalUsers} {roleFilter !== 'ALL' && roleFilter !== 'ELEVATED' ? roleFilter.toLowerCase() : ''} users)
|
View and manage user roles ({totalUsers} {roleFilter !== 'ALL' && roleFilter !== 'ELEVATED' ? roleFilter.toLowerCase() : ''} users)
|
||||||
</CardDescription>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Select value={roleFilter} onValueChange={handleFilterChange}>
|
<Select value={roleFilter} onValueChange={handleFilterChange}>
|
||||||
@ -640,8 +645,7 @@ export function UserRoleManager() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
<div className="pt-2">
|
||||||
<CardContent className="pt-6">
|
|
||||||
{loadingUsers ? (
|
{loadingUsers ? (
|
||||||
<div className="flex flex-col items-center justify-center py-8">
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-purple-500 mb-2" />
|
<Loader2 className="w-6 h-6 animate-spin text-purple-500 mb-2" />
|
||||||
@ -666,7 +670,7 @@ export function UserRoleManager() {
|
|||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<div
|
<div
|
||||||
key={user.userId}
|
key={user.userId}
|
||||||
className="border-2 border-gray-100 hover:border-purple-200 hover:shadow-md transition-all rounded-lg bg-white p-4"
|
className="border border-gray-200 hover:border-re-green hover:shadow-sm transition-all rounded-lg bg-white p-4"
|
||||||
data-testid={`user-${user.email}`}
|
data-testid={`user-${user.email}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
@ -752,10 +756,10 @@ export function UserRoleManager() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -127,16 +127,24 @@ export function ShareSummaryModal({ isOpen, onClose, summaryId, requestTitle, on
|
|||||||
|
|
||||||
{!searching && users.length > 0 && (
|
{!searching && users.length > 0 && (
|
||||||
<div className="border rounded-lg max-h-[300px] overflow-y-auto">
|
<div className="border rounded-lg max-h-[300px] overflow-y-auto">
|
||||||
{users.map((user) => (
|
{users.map((user) => {
|
||||||
|
const isSelected = selectedUserIds.has(user.userId);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={user.userId}
|
key={user.userId}
|
||||||
className="flex items-center gap-3 p-3 hover:bg-gray-50 border-b last:border-b-0 cursor-pointer"
|
className="flex items-center gap-3 p-3 hover:bg-gray-50 border-b last:border-b-0 cursor-pointer"
|
||||||
onClick={() => handleToggleUser(user.userId)}
|
onClick={() => handleToggleUser(user.userId)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
className="flex items-center"
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedUserIds.has(user.userId)}
|
checked={isSelected}
|
||||||
onCheckedChange={() => handleToggleUser(user.userId)}
|
onCheckedChange={() => handleToggleUser(user.userId)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<User className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
<User className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||||||
@ -150,7 +158,8 @@ export function ShareSummaryModal({ isOpen, onClose, summaryId, requestTitle, on
|
|||||||
<p className="text-xs text-gray-400 truncate">{user.email}</p>
|
<p className="text-xs text-gray-400 truncate">{user.email}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Bell, CheckCircle, XCircle } from 'lucide-react';
|
import { Bell, CheckCircle, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
@ -23,6 +23,11 @@ export function NotificationStatusModal({
|
|||||||
<Bell className="w-5 h-5 text-blue-600" />
|
<Bell className="w-5 h-5 text-blue-600" />
|
||||||
Push Notifications
|
Push Notifications
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
{success
|
||||||
|
? 'Push notifications have been successfully enabled for your account.'
|
||||||
|
: 'There was an error enabling push notifications. Please review the details below.'}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="py-6">
|
<div className="py-6">
|
||||||
|
|||||||
@ -121,7 +121,7 @@ export function useConclusionRemark(
|
|||||||
* Purpose: Submit conclusion remark and close the request
|
* Purpose: Submit conclusion remark and close the request
|
||||||
*
|
*
|
||||||
* Business Logic:
|
* Business Logic:
|
||||||
* - Only initiators can finalize approved requests
|
* - Only initiators can finalize approved or rejected requests
|
||||||
* - Conclusion cannot be empty
|
* - Conclusion cannot be empty
|
||||||
* - After finalization:
|
* - After finalization:
|
||||||
* → Request status changes to CLOSED
|
* → Request status changes to CLOSED
|
||||||
@ -206,13 +206,13 @@ export function useConclusionRemark(
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect: Auto-fetch existing conclusion when request becomes approved
|
* Effect: Auto-fetch existing conclusion when request becomes approved or rejected
|
||||||
*
|
*
|
||||||
* Trigger: When request status changes to "approved" and user is initiator
|
* Trigger: When request status changes to "approved" or "rejected" and user is initiator
|
||||||
* Purpose: Load any conclusion generated by final approver
|
* Purpose: Load any conclusion generated by final approver (for approved) or AI (for rejected)
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (request?.status === 'approved' && isInitiator && !conclusionRemark) {
|
if ((request?.status === 'approved' || request?.status === 'rejected') && isInitiator && !conclusionRemark) {
|
||||||
fetchExistingConclusion();
|
fetchExistingConclusion();
|
||||||
}
|
}
|
||||||
}, [request?.status, isInitiator]);
|
}, [request?.status, isInitiator]);
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import { LogIn, Shield } from 'lucide-react';
|
import { LogIn } from 'lucide-react';
|
||||||
|
import { ReLogo } from '@/assets';
|
||||||
|
|
||||||
export function Auth() {
|
export function Auth() {
|
||||||
const { login, isLoading, error } = useAuth();
|
const { login, isLoading, error } = useAuth();
|
||||||
@ -34,17 +35,14 @@ export function Auth() {
|
|||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4">
|
||||||
<Card className="w-full max-w-md shadow-xl">
|
<Card className="w-full max-w-md shadow-xl">
|
||||||
<CardHeader className="space-y-1 text-center pb-6">
|
<CardHeader className="space-y-1 text-center pb-6">
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex flex-col items-center justify-center mb-4">
|
||||||
<div className="w-16 h-16 bg-gradient-to-br from-slate-800 to-slate-900 rounded-2xl flex items-center justify-center shadow-lg">
|
<img
|
||||||
<Shield className="w-8 h-8 text-white" />
|
src={ReLogo}
|
||||||
|
alt="Royal Enfield Logo"
|
||||||
|
className="h-10 w-auto max-w-[168px] object-contain mb-2"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 text-center truncate">Approval Portal</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<CardTitle className="text-3xl font-bold text-gray-900">
|
|
||||||
Royal Enfield
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-lg text-gray-600 mt-2">
|
|
||||||
Approval & Request Management Portal
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@ -58,12 +56,14 @@ export function Auth() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleSSOLogin}
|
onClick={handleSSOLogin}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="w-full h-12 text-base font-semibold bg-slate-900 hover:bg-slate-800"
|
className="w-full h-12 text-base font-semibold bg-re-red hover:bg-re-red/90 text-white"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-background border-t-transparent" />
|
<div
|
||||||
|
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
|
||||||
|
/>
|
||||||
Logging in...
|
Logging in...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
|
import { CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
import { ReLogo } from '@/assets';
|
||||||
|
|
||||||
export function AuthCallback() {
|
export function AuthCallback() {
|
||||||
const { isAuthenticated, isLoading, error, user } = useAuth();
|
const { isAuthenticated, isLoading, error, user } = useAuth();
|
||||||
@ -59,24 +60,14 @@ export function AuthCallback() {
|
|||||||
<div className="relative z-10 text-center px-4 max-w-md w-full">
|
<div className="relative z-10 text-center px-4 max-w-md w-full">
|
||||||
{/* Logo/Brand Section */}
|
{/* Logo/Brand Section */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="inline-flex items-center justify-center w-20 h-20 mb-4 rounded-2xl bg-gradient-to-br from-orange-500 to-red-600 shadow-lg shadow-orange-500/20">
|
<div className="flex flex-col items-center justify-center">
|
||||||
<svg
|
<img
|
||||||
className="w-12 h-12 text-white"
|
src={ReLogo}
|
||||||
fill="none"
|
alt="Royal Enfield Logo"
|
||||||
stroke="currentColor"
|
className="h-10 w-auto max-w-[168px] object-contain mb-2"
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
<p className="text-xs text-gray-400 text-center truncate">Approval Portal</p>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-white mb-2">Royal Enfield</h1>
|
|
||||||
<p className="text-slate-400 text-sm">Approval Portal</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Loader Card */}
|
{/* Main Loader Card */}
|
||||||
@ -99,10 +90,10 @@ export function AuthCallback() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Loader2 className="w-16 h-16 text-orange-500 animate-spin" />
|
<Loader2 className="w-16 h-16 animate-spin text-re-red" />
|
||||||
{/* Outer rotating ring */}
|
{/* Outer rotating ring */}
|
||||||
<div className="absolute inset-0 border-4 border-orange-500/20 rounded-full"></div>
|
<div className="absolute inset-0 border-4 rounded-full border-re-red/20"></div>
|
||||||
<div className="absolute inset-0 border-4 border-transparent border-t-orange-500 rounded-full animate-spin"></div>
|
<div className="absolute inset-0 border-4 border-transparent border-t-re-red rounded-full animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -119,11 +110,11 @@ export function AuthCallback() {
|
|||||||
{authStep !== 'error' && (
|
{authStep !== 'error' && (
|
||||||
<div className="space-y-3 mb-6">
|
<div className="space-y-3 mb-6">
|
||||||
<div className={`flex items-center gap-3 text-sm transition-all duration-500 ${authStep === 'exchanging' ? 'text-white' : 'text-slate-400'}`}>
|
<div className={`flex items-center gap-3 text-sm transition-all duration-500 ${authStep === 'exchanging' ? 'text-white' : 'text-slate-400'}`}>
|
||||||
<div className={`w-2 h-2 rounded-full transition-all duration-500 ${authStep === 'exchanging' ? 'bg-orange-500 animate-pulse' : 'bg-slate-600'}`}></div>
|
<div className={`w-2 h-2 rounded-full transition-all duration-500 ${authStep === 'exchanging' ? 'bg-re-red animate-pulse' : 'bg-slate-600'}`}></div>
|
||||||
<span>Validating credentials</span>
|
<span>Validating credentials</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex items-center gap-3 text-sm transition-all duration-500 ${authStep === 'fetching' ? 'text-white' : 'text-slate-400'}`}>
|
<div className={`flex items-center gap-3 text-sm transition-all duration-500 ${authStep === 'fetching' ? 'text-white' : 'text-slate-400'}`}>
|
||||||
<div className={`w-2 h-2 rounded-full transition-all duration-500 ${authStep === 'fetching' ? 'bg-orange-500 animate-pulse' : 'bg-slate-600'}`}></div>
|
<div className={`w-2 h-2 rounded-full transition-all duration-500 ${authStep === 'fetching' ? 'bg-re-red animate-pulse' : 'bg-slate-600'}`}></div>
|
||||||
<span>Loading your profile</span>
|
<span>Loading your profile</span>
|
||||||
</div>
|
</div>
|
||||||
{authStep === 'complete' && (
|
{authStep === 'complete' && (
|
||||||
@ -156,7 +147,7 @@ export function AuthCallback() {
|
|||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<div className="h-1.5 bg-slate-700/50 rounded-full overflow-hidden">
|
<div className="h-1.5 bg-slate-700/50 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-gradient-to-r from-orange-500 to-red-600 rounded-full animate-pulse"
|
className="h-full bg-re-red rounded-full animate-pulse"
|
||||||
style={{
|
style={{
|
||||||
animation: 'progress 2s ease-in-out infinite',
|
animation: 'progress 2s ease-in-out infinite',
|
||||||
}}
|
}}
|
||||||
@ -180,8 +171,8 @@ export function AuthCallback() {
|
|||||||
|
|
||||||
{/* Animated Background Elements */}
|
{/* Animated Background Elements */}
|
||||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-orange-500/5 rounded-full blur-3xl animate-pulse"></div>
|
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-re-red/5 rounded-full blur-3xl animate-pulse"></div>
|
||||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-red-500/5 rounded-full blur-3xl animate-pulse delay-1000"></div>
|
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-re-red/5 rounded-full blur-3xl animate-pulse delay-1000"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
FileCheck,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
@ -37,13 +38,14 @@ import { downloadDocument } from '@/services/workflowApi';
|
|||||||
// Components
|
// Components
|
||||||
import { RequestDetailHeader } from './components/RequestDetailHeader';
|
import { RequestDetailHeader } from './components/RequestDetailHeader';
|
||||||
import { ShareSummaryModal } from '@/components/modals/ShareSummaryModal';
|
import { ShareSummaryModal } from '@/components/modals/ShareSummaryModal';
|
||||||
import { createSummary } from '@/services/summaryApi';
|
import { createSummary, getSummaryDetails, type SummaryDetails } from '@/services/summaryApi';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { OverviewTab } from './components/tabs/OverviewTab';
|
import { OverviewTab } from './components/tabs/OverviewTab';
|
||||||
import { WorkflowTab } from './components/tabs/WorkflowTab';
|
import { WorkflowTab } from './components/tabs/WorkflowTab';
|
||||||
import { DocumentsTab } from './components/tabs/DocumentsTab';
|
import { DocumentsTab } from './components/tabs/DocumentsTab';
|
||||||
import { ActivityTab } from './components/tabs/ActivityTab';
|
import { ActivityTab } from './components/tabs/ActivityTab';
|
||||||
import { WorkNotesTab } from './components/tabs/WorkNotesTab';
|
import { WorkNotesTab } from './components/tabs/WorkNotesTab';
|
||||||
|
import { SummaryTab } from './components/tabs/SummaryTab';
|
||||||
import { QuickActionsSidebar } from './components/QuickActionsSidebar';
|
import { QuickActionsSidebar } from './components/QuickActionsSidebar';
|
||||||
import { RequestDetailModals } from './components/RequestDetailModals';
|
import { RequestDetailModals } from './components/RequestDetailModals';
|
||||||
import { RequestDetailProps } from './types/requestDetail.types';
|
import { RequestDetailProps } from './types/requestDetail.types';
|
||||||
@ -100,6 +102,8 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
|||||||
const [activeTab, setActiveTab] = useState(initialTab);
|
const [activeTab, setActiveTab] = useState(initialTab);
|
||||||
const [showShareSummaryModal, setShowShareSummaryModal] = useState(false);
|
const [showShareSummaryModal, setShowShareSummaryModal] = useState(false);
|
||||||
const [summaryId, setSummaryId] = useState<string | null>(null);
|
const [summaryId, setSummaryId] = useState<string | null>(null);
|
||||||
|
const [summaryDetails, setSummaryDetails] = useState<SummaryDetails | null>(null);
|
||||||
|
const [loadingSummary, setLoadingSummary] = useState(false);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
// Custom hooks
|
// Custom hooks
|
||||||
@ -185,26 +189,80 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if summary already exists, if not create it
|
// Get or create summary (backend returns existing summary if it exists - idempotent)
|
||||||
|
// Summary should already exist from closure, but create if missing (handles edge cases)
|
||||||
let currentSummaryId = summaryId;
|
let currentSummaryId = summaryId;
|
||||||
if (!currentSummaryId) {
|
if (!currentSummaryId) {
|
||||||
const summary = await createSummary(apiRequest.requestId);
|
const summary = await createSummary(apiRequest.requestId);
|
||||||
currentSummaryId = summary.summaryId;
|
currentSummaryId = summary.summaryId;
|
||||||
setSummaryId(currentSummaryId);
|
setSummaryId(currentSummaryId);
|
||||||
|
// Refresh summary details after creating
|
||||||
|
try {
|
||||||
|
const details = await getSummaryDetails(currentSummaryId);
|
||||||
|
setSummaryDetails(details);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch summary details after creation:', error);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Open share modal with the summary ID (only after we have the ID)
|
||||||
|
if (currentSummaryId) {
|
||||||
setShowShareSummaryModal(true);
|
setShowShareSummaryModal(true);
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to create/get summary:', error);
|
console.error('Failed to create/get summary:', error);
|
||||||
if (error?.response?.status === 400 && error?.response?.data?.message?.includes('already exists')) {
|
const errorMessage = error?.response?.data?.error || error?.response?.data?.message || error?.message;
|
||||||
// Summary already exists, try to get it
|
toast.error(errorMessage || 'Failed to prepare summary for sharing');
|
||||||
toast.error('Summary already exists. Please refresh the page.');
|
|
||||||
} else {
|
|
||||||
toast.error(error?.response?.data?.message || 'Failed to prepare summary for sharing');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const needsClosure = request?.status === 'approved' && isInitiator;
|
const needsClosure = (request?.status === 'approved' || request?.status === 'rejected') && isInitiator;
|
||||||
|
|
||||||
|
// Check if request is closed (or needs closure for approved/rejected)
|
||||||
|
const isClosed = request?.status === 'closed' || (request?.status === 'approved' && !isInitiator) || (request?.status === 'rejected' && !isInitiator);
|
||||||
|
|
||||||
|
// Fetch summary details if request is closed
|
||||||
|
// Summary should be automatically created when request is closed, but we'll create it if missing (idempotent)
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSummaryDetails = async () => {
|
||||||
|
if (!isClosed || !apiRequest?.requestId) {
|
||||||
|
setSummaryDetails(null);
|
||||||
|
setSummaryId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoadingSummary(true);
|
||||||
|
// Use createSummary which is idempotent - returns existing summary if it exists, creates if missing
|
||||||
|
// This handles cases where summary creation failed during closure or was not created yet
|
||||||
|
const summary = await createSummary(apiRequest.requestId);
|
||||||
|
if (summary?.summaryId) {
|
||||||
|
setSummaryId(summary.summaryId);
|
||||||
|
// Fetch full summary details
|
||||||
|
try {
|
||||||
|
const details = await getSummaryDetails(summary.summaryId);
|
||||||
|
setSummaryDetails(details);
|
||||||
|
} catch (error: any) {
|
||||||
|
// If we can't get details, clear summary
|
||||||
|
console.error('Failed to fetch summary details:', error);
|
||||||
|
setSummaryDetails(null);
|
||||||
|
setSummaryId(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSummaryDetails(null);
|
||||||
|
setSummaryId(null);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// If summary creation fails, don't show tab but log error
|
||||||
|
console.error('Summary not available:', error?.message);
|
||||||
|
setSummaryDetails(null);
|
||||||
|
setSummaryId(null);
|
||||||
|
} finally {
|
||||||
|
setLoadingSummary(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSummaryDetails();
|
||||||
|
}, [isClosed, apiRequest?.requestId]);
|
||||||
|
|
||||||
// Get current levels for WorkNotesTab
|
// Get current levels for WorkNotesTab
|
||||||
const currentLevels = (request?.approvalFlow || [])
|
const currentLevels = (request?.approvalFlow || [])
|
||||||
@ -260,7 +318,7 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
|||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full" data-testid="request-detail-tabs">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full" data-testid="request-detail-tabs">
|
||||||
<div className="mb-4 sm:mb-6">
|
<div className="mb-4 sm:mb-6">
|
||||||
<TabsList className="grid grid-cols-3 sm:grid-cols-5 lg:flex lg:flex-row h-auto bg-gray-100 p-1.5 sm:p-1 rounded-lg gap-1.5 sm:gap-1">
|
<TabsList className="grid grid-cols-3 sm:grid-cols-6 lg:flex lg:flex-row h-auto bg-gray-100 p-1.5 sm:p-1 rounded-lg gap-1.5 sm:gap-1">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="overview"
|
value="overview"
|
||||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
|
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
|
||||||
@ -269,6 +327,16 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
|||||||
<ClipboardList className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
<ClipboardList className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
||||||
<span className="truncate">Overview</span>
|
<span className="truncate">Overview</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
{isClosed && summaryDetails && (
|
||||||
|
<TabsTrigger
|
||||||
|
value="summary"
|
||||||
|
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
|
||||||
|
data-testid="tab-summary"
|
||||||
|
>
|
||||||
|
<FileCheck className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
||||||
|
<span className="truncate">Summary</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="workflow"
|
value="workflow"
|
||||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
|
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
|
||||||
@ -331,6 +399,17 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{isClosed && (
|
||||||
|
<TabsContent value="summary" className="mt-0" data-testid="summary-tab-content">
|
||||||
|
<SummaryTab
|
||||||
|
summary={summaryDetails}
|
||||||
|
loading={loadingSummary}
|
||||||
|
onShare={handleShareSummary}
|
||||||
|
isInitiator={isInitiator}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
<TabsContent value="workflow" className="mt-0">
|
<TabsContent value="workflow" className="mt-0">
|
||||||
<WorkflowTab
|
<WorkflowTab
|
||||||
request={request}
|
request={request}
|
||||||
|
|||||||
@ -30,8 +30,8 @@ export function QuickActionsSidebar({
|
|||||||
}: QuickActionsSidebarProps) {
|
}: QuickActionsSidebarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 sm:space-y-6">
|
<div className="space-y-4 sm:space-y-6">
|
||||||
{/* Quick Actions Card - Hide entire card for spectators */}
|
{/* Quick Actions Card - Hide entire card for spectators and closed requests */}
|
||||||
{!isSpectator && (
|
{!isSpectator && request.status !== 'closed' && (
|
||||||
<Card data-testid="quick-actions-card">
|
<Card data-testid="quick-actions-card">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm sm:text-base">Quick Actions</CardTitle>
|
<CardTitle className="text-sm sm:text-base">Quick Actions</CardTitle>
|
||||||
@ -92,13 +92,13 @@ export function QuickActionsSidebar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Spectators Card */}
|
{/* Spectators Card */}
|
||||||
{request.spectators && request.spectators.length > 0 && (
|
|
||||||
<Card data-testid="spectators-card">
|
<Card data-testid="spectators-card">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm sm:text-base">Spectators</CardTitle>
|
<CardTitle className="text-sm sm:text-base">Spectators</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{request.spectators.map((spectator: any, index: number) => (
|
{request.spectators && request.spectators.length > 0 ? (
|
||||||
|
request.spectators.map((spectator: any, index: number) => (
|
||||||
<div key={index} className="flex items-center gap-3" data-testid={`spectator-${index}`}>
|
<div key={index} className="flex items-center gap-3" data-testid={`spectator-${index}`}>
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
<AvatarFallback className="bg-blue-100 text-blue-800 text-xs font-semibold">
|
<AvatarFallback className="bg-blue-100 text-blue-800 text-xs font-semibold">
|
||||||
@ -110,10 +110,14 @@ export function QuickActionsSidebar({
|
|||||||
<p className="text-xs text-gray-500 truncate">{spectator.role}</p>
|
<p className="text-xs text-gray-500 truncate">{spectator.role}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<div className="py-4 text-center">
|
||||||
|
<p className="text-sm text-gray-500">No spectators added</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -210,15 +210,25 @@ export function OverviewTab({
|
|||||||
{/* Conclusion Remark Section */}
|
{/* Conclusion Remark Section */}
|
||||||
{needsClosure && (
|
{needsClosure && (
|
||||||
<Card data-testid="conclusion-remark-card">
|
<Card data-testid="conclusion-remark-card">
|
||||||
<CardHeader className="bg-gradient-to-r from-green-50 to-emerald-50 border-b border-green-200">
|
<CardHeader className={`bg-gradient-to-r border-b ${
|
||||||
|
request.status === 'rejected'
|
||||||
|
? 'from-red-50 to-rose-50 border-red-200'
|
||||||
|
: 'from-green-50 to-emerald-50 border-green-200'
|
||||||
|
}`}>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
<CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${
|
||||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
request.status === 'rejected' ? 'text-red-700' : 'text-green-700'
|
||||||
|
}`}>
|
||||||
|
<CheckCircle className={`w-5 h-5 ${
|
||||||
|
request.status === 'rejected' ? 'text-red-600' : 'text-green-600'
|
||||||
|
}`} />
|
||||||
Conclusion Remark - Final Step
|
Conclusion Remark - Final Step
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="mt-1 text-xs sm:text-sm">
|
<CardDescription className="mt-1 text-xs sm:text-sm">
|
||||||
All approvals are complete. Please review and finalize the conclusion to close this request.
|
{request.status === 'rejected'
|
||||||
|
? 'This request was rejected. Please review the AI-generated closure remark and finalize it to close this request.'
|
||||||
|
: 'All approvals are complete. Please review and finalize the conclusion to close this request.'}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
203
src/pages/RequestDetail/components/tabs/SummaryTab.tsx
Normal file
203
src/pages/RequestDetail/components/tabs/SummaryTab.tsx
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { FileText, CheckCircle, XCircle, Clock, Loader2, Share2 } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import type { SummaryDetails } from '@/services/summaryApi';
|
||||||
|
|
||||||
|
interface SummaryTabProps {
|
||||||
|
summary: SummaryDetails | null;
|
||||||
|
loading: boolean;
|
||||||
|
onShare?: () => void;
|
||||||
|
isInitiator?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTabProps) {
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
const statusLower = status.toLowerCase();
|
||||||
|
if (statusLower === 'approved') return <CheckCircle className="h-4 w-4 text-green-600" />;
|
||||||
|
if (statusLower === 'rejected') return <XCircle className="h-4 w-4 text-red-600" />;
|
||||||
|
if (statusLower === 'pending' || statusLower === 'in progress') return <Clock className="h-4 w-4 text-orange-600" />;
|
||||||
|
return <FileText className="h-4 w-4 text-gray-600" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
const statusLower = status.toLowerCase();
|
||||||
|
if (statusLower === 'approved') return 'bg-green-100 text-green-700 border-green-300';
|
||||||
|
if (statusLower === 'rejected') return 'bg-red-100 text-red-700 border-red-300';
|
||||||
|
if (statusLower === 'pending' || statusLower === 'in progress') return 'bg-orange-100 text-orange-700 border-orange-300';
|
||||||
|
return 'bg-gray-100 text-gray-700 border-gray-300';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get designation or department (fallback to department if designation is N/A or empty)
|
||||||
|
const getDesignationOrDepartment = (designation?: string | null, department?: string | null) => {
|
||||||
|
if (designation && designation.trim() && designation.trim().toUpperCase() !== 'N/A') {
|
||||||
|
return designation;
|
||||||
|
}
|
||||||
|
if (department && department.trim() && department.trim().toUpperCase() !== 'N/A') {
|
||||||
|
return department;
|
||||||
|
}
|
||||||
|
return 'N/A';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="h-12 w-12 animate-spin text-blue-600 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-600">Loading summary...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-2">Summary Not Available</h2>
|
||||||
|
<p className="text-gray-600">Summary has not been generated for this request yet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary Card */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||||
|
<div className="p-6 border-b border-gray-200">
|
||||||
|
<div className="flex items-start justify-between gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">{summary.title}</h2>
|
||||||
|
<p className="text-sm text-gray-600">Request #{summary.requestNumber}</p>
|
||||||
|
</div>
|
||||||
|
{isInitiator && onShare ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onShare}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Share2 className="w-4 h-4" />
|
||||||
|
<span>Share</span>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Badge className={getStatusColor(summary.workflow.status)}>
|
||||||
|
{getStatusIcon(summary.workflow.status)}
|
||||||
|
<span className="ml-1 capitalize">{summary.workflow.status}</span>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{summary.description && (
|
||||||
|
<p className="text-gray-700 mb-4">{summary.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Initiator Section */}
|
||||||
|
<div className="p-6 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Initiator</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Name</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{summary.initiator.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Designation</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900">
|
||||||
|
{getDesignationOrDepartment(summary.initiator.designation, summary.initiator.department)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Status</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{summary.initiator.status}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Time Stamp</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900">
|
||||||
|
{format(new Date(summary.initiator.timestamp), 'MMM dd, yy, HH:mm')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Approvers Section */}
|
||||||
|
{summary.approvers && summary.approvers.length > 0 && (
|
||||||
|
<div className="p-6 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Workflow</h3>
|
||||||
|
{summary.approvers.map((approver, index) => (
|
||||||
|
<div key={index} className="mb-6 last:mb-0">
|
||||||
|
<h4 className="text-md font-semibold text-gray-800 mb-3">
|
||||||
|
{approver.levelName || `Approver ${approver.levelNumber}`}
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Name</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{approver.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Designation</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900">
|
||||||
|
{getDesignationOrDepartment(approver.designation, approver.department)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Status</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{getStatusIcon(approver.status)}
|
||||||
|
<p className="text-sm font-medium text-gray-900">{approver.status}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Time Stamp</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900">
|
||||||
|
{format(new Date(approver.timestamp), 'MMM dd, yy, HH:mm')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Remarks</p>
|
||||||
|
<p className="text-sm text-gray-700">{approver.remarks || '—'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Closing Remarks Section */}
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Closing Remarks (Conclusion)</h3>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Name</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{summary.initiator.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Designation</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900">
|
||||||
|
{getDesignationOrDepartment(summary.initiator.designation, summary.initiator.department)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Status</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900">Concluded</p>
|
||||||
|
</div>
|
||||||
|
{summary.isAiGenerated && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Source</p>
|
||||||
|
<Badge variant="outline" className="text-xs">AI Generated</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Remarks</p>
|
||||||
|
<p className="text-sm text-gray-700 whitespace-pre-wrap">{summary.closingRemarks || '—'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -2,7 +2,9 @@
|
|||||||
* User All Requests Page - For Regular Users
|
* User All Requests Page - For Regular Users
|
||||||
*
|
*
|
||||||
* This is a SEPARATE screen for regular users' "All Requests" page.
|
* This is a SEPARATE screen for regular users' "All Requests" page.
|
||||||
* Shows only requests where the user is a participant (approver/spectator), NOT initiator.
|
* Shows requests where the user is EITHER:
|
||||||
|
* - The initiator (created by the user), OR
|
||||||
|
* - A participant (approver/spectator)
|
||||||
* Completely separate from AdminAllRequests to avoid interference.
|
* Completely separate from AdminAllRequests to avoid interference.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Service for fetching user participant requests data
|
* Service for fetching user requests data (initiator + participant)
|
||||||
* SEPARATE from admin requests service to avoid interference
|
* SEPARATE from admin requests service to avoid interference
|
||||||
*
|
*
|
||||||
* This service is specifically for regular users' "All Requests" page
|
* This service is specifically for regular users' "All Requests" page
|
||||||
* Shows only requests where user is a participant (approver/spectator), NOT initiator
|
* Shows requests where user is EITHER initiator OR participant (approver/spectator)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import workflowApi from '@/services/workflowApi';
|
import workflowApi from '@/services/workflowApi';
|
||||||
@ -11,21 +11,21 @@ import type { RequestFilters } from '../types/requests.types';
|
|||||||
|
|
||||||
const EXPORT_FETCH_LIMIT = 100;
|
const EXPORT_FETCH_LIMIT = 100;
|
||||||
|
|
||||||
interface FetchUserParticipantRequestsOptions {
|
interface FetchUserAllRequestsOptions {
|
||||||
page: number;
|
page: number;
|
||||||
itemsPerPage: number;
|
itemsPerPage: number;
|
||||||
filters?: RequestFilters;
|
filters?: RequestFilters;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch participant requests for regular users
|
* Fetch all requests for regular users (initiator + participant)
|
||||||
* Uses /workflows/participant-requests endpoint which excludes initiator requests
|
* Combines requests where user is initiator AND requests where user is participant
|
||||||
*/
|
*/
|
||||||
export async function fetchUserParticipantRequestsData({
|
export async function fetchUserParticipantRequestsData({
|
||||||
page,
|
page,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
filters
|
filters
|
||||||
}: FetchUserParticipantRequestsOptions) {
|
}: FetchUserAllRequestsOptions) {
|
||||||
// Build filter params for backend API
|
// Build filter params for backend API
|
||||||
const backendFilters: any = {};
|
const backendFilters: any = {};
|
||||||
if (filters?.search) backendFilters.search = filters.search;
|
if (filters?.search) backendFilters.search = filters.search;
|
||||||
@ -42,51 +42,127 @@ export async function fetchUserParticipantRequestsData({
|
|||||||
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
|
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
|
||||||
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
|
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
|
||||||
|
|
||||||
// Fetch paginated data using SEPARATE endpoint for regular users
|
// To properly merge and paginate, we need to fetch enough data from both endpoints
|
||||||
// This endpoint automatically excludes initiator requests
|
// Fetch multiple pages from each endpoint to ensure we have enough data to merge and paginate correctly
|
||||||
const pageResult = await workflowApi.listParticipantRequests({
|
const fetchLimit = Math.max(itemsPerPage * 3, 100); // Fetch at least 3 pages worth or 100 items, whichever is larger
|
||||||
page,
|
|
||||||
limit: itemsPerPage,
|
|
||||||
...backendFilters
|
|
||||||
});
|
|
||||||
|
|
||||||
let pageData: any[] = [];
|
// Fetch from both endpoints in parallel
|
||||||
if (Array.isArray(pageResult?.data)) {
|
const [initiatorResult, participantResult] = await Promise.all([
|
||||||
pageData = pageResult.data;
|
// Fetch requests where user is initiator (fetch more to account for merging)
|
||||||
} else if (Array.isArray(pageResult)) {
|
workflowApi.listMyInitiatedWorkflows({
|
||||||
pageData = pageResult;
|
page: 1,
|
||||||
|
limit: fetchLimit,
|
||||||
|
search: backendFilters.search,
|
||||||
|
status: backendFilters.status,
|
||||||
|
priority: backendFilters.priority,
|
||||||
|
department: backendFilters.department,
|
||||||
|
slaCompliance: backendFilters.slaCompliance,
|
||||||
|
dateRange: backendFilters.dateRange,
|
||||||
|
startDate: backendFilters.startDate,
|
||||||
|
endDate: backendFilters.endDate
|
||||||
|
}),
|
||||||
|
// Fetch requests where user is participant (approver/spectator)
|
||||||
|
workflowApi.listParticipantRequests({
|
||||||
|
page: 1,
|
||||||
|
limit: fetchLimit,
|
||||||
|
...backendFilters
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Extract data from both results
|
||||||
|
let initiatorData: any[] = [];
|
||||||
|
if (Array.isArray(initiatorResult?.data)) {
|
||||||
|
initiatorData = initiatorResult.data;
|
||||||
|
} else if (Array.isArray(initiatorResult)) {
|
||||||
|
initiatorData = initiatorResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out drafts (backend should handle this, but double-check)
|
let participantData: any[] = [];
|
||||||
const nonDraftData = pageData.filter((req: any) => {
|
if (Array.isArray(participantResult?.data)) {
|
||||||
|
participantData = participantResult.data;
|
||||||
|
} else if (Array.isArray(participantResult)) {
|
||||||
|
participantData = participantResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out drafts from both
|
||||||
|
const nonDraftInitiatorData = initiatorData.filter((req: any) => {
|
||||||
const reqStatus = (req.status || '').toString().toUpperCase();
|
const reqStatus = (req.status || '').toString().toUpperCase();
|
||||||
return reqStatus !== 'DRAFT';
|
return reqStatus !== 'DRAFT';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get pagination info from backend response
|
const nonDraftParticipantData = participantData.filter((req: any) => {
|
||||||
const pagination = pageResult?.pagination || {
|
const reqStatus = (req.status || '').toString().toUpperCase();
|
||||||
|
return reqStatus !== 'DRAFT';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge and deduplicate by requestId
|
||||||
|
const mergedMap = new Map<string, any>();
|
||||||
|
|
||||||
|
// Add initiator requests
|
||||||
|
nonDraftInitiatorData.forEach((req: any) => {
|
||||||
|
const requestId = req.requestId || req.id;
|
||||||
|
if (requestId) {
|
||||||
|
mergedMap.set(requestId, req);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add participant requests (will overwrite if duplicate, but that's fine)
|
||||||
|
nonDraftParticipantData.forEach((req: any) => {
|
||||||
|
const requestId = req.requestId || req.id;
|
||||||
|
if (requestId) {
|
||||||
|
mergedMap.set(requestId, req);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert map to array
|
||||||
|
const mergedData = Array.from(mergedMap.values());
|
||||||
|
|
||||||
|
// Sort by updatedAt or createdAt (most recent first)
|
||||||
|
mergedData.sort((a: any, b: any) => {
|
||||||
|
const dateA = new Date(a.updatedAt || a.createdAt || 0).getTime();
|
||||||
|
const dateB = new Date(b.updatedAt || b.createdAt || 0).getTime();
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate combined pagination
|
||||||
|
const initiatorPagination = initiatorResult?.pagination || { page: 1, limit: fetchLimit, total: 0, totalPages: 1 };
|
||||||
|
const participantPagination = participantResult?.pagination || { page: 1, limit: fetchLimit, total: 0, totalPages: 1 };
|
||||||
|
|
||||||
|
// Estimate total: sum of both totals, but account for potential duplicates
|
||||||
|
// We'll use a conservative estimate: sum of both, but we know there might be overlap
|
||||||
|
const estimatedTotal = (initiatorPagination.total || 0) + (participantPagination.total || 0);
|
||||||
|
// The actual merged count might be less due to duplicates, but we use the merged length if we have enough data
|
||||||
|
const actualTotal = mergedData.length >= fetchLimit ? estimatedTotal : mergedData.length;
|
||||||
|
|
||||||
|
// Paginate the merged results
|
||||||
|
const startIndex = (page - 1) * itemsPerPage;
|
||||||
|
const endIndex = startIndex + itemsPerPage;
|
||||||
|
const paginatedData = mergedData.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
const pagination = {
|
||||||
page,
|
page,
|
||||||
limit: itemsPerPage,
|
limit: itemsPerPage,
|
||||||
total: nonDraftData.length,
|
total: actualTotal,
|
||||||
totalPages: 1
|
totalPages: Math.ceil(actualTotal / itemsPerPage) || 1
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: nonDraftData, // Paginated data for list display
|
data: paginatedData, // Paginated merged data for list display
|
||||||
allData: [], // Stats calculated from data
|
allData: [], // Stats calculated from data
|
||||||
filteredData: nonDraftData, // Same as data for list
|
filteredData: paginatedData, // Same as data for list
|
||||||
pagination: pagination
|
pagination: pagination
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all participant requests for export (regular users)
|
* Fetch all requests for export (regular users - initiator + participant)
|
||||||
* Uses the same endpoint but fetches all pages
|
* Fetches from both endpoints and merges results
|
||||||
*/
|
*/
|
||||||
export async function fetchAllRequestsForExport(filters?: RequestFilters): Promise<any[]> {
|
export async function fetchAllRequestsForExport(filters?: RequestFilters): Promise<any[]> {
|
||||||
const allPages: any[] = [];
|
const allInitiatorPages: any[] = [];
|
||||||
let currentPage = 1;
|
const allParticipantPages: any[] = [];
|
||||||
let hasMore = true;
|
let hasMoreInitiator = true;
|
||||||
|
let hasMoreParticipant = true;
|
||||||
const maxPages = 100; // Safety limit
|
const maxPages = 100; // Safety limit
|
||||||
|
|
||||||
// Build filter params for backend API
|
// Build filter params for backend API
|
||||||
@ -105,25 +181,98 @@ export async function fetchAllRequestsForExport(filters?: RequestFilters): Promi
|
|||||||
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
|
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
|
||||||
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
|
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
|
||||||
|
|
||||||
while (hasMore && currentPage <= maxPages) {
|
// Fetch initiator requests
|
||||||
|
const initiatorFetch = async () => {
|
||||||
|
let page = 1;
|
||||||
|
while (hasMoreInitiator && page <= maxPages) {
|
||||||
|
const pageResult = await workflowApi.listMyInitiatedWorkflows({
|
||||||
|
page,
|
||||||
|
limit: EXPORT_FETCH_LIMIT,
|
||||||
|
search: backendFilters.search,
|
||||||
|
status: backendFilters.status,
|
||||||
|
priority: backendFilters.priority,
|
||||||
|
department: backendFilters.department,
|
||||||
|
slaCompliance: backendFilters.slaCompliance,
|
||||||
|
dateRange: backendFilters.dateRange,
|
||||||
|
startDate: backendFilters.startDate,
|
||||||
|
endDate: backendFilters.endDate
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageData = pageResult?.data || [];
|
||||||
|
if (pageData.length === 0) {
|
||||||
|
hasMoreInitiator = false;
|
||||||
|
} else {
|
||||||
|
allInitiatorPages.push(...pageData);
|
||||||
|
page++;
|
||||||
|
if (pageData.length < EXPORT_FETCH_LIMIT) {
|
||||||
|
hasMoreInitiator = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch participant requests
|
||||||
|
const participantFetch = async () => {
|
||||||
|
let page = 1;
|
||||||
|
while (hasMoreParticipant && page <= maxPages) {
|
||||||
const pageResult = await workflowApi.listParticipantRequests({
|
const pageResult = await workflowApi.listParticipantRequests({
|
||||||
page: currentPage,
|
page,
|
||||||
limit: EXPORT_FETCH_LIMIT,
|
limit: EXPORT_FETCH_LIMIT,
|
||||||
...backendFilters
|
...backendFilters
|
||||||
});
|
});
|
||||||
|
|
||||||
const pageData = pageResult?.data || [];
|
const pageData = pageResult?.data || [];
|
||||||
if (pageData.length === 0) {
|
if (pageData.length === 0) {
|
||||||
hasMore = false;
|
hasMoreParticipant = false;
|
||||||
} else {
|
} else {
|
||||||
allPages.push(...pageData);
|
allParticipantPages.push(...pageData);
|
||||||
currentPage++;
|
page++;
|
||||||
if (pageData.length < EXPORT_FETCH_LIMIT) {
|
if (pageData.length < EXPORT_FETCH_LIMIT) {
|
||||||
hasMore = false;
|
hasMoreParticipant = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
return allPages;
|
|
||||||
|
// Fetch both in parallel
|
||||||
|
await Promise.all([initiatorFetch(), participantFetch()]);
|
||||||
|
|
||||||
|
// Filter out drafts
|
||||||
|
const nonDraftInitiator = allInitiatorPages.filter((req: any) => {
|
||||||
|
const reqStatus = (req.status || '').toString().toUpperCase();
|
||||||
|
return reqStatus !== 'DRAFT';
|
||||||
|
});
|
||||||
|
|
||||||
|
const nonDraftParticipant = allParticipantPages.filter((req: any) => {
|
||||||
|
const reqStatus = (req.status || '').toString().toUpperCase();
|
||||||
|
return reqStatus !== 'DRAFT';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge and deduplicate by requestId
|
||||||
|
const mergedMap = new Map<string, any>();
|
||||||
|
|
||||||
|
nonDraftInitiator.forEach((req: any) => {
|
||||||
|
const requestId = req.requestId || req.id;
|
||||||
|
if (requestId) {
|
||||||
|
mergedMap.set(requestId, req);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nonDraftParticipant.forEach((req: any) => {
|
||||||
|
const requestId = req.requestId || req.id;
|
||||||
|
if (requestId) {
|
||||||
|
mergedMap.set(requestId, req);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to array and sort by date
|
||||||
|
const mergedData = Array.from(mergedMap.values());
|
||||||
|
mergedData.sort((a: any, b: any) => {
|
||||||
|
const dateA = new Date(a.updatedAt || a.createdAt || 0).getTime();
|
||||||
|
const dateB = new Date(b.updatedAt || b.createdAt || 0).getTime();
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
|
||||||
|
return mergedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,8 +8,7 @@ import {
|
|||||||
Palette,
|
Palette,
|
||||||
Lock,
|
Lock,
|
||||||
Calendar,
|
Calendar,
|
||||||
Sliders,
|
Sliders
|
||||||
AlertCircle
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { setupPushNotifications } from '@/utils/pushNotifications';
|
import { setupPushNotifications } from '@/utils/pushNotifications';
|
||||||
import { useAuth, isAdmin as checkIsAdmin } from '@/contexts/AuthContext';
|
import { useAuth, isAdmin as checkIsAdmin } from '@/contexts/AuthContext';
|
||||||
@ -386,21 +385,6 @@ export function Settings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info: Admin features not available */}
|
|
||||||
<Card className="shadow-xl border-0 rounded-md bg-gradient-to-br from-blue-50 to-blue-100/50">
|
|
||||||
<CardContent className="p-5 sm:p-6">
|
|
||||||
<div className="flex items-start sm:items-center gap-3 sm:gap-4">
|
|
||||||
<div className="p-2 sm:p-2.5 bg-blue-500 rounded-lg shrink-0">
|
|
||||||
<AlertCircle className="w-5 h-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-semibold text-gray-900 mb-1">Admin Features Not Accessible</p>
|
|
||||||
<p className="text-xs text-gray-700">System configuration and holiday management require admin privileges. Contact your administrator for access.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -125,3 +125,21 @@ export async function listMySummaries(params: { page?: number; limit?: number }
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get summary by requestId (checks if summary exists without creating)
|
||||||
|
* Returns null if summary doesn't exist
|
||||||
|
*/
|
||||||
|
export async function getSummaryByRequestId(requestId: string): Promise<RequestSummary | null> {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/summaries/request/${requestId}`);
|
||||||
|
return res.data.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
// If summary doesn't exist (404), return null
|
||||||
|
if (error?.response?.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// For other errors, also return null
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -230,8 +230,8 @@ export async function listMyWorkflows(params: { page?: number; limit?: number; s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// List requests where user is the initiator - for "My Requests" page
|
// List requests where user is the initiator - for "My Requests" page
|
||||||
export async function listMyInitiatedWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; department?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
|
export async function listMyInitiatedWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; department?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
|
||||||
const { page = 1, limit = 20, search, status, priority, department, dateRange, startDate, endDate } = params;
|
const { page = 1, limit = 20, search, status, priority, department, slaCompliance, dateRange, startDate, endDate } = params;
|
||||||
const res = await apiClient.get('/workflows/my-initiated', {
|
const res = await apiClient.get('/workflows/my-initiated', {
|
||||||
params: {
|
params: {
|
||||||
page,
|
page,
|
||||||
@ -240,6 +240,7 @@ export async function listMyInitiatedWorkflows(params: { page?: number; limit?:
|
|||||||
status,
|
status,
|
||||||
priority,
|
priority,
|
||||||
department,
|
department,
|
||||||
|
slaCompliance,
|
||||||
dateRange,
|
dateRange,
|
||||||
startDate,
|
startDate,
|
||||||
endDate
|
endDate
|
||||||
|
|||||||
@ -47,6 +47,7 @@
|
|||||||
--re-gold: #c9b037;
|
--re-gold: #c9b037;
|
||||||
--re-dark: #1a1a1a;
|
--re-dark: #1a1a1a;
|
||||||
--re-light-green: #8a9b8e;
|
--re-light-green: #8a9b8e;
|
||||||
|
--re-red: #DA281C;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@ -129,6 +130,7 @@
|
|||||||
--color-re-gold: var(--re-gold);
|
--color-re-gold: var(--re-gold);
|
||||||
--color-re-dark: var(--re-dark);
|
--color-re-dark: var(--re-dark);
|
||||||
--color-re-light-green: var(--re-light-green);
|
--color-re-light-green: var(--re-light-green);
|
||||||
|
--color-re-red: var(--re-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
@ -45,23 +45,79 @@ export async function subscribeUserToPush(register: ServiceWorkerRegistration) {
|
|||||||
throw new Error('Missing VAPID public key configuration');
|
throw new Error('Missing VAPID public key configuration');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate VAPID key format (should be base64 URL-safe string)
|
||||||
|
if (!VAPID_PUBLIC_KEY || VAPID_PUBLIC_KEY.trim().length === 0) {
|
||||||
|
throw new Error('VAPID public key is empty. Please configure VITE_PUBLIC_VAPID_KEY in your environment variables.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if pushManager is available
|
||||||
|
if (!register.pushManager) {
|
||||||
|
throw new Error('Push manager is not available. Please ensure your browser supports push notifications and the service worker is properly registered.');
|
||||||
|
}
|
||||||
|
|
||||||
// Check if already subscribed
|
// Check if already subscribed
|
||||||
let subscription: PushSubscription;
|
let subscription: PushSubscription;
|
||||||
try {
|
try {
|
||||||
const existingSubscription = await register.pushManager.getSubscription();
|
const existingSubscription = await register.pushManager.getSubscription();
|
||||||
|
|
||||||
if (existingSubscription) {
|
if (existingSubscription) {
|
||||||
// Already subscribed, check if it's still valid
|
// Already subscribed, check if it's still valid by trying to use it
|
||||||
|
try {
|
||||||
|
// Verify the subscription is still valid
|
||||||
subscription = existingSubscription;
|
subscription = existingSubscription;
|
||||||
} else {
|
} catch (error) {
|
||||||
// Subscribe to push
|
// Existing subscription is invalid, unsubscribe and create new one
|
||||||
|
console.warn('[Push] Existing subscription is invalid, creating new one...');
|
||||||
|
await existingSubscription.unsubscribe().catch(() => {
|
||||||
|
// Ignore unsubscribe errors
|
||||||
|
});
|
||||||
subscription = await register.pushManager.subscribe({
|
subscription = await register.pushManager.subscribe({
|
||||||
userVisibleOnly: true,
|
userVisibleOnly: true,
|
||||||
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
|
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Subscribe to push
|
||||||
|
try {
|
||||||
|
subscription = await register.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
|
||||||
|
});
|
||||||
|
} catch (subscribeError: any) {
|
||||||
|
// If subscription fails, try to clear any invalid subscriptions and retry once
|
||||||
|
console.warn('[Push] Initial subscription failed, attempting to clear and retry...');
|
||||||
|
try {
|
||||||
|
const allSubscriptions = await register.pushManager.getSubscription();
|
||||||
|
if (allSubscriptions) {
|
||||||
|
await allSubscriptions.unsubscribe().catch(() => {
|
||||||
|
// Ignore unsubscribe errors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Retry subscription
|
||||||
|
subscription = await register.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
|
||||||
|
});
|
||||||
|
} catch (retryError: any) {
|
||||||
|
// Provide more specific error messages
|
||||||
|
const errorMsg = subscribeError?.message || retryError?.message || 'Unknown error';
|
||||||
|
if (errorMsg.includes('push service error') || errorMsg.includes('Registration failed')) {
|
||||||
|
throw new Error('Push service error: The browser\'s push service rejected the subscription. This may be due to an invalid VAPID key, network issues, or browser push service problems. Please verify your VAPID key configuration and try again.');
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to subscribe to push notifications: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
throw new Error(`Failed to subscribe to push notifications: ${error?.message || 'Unknown error'}`);
|
// Provide more helpful error messages
|
||||||
|
const errorMsg = error?.message || 'Unknown error';
|
||||||
|
if (errorMsg.includes('push service error') || errorMsg.includes('Registration failed')) {
|
||||||
|
throw new Error('Push service error: The browser\'s push service rejected the subscription. Please verify your VAPID key is correct and matches the backend configuration. If the problem persists, try clearing your browser cache and service workers.');
|
||||||
|
}
|
||||||
|
if (errorMsg.includes('Invalid key')) {
|
||||||
|
throw new Error('Invalid VAPID key format. Please verify that VITE_PUBLIC_VAPID_KEY is correctly set and matches the backend VAPID_PUBLIC_KEY.');
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to subscribe to push notifications: ${errorMsg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert subscription to JSON format for backend
|
// Convert subscription to JSON format for backend
|
||||||
|
|||||||
@ -62,6 +62,7 @@ module.exports = {
|
|||||||
're-gold': 'var(--re-gold)',
|
're-gold': 'var(--re-gold)',
|
||||||
're-dark': 'var(--re-dark)',
|
're-dark': 'var(--re-dark)',
|
||||||
're-light-green': 'var(--re-light-green)',
|
're-light-green': 'var(--re-light-green)',
|
||||||
|
're-red': 'var(--re-red)',
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: 'var(--radius)',
|
lg: 'var(--radius)',
|
||||||
|
|||||||
@ -63,6 +63,7 @@ const config: Config = {
|
|||||||
're-gold': 'var(--re-gold)',
|
're-gold': 'var(--re-gold)',
|
||||||
're-dark': 'var(--re-dark)',
|
're-dark': 'var(--re-dark)',
|
||||||
're-light-green': 'var(--re-light-green)',
|
're-light-green': 'var(--re-light-green)',
|
||||||
|
're-red': 'var(--re-red)',
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: 'var(--radius)',
|
lg: 'var(--radius)',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user