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 10:52:27 +02:00
commit 6c231d1d36
370 changed files with 35571 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
import { Select } from '@pikku/mantine/core';
import type { DB } from '@heygermany/sdk';
import { ApplicantStatuses } from '@heygermany/sdk';
import { useI18n } from '@/context/i18n-provider';
interface ApplicantStatusSelectProps {
value: DB.ApplicantStatus | null;
onChange: (value: DB.ApplicantStatus | null) => void;
isValid: boolean;
}
export const ApplicantStatusSelect: React.FC<ApplicantStatusSelectProps> = ({
value,
onChange,
isValid
}) => {
const t = useI18n();
const allStatuses = Object.values(ApplicantStatuses);
const preferredOrder: DB.ApplicantStatus[] = [
ApplicantStatuses.INITIAL,
ApplicantStatuses.PENDING,
ApplicantStatuses.INVALID,
ApplicantStatuses.COMPLETE,
];
const orderedStatuses = [
...preferredOrder.filter((status) => allStatuses.includes(status)),
...allStatuses.filter((status) => !preferredOrder.includes(status)),
];
return (
<Select
placeholder={t('backoffice.candidate.fields.selectstatus')}
value={value}
onChange={(value) => onChange(value as DB.ApplicantStatus | null)}
data={orderedStatuses.map((status) => ({
value: status,
label: t(`enums.applicantstatus.${status.toLowerCase()}` as any),
disabled: status === ApplicantStatuses.COMPLETE && !isValid,
}))}
/>
);
};

View File

