Files
e2e 2c4174b44b
Some checks failed
Main / Setup and Test (push) Has been cancelled
Main / Build Website (push) Has been cancelled
chore: heygermany customer project
2026-07-11 11:13:08 +02:00

771 lines
26 KiB
TypeScript

import type { ReactNode } from 'react'
import { useParams } from '@tanstack/react-router'
import {
Container,
Title,
Text,
Card,
Badge,
Group,
Stack,
Button,
Box,
Grid,
Loader,
Center,
Alert,
} from '@pikku/mantine/core'
import {
IconMapPin,
IconLanguage,
IconCalendar,
IconBriefcase,
IconAward,
IconMail,
IconInfoCircle,
IconSchool,
IconCheck,
} from '@tabler/icons-react'
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
import { useGetCompanyCandidate } from '@/hooks/company/useGetCompanyCandidate'
import { useGetCountries } from '@/hooks/useGetCountries'
import { CandidateProfileAvatar } from '@/components/company/CandidateProfileAvatar'
function SectionHeader({
icon,
title,
meta,
titleOrder = 3,
}: {
icon: ReactNode
title: string
meta?: ReactNode
titleOrder?: 3 | 4
}) {
return (
<Group justify="space-between" align="center" mb="lg">
<Group gap="xs" wrap="nowrap">
{icon}
<Title order={titleOrder}>{asI18n(title)}</Title>
</Group>
{meta}
</Group>
)
}
export default function CandidateDetailPage() {
const params = useParams({ strict: false })
const candidateId = params.candidateId as string
const t = useI18n()
const { getCountryNameFromAbbreviation } = useGetCountries()
const { isLoading, data: candidate } = useGetCompanyCandidate(candidateId)
const locale = t.locale || 'en'
// Format language level for display
const formatLanguageLevel = (level: string | null) => {
if (!level) return t('enums.languagelevel.none' as any)
return t(`enums.languagelevel.${level.toLowerCase()}` as any)
}
// Format profession for display
const formatProfession = (profession: string | null) => {
if (!profession) return t('common.na')
return t(`enums.nurserecognitiongermany.${profession.toLowerCase()}` as any)
}
const getRecognitionLikelihoodColor = (
likelihood: string | null | undefined
) => {
if (likelihood === 'high') return 'green'
if (likelihood === 'medium') return 'yellow'
if (likelihood === 'low') return 'red'
return 'gray'
}
const formatExperienceDate = (date: Date | string | null | undefined) => {
if (!date) return null
const parsedDate = new Date(date)
if (Number.isNaN(parsedDate.getTime())) return null
return new Intl.DateTimeFormat(locale, {
month: 'short',
year: 'numeric',
}).format(parsedDate)
}
const getExperienceMonthIndex = (
date: Date | string | null | undefined
): number | null => {
if (!date) return null
const parsedDate = new Date(date)
if (Number.isNaN(parsedDate.getTime())) return null
return parsedDate.getUTCFullYear() * 12 + parsedDate.getUTCMonth()
}
const getExperienceDurationMonths = (
startDate: Date | string | null | undefined,
endDate: Date | string | null | undefined
): number | null => {
const startMonth = getExperienceMonthIndex(startDate)
const endMonth = getExperienceMonthIndex(endDate || new Date())
if (
startMonth === null ||
endMonth === null ||
endMonth < startMonth
) {
return null
}
return endMonth - startMonth + 1
}
const formatExperienceDuration = (
startDate: Date | string | null | undefined,
endDate: Date | string | null | undefined
) => {
const totalMonths = getExperienceDurationMonths(startDate, endDate)
if (totalMonths === null) return null
const years = Math.floor(totalMonths / 12)
const months = totalMonths % 12
const parts: string[] = []
if (years > 0) {
parts.push(
`${years} ${t(
years === 1
? 'company.candidates.duration.year'
: 'company.candidates.duration.years'
)}`
)
}
if (months > 0) {
parts.push(
`${months} ${t(
months === 1
? 'company.candidates.duration.month'
: 'company.candidates.duration.months'
)}`
)
}
return parts.length > 0 ? parts.join(' ') : null
}
const formatExperienceTimeline = (
startDate: Date | string | null | undefined,
endDate: Date | string | null | undefined
) => {
const start = formatExperienceDate(startDate)
const end = endDate
? formatExperienceDate(endDate)
: start
? t('company.candidate.experience.present' as any)
: null
if (start && end) return `${start} - ${end}`
return start || end || t('common.na')
}
const formatExperiencePeriod = (
startDate: Date | string | null | undefined,
endDate: Date | string | null | undefined
) => {
const timeline = formatExperienceTimeline(startDate, endDate)
const duration = formatExperienceDuration(startDate, endDate)
return duration ? `${timeline} · ${duration}` : timeline
}
const formatFacilityType = (facilityType: string | null | undefined) => {
if (!facilityType) return t('common.na')
return facilityType
.split('_')
.filter(Boolean)
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(' ')
}
const getExperienceLocation = (
city: string | null | undefined,
country: string | null | undefined
) => {
const locationParts = [
city,
country
? getCountryNameFromAbbreviation(country) || country
: null,
].filter(Boolean)
return locationParts.length > 0
? locationParts.join(', ')
: null
}
const formatExperienceSummary = (
city: string | null | undefined,
country: string | null | undefined,
facilityType: string | null | undefined
) => {
const parts = [
getExperienceLocation(city, country),
facilityType ? formatFacilityType(facilityType) : null,
].filter(Boolean)
return parts.length > 0 ? parts.join(' · ') : t('common.na')
}
const formatExperienceDepartments = (
department: string | null | undefined
) => {
if (!department) return null
const departments = department
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
.slice(0, 3)
return departments.length > 0 ? departments.join(', ') : null
}
const formatHomeCountryQualification = (qualification: string | null) => {
if (!qualification) return t('common.na')
return t(
`enums.nurserecognitionhomecountry.${qualification.toLowerCase()}` as any
)
}
const formatEducationSummary = (
program: string | null | undefined,
institution: string | null | undefined
) => {
const educationParts = [program, institution].filter(Boolean)
return educationParts.length > 0
? educationParts.join(' · ')
: t('common.na')
}
const formatEducationCountry = (country: string | null | undefined) =>
country ? getCountryNameFromAbbreviation(country) || country : t('common.na')
if (isLoading) {
return (
<Center h={400}>
<Loader size="lg" />
</Center>
)
}
if (!candidate) {
return (
<Container size="xl" py="xl">
<Alert color="red" title={t('company.candidate.notfound.title')}>
{t('company.candidate.notfound.message')}
</Alert>
</Container>
)
}
const experiences = candidate.experience.filter((experience: any) =>
Boolean(
experience.roleTitle ||
experience.employerName ||
experience.department ||
experience.country ||
experience.city ||
experience.facilityType ||
experience.startDate ||
experience.endDate
)
)
const education = candidate.education
const hasExperienceDetails = experiences.length > 0
const hasEducationDetails = Boolean(
candidate.qualificationStatusHomeCountry ||
education?.program ||
education?.institution ||
education?.country ||
typeof education?.nursingRelatedDegree === 'boolean'
)
const sectionCardStyle = {
borderColor: 'var(--mantine-color-gray-2)',
}
const contactEmailHref = `mailto:info@hey-germany.com?subject=${encodeURIComponent(
`${t('company.candidate.contact.emailsubject')}: ${candidate.name} (${candidate.candidateId})`
)}&body=${encodeURIComponent(
`${t('company.candidate.contact.emailbody')}\n\n${t('company.candidates.candidate')}: ${candidate.name}\n${t('company.candidate.contact.emailcandidateid')}: ${candidate.candidateId}\n`
)}`
return (
<Box style={{ minHeight: 'calc(100vh - 80px)' }} suppressHydrationWarning>
<Container size="xl" py="xl" suppressHydrationWarning>
{/* Profile Header Card */}
<Card
shadow="sm"
padding="xl"
radius="md"
withBorder
mb="xl"
style={sectionCardStyle}
suppressHydrationWarning
>
<Group gap="xl" wrap="nowrap" align="flex-start">
<CandidateProfileAvatar
countryCode={candidate.countryEducation}
imageBorderWidth={4}
badgeBorderWidth={4}
badgeOffset={-8}
badgeSize={48}
iconSize={64}
name={candidate.name}
profileImageUrl={candidate.profileImageUrl}
size={128}
/>
{/* Info */}
<Box style={{ flex: 1 }}>
<Title order={1} mb="xs">
{asI18n(candidate.name)}
</Title>
<Text size="lg" c="gray.7" mb="md">
{formatProfession(candidate.qualificationStatusGermany)}
</Text>
{candidate.recognitionLikelihood && (
<Badge
size="md"
color={getRecognitionLikelihoodColor(
candidate.recognitionLikelihood
)}
variant="light"
mb="lg"
>
{asI18n(`${t(
`enums.recognitionlikelihood.${candidate.recognitionLikelihood}` as any
)} ${t('company.candidate.badge.recognitionlikelihood')}`)}
</Badge>
)}
<Grid gutter="md">
<Grid.Col span={{ base: 12, sm: 6 }}>
<Group gap="xs">
<IconMapPin size={18} color="var(--mantine-color-gray-6)" />
<Text size="sm" c="gray.7">
{asI18n(`${getCountryNameFromAbbreviation(
candidate.countryEducation
) || t('common.na')}${candidate.livingInGermany ? ` (${t('company.candidates.badge.ingermany')})` : ''}`)}
</Text>
</Group>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Group gap="xs">
<IconSchool size={18} color="var(--mantine-color-gray-6)" />
<Text size="sm" c="gray.7">
{asI18n(education?.program || t('common.na'))}
</Text>
</Group>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Group gap="xs">
<IconLanguage
size={18}
color="var(--mantine-color-gray-6)"
/>
<Text size="sm" c="gray.7">
{asI18n(`${t('company.candidates.german')} ${formatLanguageLevel(
candidate.languageSelfAssessmentLevel
)}`)}
</Text>
</Group>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Group gap="xs">
<IconCalendar
size={18}
color="var(--mantine-color-gray-6)"
/>
<Text size="sm" c="gray.7">
{candidate.age
? asI18n(`${candidate.age} ${t('company.candidate.yearsold')}`)
: t('common.na')}
</Text>
</Group>
</Grid.Col>
</Grid>
</Box>
</Group>
</Card>
{/* Main Content Grid */}
<Grid gutter="xl">
{/* Left Column */}
<Grid.Col span={{ base: 12, md: 8 }}>
<Stack gap="xl">
{/* Education Card */}
<Card
shadow="sm"
padding="lg"
radius="md"
withBorder
style={sectionCardStyle}
>
<SectionHeader
icon={<IconSchool size={18} />}
title={t('company.candidate.section.education')}
/>
{hasEducationDetails ? (
<Stack gap="md">
<div>
<Text size="sm" c="gray.6" mb={4}>
{t('company.candidate.field.qualificationlevel')}
</Text>
<Text fw={500}>
{asI18n(formatHomeCountryQualification(
candidate.qualificationStatusHomeCountry
))}
</Text>
</div>
<div>
<Text size="sm" c="gray.6" mb={4}>
{t('company.candidate.field.educationdegree')}
</Text>
<Text fw={500}>
{asI18n(formatEducationSummary(
education?.program,
education?.institution
))}
</Text>
</div>
<div>
<Text size="sm" c="gray.6" mb={4}>
{t('company.candidate.field.countryofeducation')}
</Text>
<Text fw={500}>
{asI18n(formatEducationCountry(
education?.country || candidate.countryEducation
))}
</Text>
</div>
<div>
<Text size="sm" c="gray.6" mb={4}>
{t('company.candidate.field.nursinglicense')}
</Text>
{candidate.hasVerifiedLicense ? (
<Group gap="xs">
<Text fw={500}>
{t('company.candidate.license.verified')}
</Text>
<IconCheck
size={18}
color="var(--mantine-color-green-6)"
/>
</Group>
) : (
<Text fw={500}>
{t('company.candidate.license.notprovided')}
</Text>
)}
</div>
</Stack>
) : (
<Text c="dimmed">{t('company.candidate.education.nodetails')}</Text>
)}
</Card>
{/* Languages Card */}
<Card
shadow="sm"
padding="lg"
radius="md"
withBorder
style={sectionCardStyle}
>
<SectionHeader
icon={<IconLanguage size={18} />}
title={t('company.candidate.section.languages')}
/>
{candidate.languagesSpoken &&
candidate.languagesSpoken.length > 0 && (
<Group gap="xs" mb="md">
{candidate.languagesSpoken.map((lang: any, index: number) => (
<Badge key={index} variant="outline" size="md">
{t(`enums.languagecode.${lang.toLowerCase()}` as any)}
</Badge>
))}
</Group>
)}
<Box
pt="md"
style={{ borderTop: '1px solid var(--mantine-color-gray-2)' }}
>
<Stack gap="md">
<div>
<Text size="sm" c="gray.6" mb={4}>
{t('company.candidate.field.germanlevel')}
</Text>
<Text fw={500}>
{asI18n(formatLanguageLevel(
candidate.languageSelfAssessmentLevel
))}
</Text>
</div>
<div>
<Text size="sm" c="gray.6" mb={4}>
{t('company.candidate.field.assessmenttype')}
</Text>
<Text fw={500}>
{candidate.languageCertificateProvided
? t('company.candidate.assessmenttype.certificate')
: t('company.candidate.assessmenttype.selfassessment')}
</Text>
</div>
</Stack>
</Box>
</Card>
<Card
shadow="sm"
padding="lg"
radius="md"
withBorder
style={sectionCardStyle}
>
<SectionHeader
icon={<IconBriefcase size={18} />}
title={t('company.candidate.section.workexperience')}
/>
{hasExperienceDetails ? (
<Stack gap={0}>
{experiences.map((experience: any, index: number) => (
<Box
key={`${experience.roleTitle || 'role'}-${experience.employerName || 'employer'}-${experience.startDate || index}`}
style={{
paddingTop: index === 0 ? 0 : 20,
paddingBottom:
index === experiences.length - 1 ? 0 : 20,
borderTop:
index === 0
? 'none'
: '1px solid var(--mantine-color-gray-2)',
}}
>
<Box
style={{
paddingLeft: 20,
marginLeft: 8,
borderLeft: '2px solid var(--mantine-color-gray-3)',
position: 'relative',
}}
>
<Box
style={{
position: 'absolute',
left: -7,
top: 6,
width: 12,
height: 12,
borderRadius: '50%',
background: 'var(--mantine-color-dark-9)',
}}
/>
<Stack gap={2}>
<Text fw={600} size="lg" c="dark.9">
{asI18n(experience.roleTitle || t('common.na'))}
</Text>
{experience.employerName ? (
<Text size="md" c="dark.7">
{asI18n(experience.employerName)}
</Text>
) : null}
{experience.department ? (
<Text size="sm" c="gray.6">
{asI18n(formatExperienceDepartments(
experience.department
) || t('common.na'))}
</Text>
) : null}
<Text size="sm" c="gray.6">
{asI18n(formatExperiencePeriod(
experience.startDate,
experience.endDate
) || t('common.na'))}
</Text>
<Text size="sm" c="gray.6">
{asI18n(formatExperienceSummary(
experience.city,
experience.country,
experience.facilityType
) || t('common.na'))}
</Text>
</Stack>
</Box>
</Box>
))}
</Stack>
) : (
<Text c="dimmed">{t('company.candidate.experience.nodetails' as any)}</Text>
)}
</Card>
{/* Recognition Status Card */}
<Card
shadow="sm"
padding="lg"
radius="md"
withBorder
style={sectionCardStyle}
>
<SectionHeader
icon={<IconAward size={18} />}
title={t('company.candidate.section.recognitionstatus')}
/>
<Stack gap="md">
<div>
<Group gap="xs" mb={4}>
<Text size="sm" c="gray.6">
{t('company.candidate.field.referenceprofession')}
</Text>
<IconInfoCircle
size={16}
color="var(--mantine-color-gray-6)"
/>
</Group>
<Text fw={500}>
{asI18n(formatProfession(candidate.qualificationStatusGermany))}
</Text>
</div>
<div>
<Text size="sm" c="gray.6" mb={4}>
{t('company.candidate.field.recognitionlikelihood')}
</Text>
{candidate.recognitionLikelihood ? (
<Badge
size="md"
color={getRecognitionLikelihoodColor(
candidate.recognitionLikelihood
)}
variant="light"
>
{t(
`enums.recognitionlikelihood.${candidate.recognitionLikelihood}` as any
)}
</Badge>
) : (
<Text fw={500}>{t('common.na')}</Text>
)}
</div>
</Stack>
</Card>
</Stack>
</Grid.Col>
{/* Right Column */}
<Grid.Col span={{ base: 12, md: 4 }}>
<Stack gap="xl">
{/* Personal Information Card */}
<Card
shadow="sm"
padding="lg"
radius="md"
withBorder
style={sectionCardStyle}
>
<SectionHeader
icon={<IconInfoCircle size={18} />}
title={t('company.candidate.section.personalinformation')}
titleOrder={4}
/>
<Stack gap="md">
<div>
<Text size="sm" c="gray.6" mb={4}>
{t('company.candidate.field.nationality')}
</Text>
<Text fw={500}>
{asI18n(getCountryNameFromAbbreviation(
candidate.nationality || candidate.countryEducation
) || t('common.na'))}
</Text>
</div>
<div>
<Text size="sm" c="gray.6" mb={4}>
{t('company.candidate.field.countryofresidence')}
</Text>
<Text fw={500}>
{asI18n(getCountryNameFromAbbreviation(
candidate.currentResidence
) || t('common.na'))}
</Text>
</div>
{candidate.stateWorkPreference &&
candidate.stateWorkPreference.length > 0 && (
<div>
<Text size="sm" c="gray.6" mb={4}>
{t('company.candidate.field.preferredlocations')}
</Text>
<Group gap="xs" mt="xs">
{candidate.stateWorkPreference.map((state: any, index: number) => (
<Badge key={index} variant="outline" size="sm">
{t(
`enums.germanstate.${state.toLowerCase().replace(/_/g, '-')}` as any
)}
</Badge>
))}
</Group>
</div>
)}
</Stack>
</Card>
{/* Contact Information Card */}
<Card
shadow="sm"
padding="lg"
radius="md"
withBorder
style={sectionCardStyle}
>
<SectionHeader
icon={<IconMail size={18} />}
title={t('company.candidate.section.contactinformation')}
titleOrder={4}
/>
<Stack gap="md">
<Text size="sm" c="gray.7">
{t('company.candidate.contact.emaildescription')}
</Text>
<Button
component="a"
href={contactEmailHref}
variant="default"
fullWidth
leftSection={<IconMail size={18} />}
styles={{
root: {
height: 48,
borderRadius: 10,
},
}}
>
{t('company.candidate.contact.emailcta')}
</Button>
</Stack>
</Card>
</Stack>
</Grid.Col>
</Grid>
</Container>
</Box>
)
}