189 lines
8.3 KiB
TypeScript
189 lines
8.3 KiB
TypeScript
/**
|
|
* Individual Request Card Component
|
|
*/
|
|
|
|
import { motion } from 'framer-motion';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { User, ArrowRight, TrendingUp, Clock, Pause } from 'lucide-react';
|
|
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
|
|
import type { ConvertedRequest } from '../types/requests.types';
|
|
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
|
|
|
/**
|
|
* Strip HTML tags and convert to plain text for card preview
|
|
*/
|
|
const stripHtmlTags = (html: string): string => {
|
|
if (!html) return '';
|
|
|
|
// 1. Replace block-level tags with a space to avoid merging words (e.g. </div><div> -> " ")
|
|
// This preserves readability for the card preview
|
|
let text = html.replace(/<(address|article|aside|blockquote|canvas|dd|div|dl|dt|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hr|li|main|nav|noscript|ol|p|pre|section|table|tfoot|ul|video)[^>]*>/gi, ' ');
|
|
|
|
// 2. Replace <br> with space
|
|
text = text.replace(/<br\s*\/?>/gi, ' ');
|
|
|
|
// 3. Strip all other tags
|
|
text = text.replace(/<[^>]*>/g, '');
|
|
|
|
// 4. Clean up extra whitespace
|
|
text = text.replace(/\s+/g, ' ').trim();
|
|
|
|
// 5. Basic HTML entity decoding for common characters
|
|
text = text
|
|
.replace(/ /g, ' ')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'");
|
|
|
|
return text;
|
|
};
|
|
|
|
interface RequestCardProps {
|
|
request: ConvertedRequest;
|
|
index: number;
|
|
onViewRequest: (requestId: string, requestTitle?: string, status?: string) => void;
|
|
}
|
|
|
|
export function RequestCard({ request, index, onViewRequest }: RequestCardProps) {
|
|
const statusConfig = getStatusConfig(request.status);
|
|
const priorityConfig = getPriorityConfig(request.priority);
|
|
const StatusIcon = statusConfig.icon;
|
|
const PriorityIcon = priorityConfig.icon;
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: index * 0.1 }}
|
|
>
|
|
<Card
|
|
className="group hover:shadow-lg transition-all duration-300 cursor-pointer border border-gray-200 shadow-sm hover:shadow-md"
|
|
onClick={() => onViewRequest(request.id, request.title, request.status)}
|
|
data-testid={`request-card-${request.id}`}
|
|
>
|
|
<CardContent className="p-3 sm:p-6">
|
|
<div className="space-y-3 sm:space-y-4">
|
|
{/* Header with Title and Status Badges */}
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<h4
|
|
className="text-base sm:text-lg font-semibold text-gray-900 mb-2 group-hover:text-blue-600 transition-colors line-clamp-2"
|
|
data-testid="request-title"
|
|
>
|
|
{request.title}
|
|
</h4>
|
|
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mb-2">
|
|
<Badge
|
|
variant="outline"
|
|
className={`${statusConfig.color} border font-medium text-xs shrink-0`}
|
|
data-testid="status-badge"
|
|
>
|
|
<StatusIcon className="w-3 h-3 mr-1" />
|
|
<span className="capitalize">{request.status}</span>
|
|
</Badge>
|
|
{((request as any).pauseInfo?.isPaused || (request as any).isPaused) && (
|
|
<Badge
|
|
variant="outline"
|
|
className="bg-orange-50 text-orange-700 border-orange-300 font-medium text-xs shrink-0"
|
|
data-testid="pause-badge"
|
|
>
|
|
<Pause className="w-3 h-3 mr-1" />
|
|
Paused
|
|
</Badge>
|
|
)}
|
|
<Badge
|
|
variant="outline"
|
|
className={`${priorityConfig.color} border font-medium text-xs capitalize shrink-0`}
|
|
data-testid="priority-badge"
|
|
>
|
|
<PriorityIcon className="w-3 h-3 mr-1" />
|
|
{request.priority}
|
|
</Badge>
|
|
{/* Template Type Badge */}
|
|
{(() => {
|
|
const templateType = request?.templateType || (request as any)?.template_type || '';
|
|
const templateTypeUpper = templateType?.toUpperCase() || '';
|
|
|
|
// Direct mapping from templateType
|
|
let templateLabel = 'Non-Templatized';
|
|
let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200';
|
|
|
|
if (templateTypeUpper === 'DEALER CLAIM') {
|
|
templateLabel = 'Dealer Claim';
|
|
templateColor = 'bg-blue-100 !text-blue-700 border-blue-200';
|
|
} else if (templateTypeUpper === 'TEMPLATE') {
|
|
templateLabel = 'Template';
|
|
}
|
|
|
|
return (
|
|
<Badge
|
|
variant="outline"
|
|
className={`${templateColor} font-medium text-xs shrink-0`}
|
|
data-testid="template-type-badge"
|
|
>
|
|
{templateLabel}
|
|
</Badge>
|
|
);
|
|
})()}
|
|
{request.department && (
|
|
<Badge
|
|
variant="secondary"
|
|
className="bg-purple-100 text-purple-700 text-xs shrink-0"
|
|
data-testid="department-badge"
|
|
>
|
|
{request.department}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<p
|
|
className="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3 line-clamp-2 leading-relaxed"
|
|
data-testid="request-description"
|
|
>
|
|
{stripHtmlTags(request.description || '') || 'No description provided'}
|
|
</p>
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4 text-xs sm:text-sm text-gray-500">
|
|
<span className="truncate" data-testid="request-id-display">
|
|
<span className="font-medium">ID:</span> {request.displayId || request.id}
|
|
</span>
|
|
<span className="truncate" data-testid="submitted-date">
|
|
<span className="font-medium">Submitted:</span> {formatDateDDMMYYYY(request.submittedDate)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<ArrowRight className="w-4 h-4 sm:w-5 sm:h-5 text-gray-400 group-hover:text-blue-600 transition-colors flex-shrink-0 mt-1" />
|
|
</div>
|
|
|
|
{/* Current Approver and Level Info */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 pt-3 border-t border-gray-100">
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<User className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0" />
|
|
<span className="text-xs sm:text-sm truncate" data-testid="current-approver">
|
|
<span className="text-gray-500">Current Approver:</span> <span className="text-gray-900 font-medium">{request.currentApprover}</span>
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0" />
|
|
<span className="text-xs sm:text-sm" data-testid="approval-level">
|
|
<span className="text-gray-500">Approval Level:</span> <span className="text-gray-900 font-medium">{request.approverLevel}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
|
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
|
|
<span data-testid="submitted-timestamp">
|
|
Submitted: {formatDateDDMMYYYY(request.submittedDate)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|