@@ -0,0 +1,403 @@
import { TextInput, Select, Textarea, NumberInput, Group, Text, MultiSelect } from '@pikku/mantine/core';
import { DateInput } from '@mantine/dates';
import {
ApplicantStatuses,
CandidateDocumentTypes,
LanguageLevels,
NurseRecognitionGermanyValues,
NurseRecognitionHomeCountryValues,
} from '@heygermany/sdk';
import React from 'react';
import { EnumSelect } from '@/components/common/EnumSelect';
import { useI18n } from '@/context/i18n-provider';
import { asI18n } from '@pikku/react';
export type FieldComponentProps = {
value: any;
onChange: (value: any) => void;
label?: any;
placeholder?: any;
disabled?: boolean;
};
export type FieldComponent = (props: FieldComponentProps) => React.ReactElement;
// Reusable component functions
export const TextInputComponent: FieldComponent = ({ value, onChange, label, placeholder, disabled }) => (
<TextInput
value={value || ''}
onChange={(e) => onChange(e.target.value)}
label={label as any}
placeholder={placeholder as any}
disabled={disabled}
/>
);
export const TextareaComponent: FieldComponent = ({ value, onChange, label, placeholder, disabled }) => (
<Textarea
value={value || ''}
onChange={(e) => onChange(e.target.value)}
label={label as any}
placeholder={placeholder as any}
disabled={disabled}
autosize
minRows={2}
/>
);
export const NumberInputComponent: FieldComponent = ({ value, onChange, label, placeholder, disabled }) => (
<NumberInput
value={value || undefined}
onChange={onChange}
label={label as any}
placeholder={placeholder as any}
disabled={disabled}
/>
);
export const DateInputComponent: FieldComponent = ({ value, onChange, label, placeholder, disabled }) => (
<DateInput
value={value ? new Date(value) : null}
onChange={(date: string | null) => onChange(date)}
label={label as any}
placeholder={placeholder as any}
disabled={disabled}
valueFormat="YYYY-MM-DD"
/>
);
export const LanguageLevelSelectComponent: FieldComponent = ({ value, onChange, label, disabled }) => {
const t = useI18n();
return (
<EnumSelect
enumObject={LanguageLevels}
enumName="LanguageLevel"
value={value || null}
onChange={onChange}
label={label}
placeholder={t('backoffice.filters.selectlevel')}
disabled={disabled}
/>
);
};
export const BooleanSelectComponent: FieldComponent = ({ value, onChange, label, disabled }) => {
const t = useI18n();
const options = [
{ value: 'true', label: t('common.yes') },
{ value: 'false', label: t('common.no') },
{ value: 'null', label: t('backoffice.filters.unknown') }
];
const getCurrentValue = () => {
if (value === true) return 'true';
if (value === false) return 'false';
return 'null';
};
const handleChange = (newValue: string | null) => {
if (newValue === 'true') onChange(true);
else if (newValue === 'false') onChange(false);
else onChange(null);
};
return (
<Select
value={getCurrentValue()}
onChange={handleChange}
label={label as any}
placeholder={t('backoffice.filters.select')}
disabled={disabled}
data={options}
styles={{
input: {
...(value === null && !disabled ? {
borderColor: '#FFA500',
borderWidth: '2px'
} : {})
}
}}
/>
);
};
export const StringArrayComponent: FieldComponent = ({ value, onChange, label, disabled }) => {
const t = useI18n();
const arrayValue = Array.isArray(value) ? value : [];
return (
<Group gap="xs" align="flex-start">
<Text size="sm" fw={500}>{asI18n(String(label ?? ''))}</Text>
<Textarea
value={arrayValue.join('\n')}
onChange={(e) => onChange(e.target.value.split('\n').filter(item => item.trim()))}
placeholder={t('backoffice.filters.oneitemperline')}
disabled={disabled}
autosize
minRows={2}
style={{ flex: 1 }}
/>
</Group>
);
};
export const NurseRecognitionGermanySelectComponent: FieldComponent = ({ value, onChange, label, disabled }) => {
const t = useI18n();
return (
<EnumSelect
enumObject={NurseRecognitionGermanyValues}
enumName="NurseRecognitionGermany"
value={value || null}
onChange={onChange}
label={label}
placeholder={t('backoffice.filters.selectrecognitionlevel')}
disabled={disabled}
/>
);
};
export const NurseRecognitionHomeCountrySelectComponent: FieldComponent = ({ value, onChange, label, disabled }) => {
const t = useI18n();
return (
<EnumSelect
enumObject={NurseRecognitionHomeCountryValues}
enumName="NurseRecognitionHomeCountry"
value={value || null}
onChange={onChange}
label={label}
placeholder={t('backoffice.filters.selectrecognitionlevel')}
disabled={disabled}
/>
);
};
export const ApplicantStatusSelectComponent: FieldComponent = ({ value, onChange, label, placeholder, disabled }) => {
const t = useI18n();
return (
<EnumSelect
enumObject={ApplicantStatuses}
enumName="ApplicantStatus"
value={value || null}
onChange={onChange}
label={label}
placeholder={placeholder || t('backoffice.candidate.fields.selectstatus')}
disabled={disabled}
/>
);
};
export const WhatItMeansSelectComponent: FieldComponent = ({ value, onChange, label, disabled }) => {
const t = useI18n();
// Define the enum values based on the SQL schema
const WhatItMeansEnum = {
PARTIAL_RECOGNITION_B_ONE: 'PARTIAL_RECOGNITION_B_ONE',
PARTIAL_RECOGNITION_B_TWO: 'PARTIAL_RECOGNITION_B_TWO',
NONE: 'NONE',
} as const;
const whatItMeansOptions = Object.values(WhatItMeansEnum).map(meaning => ({
value: meaning,
label: meaning.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase())
}));
return (
<Select
value={value || null}
onChange={onChange}
label={label as any}
placeholder={t('backoffice.filters.selectmeaning')}
disabled={disabled}
data={whatItMeansOptions}
/>
);
};
export const NextStepsArraySelectComponent: FieldComponent = ({ value, onChange, label, disabled }) => {
const t = useI18n();
const arrayValue = Array.isArray(value) ? value : [];
// Define the enum values based on the SQL schema
const NextStepsEnum = {
IMPROVE_GERMAN_B_TWO: 'IMPROVE_GERMAN_B_TWO',
IMPROVE_GERMAN_B_ONE: 'IMPROVE_GERMAN_B_ONE',
SUBMIT_APPLICATION_ASSISTANT: 'SUBMIT_APPLICATION_ASSISTANT',
GET_HELP: 'GET_HELP',
CONNECT_WITH_EMPLOYERS: 'CONNECT_WITH_EMPLOYERS',
} as const;
const nextStepsOptions = Object.values(NextStepsEnum).map(step => ({
value: step,
label: step.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase())
}));
return (
<MultiSelect
label={label as any}
placeholder={t('backoffice.filters.selectsteps')}
value={arrayValue}
onChange={(val) => onChange(val || [])}
data={nextStepsOptions}
disabled={disabled}
/>
);
};
export const MinimumGermanLevelSelectComponent: FieldComponent = ({ value, onChange, label, disabled }) => {
const t = useI18n();
return (
<EnumSelect
enumObject={LanguageLevels}
enumName="LanguageLevel"
value={value || null}
onChange={onChange}
label={label}
placeholder={t('backoffice.filters.selectlevel')}
disabled={disabled}
/>
);
};
// Duration input component that converts text like "1y 5m" to hours
export const DurationInputComponent: FieldComponent = ({ value, onChange, label, placeholder, disabled }) => {
const t = useI18n();
const [inputValue, setInputValue] = React.useState('');
// Convert hours to display format
const hoursToDisplayFormat = (hours: number): string => {
if (!hours || hours === 0) return '';
let remaining = hours;
const parts = [];
// Calculate years
const years = Math.floor(remaining / (365 * 24));
if (years > 0) {
parts.push(`${years}y`);
remaining -= years * 365 * 24;
}
// Calculate months
const months = Math.floor(remaining / (30 * 24));
if (months > 0) {
parts.push(`${months}m`);
remaining -= months * 30 * 24;
}
// Calculate days
const days = Math.floor(remaining / 24);
if (days > 0) {
parts.push(`${days}d`);
remaining -= days * 24;
}
// Remaining hours
if (remaining > 0) {
parts.push(`${remaining}h`);
}
return parts.join(' ') || '0h';
};
// Convert display format to hours
const displayFormatToHours = (input: string): number => {
if (!input) return 0;
let totalHours = 0;
const yearMatch = input.match(/(\d+)y/);
const monthMatch = input.match(/(\d+)m/);
const dayMatch = input.match(/(\d+)d/);
const hourMatch = input.match(/(\d+)h/);
if (yearMatch) totalHours += parseInt(yearMatch[1]) * 365 * 24;
if (monthMatch) totalHours += parseInt(monthMatch[1]) * 30 * 24;
if (dayMatch) totalHours += parseInt(dayMatch[1]) * 24;
if (hourMatch) totalHours += parseInt(hourMatch[1]);
return totalHours;
};
// Initialize input value from props
React.useEffect(() => {
if (value !== undefined && value !== null) {
setInputValue(hoursToDisplayFormat(value));
}
}, [value]);
const handleInputChange = (newInputValue: string) => {
setInputValue(newInputValue);
const hours = displayFormatToHours(newInputValue);
onChange(hours);
};
return (
<Group gap="xs" align="flex-end">
<TextInput
value={inputValue}
onChange={(e) => handleInputChange(e.target.value)}
label={label as any}
placeholder={placeholder || t('backoffice.filters.durationexample')}
disabled={disabled}
style={{ flex: 1 }}
/>
<Text size="xs" c="dimmed" pb="xs">
{value ? asI18n(`${value} hours`) : asI18n('')}
</Text>
</Group>
);
};
// Field metadata mapping using enum values directly
export const FIELD_METADATA: Record<string, Record<string, FieldComponent>> = {
// Nursing License fields
[CandidateDocumentTypes.LICENSE]: {
licenseNumber: TextInputComponent,
licenseType: TextInputComponent,
issuingAuthority: TextInputComponent,
issueDate: DateInputComponent,
expiryDate: DateInputComponent,
status: TextInputComponent,
},
// Training Certificate fields
[CandidateDocumentTypes.EDUCATION]: {
institution: TextInputComponent,
program: TextInputComponent,
country: TextInputComponent,
completionDate: DateInputComponent,
nursingRelatedDegree: BooleanSelectComponent,
accreditedUniversity: BooleanSelectComponent,
accreditedStudyProgram: BooleanSelectComponent,
hasTheory: BooleanSelectComponent,
hasPractice: BooleanSelectComponent,
durationHours: DurationInputComponent,
isNursingFocus: BooleanSelectComponent,
},
// German Language Certificate fields
[CandidateDocumentTypes.LANGUAGE]: {
level: LanguageLevelSelectComponent,
issuingBody: TextInputComponent,
issueDate: DateInputComponent,
certifiedSkills: StringArrayComponent,
},
// Work Experience fields
[CandidateDocumentTypes.WORK_EXPERIENCE]: {
employer: TextInputComponent,
position: TextInputComponent,
department: TextInputComponent,
startDate: DateInputComponent,
endDate: DateInputComponent,
durationMonths: NumberInputComponent,
isFullTime: BooleanSelectComponent,
duties: StringArrayComponent,
},
// Nursing Qualification Rule fields
'nursing_qualification_rule': {
},
};
export function getFieldComponent(documentType: string, fieldName: string): FieldComponent {
return FIELD_METADATA[documentType]?.[fieldName] || TextInputComponent;
}

