misse request detail added
This commit is contained in:
parent
90f29c11bd
commit
231d99ad95
141
src/components/common/Pagination/Pagination.tsx
Normal file
141
src/components/common/Pagination/Pagination.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
2
src/components/common/Pagination/index.ts
Normal file
2
src/components/common/Pagination/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Pagination } from './Pagination';
|
||||||
|
|
||||||
215
src/components/dashboard/ActivityFeedItem/ActivityFeedItem.tsx
Normal file
215
src/components/dashboard/ActivityFeedItem/ActivityFeedItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
3
src/components/dashboard/ActivityFeedItem/index.ts
Normal file
3
src/components/dashboard/ActivityFeedItem/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { ActivityFeedItem } from './ActivityFeedItem';
|
||||||
|
export type { ActivityData } from './ActivityFeedItem';
|
||||||
|
|
||||||
138
src/components/dashboard/CriticalAlertCard/CriticalAlertCard.tsx
Normal file
138
src/components/dashboard/CriticalAlertCard/CriticalAlertCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
3
src/components/dashboard/CriticalAlertCard/index.ts
Normal file
3
src/components/dashboard/CriticalAlertCard/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { CriticalAlertCard } from './CriticalAlertCard';
|
||||||
|
export type { CriticalAlertData } from './CriticalAlertCard';
|
||||||
|
|
||||||
72
src/components/dashboard/KPICard/KPICard.tsx
Normal file
72
src/components/dashboard/KPICard/KPICard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
2
src/components/dashboard/KPICard/index.ts
Normal file
2
src/components/dashboard/KPICard/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { KPICard } from './KPICard';
|
||||||
|
|
||||||
41
src/components/dashboard/StatCard/StatCard.tsx
Normal file
41
src/components/dashboard/StatCard/StatCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
2
src/components/dashboard/StatCard/index.ts
Normal file
2
src/components/dashboard/StatCard/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { StatCard } from './StatCard';
|
||||||
|
|
||||||
2518
src/pages/RequestDetail/RequestDetail.tsx
Normal file
2518
src/pages/RequestDetail/RequestDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user