misse request detail added

This commit is contained in:
laxmanhalaki 2025-11-12 12:21:51 +05:30
parent 90f29c11bd
commit 231d99ad95
11 changed files with 3137 additions and 0 deletions

View File

@ -0,0 +1,141 @@
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { ArrowRight } from 'lucide-react';
interface PaginationProps {
currentPage: number;
totalPages: number;
totalRecords: number;
itemsPerPage: number;
onPageChange: (page: number) => void;
loading?: boolean;
itemLabel?: string; // e.g., "requests", "activities", "approvers"
testIdPrefix?: string;
}
export function Pagination({
currentPage,
totalPages,
itemsPerPage,
totalRecords,
onPageChange,
loading = false,
itemLabel = 'items',
testIdPrefix = 'pagination'
}: PaginationProps) {
const getPageNumbers = () => {
const pages = [];
const maxPagesToShow = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2));
let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1);
if (endPage - startPage < maxPagesToShow - 1) {
startPage = Math.max(1, endPage - maxPagesToShow + 1);
}
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return pages;
};
// Don't show pagination if only 1 page or loading
if (totalPages <= 1 || loading) {
return null;
}
const startItem = ((currentPage - 1) * itemsPerPage) + 1;
const endItem = Math.min(currentPage * itemsPerPage, totalRecords);
return (
<Card className="shadow-md" data-testid={`${testIdPrefix}-container`}>
<CardContent className="p-4">
<div className="flex flex-col sm:flex-row items-center justify-between gap-3">
<div
className="text-xs sm:text-sm text-muted-foreground"
data-testid={`${testIdPrefix}-info`}
>
Showing {startItem} to {endItem} of {totalRecords} {itemLabel}
</div>
<div className="flex items-center gap-2" data-testid={`${testIdPrefix}-controls`}>
{/* Previous Button */}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="h-8 w-8 p-0"
data-testid={`${testIdPrefix}-prev-btn`}
aria-label="Previous page"
>
<ArrowRight className="h-4 w-4 rotate-180" />
</Button>
{/* First page + ellipsis */}
{currentPage > 3 && totalPages > 5 && (
<>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(1)}
className="h-8 w-8 p-0"
data-testid={`${testIdPrefix}-page-1`}
>
1
</Button>
<span className="text-muted-foreground" data-testid={`${testIdPrefix}-ellipsis-start`}>...</span>
</>
)}
{/* Page Numbers */}
{getPageNumbers().map((pageNum) => (
<Button
key={pageNum}
variant={pageNum === currentPage ? "default" : "outline"}
size="sm"
onClick={() => onPageChange(pageNum)}
className={`h-8 w-8 p-0 ${pageNum === currentPage ? 'bg-blue-600 text-white hover:bg-blue-700' : ''}`}
data-testid={`${testIdPrefix}-page-${pageNum}`}
aria-current={pageNum === currentPage ? 'page' : undefined}
>
{pageNum}
</Button>
))}
{/* Last page + ellipsis */}
{currentPage < totalPages - 2 && totalPages > 5 && (
<>
<span className="text-muted-foreground" data-testid={`${testIdPrefix}-ellipsis-end`}>...</span>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(totalPages)}
className="h-8 w-8 p-0"
data-testid={`${testIdPrefix}-page-${totalPages}`}
>
{totalPages}
</Button>
</>
)}
{/* Next Button */}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="h-8 w-8 p-0"
data-testid={`${testIdPrefix}-next-btn`}
aria-label="Next page"
>
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,2 @@
export { Pagination } from './Pagination';

View File