View File

@@ -0,0 +1,29 @@
import { Select } from '@pikku/mantine/core';
import type { DB } from '@heygermany/sdk';
import { DocumentStatuses } from '@heygermany/sdk';
import { useI18n } from '@/context/i18n-provider';
interface DocumentStatusSelectProps {
value: DB.DocumentStatus;
onChange: (value: DB.DocumentStatus) => void;
}
export const DocumentStatusSelect: React.FC<DocumentStatusSelectProps> = ({
value,
onChange
}) => {
const t = useI18n();
return (
<Select
placeholder={t('backoffice.candidate.fields.selectstatus')}
value={value}
onChange={(value) => onChange(value as DB.DocumentStatus)}
data={[
{ value: DocumentStatuses.UNVERIFIED, label: t('backoffice.documents.status.unverified') },
{ value: DocumentStatuses.VERIFIED, label: t('backoffice.documents.status.verified') },
{ value: DocumentStatuses.INVALID, label: t('backoffice.documents.status.invalid') },
{ value: DocumentStatuses.MISSING, label: t('backoffice.documents.status.missing') }
]}
/>
);
};

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

View File

@@ -0,0 +1,68 @@
import { DocumentVerificationCard } from './DocumentVerificationCard';
import { useUpdateCandidateEducation } from '@/hooks/backoffice/useUpdateCandidateEducation';
import { CandidateDocumentTypes, DocumentStatuses } from '@heygermany/sdk';
type EducationVerificationCardProps = {
candidateId: string;
education: any;
documents?: any[];
verbose?: boolean;
};
export function EducationVerificationCard({ candidateId, education, documents = [], verbose = false }: EducationVerificationCardProps) {
const mutationHook = useUpdateCandidateEducation(candidateId);
const verificationDocuments = documents
.map((document, index) => ({
documentId: document.documentId,
documentStatus: document.documentStatus ?? DocumentStatuses.UNVERIFIED,
label: document.fileName || `File ${index + 1}`
}));
return (
<DocumentVerificationCard
candidateId={candidateId}
documents={verificationDocuments}
category={CandidateDocumentTypes.EDUCATION}
aiExtractedData={{
institution: education.aiInstitution,
country: education.aiCountry,
program: education.aiProgram,
completionDate: education.aiCompletionDate,
nursingRelatedDegree: education.aiNursingRelatedDegree,
accreditedUniversity: education.aiAccreditedUniversity,
accreditedStudyProgram: education.aiAccreditedStudyProgram,
isNursingFocus: education.aiIsNursingFocus,
hasTheory: education.aiHasTheory,
hasPractice: education.aiHasPractice,
durationHours: education.aiDurationHours
}}
finalData={{
institution: education.institution,
country: education.country,
program: education.program,
completionDate: education.completionDate,
nursingRelatedDegree: education.nursingRelatedDegree,
accreditedUniversity: education.accreditedUniversity,
accreditedStudyProgram: education.accreditedStudyProgram,
isNursingFocus: education.isNursingFocus,
hasTheory: education.hasTheory,
hasPractice: education.hasPractice,
durationHours: education.durationHours
}}
mutationHook={mutationHook}
verbose={verbose}
requiredFields={[
'institution',
'country',
'program',
'nursingRelatedDegree',
'accreditedUniversity',
'accreditedStudyProgram',
'isNursingFocus',
'hasTheory',
'hasPractice',
'durationHours'
]}
/>
);
}

