chore: heygermany customer project
Some checks failed
Main / Setup and Test (push) Has been cancelled
Main / Build Website (push) Has been cancelled

This commit is contained in:
e2e
2026-07-11 11:03:59 +02:00
commit 36f1b746bd
370 changed files with 35585 additions and 0 deletions

View 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>
</>
);
}