@ -0,0 +1,215 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import {
CheckCircle,
AlertTriangle,
MessageSquare,
Flame,
FileText,
Paperclip,
Activity,
ArrowRight
} from 'lucide-react';
import { differenceInMinutes, differenceInHours, differenceInDays } from 'date-fns';
export interface ActivityData {
activityId: string;
requestNumber: string;
requestTitle: string;
action: string;
userId: string;
userName: string;
timestamp: string;
priority: string;
}
interface ActivityFeedItemProps {
activity: ActivityData;
currentUserId?: string;
currentUserDisplayName?: string;
currentUserEmail?: string;
onNavigate?: (requestNumber: string) => void;
testId?: string;
}
// Utility functions
const getPriorityColor = (priority: string) => {
const p = priority.toLowerCase();
switch (p) {
case 'express': return 'bg-orange-100 text-orange-800 border-orange-200';
case 'standard': return 'bg-blue-100 text-blue-800 border-blue-200';
case 'high': return 'bg-red-100 text-red-800 border-red-200';
case 'medium': return 'bg-orange-100 text-orange-800 border-orange-200';
case 'low': return 'bg-green-100 text-green-800 border-green-200';
default: return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
const getRelativeTime = (timestamp: string) => {
const now = new Date();
const time = new Date(timestamp);
const diffMin = differenceInMinutes(now, time);
if (diffMin < 1) return 'just now';
if (diffMin < 60) return `${diffMin} minute${diffMin > 1 ? 's' : ''} ago`;
const diffHrs = differenceInHours(now, time);
if (diffHrs < 24) return `${diffHrs} hour${diffHrs > 1 ? 's' : ''} ago`;
const diffDay = differenceInDays(now, time);
return `${diffDay} day${diffDay > 1 ? 's' : ''} ago`;
};
const cleanActivityDescription = (desc: string) => {
if (!desc) return desc;
// Remove email addresses in parentheses
let cleaned = desc.replace(/\s*\([^)]*@[^)]*\)/g, '');
// Remove "by [user]" at the end - we show user separately
cleaned = cleaned.replace(/\s+by\s+.+$/i, '');
// Shorten common phrases
cleaned = cleaned.replace(/has been added as approver/gi, 'added as approver');
cleaned = cleaned.replace(/has been added as spectator/gi, 'added as spectator');
cleaned = cleaned.replace(/has been/gi, '');
// Make TAT format more compact
cleaned = cleaned.replace(/with TAT of (\d+) hours?/gi, '(TAT: $1h)');
cleaned = cleaned.replace(/with TAT of (\d+) days?/gi, '(TAT: $1d)');
// Replace multiple spaces with single space
cleaned = cleaned.replace(/\s+/g, ' ');
return cleaned.trim();
};
const getActionIcon = (action: string) => {
const actionLower = action.toLowerCase();
if (actionLower.includes('approv')) return <CheckCircle className="w-2.5 h-2.5 sm:w-3 sm:h-3 text-green-600" />;
if (actionLower.includes('reject')) return <AlertTriangle className="w-2.5 h-2.5 sm:w-3 sm:h-3 text-red-600" />;
if (actionLower.includes('comment')) return <MessageSquare className="w-2.5 h-2.5 sm:w-3 sm:h-3 text-blue-600" />;
if (actionLower.includes('escalat')) return <Flame className="w-2.5 h-2.5 sm:w-3 sm:h-3 text-orange-600" />;
if (actionLower.includes('submit')) return <FileText className="w-2.5 h-2.5 sm:w-3 sm:h-3 text-purple-600" />;
if (actionLower.includes('document')) return <Paperclip className="w-2.5 h-2.5 sm:w-3 sm:h-3 text-indigo-600" />;
return <Activity className="w-2.5 h-2.5 sm:w-3 sm:h-3 text-gray-600" />;
};
export function ActivityFeedItem({
activity,
currentUserId,
currentUserDisplayName,
currentUserEmail,
onNavigate,
testId = 'activity-feed-item'
}: ActivityFeedItemProps) {
const isCurrentUser = activity.userId === currentUserId;
const displayName = isCurrentUser ? 'You' : (activity.userName || 'System');
const userInitials = isCurrentUser
? ((currentUserDisplayName || currentUserEmail || 'ME')
.split(' ')
.map((n: string) => n[0])
.join('')
.toUpperCase()
.substring(0, 2))
: activity.userName
? activity.userName
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.substring(0, 2)
: 'SY'; // System default
return (
<div
className="flex items-start gap-2 sm:gap-3 p-3 sm:p-4 rounded-lg hover:bg-gray-50 transition-all duration-200 cursor-pointer border border-gray-100 hover:border-gray-300"
onClick={() => onNavigate?.(activity.requestNumber)}
data-testid={`${testId}-${activity.activityId}`}
>
<div className="relative flex-shrink-0 mt-0.5">
<Avatar
className={`h-8 w-8 sm:h-10 sm:w-10 ring-2 shadow-sm ${isCurrentUser ? 'ring-blue-200' : 'ring-white'}`}
data-testid={`${testId}-avatar`}
>
<AvatarImage src="" />
<AvatarFallback className={`text-white font-semibold text-xs sm:text-sm ${isCurrentUser ? 'bg-blue-600' : 'bg-slate-700'}`}>
{userInitials}
</AvatarFallback>
</Avatar>
<div className="absolute -bottom-0.5 -right-0.5 sm:-bottom-1 sm:-right-1 w-4 h-4 sm:w-5 sm:h-5 bg-white rounded-full flex items-center justify-center shadow-sm border border-gray-200">
{getActionIcon(activity.action)}
</div>
</div>
<div className="flex-1 min-w-0">
{/* Header with Request Number and Priority Badge */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2">
<span
className="font-semibold text-xs sm:text-sm text-gray-900"
data-testid={`${testId}-request-number`}
>
{activity.requestNumber}
</span>
<Badge
variant="outline"
className={`text-xs ${getPriorityColor(activity.priority)} font-medium flex-shrink-0`}
data-testid={`${testId}-priority`}
>
{activity.priority}
</Badge>
</div>
</div>
{/* Action Description as Text */}
<p className="text-xs text-muted-foreground mb-1 line-clamp-2">
<span
className={`font-medium ${
activity.action.toLowerCase().includes('approv') ? 'text-green-600' :
activity.action.toLowerCase().includes('reject') ? 'text-red-600' :
activity.action.toLowerCase().includes('submit') ? 'text-blue-600' :
activity.action.toLowerCase().includes('add') ? 'text-indigo-600' :
'text-gray-700'
}`}
data-testid={`${testId}-action`}
>
{cleanActivityDescription(activity.action)}
</span>
</p>
{/* Request Title */}
<p
className="text-xs sm:text-sm text-gray-700 line-clamp-1 mb-1"
data-testid={`${testId}-request-title`}
>
{activity.requestTitle}
</p>
{/* User and Time */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span
className={`font-medium truncate max-w-[150px] sm:max-w-[200px] ${isCurrentUser ? 'text-blue-600' : 'text-gray-900'}`}
data-testid={`${testId}-user-name`}
>
{displayName}
</span>
<span></span>
<span
className="whitespace-nowrap"
data-testid={`${testId}-timestamp`}
>
{getRelativeTime(activity.timestamp)}
</span>
</div>
</div>
<ArrowRight
className="h-4 w-4 text-gray-400 hover:text-blue-600 transition-colors flex-shrink-0 hidden sm:block mt-1"
data-testid={`${testId}-arrow`}
/>
</div>
);
}

View File

@ -0,0 +1,3 @@
export { ActivityFeedItem } from './ActivityFeedItem';
export type { ActivityData } from './ActivityFeedItem';

View File

@ -0,0 +1,138 @@
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Star } from 'lucide-react';
export interface CriticalAlertData {
requestId: string;
requestNumber: string;
title: string;
priority: string;
totalTATHours: number;
originalTATHours: number;
breachCount: number;
currentLevel: number;
totalLevels: number;
}
interface CriticalAlertCardProps {
alert: CriticalAlertData;
onNavigate?: (requestNumber: string) => void;
testId?: string;
}
// Utility functions
const calculateProgress = (alert: CriticalAlertData) => {
if (!alert.originalTATHours || alert.originalTATHours === 0) return 0;
const originalTAT = alert.originalTATHours;
const remainingTAT = alert.totalTATHours;
// If breached (negative remaining), show 100%
if (remainingTAT <= 0) return 100;
// Calculate elapsed time
const elapsedTAT = originalTAT - remainingTAT;
// Calculate percentage used
const percentageUsed = (elapsedTAT / originalTAT) * 100;
// Ensure it's between 0 and 100
return Math.min(100, Math.max(0, Math.round(percentageUsed)));
};
const formatRemainingTime = (alert: CriticalAlertData) => {
if (alert.totalTATHours === undefined || alert.totalTATHours === null) return 'N/A';
const hours = alert.totalTATHours;
// If TAT is breached (negative or zero)
if (hours <= 0) {
const overdue = Math.abs(hours);
if (overdue < 1) return `Breached`;
if (overdue < 24) return `${Math.round(overdue)}h overdue`;
return `${Math.round(overdue / 24)}d overdue`;
}
// If TAT is still remaining
if (hours < 1) return `${Math.round(hours * 60)}min left`;
if (hours < 24) return `${Math.round(hours)}h left`;
return `${Math.round(hours / 24)}d left`;
};
export function CriticalAlertCard({
alert,
onNavigate,
testId = 'critical-alert-card'
}: CriticalAlertCardProps) {
const progress = calculateProgress(alert);
return (
<div
className="p-3 sm:p-4 bg-red-50 rounded-lg sm:rounded-xl border border-red-100 hover:shadow-md transition-all duration-200 cursor-pointer"
onClick={() => onNavigate?.(alert.requestNumber)}
data-testid={`${testId}-${alert.requestId}`}
>
<div className="flex items-start justify-between gap-2 mb-2 sm:mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1 sm:gap-2 mb-1 flex-wrap">
<p
className="font-semibold text-xs sm:text-sm text-gray-900"
data-testid={`${testId}-request-number`}
>
{alert.requestNumber}
</p>
{alert.priority === 'express' && (
<Star
className="h-3 w-3 text-red-500 flex-shrink-0"
data-testid={`${testId}-priority-icon`}
/>
)}
{alert.breachCount > 0 && (
<Badge
variant="destructive"
className="text-xs"
data-testid={`${testId}-breach-count`}
>
{alert.breachCount}
</Badge>
)}
</div>
<p
className="text-xs sm:text-sm text-gray-700 line-clamp-2"
data-testid={`${testId}-title`}
>
{alert.title}
</p>
</div>
<Badge
variant="outline"
className="text-xs bg-white border-red-200 text-red-700 font-medium whitespace-nowrap"
data-testid={`${testId}-remaining-time`}
>
{formatRemainingTime(alert)}
</Badge>
</div>
<div className="space-y-1 sm:space-y-2">
<div className="flex justify-between text-xs text-gray-600">
<span>TAT Used</span>
<span
className="font-medium"
data-testid={`${testId}-progress-percentage`}
>
{progress}%
</span>
</div>
<Progress
value={progress}
className={`h-1.5 sm:h-2 ${
progress >= 80 ? '[&>div]:bg-red-600' :
progress >= 50 ? '[&>div]:bg-orange-500' :
'[&>div]:bg-green-600'
}`}
data-testid={`${testId}-progress-bar`}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,3 @@
export { CriticalAlertCard } from './CriticalAlertCard';
export type { CriticalAlertData } from './CriticalAlertCard';

View File

@ -0,0 +1,72 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { LucideIcon } from 'lucide-react';
import { ReactNode } from 'react';
interface KPICardProps {
title: string;
value: string | number;
icon: LucideIcon;
iconBgColor: string;
iconColor: string;
subtitle?: string;
children?: ReactNode;
testId?: string;
onClick?: () => void;
}
export function KPICard({
title,
value,
icon: Icon,
iconBgColor,
iconColor,
subtitle,
children,
testId = 'kpi-card',
onClick
}: KPICardProps) {
return (
<Card
className="hover:shadow-lg transition-all duration-200 shadow-md cursor-pointer"
onClick={onClick}
data-testid={testId}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle
className="text-sm font-medium text-muted-foreground"
data-testid={`${testId}-title`}
>
{title}
</CardTitle>
<div className={`p-2 sm:p-3 rounded-lg ${iconBgColor}`} data-testid={`${testId}-icon-wrapper`}>
<Icon
className={`h-4 w-4 sm:h-5 sm:w-5 ${iconColor}`}
data-testid={`${testId}-icon`}
/>
</div>
</CardHeader>
<CardContent>
<div
className="text-2xl sm:text-3xl font-bold text-gray-900 mb-3"
data-testid={`${testId}-value`}
>
{value}
</div>
{subtitle && (
<div
className="text-xs text-muted-foreground mb-3"
data-testid={`${testId}-subtitle`}
>
{subtitle}
</div>
)}
{children && (
<div data-testid={`${testId}-children`}>
{children}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,2 @@
export { KPICard } from './KPICard';

View File

@ -0,0 +1,41 @@
import { ReactNode } from 'react';
interface StatCardProps {
label: string;
value: string | number;
bgColor: string;
textColor: string;
testId?: string;
children?: ReactNode;
}
export function StatCard({
label,
value,
bgColor,
textColor,
testId = 'stat-card',
children
}: StatCardProps) {
return (
<div
className={`text-center p-2 ${bgColor} rounded`}
data-testid={testId}
>
<p
className="text-muted-foreground text-xs"
data-testid={`${testId}-label`}
>
{label}
</p>
<p
className={`font-bold ${textColor}`}
data-testid={`${testId}-value`}
>
{value}
</p>
{children}
</div>
);
}

View File

@ -0,0 +1,2 @@
export { StatCard } from './StatCard';

File diff suppressed because it is too large Load Diff