View File

@@ -0,0 +1,51 @@
import { DocumentVerificationCard } from './DocumentVerificationCard';
import { useUpdateCandidateExperience } from '@/hooks/backoffice/useUpdateCandidateExperience';
import { CandidateDocumentTypes, DocumentStatuses } from '@heygermany/sdk';
type ExperienceVerificationCardProps = {
candidateId: string;
experience: any;
documents?: any[];
verbose?: boolean;
};
export function ExperienceVerificationCard({ candidateId, experience, documents = [], verbose = false }: ExperienceVerificationCardProps) {
const mutationHook = useUpdateCandidateExperience(candidateId);
const verificationDocuments = documents
.map((document, index) => ({
documentId: document.documentId,
documentStatus: document.documentStatus ?? DocumentStatuses.UNVERIFIED,
label: document.fileName || `File ${index + 1}`
}));
return (
<DocumentVerificationCard
candidateId={candidateId}
documents={verificationDocuments}
category={CandidateDocumentTypes.WORK_EXPERIENCE}
aiExtractedData={{
employer: experience.aiEmployer,
position: experience.aiPosition,
department: experience.aiDepartment,
startDate: experience.aiStartDate,
endDate: experience.aiEndDate,
durationMonths: experience.aiDurationMonths,
isFullTime: experience.aiIsFullTime,
duties: experience.aiDuties
}}
finalData={{
employer: experience.employer,
position: experience.position,
department: experience.department,
startDate: experience.startDate,
endDate: experience.endDate,
durationMonths: experience.durationMonths,
isFullTime: experience.isFullTime,
duties: experience.duties
}}
mutationHook={mutationHook}
verbose={verbose}
requiredFields={[]} // Work experience: only show header when not verbose
/>
);
}

