chore: heygermany customer project
This commit is contained in:
324
apps/website/components/backoffice/DocumentVerificationCard.tsx
Normal file
324
apps/website/components/backoffice/DocumentVerificationCard.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user