chore: heygermany customer project
This commit is contained in:
43
apps/website/components/backoffice/ApplicantStatusSelect.tsx
Normal file
43
apps/website/components/backoffice/ApplicantStatusSelect.tsx
Normal 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,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
403
apps/website/components/backoffice/DocumentFieldFactory.tsx
Normal file
403
apps/website/components/backoffice/DocumentFieldFactory.tsx
Normal 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;
|
||||
}
|
||||
29
apps/website/components/backoffice/DocumentStatusSelect.tsx
Normal file
29
apps/website/components/backoffice/DocumentStatusSelect.tsx
Normal 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') }
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user