View File

@@ -0,0 +1,45 @@
import { DocumentVerificationCard } from './DocumentVerificationCard';
import { useUpdateCandidateLanguage } from '@/hooks/backoffice/useUpdateCandidateLanguage';
import { CandidateDocumentTypes, DocumentStatuses } from '@heygermany/sdk';
type LanguageVerificationCardProps = {
candidateId: string;
language: any;
documents?: any[];
verbose?: boolean;
};
export function LanguageVerificationCard({ candidateId, language, documents = [], verbose = false }: LanguageVerificationCardProps) {
const mutationHook = useUpdateCandidateLanguage(candidateId);
const verificationDocuments = documents
.map((document, index) => ({
documentId: document.documentId,
documentStatus: document.documentStatus ?? DocumentStatuses.UNVERIFIED,
label: document.fileName || `File ${index + 1}`
}));
return (
<DocumentVerificationCard
candidateId={candidateId}
documents={verificationDocuments}
category={CandidateDocumentTypes.LANGUAGE}
aiExtractedData={{
level: language.aiLevel,
issuingBody: language.aiIssuingBody,
issueDate: language.aiIssueDate,
certifiedSkills: language.aiCertifiedSkills
}}
finalData={{
level: language.level,
issuingBody: language.issuingBody,
issueDate: language.issueDate,
certifiedSkills: language.certifiedSkills
}}
mutationHook={mutationHook}
verbose={verbose}
requiredFields={[
'level'
]}
/>
);
}

View File

@@ -0,0 +1,47 @@
import { DocumentVerificationCard } from './DocumentVerificationCard';
import { useUpdateCandidateLicense } from '@/hooks/backoffice/useUpdateCandidateLicense';
import { CandidateDocumentTypes, DocumentStatuses } from '@heygermany/sdk';
type LicenseVerificationCardProps = {
candidateId: string;
license: any;
documents?: any[];
verbose?: boolean;
};
export function LicenseVerificationCard({ candidateId, license, documents = [], verbose = false }: LicenseVerificationCardProps) {
const mutationHook = useUpdateCandidateLicense(candidateId);
const verificationDocuments = documents
.map((document, index) => ({
documentId: document.documentId,
documentStatus: document.documentStatus ?? DocumentStatuses.UNVERIFIED,
label: document.fileName || `File ${index + 1}`
}));
return (
<DocumentVerificationCard
candidateId={candidateId}
documents={verificationDocuments}
category={CandidateDocumentTypes.LICENSE}
aiExtractedData={{
licenseNumber: license.aiLicenseNumber,
issuingAuthority: license.aiIssuingAuthority,
issueDate: license.aiIssueDate,
expiryDate: license.aiExpiryDate,
status: license.aiStatus,
licenseType: license.aiLicenseType
}}
finalData={{
licenseNumber: license.licenseNumber,
issuingAuthority: license.issuingAuthority,
issueDate: license.issueDate,
expiryDate: license.expiryDate,
status: license.status,
licenseType: license.licenseType
}}
mutationHook={mutationHook}
verbose={verbose}
requiredFields={[]} // License: none (just need to verify its valid)
/>
);
}

View File

