227 lines
11 KiB
TypeScript
227 lines
11 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;
|
||
}
|
||
|
||
function formatInr(val: number | null | undefined): string {
|
||
if (val == null) return '—';
|
||
return new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', maximumFractionDigits: 0 }).format(val);
|
||
}
|
||
|
||
export function RequestCard({ request, index, onViewRequest }: RequestCardProps) {
|
||
const isForm16 = (request?.templateType || (request as any)?.template_type || '').toString().toUpperCase() === 'FORM_16';
|
||
const form16 = (request as any)?.form16Submission;
|
||
const form16DisplayStatus = form16?.displayStatus;
|
||
const isForm16MismatchOrFailed = form16DisplayStatus && /balance mismatch|failed/i.test(String(form16DisplayStatus));
|
||
const statusConfig = isForm16 && form16DisplayStatus
|
||
? {
|
||
color: isForm16MismatchOrFailed ? 'bg-red-100 !text-red-800 border-red-200' : form16DisplayStatus === 'Completed' ? 'bg-green-100 !text-green-800 border-green-200' : 'bg-gray-100 !text-gray-700 border-gray-200',
|
||
icon: getStatusConfig(request.status).icon,
|
||
label: form16DisplayStatus,
|
||
}
|
||
: 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">{statusConfig.label}</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 – Form 16 shows green "Form 16", others unchanged */}
|
||
{(() => {
|
||
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 === 'FORM_16') {
|
||
templateLabel = 'Form 16';
|
||
templateColor = 'bg-emerald-100 !text-emerald-700 border-emerald-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>
|
||
{isForm16 && form16 && (
|
||
<>
|
||
<span className="truncate" data-testid="form16-total-amount">
|
||
<span className="font-medium">Total amount:</span> {form16.totalAmount != null ? formatInr(form16.totalAmount) : '—'}
|
||
</span>
|
||
<span className="truncate" data-testid="form16-credit-note">
|
||
<span className="font-medium">Credit note:</span> {form16.creditNoteNumber || '—'}
|
||
</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 – Form 16 shows "Form 16 OCR FLOW" instead */}
|
||
<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">
|
||
{(request?.templateType || (request as any)?.template_type || '').toString().toUpperCase() === 'FORM_16' ? (
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-emerald-600 flex-shrink-0" />
|
||
<span className="text-xs sm:text-sm font-medium text-emerald-700" data-testid="form16-ocr-flow">
|
||
Form 16 OCR FLOW
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<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>
|
||
);
|
||
}
|
||
|