873 lines
38 KiB
TypeScript
873 lines
38 KiB
TypeScript
/**
|
|
* DMSPushModal Component
|
|
* Modal for Step 6: Push to DMS Verification
|
|
* Allows user to verify completion details and expenses before pushing to DMS
|
|
*/
|
|
|
|
import { useState, useMemo, useEffect } from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import {
|
|
Receipt,
|
|
DollarSign,
|
|
TriangleAlert,
|
|
Activity,
|
|
CheckCircle2,
|
|
Download,
|
|
Eye,
|
|
Loader2,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
|
|
import '@/components/common/FilePreview/FilePreview.css';
|
|
import './DMSPushModal.css';
|
|
|
|
interface ExpenseItem {
|
|
description: string;
|
|
amount: number;
|
|
}
|
|
|
|
interface CompletionDetails {
|
|
activityCompletionDate?: string;
|
|
numberOfParticipants?: number;
|
|
closedExpenses?: ExpenseItem[];
|
|
totalClosedExpenses?: number;
|
|
completionDescription?: string;
|
|
}
|
|
|
|
interface IODetails {
|
|
ioNumber?: string;
|
|
blockedAmount?: number;
|
|
availableBalance?: number;
|
|
remainingBalance?: number;
|
|
}
|
|
|
|
interface CompletionDocuments {
|
|
completionDocuments?: Array<{
|
|
name: string;
|
|
url?: string;
|
|
id?: string;
|
|
}>;
|
|
activityPhotos?: Array<{
|
|
name: string;
|
|
url?: string;
|
|
id?: string;
|
|
}>;
|
|
invoicesReceipts?: Array<{
|
|
name: string;
|
|
url?: string;
|
|
id?: string;
|
|
}>;
|
|
attendanceSheet?: {
|
|
name: string;
|
|
url?: string;
|
|
id?: string;
|
|
};
|
|
}
|
|
|
|
interface DMSPushModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onPush: (comments: string) => Promise<void>;
|
|
completionDetails?: CompletionDetails | null;
|
|
ioDetails?: IODetails | null;
|
|
completionDocuments?: CompletionDocuments | null;
|
|
requestTitle?: string;
|
|
requestNumber?: string;
|
|
}
|
|
|
|
export function DMSPushModal({
|
|
isOpen,
|
|
onClose,
|
|
onPush,
|
|
completionDetails,
|
|
ioDetails,
|
|
completionDocuments,
|
|
requestTitle,
|
|
requestNumber,
|
|
}: DMSPushModalProps) {
|
|
const [comments, setComments] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [previewDocument, setPreviewDocument] = useState<{
|
|
name: string;
|
|
url: string;
|
|
type?: string;
|
|
size?: number;
|
|
} | null>(null);
|
|
const [previewLoading, setPreviewLoading] = useState(false);
|
|
|
|
const commentsChars = comments.length;
|
|
const maxCommentsChars = 500;
|
|
|
|
// Calculate total closed expenses
|
|
const totalClosedExpenses = useMemo(() => {
|
|
if (completionDetails?.totalClosedExpenses) {
|
|
return completionDetails.totalClosedExpenses;
|
|
}
|
|
if (completionDetails?.closedExpenses && Array.isArray(completionDetails.closedExpenses)) {
|
|
return completionDetails.closedExpenses.reduce((sum, item) => {
|
|
const amount = typeof item === 'object' ? (item.amount || 0) : 0;
|
|
return sum + (Number(amount) || 0);
|
|
}, 0);
|
|
}
|
|
return 0;
|
|
}, [completionDetails]);
|
|
|
|
// Format date
|
|
const formatDate = (dateString?: string) => {
|
|
if (!dateString) return '—';
|
|
try {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString('en-IN', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
});
|
|
} catch {
|
|
return dateString;
|
|
}
|
|
};
|
|
|
|
// Format currency
|
|
const formatCurrency = (amount: number) => {
|
|
return `₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
};
|
|
|
|
// Check if document can be previewed
|
|
const canPreviewDocument = (doc: { name: string; url?: string; id?: string }): boolean => {
|
|
if (!doc.name) return false;
|
|
const name = doc.name.toLowerCase();
|
|
return name.endsWith('.pdf') ||
|
|
name.endsWith('.jpg') ||
|
|
name.endsWith('.jpeg') ||
|
|
name.endsWith('.png') ||
|
|
name.endsWith('.gif') ||
|
|
name.endsWith('.webp');
|
|
};
|
|
|
|
// Handle document preview - fetch as blob to avoid CSP issues
|
|
const handlePreviewDocument = async (doc: { name: string; url?: string; id?: string }) => {
|
|
if (!doc.id) {
|
|
toast.error('Document preview not available - document ID missing');
|
|
return;
|
|
}
|
|
|
|
setPreviewLoading(true);
|
|
try {
|
|
const previewUrl = getDocumentPreviewUrl(doc.id);
|
|
|
|
// Determine file type from name
|
|
const fileName = doc.name.toLowerCase();
|
|
const isPDF = fileName.endsWith('.pdf');
|
|
const isImage = fileName.match(/\.(jpg|jpeg|png|gif|webp)$/i);
|
|
|
|
// Fetch document as a blob to create a blob URL (CSP compliant)
|
|
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
|
const token = isProduction ? null : localStorage.getItem('accessToken');
|
|
|
|
const headers: HeadersInit = {
|
|
'Accept': isPDF ? 'application/pdf' : '*/*'
|
|
};
|
|
|
|
if (!isProduction && token) {
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
|
|
const response = await fetch(previewUrl, {
|
|
headers,
|
|
credentials: 'include',
|
|
mode: 'cors'
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
|
|
if (blob.size === 0) {
|
|
throw new Error('File is empty or could not be loaded');
|
|
}
|
|
|
|
// Create blob URL (CSP compliant - uses 'blob:' protocol)
|
|
const blobUrl = window.URL.createObjectURL(blob);
|
|
|
|
setPreviewDocument({
|
|
name: doc.name,
|
|
url: blobUrl,
|
|
type: blob.type || (isPDF ? 'application/pdf' : isImage ? 'image' : undefined),
|
|
size: blob.size,
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to load document preview:', error);
|
|
toast.error('Failed to load document preview');
|
|
} finally {
|
|
setPreviewLoading(false);
|
|
}
|
|
};
|
|
|
|
// Cleanup blob URLs on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (previewDocument?.url && previewDocument.url.startsWith('blob:')) {
|
|
window.URL.revokeObjectURL(previewDocument.url);
|
|
}
|
|
};
|
|
}, [previewDocument]);
|
|
|
|
const handleSubmit = async () => {
|
|
if (!comments.trim()) {
|
|
toast.error('Please provide comments before pushing to DMS');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setSubmitting(true);
|
|
await onPush(comments.trim());
|
|
handleReset();
|
|
onClose();
|
|
} catch (error) {
|
|
console.error('Failed to push to DMS:', error);
|
|
toast.error('Failed to push to DMS. Please try again.');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setComments('');
|
|
};
|
|
|
|
const handleClose = () => {
|
|
if (!submitting) {
|
|
handleReset();
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
|
<DialogContent className="dms-push-modal overflow-hidden flex flex-col">
|
|
<DialogHeader className="px-6 pt-6 pb-3 flex-shrink-0">
|
|
<div className="flex items-center gap-2 sm:gap-3 mb-2">
|
|
<div className="p-1.5 sm:p-2 rounded-lg bg-indigo-100">
|
|
<Activity className="w-4 h-4 sm:w-5 sm:h-5 sm:w-6 sm:h-6 text-indigo-600" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<DialogTitle className="font-semibold text-lg sm:text-xl">
|
|
Push to DMS - Verification
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm mt-1">
|
|
Review completion details and expenses before pushing to DMS for e-invoice generation
|
|
</DialogDescription>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Request Info Card - Grid layout */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 p-3 sm:p-4 bg-gray-50 rounded-lg border">
|
|
<div className="flex items-center justify-between sm:flex-col sm:items-start sm:gap-1">
|
|
<span className="font-medium text-xs sm:text-sm text-gray-600">Workflow Step:</span>
|
|
<Badge variant="outline" className="font-mono text-xs">Requestor Claim Approval</Badge>
|
|
</div>
|
|
{requestNumber && (
|
|
<div className="flex flex-col gap-1">
|
|
<span className="font-medium text-xs sm:text-sm text-gray-600">Request Number:</span>
|
|
<p className="text-gray-700 font-mono text-xs sm:text-sm">{requestNumber}</p>
|
|
</div>
|
|
)}
|
|
<div className="flex flex-col gap-1">
|
|
<span className="font-medium text-xs sm:text-sm text-gray-600">Title:</span>
|
|
<p className="text-gray-700 text-xs sm:text-sm line-clamp-2">{requestTitle || '—'}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 sm:flex-col sm:items-start sm:gap-1">
|
|
<span className="font-medium text-xs sm:text-sm text-gray-600">Action:</span>
|
|
<Badge className="bg-indigo-100 text-indigo-800 border-indigo-200 text-xs">
|
|
<Activity className="w-3 h-3 mr-1" />
|
|
PUSH TO DMS
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-3">
|
|
<div className="space-y-3 sm:space-y-4 max-w-7xl mx-auto">
|
|
{/* Grid layout for all three cards on larger screens */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
|
{/* Completion Details Card */}
|
|
{completionDetails && (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
|
<CheckCircle2 className="w-4 h-4 sm:w-5 sm:h-5 text-green-600" />
|
|
Completion Details
|
|
</CardTitle>
|
|
<CardDescription className="text-xs sm:text-sm">
|
|
Review activity completion information
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2 sm:space-y-3">
|
|
{completionDetails.activityCompletionDate && (
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2 border-b">
|
|
<span className="text-xs sm:text-sm text-gray-600">Activity Completion Date:</span>
|
|
<span className="text-xs sm:text-sm font-semibold text-gray-900">
|
|
{formatDate(completionDetails.activityCompletionDate)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{completionDetails.numberOfParticipants !== undefined && (
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2 border-b">
|
|
<span className="text-xs sm:text-sm text-gray-600">Number of Participants:</span>
|
|
<span className="text-xs sm:text-sm font-semibold text-gray-900">
|
|
{completionDetails.numberOfParticipants}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{completionDetails.completionDescription && (
|
|
<div className="pt-2">
|
|
<p className="text-xs text-gray-600 mb-1">Completion Description:</p>
|
|
<p className="text-xs sm:text-sm text-gray-900 line-clamp-3">
|
|
{completionDetails.completionDescription}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* IO Details Card */}
|
|
{ioDetails && ioDetails.ioNumber && (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
|
<Receipt className="w-4 h-4 sm:w-5 sm:h-5 text-purple-600" />
|
|
IO Details
|
|
</CardTitle>
|
|
<CardDescription className="text-xs sm:text-sm">
|
|
Internal Order information for budget reference
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2 sm:space-y-3">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2 border-b">
|
|
<span className="text-xs sm:text-sm text-gray-600">IO Number:</span>
|
|
<span className="text-xs sm:text-sm font-semibold text-gray-900 font-mono">
|
|
{ioDetails.ioNumber}
|
|
</span>
|
|
</div>
|
|
{ioDetails.blockedAmount !== undefined && ioDetails.blockedAmount > 0 && (
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2 border-b">
|
|
<span className="text-xs sm:text-sm text-gray-600">Blocked Amount:</span>
|
|
<span className="text-xs sm:text-sm font-bold text-green-700">
|
|
{formatCurrency(ioDetails.blockedAmount)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{ioDetails.remainingBalance !== undefined && ioDetails.remainingBalance !== null && (
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2">
|
|
<span className="text-xs sm:text-sm text-gray-600">Remaining Balance:</span>
|
|
<span className="text-xs sm:text-sm font-semibold text-gray-900">
|
|
{formatCurrency(ioDetails.remainingBalance)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Expense Breakdown Card */}
|
|
{completionDetails?.closedExpenses && completionDetails.closedExpenses.length > 0 && (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
|
<DollarSign className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
|
|
Expense Breakdown
|
|
</CardTitle>
|
|
<CardDescription className="text-xs sm:text-sm">
|
|
Review closed expenses before pushing to DMS
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-1.5 sm:space-y-2 max-h-[200px] overflow-y-auto">
|
|
{completionDetails.closedExpenses.map((expense, index) => (
|
|
<div
|
|
key={index}
|
|
className="flex items-center justify-between py-1.5 sm:py-2 px-2 sm:px-3 bg-gray-50 rounded border"
|
|
>
|
|
<div className="flex-1 min-w-0 pr-2">
|
|
<p className="text-xs sm:text-sm font-medium text-gray-900 truncate">
|
|
{expense.description || `Expense ${index + 1}`}
|
|
</p>
|
|
</div>
|
|
<div className="ml-2 flex-shrink-0">
|
|
<p className="text-xs sm:text-sm font-semibold text-gray-900">
|
|
{formatCurrency(typeof expense === 'object' ? (expense.amount || 0) : 0)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center justify-between py-2 sm:py-3 px-2 sm:px-3 bg-blue-50 rounded border-2 border-blue-200 mt-2 sm:mt-3">
|
|
<span className="text-xs sm:text-sm font-semibold text-gray-900">Total:</span>
|
|
<span className="text-sm sm:text-base font-bold text-blue-700">
|
|
{formatCurrency(totalClosedExpenses)}
|
|
</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
|
|
{/* Completion Documents Section */}
|
|
{completionDocuments && (
|
|
<div className="space-y-4">
|
|
{/* Completion Documents */}
|
|
{completionDocuments.completionDocuments && completionDocuments.completionDocuments.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold text-sm sm:text-base flex items-center gap-2">
|
|
<CheckCircle2 className="w-4 h-4 sm:w-5 sm:h-5 text-green-600" />
|
|
Completion Documents
|
|
</h3>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{completionDocuments.completionDocuments.length} file(s)
|
|
</Badge>
|
|
</div>
|
|
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
|
{completionDocuments.completionDocuments.map((doc, index) => (
|
|
<div
|
|
key={index}
|
|
className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 flex items-center justify-between gap-2"
|
|
>
|
|
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
|
|
<CheckCircle2 className="w-4 h-4 lg:w-5 lg:h-5 text-green-600 flex-shrink-0" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium text-xs lg:text-sm text-gray-900 truncate" title={doc.name}>
|
|
{doc.name}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{doc.id && (
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
{canPreviewDocument(doc) && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handlePreviewDocument(doc)}
|
|
disabled={previewLoading}
|
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
title="Preview document"
|
|
>
|
|
{previewLoading ? (
|
|
<Loader2 className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 animate-spin" />
|
|
) : (
|
|
<Eye className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" />
|
|
)}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
try {
|
|
if (doc.id) {
|
|
await downloadDocument(doc.id);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to download document:', error);
|
|
toast.error('Failed to download document');
|
|
}
|
|
}}
|
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
|
title="Download document"
|
|
>
|
|
<Download className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Activity Photos */}
|
|
{completionDocuments.activityPhotos && completionDocuments.activityPhotos.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold text-sm sm:text-base flex items-center gap-2">
|
|
<Activity className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
|
|
Activity Photos
|
|
</h3>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{completionDocuments.activityPhotos.length} file(s)
|
|
</Badge>
|
|
</div>
|
|
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
|
{completionDocuments.activityPhotos.map((doc, index) => (
|
|
<div
|
|
key={index}
|
|
className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 flex items-center justify-between gap-2"
|
|
>
|
|
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
|
|
<Activity className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 flex-shrink-0" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium text-xs lg:text-sm text-gray-900 truncate" title={doc.name}>
|
|
{doc.name}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{doc.id && (
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
{canPreviewDocument(doc) && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handlePreviewDocument(doc)}
|
|
disabled={previewLoading}
|
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
title="Preview photo"
|
|
>
|
|
{previewLoading ? (
|
|
<Loader2 className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 animate-spin" />
|
|
) : (
|
|
<Eye className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" />
|
|
)}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
try {
|
|
if (doc.id) {
|
|
await downloadDocument(doc.id);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to download document:', error);
|
|
toast.error('Failed to download document');
|
|
}
|
|
}}
|
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
|
title="Download photo"
|
|
>
|
|
<Download className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Invoices / Receipts */}
|
|
{completionDocuments.invoicesReceipts && completionDocuments.invoicesReceipts.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold text-sm sm:text-base flex items-center gap-2">
|
|
<Receipt className="w-4 h-4 sm:w-5 sm:h-5 text-purple-600" />
|
|
Invoices / Receipts
|
|
</h3>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{completionDocuments.invoicesReceipts.length} file(s)
|
|
</Badge>
|
|
</div>
|
|
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
|
{completionDocuments.invoicesReceipts.map((doc, index) => (
|
|
<div
|
|
key={index}
|
|
className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 flex items-center justify-between gap-2"
|
|
>
|
|
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
|
|
<Receipt className="w-4 h-4 lg:w-5 lg:h-5 text-purple-600 flex-shrink-0" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium text-xs lg:text-sm text-gray-900 truncate" title={doc.name}>
|
|
{doc.name}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{doc.id && (
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
{canPreviewDocument(doc) && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handlePreviewDocument(doc)}
|
|
disabled={previewLoading}
|
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
title="Preview document"
|
|
>
|
|
{previewLoading ? (
|
|
<Loader2 className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 animate-spin" />
|
|
) : (
|
|
<Eye className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" />
|
|
)}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
try {
|
|
if (doc.id) {
|
|
await downloadDocument(doc.id);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to download document:', error);
|
|
toast.error('Failed to download document');
|
|
}
|
|
}}
|
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
|
title="Download document"
|
|
>
|
|
<Download className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Attendance Sheet */}
|
|
{completionDocuments.attendanceSheet && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold text-sm sm:text-base flex items-center gap-2">
|
|
<Activity className="w-4 h-4 sm:w-5 sm:h-5 text-indigo-600" />
|
|
Attendance Sheet
|
|
</h3>
|
|
</div>
|
|
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
|
|
<Activity className="w-4 h-4 lg:w-5 lg:h-5 text-indigo-600 flex-shrink-0" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium text-xs lg:text-sm text-gray-900 truncate" title={completionDocuments.attendanceSheet.name}>
|
|
{completionDocuments.attendanceSheet.name}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{completionDocuments.attendanceSheet.id && (
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
{canPreviewDocument(completionDocuments.attendanceSheet) && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handlePreviewDocument(completionDocuments.attendanceSheet!)}
|
|
disabled={previewLoading}
|
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
title="Preview document"
|
|
>
|
|
{previewLoading ? (
|
|
<Loader2 className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 animate-spin" />
|
|
) : (
|
|
<Eye className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" />
|
|
)}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
try {
|
|
if (completionDocuments.attendanceSheet?.id) {
|
|
await downloadDocument(completionDocuments.attendanceSheet.id);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to download document:', error);
|
|
toast.error('Failed to download document');
|
|
}
|
|
}}
|
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
|
title="Download document"
|
|
>
|
|
<Download className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Verification Warning */}
|
|
<div className="p-2.5 sm:p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
|
<div className="flex items-start gap-2">
|
|
<TriangleAlert className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="text-xs sm:text-sm font-semibold text-yellow-900">
|
|
Please verify all details before pushing to DMS
|
|
</p>
|
|
<p className="text-xs text-yellow-700 mt-1">
|
|
Once pushed, the system will automatically generate an e-invoice and log it as an activity.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Comments & Remarks */}
|
|
<div className="space-y-1.5 max-w-2xl">
|
|
<Label htmlFor="comment" className="text-xs sm:text-sm font-semibold text-gray-900 flex items-center gap-2">
|
|
Comments & Remarks <span className="text-red-500">*</span>
|
|
</Label>
|
|
<Textarea
|
|
id="comment"
|
|
placeholder="Enter your comments about pushing to DMS (e.g., verified expenses, ready for invoice generation)..."
|
|
value={comments}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
if (value.length <= maxCommentsChars) {
|
|
setComments(value);
|
|
}
|
|
}}
|
|
rows={4}
|
|
className="text-sm min-h-[80px] resize-none"
|
|
/>
|
|
<div className="flex items-center justify-between text-xs text-gray-500">
|
|
<div className="flex items-center gap-1">
|
|
<TriangleAlert className="w-3 h-3" />
|
|
Required and visible to all
|
|
</div>
|
|
<span>{commentsChars}/{maxCommentsChars}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 px-6 pt-3 pb-6 border-t flex-shrink-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleClose}
|
|
disabled={submitting}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleSubmit}
|
|
disabled={!comments.trim() || submitting}
|
|
className="bg-indigo-600 hover:bg-indigo-700 text-white"
|
|
>
|
|
{submitting ? (
|
|
'Pushing to DMS...'
|
|
) : (
|
|
<>
|
|
<Activity className="w-4 h-4 mr-2" />
|
|
Push to DMS
|
|
</>
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
|
|
{/* File Preview Modal - Matching DocumentsTab style */}
|
|
{previewDocument && (
|
|
<Dialog
|
|
open={!!previewDocument}
|
|
onOpenChange={() => setPreviewDocument(null)}
|
|
>
|
|
<DialogContent className="file-preview-dialog p-3 sm:p-6">
|
|
<div className="file-preview-content">
|
|
<DialogHeader className="pb-4 flex-shrink-0 pr-8">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
<Eye className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<DialogTitle className="text-base sm:text-lg font-bold text-gray-900 truncate pr-2">
|
|
{previewDocument.name}
|
|
</DialogTitle>
|
|
{previewDocument.type && (
|
|
<p className="text-xs sm:text-sm text-gray-500">
|
|
{previewDocument.type} {previewDocument.size && `• ${(previewDocument.size / 1024).toFixed(1)} KB`}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-wrap mr-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
const link = document.createElement('a');
|
|
link.href = previewDocument.url;
|
|
link.download = previewDocument.name;
|
|
link.click();
|
|
}}
|
|
className="gap-2 h-9"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
<span className="hidden sm:inline">Download</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
<div className="file-preview-body bg-gray-100 rounded-lg p-2 sm:p-4">
|
|
{previewLoading ? (
|
|
<div className="flex items-center justify-center h-full min-h-[70vh]">
|
|
<div className="text-center">
|
|
<Loader2 className="w-8 h-8 animate-spin text-blue-600 mx-auto mb-2" />
|
|
<p className="text-sm text-gray-600">Loading preview...</p>
|
|
</div>
|
|
</div>
|
|
) : previewDocument.name.toLowerCase().endsWith('.pdf') || previewDocument.type?.includes('pdf') ? (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<iframe
|
|
src={previewDocument.url}
|
|
className="w-full h-full rounded-lg border-0"
|
|
title={previewDocument.name}
|
|
style={{
|
|
minHeight: '70vh',
|
|
height: '100%'
|
|
}}
|
|
/>
|
|
</div>
|
|
) : previewDocument.name.match(/\.(jpg|jpeg|png|gif|webp)$/i) || previewDocument.type?.includes('image') ? (
|
|
<div className="flex items-center justify-center h-full">
|
|
<img
|
|
src={previewDocument.url}
|
|
alt={previewDocument.name}
|
|
style={{
|
|
maxWidth: '100%',
|
|
maxHeight: '100%',
|
|
objectFit: 'contain'
|
|
}}
|
|
className="rounded-lg shadow-lg"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center h-full text-center">
|
|
<div className="w-20 h-20 bg-gray-200 rounded-full flex items-center justify-center mb-4">
|
|
<Eye className="w-10 h-10 text-gray-400" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Preview Not Available</h3>
|
|
<p className="text-sm text-gray-600 mb-6">
|
|
This file type cannot be previewed. Please download to view.
|
|
</p>
|
|
<Button
|
|
onClick={() => {
|
|
const link = document.createElement('a');
|
|
link.href = previewDocument.url;
|
|
link.download = previewDocument.name;
|
|
link.click();
|
|
}}
|
|
className="gap-2"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
Download {previewDocument.name}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</Dialog>
|
|
);
|
|
}
|
|
|