@@ -0,0 +1,265 @@
import { Button, Divider, Group, Paper, Stack, Table, Text, Textarea, Title } from '@pikku/mantine/core';
import { CandidateLifecycleNotifications } from '@heygermany/sdk';
import dayjs from 'dayjs';
import { useI18n } from '@/context/i18n-provider';
import { asI18n } from '@pikku/react';
type LifecycleNotificationsCardProps = {
lifecycleNotifications?: CandidateLifecycleNotifications | null
analysisIssuesDraft: string
onAnalysisIssuesDraftChange: (value: string) => void
onSaveAnalysisIssues: () => void
isSaving: boolean
}
type ReminderCategory = 'initial' | 'invalid'
type ReminderType = 'day_1' | 'day_3' | 'day_6'
type ReminderRow = {
type: ReminderType
sentAt: string | null
mailgunMessageId: string | null
errorMessage: string | null
lastErrorAt: string | null
}
const REMINDER_TYPES: ReminderRow[] = [
{ type: 'day_1', sentAt: null, mailgunMessageId: null, errorMessage: null, lastErrorAt: null },
{ type: 'day_3', sentAt: null, mailgunMessageId: null, errorMessage: null, lastErrorAt: null },
{ type: 'day_6', sentAt: null, mailgunMessageId: null, errorMessage: null, lastErrorAt: null },
]
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
const normalizeLifecycleNotifications = (value: unknown): CandidateLifecycleNotifications | null => {
if (isPlainObject(value)) {
return value as CandidateLifecycleNotifications
}
if (typeof value !== 'string') {
return null
}
try {
const parsedValue = JSON.parse(value)
return isPlainObject(parsedValue) ? parsedValue as CandidateLifecycleNotifications : null
} catch {
return null
}
}
const getString = (value: unknown) => {
if (typeof value !== 'string') {
return null
}
const normalizedValue = value.trim()
return normalizedValue.length > 0 ? normalizedValue : null
}
const normalizeMessage = (value: string | null) => {
if (!value) {
return null
}
const normalizedValue = value.replace(/\s+/g, ' ').trim()
return normalizedValue.length > 0 ? normalizedValue : null
}
const displayValue = (value: string | null) => value || '-'
const formatTimestamp = (value: string | null) => {
if (!value) {
return '-'
}
const parsedValue = dayjs(value)
return parsedValue.isValid() ? parsedValue.format('YYYY-MM-DD HH:mm') : value
}
const getReminderKey = (category: ReminderCategory, type: ReminderType) =>
`${category}.${type}`
const normalizeKey = (value: string) => value.toLowerCase().replace(/[_\-.]/g, '')
const getReminderValue = (
lifecycleNotifications: CandidateLifecycleNotifications | null | undefined,
category: ReminderCategory,
type: ReminderType
) => {
const directValue = lifecycleNotifications?.[getReminderKey(category, type)]
if (directValue !== undefined) {
return directValue
}
const normalizedReminderKey = normalizeKey(getReminderKey(category, type))
return Object.entries(lifecycleNotifications ?? {}).find(([key]) => (
normalizeKey(key) === normalizedReminderKey
))?.[1]
}
const getStringFromKeys = (record: Record<string, unknown>, keys: string[]) => {
for (const key of keys) {
const value = getString(record[key])
if (value) {
return value
}
}
return null
}
const getReminderRows = (
lifecycleNotifications: CandidateLifecycleNotifications | null | undefined,
category: ReminderCategory
) =>
REMINDER_TYPES.map((baseRow) => {
const reminderValue = getReminderValue(lifecycleNotifications, category, baseRow.type)
if (!isPlainObject(reminderValue)) {
return baseRow
}
return {
...baseRow,
sentAt: getStringFromKeys(reminderValue, ['sent_at', 'sentAt']),
mailgunMessageId: getStringFromKeys(reminderValue, ['mailgun_message_id', 'mailgunMessageId']),
errorMessage: normalizeMessage(getStringFromKeys(reminderValue, ['last_error', 'lastError'])),
lastErrorAt: getStringFromKeys(reminderValue, ['last_error_at', 'lastErrorAt']),
}
})
const renderReminderSection = (
title: string,
rows: ReminderRow[]
) => (
<Paper withBorder radius="sm" p="md">
<Stack gap="xs">
<Text fw={600}>{asI18n(title)}</Text>
<div style={{ overflowX: 'auto' }}>
<Table
withTableBorder
withColumnBorders={false}
striped={false}
highlightOnHover={false}
style={{ tableLayout: 'fixed', minWidth: 980 }}
>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: 120 }}>notification_type</Table.Th>
<Table.Th style={{ width: 180 }}>sent_at</Table.Th>
<Table.Th style={{ width: 280 }}>mailgun_message_id</Table.Th>
<Table.Th>last_error</Table.Th>
<Table.Th style={{ width: 180 }}>last_error_at</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => (
<Table.Tr key={row.type}>
<Table.Td style={{ verticalAlign: 'top' }}>
<Text size="sm" style={{ fontFamily: 'monospace' }}>
{asI18n(row.type)}
</Text>
</Table.Td>
<Table.Td style={{ verticalAlign: 'top' }}>
<Text size="sm" style={{ fontFamily: 'monospace', overflowWrap: 'anywhere' }}>
{asI18n(displayValue(row.sentAt) || '-')}
</Text>
</Table.Td>
<Table.Td style={{ verticalAlign: 'top' }}>
<Text size="sm" style={{ fontFamily: 'monospace', overflowWrap: 'anywhere' }}>
{asI18n(displayValue(row.mailgunMessageId) || '-')}
</Text>
</Table.Td>
<Table.Td style={{ verticalAlign: 'top' }}>
<Text size="sm" c={row.errorMessage ? 'red' : 'dimmed'} style={{ whiteSpace: 'normal', overflowWrap: 'anywhere', lineHeight: 1.4 }}>
{asI18n(displayValue(row.errorMessage) || '-')}
</Text>
</Table.Td>
<Table.Td style={{ verticalAlign: 'top' }}>
<Text size="sm" style={{ fontFamily: 'monospace', overflowWrap: 'anywhere' }}>
{asI18n(displayValue(row.lastErrorAt) || '-')}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</div>
</Stack>
</Paper>
)
export const LifecycleNotificationsCard: React.FunctionComponent<LifecycleNotificationsCardProps> = ({
lifecycleNotifications,
analysisIssuesDraft,
onAnalysisIssuesDraftChange,
onSaveAnalysisIssues,
isSaving,
}) => {
const t = useI18n()
const normalizedLifecycleNotifications = normalizeLifecycleNotifications(lifecycleNotifications)
const initialRows = getReminderRows(normalizedLifecycleNotifications, 'initial')
const invalidRows = getReminderRows(normalizedLifecycleNotifications, 'invalid')
const analysisUpdatedAt =
isPlainObject(normalizedLifecycleNotifications?.analysis)
? getString(normalizedLifecycleNotifications.analysis.updated_at ?? normalizedLifecycleNotifications.analysis.updatedAt)
: null
return (
<Paper shadow="sm" p="lg" radius="md" withBorder mb="md">
<Stack gap="md">
<Stack gap={4}>
<Title order={4}>{t('backoffice.lifecycle.title' as any)}</Title>
<Text size="sm" c="dimmed">
{t('backoffice.lifecycle.description' as any)}
</Text>
</Stack>
{renderReminderSection(t('backoffice.lifecycle.initial' as any), initialRows)}
{renderReminderSection(t('backoffice.lifecycle.invalid' as any), invalidRows)}
<Divider />
<Paper withBorder radius="sm" p="md">
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text fw={600}>{t('backoffice.lifecycle.customissues' as any)}</Text>
{analysisUpdatedAt ? (
<Group gap={4} wrap="nowrap">
<Text size="xs" c="dimmed">{t('backoffice.lifecycle.updated' as any)}</Text>
<Text size="xs" c="dimmed">{asI18n(formatTimestamp(analysisUpdatedAt))}</Text>
</Group>
) : null}
</Group>
<Text size="sm" c="dimmed">
{t('backoffice.lifecycle.helptext' as any)}
</Text>
<Textarea
minRows={4}
autosize
value={analysisIssuesDraft}
onChange={(event) => onAnalysisIssuesDraftChange(event.currentTarget.value)}
placeholder={t('backoffice.lifecycle.placeholder' as any)}
/>
<Group justify="flex-end">
<Button
variant="light"
loading={isSaving}
onClick={onSaveAnalysisIssues}
>
{t('backoffice.lifecycle.save' as any)}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,97 @@
import { Paper, Text, Box, Stack, Group } from '@pikku/mantine/core';
import { GetCandidateResult } from '@heygermany/sdk';
import React from 'react';
import { asI18n, type I18nNode } from '@pikku/react';
const renderFieldValue = (value: any, key: string): I18nNode => {
let displayValue: string;
if (typeof value === 'boolean') {
displayValue = value ? 'Yes' : 'No';
} else if (value === null || value === undefined) {
displayValue = 'N/A';
} else if (key === 'languageLevel') {
displayValue = (value as string).replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase());
} else if (key === 'qualificationStatusHomeCountry') {
displayValue = (value as string).replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase());
} else if (key === 'qualificationStatusGermany') {
displayValue = (value as string).replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase());
} else if (key === 'languageAssessmentType') {
displayValue = value === 'certificate' ? 'Certificate' : value === 'self_assessment' ? 'Self Assessment' : 'N/A';
} else {
displayValue = String(value);
}
const getColor = (val: string) => {
if (val === 'Verified') return 'green';
if (val === 'Unverified') return 'yellow';
if (val === 'Missing') return 'red';
if (val === 'Yes') return 'green';
if (val === 'No') return 'red';
if (val === 'N/A') return 'dimmed';
return undefined;
};
return <Text span c={getColor(displayValue)}>{asI18n(displayValue)}</Text>;
};
const renderRow = (label: string, value: I18nNode) => (
<Group gap={4} wrap="wrap" align="baseline">
<Text fw={500} size="sm">{asI18n(label)}</Text>
<Text size="sm">{value}</Text>
</Group>
);
export const QualificationAssessment: React.FunctionComponent<{ candidateResult?: GetCandidateResult }> = ({ candidateResult }) => {
if (!candidateResult) return null;
return (
<>
<Paper p="md" radius="sm" withBorder my="lg" shadow='lg'>
<Stack gap="sm">
{renderRow('Qualification Status Home Country:', renderFieldValue(candidateResult.qualificationStatusHomeCountry, 'qualificationStatusHomeCountry'))}
{renderRow('Qualification Status Germany:', renderFieldValue(candidateResult.qualificationStatusGermany, 'qualificationStatusGermany'))}
{renderRow('Nursing Related Degree:', renderFieldValue(candidateResult.nursingRelatedDegree, 'nursingRelatedDegree'))}
{renderRow('Accredited University:', renderFieldValue(candidateResult.accreditedUniversity, 'accreditedUniversity'))}
{renderRow('Accredited Study Program:', renderFieldValue(candidateResult.accreditedStudyProgram, 'accreditedStudyProgram'))}
<Box>
{renderRow('Structured Training:', renderFieldValue(candidateResult.structuredTraining?.overallStatus, 'structuredTraining'))}
<Box ml="xl" mt="xs" p="xs" style={{ borderLeft: '2px solid #e0e0e0' }}>
<Stack gap="xs">
{renderRow('Duration:', renderFieldValue(candidateResult.structuredTraining?.hasDuration, 'hasDuration'))}
{renderRow('Nursing Focus:', renderFieldValue(candidateResult.structuredTraining?.isNursingFocus, 'isNursingFocus'))}
{renderRow('Theory & Practice:', renderFieldValue(
candidateResult.structuredTraining?.hasTheoryHours && candidateResult.structuredTraining?.hasPracticeHours,
'theoryAndPractice'
))}
</Stack>
</Box>
</Box>
<Box>
{renderRow('License:', null)}
<Box ml="xl" mt="xs" p="xs" style={{ borderLeft: '2px solid #e0e0e0' }}>
<Stack gap="xs">
{renderRow('Mandatory:', renderFieldValue(candidateResult.licenseMandatory, 'licenseMandatory'))}
{renderRow('Uploaded:', renderFieldValue(candidateResult.docs?.license, 'licenseUploaded'))}
</Stack>
</Box>
</Box>
<Box>
{renderRow('Language:', null)}
<Box ml="xl" mt="xs" p="xs" style={{ borderLeft: '2px solid #e0e0e0' }}>
<Stack gap="xs">
{renderRow('Level:', renderFieldValue(candidateResult.languageLevel, 'languageLevel'))}
{renderRow('Assessment Type:', renderFieldValue(candidateResult.languageAssessmentType, 'languageAssessmentType'))}
</Stack>
</Box>
</Box>
{renderRow('CV Uploaded:', renderFieldValue(candidateResult.docs?.cv, 'cvUploaded'))}
</Stack>
</Paper>
</>
);
}