376 lines
16 KiB
TypeScript
376 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { Container, Title, Text, Paper, Stack, Loader, Alert, Grid, Group, Flex, Card, Button, Switch, ActionIcon } from '@pikku/mantine/core';
|
|
import { IconExternalLink, IconEye } from '@tabler/icons-react';
|
|
import { EnumSelect } from '@/components/common/EnumSelect';
|
|
import dayjs from 'dayjs';
|
|
import { useParams } from '@/framework/navigation';
|
|
import { useGetBackOfficeCandidate } from '@/hooks/backoffice/useGetBackOfficeCandidate';
|
|
import { useUpdateBackOfficeCandidate } from '@/hooks/backoffice/useUpdateBackOfficeCandidate';
|
|
import { useGetBackOfficeCandidateResult } from '@/hooks/backoffice/useGetBackOfficeCandidateResult';
|
|
import { EducationVerificationCard } from '@/components/backoffice/EducationVerificationCard';
|
|
import { LicenseVerificationCard } from '@/components/backoffice/LicenseVerificationCard';
|
|
import { LanguageVerificationCard } from '@/components/backoffice/LanguageVerificationCard';
|
|
import { ExperienceVerificationCard } from '@/components/backoffice/ExperienceVerificationCard';
|
|
import type { DB } from '@heygermany/sdk';
|
|
import {
|
|
NurseRecognitionGermanyValues,
|
|
NurseRecognitionHomeCountryValues,
|
|
RecognitionLikelihoodValues,
|
|
} from '@heygermany/sdk';
|
|
import { CandidateResult } from '@/components/jobs/CandidateResult';
|
|
import { QualificationAssessment } from '@/components/backoffice/QualificationAssessment';
|
|
import { BooleanSelectComponent } from '@/components/backoffice/DocumentFieldFactory';
|
|
import { useGetCandidateResults } from '@/hooks/useGetCandidateResult';
|
|
import { ApplicantStatusSelect } from '@/components/backoffice/ApplicantStatusSelect';
|
|
import { useGetCountries } from '@/hooks/useGetCountries';
|
|
import { LifecycleNotificationsCard } from '@/components/backoffice/LifecycleNotificationsCard';
|
|
import { asI18n } from '@pikku/react';
|
|
import { useI18n } from '@/context/i18n-provider';
|
|
|
|
const CandidateResultPreview: React.FunctionComponent<{ candidateId: string }> = ({ candidateId }) => {
|
|
const { data: candidateResults } = useGetCandidateResults(candidateId)
|
|
if (!candidateResults) return null
|
|
return <CandidateResult candidateResults={candidateResults} />
|
|
}
|
|
|
|
export default function BackOfficeCandidateDetailPage() {
|
|
const params = useParams();
|
|
const candidateId = params.candidateId as string;
|
|
const t = useI18n();
|
|
|
|
const { data: candidate, isLoading, error } = useGetBackOfficeCandidate(candidateId);
|
|
const { data: candidateResult } = useGetBackOfficeCandidateResult(candidateId);
|
|
const updateCandidateMutation = useUpdateBackOfficeCandidate(candidateId);
|
|
const { getCountryNameFromAbbreviation } = useGetCountries();
|
|
const [showResultsDrawer, setShowResultsDrawer] = useState(false);
|
|
const [verbose, setVerbose] = useState(false);
|
|
const [analysisIssuesDraft, setAnalysisIssuesDraft] = useState('');
|
|
const notAvailable = t('common.na');
|
|
const yesNo = (value: boolean | null | undefined) => {
|
|
if (value === true) return t('common.yes');
|
|
if (value === false) return t('common.no');
|
|
return notAvailable;
|
|
};
|
|
|
|
const currentAnalysisIssues = Array.isArray(candidate?.lifecycleNotifications?.analysis?.issues)
|
|
? candidate.lifecycleNotifications.analysis.issues.filter((issue: any): issue is string => typeof issue === 'string')
|
|
: [];
|
|
const currentAnalysisIssuesDraft = currentAnalysisIssues.join('\n');
|
|
|
|
useEffect(() => {
|
|
setAnalysisIssuesDraft(currentAnalysisIssuesDraft);
|
|
}, [candidateId, currentAnalysisIssuesDraft]);
|
|
|
|
const saveAnalysisIssues = () => {
|
|
const analysisIssues = analysisIssuesDraft
|
|
.split('\n')
|
|
.map((issue: string) => issue.trim())
|
|
.filter((issue: string) => issue.length > 0)
|
|
|
|
updateCandidateMutation.mutate({ analysisIssues })
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Flex justify="center" align="center" style={{ minHeight: 'calc(100vh - 60px)' }}>
|
|
<Loader size="xl" />
|
|
</Flex>
|
|
);
|
|
}
|
|
|
|
if (error || !candidate) {
|
|
return (
|
|
<Container size="xl">
|
|
<Alert color="red" title={t('common.error')}>
|
|
{t('backoffice.candidate.error.notfound')}
|
|
</Alert>
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Flex>
|
|
<Container>
|
|
<Stack>
|
|
<Group justify="space-between" align="center" mb="lg">
|
|
<Title order={1}>
|
|
{asI18n(candidate.name)}
|
|
</Title>
|
|
<Group gap="sm">
|
|
<ApplicantStatusSelect
|
|
value={candidate.status}
|
|
onChange={(status) => {
|
|
updateCandidateMutation.mutate({ status: status! })
|
|
}}
|
|
isValid={candidateResult?.isValid ?? false}
|
|
/>
|
|
<Group gap="xs">
|
|
<Text size="sm" fw={500}>{t('backoffice.candidate.verbose')}</Text>
|
|
<Switch
|
|
checked={verbose}
|
|
onChange={(event) => setVerbose(event.currentTarget.checked)}
|
|
/>
|
|
</Group>
|
|
{candidateResult && (
|
|
<Text
|
|
size="sm"
|
|
c={candidateResult.isValid ? 'success' : 'error'}
|
|
fw={500}
|
|
>
|
|
{candidateResult.isValid ? t('backoffice.candidate.result.valid') : t('backoffice.candidate.result.invalid')}
|
|
</Text>
|
|
)}
|
|
<ActionIcon
|
|
size="lg"
|
|
variant="light"
|
|
disabled={!candidateResult?.isValid}
|
|
onClick={() => setShowResultsDrawer(!showResultsDrawer)}
|
|
title={t('backoffice.candidate.actions.showresultspanel')}
|
|
>
|
|
<IconEye size={18} />
|
|
</ActionIcon>
|
|
<Button
|
|
size="sm"
|
|
variant="filled"
|
|
disabled={!candidateResult?.isValid}
|
|
onClick={() => window.open(`/jobs/application?candidateId=${candidateId}`, '_blank')}
|
|
leftSection={<IconExternalLink size={16} />}
|
|
>
|
|
{t('backoffice.candidate.actions.resultpage')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="light"
|
|
disabled={!candidateResult?.isValid}
|
|
onClick={() => window.open(`/company/candidate/${candidateId}`, '_blank')}
|
|
leftSection={<IconExternalLink size={16} />}
|
|
>
|
|
{t('backoffice.candidate.actions.companyview')}
|
|
</Button>
|
|
</Group>
|
|
</Group>
|
|
|
|
{/* Qualification Result Summary */}
|
|
<QualificationAssessment candidateResult={candidateResult} />
|
|
|
|
{/* Manual Assessment Section */}
|
|
<Title order={3} mb="md">{t('backoffice.candidate.manualassessment')}</Title>
|
|
<Card shadow="sm" padding="lg" radius="md" withBorder mb="md">
|
|
<Grid gutter="md">
|
|
<Grid.Col span={6}>
|
|
<EnumSelect
|
|
enumObject={NurseRecognitionHomeCountryValues}
|
|
enumName="NurseRecognitionHomeCountry"
|
|
label={t('backoffice.candidate.fields.statushomecountry')}
|
|
placeholder={t('backoffice.candidate.fields.selectstatus')}
|
|
value={candidate.qualificationStatusHomeCountry || null}
|
|
onChange={(qualificationStatusHomeCountry) =>
|
|
updateCandidateMutation.mutate({
|
|
qualificationStatusHomeCountry: qualificationStatusHomeCountry! as DB.NurseRecognitionHomeCountry
|
|
})
|
|
}
|
|
/>
|
|
</Grid.Col>
|
|
<Grid.Col span={6}>
|
|
<EnumSelect
|
|
enumObject={NurseRecognitionGermanyValues}
|
|
enumName="NurseRecognitionGermany"
|
|
label={t('backoffice.candidate.fields.statusgermany')}
|
|
placeholder={t('backoffice.candidate.fields.selectstatus')}
|
|
value={candidate.qualificationStatusGermany || null}
|
|
onChange={(qualificationStatusGermany) =>
|
|
updateCandidateMutation.mutate({
|
|
qualificationStatusGermany: qualificationStatusGermany! as DB.NurseRecognitionGermany
|
|
})
|
|
}
|
|
/>
|
|
</Grid.Col>
|
|
<Grid.Col span={6}>
|
|
<BooleanSelectComponent
|
|
label={t('backoffice.candidate.fields.licensemandatory')}
|
|
value={candidate.licenseMandatory}
|
|
onChange={(licenseMandatory) =>
|
|
updateCandidateMutation.mutate({ licenseMandatory })}
|
|
/>
|
|
</Grid.Col>
|
|
<Grid.Col span={6}>
|
|
<BooleanSelectComponent
|
|
label={t('backoffice.candidate.fields.durationrequirementsmet')}
|
|
value={candidate.durationRequirementsMet}
|
|
onChange={(durationRequirementsMet) =>
|
|
updateCandidateMutation.mutate({ durationRequirementsMet })}
|
|
/>
|
|
</Grid.Col>
|
|
<Grid.Col span={6}>
|
|
<EnumSelect
|
|
enumObject={RecognitionLikelihoodValues}
|
|
enumName="RecognitionLikelihood"
|
|
label={t('backoffice.candidate.fields.recognitionlikelihood')}
|
|
placeholder={t('backoffice.candidate.fields.selectlikelihood')}
|
|
value={candidate.recognitionLikelihood || null}
|
|
onChange={(recognitionLikelihood) =>
|
|
updateCandidateMutation.mutate({
|
|
recognitionLikelihood: recognitionLikelihood! as DB.RecognitionLikelihood
|
|
})
|
|
}
|
|
/>
|
|
</Grid.Col>
|
|
</Grid>
|
|
</Card>
|
|
<Title order={2} mb="md">{t('backoffice.candidate.basicinfo')}</Title>
|
|
|
|
<Card shadow="sm" padding="lg" radius="md" withBorder>
|
|
<Stack gap="md">
|
|
<Grid>
|
|
<Grid.Col span={6}>
|
|
<Group gap="xs">
|
|
<Text fw={500} size="sm">{t('backoffice.candidate.fields.email')}</Text>
|
|
<Text size="sm">{candidate.email ? asI18n(candidate.email) : notAvailable}</Text>
|
|
</Group>
|
|
</Grid.Col>
|
|
|
|
<Grid.Col span={6}>
|
|
<Group gap="xs">
|
|
<Text fw={500} size="sm">{t('backoffice.candidate.fields.currentresidence')}</Text>
|
|
<Text size="sm">{asI18n(getCountryNameFromAbbreviation(candidate.currentResidence))}</Text>
|
|
</Group>
|
|
</Grid.Col>
|
|
|
|
<Grid.Col span={6}>
|
|
<Group gap="xs">
|
|
<Text fw={500} size="sm">{t('backoffice.candidate.fields.countryofeducation')}</Text>
|
|
<Text size="sm">{asI18n(getCountryNameFromAbbreviation(candidate.countryEducation))}</Text>
|
|
</Group>
|
|
</Grid.Col>
|
|
|
|
<Grid.Col span={6}>
|
|
<Group gap="xs">
|
|
<Text fw={500} size="sm">{t('backoffice.candidate.fields.eupassport')}</Text>
|
|
<Text size="sm">{yesNo(candidate.euPassport)}</Text>
|
|
</Group>
|
|
</Grid.Col>
|
|
|
|
<Grid.Col span={6}>
|
|
<Group gap="xs">
|
|
<Text fw={500} size="sm">{t('backoffice.candidate.fields.dateofbirthage')}</Text>
|
|
<Text size="sm">
|
|
{candidate.dateOfBirth ? (
|
|
<>
|
|
{dayjs(candidate.dateOfBirth).format('YYYY-MM-DD')}
|
|
{` (${dayjs().diff(dayjs(candidate.dateOfBirth), 'year')} ${t('backoffice.candidate.fields.years')})`}
|
|
</>
|
|
) : notAvailable}
|
|
</Text>
|
|
</Group>
|
|
</Grid.Col>
|
|
|
|
<Grid.Col span={6}>
|
|
<Group gap="xs">
|
|
<Text fw={500} size="sm">{t('backoffice.candidate.fields.livingingermany')}</Text>
|
|
<Text size="sm">{yesNo(candidate.livingInGermany)}</Text>
|
|
</Group>
|
|
</Grid.Col>
|
|
|
|
<Grid.Col span={6}>
|
|
<Group gap="xs">
|
|
<Text fw={500} size="sm">{t('backoffice.candidate.fields.jointalentpool')}</Text>
|
|
<Text size="sm">{yesNo(candidate.joinTalentPool)}</Text>
|
|
</Group>
|
|
</Grid.Col>
|
|
|
|
<Grid.Col span={6}>
|
|
<Group gap="xs">
|
|
<Text fw={500} size="sm">{t('backoffice.candidate.fields.worklocation')}</Text>
|
|
<Text size="sm">{asI18n(candidate.stateWorkPreference !== null ? candidate.stateWorkPreference.join(', ') : notAvailable)}</Text>
|
|
</Group>
|
|
</Grid.Col>
|
|
|
|
<Grid.Col span={6}>
|
|
<Group gap="xs">
|
|
<Text fw={500} size="sm">{t('backoffice.candidate.fields.submittedat')}</Text>
|
|
<Text size="sm">{candidate.submittedAt ? asI18n(new Date(candidate.submittedAt).toLocaleDateString()) : notAvailable}</Text>
|
|
</Group>
|
|
</Grid.Col>
|
|
|
|
</Grid>
|
|
</Stack>
|
|
</Card>
|
|
|
|
<Stack gap="xl">
|
|
{/* Education Section */}
|
|
<Paper my="md">
|
|
<Title order={2} mb="md">{t('enums.candidatedocumenttype.education')}</Title>
|
|
<Stack gap="md">
|
|
<EducationVerificationCard
|
|
candidateId={candidateId}
|
|
education={candidate.analysis?.education || {}}
|
|
documents={candidate.documents?.education || []}
|
|
verbose={verbose}
|
|
/>
|
|
</Stack>
|
|
</Paper>
|
|
|
|
{/* License Section */}
|
|
<Paper my="md">
|
|
<Title order={2} mb="md">{t('enums.candidatedocumenttype.license')}</Title>
|
|
<Stack gap="md">
|
|
<LicenseVerificationCard
|
|
candidateId={candidateId}
|
|
license={candidate.analysis?.license || {}}
|
|
documents={candidate.documents?.license || []}
|
|
verbose={verbose}
|
|
/>
|
|
</Stack>
|
|
</Paper>
|
|
|
|
{/* Language Section */}
|
|
<Paper my="md">
|
|
<Title order={2} mb="md">{t('enums.candidatedocumenttype.language')}</Title>
|
|
<Stack gap="md">
|
|
<LanguageVerificationCard
|
|
candidateId={candidateId}
|
|
language={candidate.analysis?.language || {}}
|
|
documents={candidate.documents?.language || []}
|
|
verbose={verbose}
|
|
/>
|
|
</Stack>
|
|
</Paper>
|
|
|
|
{/* Experience Section */}
|
|
<Paper my="md">
|
|
<Title order={2} mb="md">{t('enums.candidatedocumenttype.work_experience')}</Title>
|
|
<Stack gap="md">
|
|
<ExperienceVerificationCard
|
|
candidateId={candidateId}
|
|
experience={candidate.analysis?.experience || {}}
|
|
documents={candidate.documents?.experience || []}
|
|
verbose={verbose}
|
|
/>
|
|
</Stack>
|
|
</Paper>
|
|
</Stack>
|
|
|
|
<LifecycleNotificationsCard
|
|
lifecycleNotifications={candidate.lifecycleNotifications}
|
|
analysisIssuesDraft={analysisIssuesDraft}
|
|
onAnalysisIssuesDraftChange={setAnalysisIssuesDraft}
|
|
onSaveAnalysisIssues={saveAnalysisIssues}
|
|
isSaving={updateCandidateMutation.isPending}
|
|
/>
|
|
</Stack>
|
|
</Container>
|
|
|
|
{/* Results Drawer */}
|
|
{showResultsDrawer && <Paper ml='lg' withBorder><Stack>
|
|
<CandidateResultPreview candidateId={candidateId} />
|
|
</Stack></Paper>}
|
|
</Flex>
|
|
|
|
|
|
</>
|
|
);
|
|
}
|