325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
import { Card, Text, Table, Badge, ActionIcon, Drawer, Box, Group, Stack, Alert } from '@pikku/mantine/core';
|
|
import { IconArrowRight, IconEye, IconMaximize, IconMinimize, IconPlus, IconRotateClockwise, IconX } from '@tabler/icons-react';
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|
import type { DB } from '@heygermany/sdk';
|
|
import { DocumentStatuses } from '@heygermany/sdk';
|
|
import { getFieldComponent } from './DocumentFieldFactory';
|
|
import { pikku } from '@/pikku/http';
|
|
import { useUpdateDocumentVerification } from '@/hooks/backoffice/useUpdateDocumentVerification';
|
|
import { useUploadBackOfficeCandidateFiles } from '@/hooks/backoffice/useUploadBackOfficeCandidateFiles';
|
|
import { UseMutationResult } from '@tanstack/react-query';
|
|
import { DocumentStatusSelect } from './DocumentStatusSelect';
|
|
import { useI18n } from '@/context/i18n-provider';
|
|
import { asI18n } from '@pikku/react';
|
|
|
|
type VerificationDocument = {
|
|
documentId: string;
|
|
documentStatus: DB.DocumentStatus | null;
|
|
label?: string;
|
|
};
|
|
|
|
type DocumentVerificationCardProps = {
|
|
category: DB.CandidateDocumentType;
|
|
candidateId: string;
|
|
documents: VerificationDocument[];
|
|
aiExtractedData: Record<string, any>;
|
|
finalData: Record<string, any>;
|
|
mutationHook: UseMutationResult<any, Error, any, unknown>;
|
|
verbose?: boolean;
|
|
requiredFields?: string[];
|
|
};
|
|
|
|
export function DocumentVerificationCard({
|
|
candidateId,
|
|
documents,
|
|
category,
|
|
aiExtractedData,
|
|
finalData,
|
|
mutationHook,
|
|
verbose = false,
|
|
requiredFields = [],
|
|
}: DocumentVerificationCardProps) {
|
|
const [documentUrl, setDocumentUrl] = useState<string | null>(null);
|
|
const [documentPreviewTitle, setDocumentPreviewTitle] = useState('Document Preview');
|
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
const [rotation, setRotation] = useState(0);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
const [deletingDocumentId, setDeletingDocumentId] = useState<string | null>(null);
|
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
const updateDocumentVerification = useUpdateDocumentVerification(candidateId);
|
|
const { uploadFiles, deleteFile } = useUploadBackOfficeCandidateFiles(candidateId, category);
|
|
const t = useI18n();
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
const showDocument = async (documentId: string, label: string) => {
|
|
const response = await pikku().get('/backoffice/candidate/:candidateId/document/:documentId', { documentId, candidateId });
|
|
setDocumentPreviewTitle(label);
|
|
setDocumentUrl(response.url)
|
|
}
|
|
|
|
const allFields = new Set([
|
|
...Object.keys(aiExtractedData || {}),
|
|
...Object.keys(finalData || {})
|
|
]);
|
|
|
|
// Show only required fields when not in verbose mode
|
|
const fieldsToShow = verbose
|
|
? Array.from(allFields)
|
|
: requiredFields.filter(field => allFields.has(field));
|
|
|
|
const getFieldLabel = (fieldName: string) => {
|
|
return fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/([A-Z])/g, ' $1');
|
|
};
|
|
|
|
useEffect(() => {
|
|
setFormData({ ...finalData });
|
|
}, [finalData]);
|
|
|
|
const handleCopyOver = (fieldName: string) => {
|
|
const aiValue = aiExtractedData?.[fieldName];
|
|
if (aiValue !== undefined) {
|
|
const updatedData = { ...formData, [fieldName]: aiValue };
|
|
setFormData(updatedData);
|
|
mutationHook.mutate({ [fieldName]: aiValue });
|
|
}
|
|
};
|
|
|
|
const handleFieldChange = (fieldName: string, value: any) => {
|
|
setFormData((currentData) => ({
|
|
...currentData,
|
|
[fieldName]: value,
|
|
}));
|
|
mutationHook.mutate({ [fieldName]: value });
|
|
};
|
|
|
|
const handleFileSelection = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const selectedFiles = Array.from(event.target.files || []);
|
|
|
|
if (!selectedFiles.length) {
|
|
return;
|
|
}
|
|
|
|
setUploadError(null);
|
|
setIsUploading(true);
|
|
|
|
try {
|
|
await uploadFiles(selectedFiles);
|
|
} catch (error) {
|
|
console.error('Error uploading files in backoffice:', error);
|
|
setUploadError('Failed to upload document(s). Please try again.');
|
|
} finally {
|
|
setIsUploading(false);
|
|
event.target.value = '';
|
|
}
|
|
};
|
|
|
|
const handleDeleteDocument = async (documentId: string, label: string) => {
|
|
const confirmed = window.confirm(`Delete ${label}?`);
|
|
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
setDeleteError(null);
|
|
setDeletingDocumentId(documentId);
|
|
|
|
try {
|
|
await deleteFile(documentId);
|
|
} catch (error) {
|
|
console.error('Error deleting file in backoffice:', error);
|
|
setDeleteError('Failed to delete document. Please try again.');
|
|
} finally {
|
|
setDeletingDocumentId(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Card shadow="sm" px={0} pb={0} radius="md" withBorder>
|
|
<Card.Section withBorder inheritPadding py="sm">
|
|
<Stack gap="sm" px="md">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".pdf,.jpg,.jpeg,.png"
|
|
multiple
|
|
hidden
|
|
onChange={handleFileSelection}
|
|
/>
|
|
{documents.length > 0 ? (
|
|
documents.map((document, index) => {
|
|
const label = document.label || `Document ${index + 1}`;
|
|
return (
|
|
<Group key={`${document.documentId}-${index}`} justify="space-between">
|
|
<Group gap="xs">
|
|
<Badge
|
|
color="blue"
|
|
variant="light"
|
|
style={{ cursor: 'pointer' }}
|
|
onClick={() => showDocument(document.documentId, label)}
|
|
>
|
|
{asI18n(label)}
|
|
</Badge>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="red"
|
|
size="sm"
|
|
onClick={() => handleDeleteDocument(document.documentId, label)}
|
|
disabled={deletingDocumentId === document.documentId}
|
|
aria-label={asI18n(`Delete ${label}`)}
|
|
title={asI18n(`Delete ${label}`)}
|
|
>
|
|
<IconX size={14} />
|
|
</ActionIcon>
|
|
</Group>
|
|
<DocumentStatusSelect
|
|
value={document.documentStatus ?? DocumentStatuses.UNVERIFIED}
|
|
onChange={(status) => updateDocumentVerification.mutate({
|
|
documentId: document.documentId,
|
|
documentStatus: status
|
|
})}
|
|
/>
|
|
</Group>
|
|
);
|
|
})
|
|
) : (
|
|
<Text size="sm" c="dimmed">{t('backoffice.documents.nofilesavailable' as any)}</Text>
|
|
)}
|
|
<Badge
|
|
color="blue"
|
|
variant="light"
|
|
style={{
|
|
cursor: isUploading ? 'default' : 'pointer',
|
|
alignSelf: 'flex-start',
|
|
opacity: isUploading ? 0.7 : 1,
|
|
}}
|
|
onClick={isUploading ? undefined : () => fileInputRef.current?.click()}
|
|
>
|
|
<Group gap={4} wrap="nowrap">
|
|
<IconPlus size={12} />
|
|
<Text size="xs" inherit>{asI18n(isUploading ? t('backoffice.documents.uploading' as any) : t('backoffice.documents.uploaddocuments' as any))}</Text>
|
|
</Group>
|
|
</Badge>
|
|
{uploadError ? (
|
|
<Alert color="red" variant="light">
|
|
{asI18n(uploadError)}
|
|
</Alert>
|
|
) : null}
|
|
{deleteError ? (
|
|
<Alert color="red" variant="light">
|
|
{asI18n(deleteError)}
|
|
</Alert>
|
|
) : null}
|
|
</Stack>
|
|
</Card.Section>
|
|
|
|
{fieldsToShow.length > 0 ? (
|
|
<Table>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>Field</Table.Th>
|
|
<Table.Th>AI Value</Table.Th>
|
|
<Table.Th></Table.Th>
|
|
<Table.Th>User Value</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{fieldsToShow.map((fieldName) => {
|
|
const FieldComponent = getFieldComponent(category, fieldName);
|
|
|
|
return (
|
|
<Table.Tr key={fieldName}>
|
|
<Table.Td>
|
|
<Text size="sm" fw={500}>
|
|
{asI18n(getFieldLabel(fieldName))}
|
|
</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<FieldComponent
|
|
value={aiExtractedData?.[fieldName]}
|
|
onChange={() => { }} // Read-only
|
|
disabled={true}
|
|
/>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<ActionIcon
|
|
variant="light"
|
|
size="sm"
|
|
onClick={() => handleCopyOver(fieldName)}
|
|
disabled={!aiExtractedData?.[fieldName]}
|
|
>
|
|
<IconArrowRight size={14} />
|
|
</ActionIcon>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<FieldComponent
|
|
value={formData?.[fieldName]}
|
|
onChange={(value) => handleFieldChange(fieldName, value)}
|
|
/>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
);
|
|
})}
|
|
</Table.Tbody>
|
|
</Table>
|
|
) : (
|
|
<Box p="md" ta="center">
|
|
<Text c="dimmed" size="sm">{asI18n('No required fields')}</Text>
|
|
</Box>
|
|
)}
|
|
</Card>
|
|
|
|
<Drawer
|
|
opened={!!documentUrl}
|
|
onClose={() => {
|
|
setDocumentUrl(null);
|
|
setIsFullscreen(false);
|
|
setRotation(0);
|
|
}}
|
|
position="right"
|
|
size={isFullscreen ? "100%" : "60%"}
|
|
title={
|
|
<Group justify="space-between" style={{ width: '100%', paddingRight: '1rem' }}>
|
|
<Text>{asI18n(documentPreviewTitle)}</Text>
|
|
<Group gap="xs">
|
|
<ActionIcon
|
|
variant="light"
|
|
onClick={() => setRotation((prev) => (prev + 90) % 360)}
|
|
title={asI18n('Rotate image')}
|
|
>
|
|
<IconRotateClockwise size={18} />
|
|
</ActionIcon>
|
|
<ActionIcon
|
|
variant="light"
|
|
onClick={() => setIsFullscreen(!isFullscreen)}
|
|
title={asI18n(isFullscreen ? 'Exit fullscreen' : 'Fullscreen')}
|
|
>
|
|
{isFullscreen ? <IconMinimize size={18} /> : <IconMaximize size={18} />}
|
|
</ActionIcon>
|
|
</Group>
|
|
</Group>
|
|
}
|
|
>
|
|
{documentUrl && (
|
|
<Box style={{ height: '100%' }}>
|
|
<iframe
|
|
src={documentUrl}
|
|
style={{
|
|
width: '100%',
|
|
height: 'calc(100vh - 100px)',
|
|
border: 'none',
|
|
transform: `rotate(${rotation}deg)`,
|
|
transformOrigin: 'center center',
|
|
transition: 'transform 0.3s ease'
|
|
}}
|
|
title={asI18n('Document Preview')}
|
|
/>
|
|
</Box>
|
|
)}
|
|
</Drawer>
|
|
</>
|
|
);
|
|
}
|