Files
e2e 81ea8ef5d1
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 10:31:48 +02:00

586 lines
18 KiB
TypeScript

import { useState, useMemo } from 'react'
import {
Container,
Title,
Text,
SimpleGrid,
Card,
Badge,
Group,
Stack,
Checkbox,
Drawer,
ActionIcon,
Loader,
Center,
Anchor,
Box,
Popover,
} from '@pikku/mantine/core'
import { useDisclosure } from '@mantine/hooks'
import {
IconAward,
IconBriefcase,
IconCalendar,
IconFilter,
IconInfoCircle,
IconLanguage,
IconSchool,
} from '@tabler/icons-react'
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
import { useGetCompanyCandidates } from '@/hooks/company/useGetCompanyCandidates'
import { useGetCountries } from '@/hooks/useGetCountries'
import { useParams } from '@tanstack/react-router'
import { SUPPORTED_COUNTRIES } from '@heygermany/sdk/config'
import {
LanguageLevels,
NurseRecognitionGermanyValues,
RecognitionLikelihoodValues,
type LanguageLevel,
} from '@heygermany/sdk'
import { CandidateProfileAvatar } from '@/components/company/CandidateProfileAvatar'
const LANGUAGE_LEVEL_SORT_RANK: Record<LanguageLevel, number> = {
[LanguageLevels.NONE]: 0,
[LanguageLevels.A_ONE]: 1,
[LanguageLevels.A_TWO]: 2,
[LanguageLevels.B_ONE]: 3,
[LanguageLevels.B_TWO]: 4,
[LanguageLevels.C_ONE]: 5,
[LanguageLevels.C_TWO]: 6,
}
const getLanguageLevelSortRank = (
level: LanguageLevel | null | undefined
): number => {
if (!level) return -1
return LANGUAGE_LEVEL_SORT_RANK[level] ?? -1
}
const getCreatedAtTimestamp = (
createdAt: string | Date | null | undefined
): number => {
if (!createdAt) return 0
return new Date(createdAt).getTime()
}
export default function CompanyCandidates() {
const { isLoading, data: response } = useGetCompanyCandidates()
const candidates = response?.data || []
const t = useI18n()
const { lang } = useParams({ strict: false })
const { data: countriesData, getCountryNameFromAbbreviation } =
useGetCountries()
const [drawerOpened, { open: openDrawer, close: closeDrawer }] =
useDisclosure(false)
const [selectedProfessions, setSelectedProfessions] = useState<string[]>([])
const [selectedLanguageLevels, setSelectedLanguageLevels] = useState<
string[]
>([])
const [selectedCountries, setSelectedCountries] = useState<string[]>([])
const [selectedResidency, setSelectedResidency] = useState<string[]>([])
const candidateSourceCountries = SUPPORTED_COUNTRIES.filter(
(code) => code !== 'DEU'
)
const allCountries = useMemo(() => {
if (!countriesData) return []
return candidateSourceCountries
.map((code) => ({
code,
name: countriesData[code] || code,
}))
.sort((a, b) => a.name.localeCompare(b.name))
}, [countriesData])
const filteredCandidates = useMemo(() => {
return candidates
.filter((candidate: any) => {
if (
selectedProfessions.length > 0 &&
!selectedProfessions.includes(
candidate.qualificationStatusGermany || ''
)
) {
return false
}
if (
selectedLanguageLevels.length > 0 &&
!selectedLanguageLevels.includes(
candidate.languageSelfAssessmentLevel || ''
)
) {
return false
}
if (
selectedCountries.length > 0 &&
!selectedCountries.includes(candidate.countryEducation || '')
) {
return false
}
if (selectedResidency.length > 0) {
const isInGermany = candidate.livingInGermany
? 'in-germany'
: 'not-in-germany'
if (!selectedResidency.includes(isInGermany)) {
return false
}
}
return true
})
.sort((a: any, b: any) => {
const languageLevelDifference =
getLanguageLevelSortRank(b.languageSelfAssessmentLevel) -
getLanguageLevelSortRank(a.languageSelfAssessmentLevel)
if (languageLevelDifference !== 0) {
return languageLevelDifference
}
return (
getCreatedAtTimestamp(b.createdAt) -
getCreatedAtTimestamp(a.createdAt)
)
})
}, [
candidates,
selectedProfessions,
selectedLanguageLevels,
selectedCountries,
selectedResidency,
])
const languageLevelOptions = useMemo(
() => t.getEnumOptions(LanguageLevels, 'languagelevel'),
[t]
)
const professionOptions = useMemo(
() => t.getEnumOptions(NurseRecognitionGermanyValues, 'nurserecognitiongermany'),
[t]
)
const formatLanguageLevel = (level: string | null) => {
if (!level) return t('enums.languagelevel.none' as any)
const option = languageLevelOptions.find((opt) => opt.value === level)
return option?.label || level
}
const formatProfession = (profession: string | null) => {
if (!profession) return t('company.candidates.profession.na' as any)
const option = professionOptions.find((opt) => opt.value === profession)
return option?.label || profession
}
const formatEducationSummary = (program: string | null | undefined) =>
program || t('common.na')
const formatTotalExperienceDuration = (
totalExperienceMonths: number | null | undefined
) => {
if (!totalExperienceMonths) return t('common.na')
const years = Math.floor(totalExperienceMonths / 12)
const months = totalExperienceMonths % 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.length === 0) {
parts.push(
`${months} ${t(
months === 1
? 'company.candidates.duration.month'
: 'company.candidates.duration.months'
)}`
)
}
return parts.join(' ')
}
const getRecognitionLikelihoodBadge = (likelihood: string | null) => {
if (!likelihood) return null
const colorMap: Record<string, string> = {
[RecognitionLikelihoodValues.HIGH]: 'green',
[RecognitionLikelihoodValues.MEDIUM]: 'yellow',
[RecognitionLikelihoodValues.LOW]: 'red',
}
return {
color: colorMap[likelihood] || 'gray',
label: t(`enums.recognitionlikelihood.${likelihood}` as any),
}
}
const FilterContent = () => (
<Stack gap="xl">
<div>
<Group gap={6} mb="sm">
<Text fw={500} size="sm" c="dark.7">
{t('company.candidates.filters.profession')}
</Text>
<Popover width={320} position="right" withArrow shadow="md">
<Popover.Target>
<ActionIcon variant="subtle" size="xs" color="gray">
<IconInfoCircle size={14} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<Text size="sm" style={{ whiteSpace: 'pre-line', lineHeight: 1.6 }}>
{t('company.candidates.filters.profession.tooltip' as any)}
</Text>
</Popover.Dropdown>
</Popover>
</Group>
<Checkbox.Group
value={selectedProfessions}
onChange={setSelectedProfessions}
>
<Stack gap="sm">
{professionOptions.map((option) => (
<Checkbox
key={option.value}
value={option.value}
label={asI18n(option.label)}
size="sm"
/>
))}
</Stack>
</Checkbox.Group>
</div>
<div>
<Text fw={500} size="sm" mb="sm" c="dark.7">
{t('company.candidates.filters.language')}
</Text>
<Checkbox.Group
value={selectedLanguageLevels}
onChange={setSelectedLanguageLevels}
>
<Stack gap="sm">
{languageLevelOptions.map((option) => (
<Checkbox
key={option.value}
value={option.value}
label={asI18n(option.label)}
size="sm"
/>
))}
</Stack>
</Checkbox.Group>
</div>
<div>
<Text fw={500} size="sm" mb="sm" c="dark.7">
{t('company.candidates.filters.country')}
</Text>
<Checkbox.Group
value={selectedCountries}
onChange={setSelectedCountries}
>
<Stack gap="sm">
{allCountries.map(({ code, name }) => (
<Checkbox key={code} value={code} label={asI18n(name)} size="sm" />
))}
</Stack>
</Checkbox.Group>
</div>
<div>
<Text fw={500} size="sm" mb="sm" c="dark.7">
{t('company.candidates.filters.residency')}
</Text>
<Checkbox.Group
value={selectedResidency}
onChange={setSelectedResidency}
>
<Stack gap="sm">
<Checkbox
value="in-germany"
label={t('company.candidates.filters.iningermany')}
size="sm"
/>
<Checkbox
value="not-in-germany"
label={t('company.candidates.filters.notingermany')}
size="sm"
/>
</Stack>
</Checkbox.Group>
</div>
</Stack>
)
const filterPanelScrollStyle = {
minHeight: 0,
overflowY: 'auto' as const,
paddingRight: 4,
}
if (isLoading) {
return (
<Center h={400}>
<Loader size="lg" />
</Center>
)
}
return (
<>
<Drawer
opened={drawerOpened}
onClose={closeDrawer}
title={
<Text fw={600} size="lg">
{t('company.candidates.filters.title')}
</Text>
}
padding="lg"
size="sm"
hiddenFrom="lg"
>
<Box
style={{
...filterPanelScrollStyle,
maxHeight: 'calc(100dvh - 140px)',
}}
>
<FilterContent />
</Box>
</Drawer>
<Box style={{ minHeight: 'calc(100vh - 80px)' }}>
<Container size="xl" py="xl">
<Group align="flex-start" gap="xl">
<Card
shadow="sm"
padding="lg"
radius="md"
withBorder
w={280}
visibleFrom="lg"
style={{
position: 'sticky',
top: 100,
maxHeight: 'calc(100dvh - 120px)',
display: 'flex',
flexDirection: 'column',
}}
>
<Text fw={600} size="lg" mb="lg">
{t('company.candidates.filters.title')}
</Text>
<Box style={filterPanelScrollStyle}>
<FilterContent />
</Box>
</Card>
<Box style={{ flex: 1 }}>
<Group justify="space-between" mb="xl">
<Title order={2}>
{t('company.candidates.candidatesfound' as any, {
count: filteredCandidates.length,
})}
</Title>
<ActionIcon
variant="default"
size="lg"
onClick={openDrawer}
hiddenFrom="lg"
>
<IconFilter size={20} />
</ActionIcon>
</Group>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
{filteredCandidates.map((candidate: any) => {
const recognitionBadge = getRecognitionLikelihoodBadge(
candidate.recognitionLikelihood
)
return (
<Anchor
key={candidate.candidateId}
href={`/${lang}/company/candidate/${candidate.candidateId}`}
underline="never"
style={{ display: 'block', height: '100%' }}
>
<Card
shadow="sm"
padding="xl"
radius="md"
withBorder
h="100%"
style={{
cursor: 'pointer',
transition: 'all 0.2s ease-in-out',
position: 'relative',
}}
styles={{
root: {
'&:hover': {
boxShadow: '0 12px 24px rgba(0, 0, 0, 0.12)',
transform: 'translateY(-4px)',
borderColor: 'var(--mantine-color-blue-5)',
backgroundColor: 'var(--mantine-color-gray-0)',
},
},
}}
>
{candidate.livingInGermany && (
<Box
style={{
position: 'absolute',
top: 16,
right: 16,
backgroundColor: 'var(--mantine-color-gray-1)',
padding: '4px 8px',
borderRadius: '4px',
}}
>
<Text size="xs" c="black" tt="uppercase" fw={500}>
{t('company.candidates.card.in_germany')}
</Text>
</Box>
)}
<Group gap="md" mb="md" wrap="nowrap" align="flex-start">
<CandidateProfileAvatar
countryCode={candidate.countryEducation}
imageBorderWidth={2}
badgeBorderWidth={2}
badgeOffset={-2}
badgeSize={28}
iconSize={28}
name={candidate.name}
profileImageUrl={candidate.profileImageUrl}
size={56}
/>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text fw={600} size="lg" mb={4} c="dark.9">
{candidate.name}
</Text>
<Text size="md" c="gray.6">
{asI18n(getCountryNameFromAbbreviation(
candidate.countryEducation
) || t('common.na'))}
</Text>
</Box>
</Group>
<Stack gap="xs">
<Group gap="xs" wrap="nowrap">
<IconBriefcase
size={18}
color="var(--mantine-color-gray-6)"
style={{ flexShrink: 0 }}
/>
<Text size="md" c="gray.7" lineClamp={1}>
{asI18n(formatProfession(
candidate.qualificationStatusGermany
))}
</Text>
</Group>
<Group gap="xs" wrap="nowrap" align="flex-start">
<IconSchool
size={18}
color="var(--mantine-color-gray-6)"
style={{ flexShrink: 0, marginTop: 2 }}
/>
<Text size="md" c="gray.7" lineClamp={2}>
{asI18n(formatEducationSummary(
candidate.education?.program
))}
</Text>
</Group>
<Group gap="xs" wrap="nowrap">
<IconLanguage
size={18}
color="var(--mantine-color-gray-6)"
style={{ flexShrink: 0 }}
/>
<Text size="md" c="gray.7">
{asI18n(`${t('company.candidates.card.germanlevel' as any)}: ${formatLanguageLevel(candidate.languageSelfAssessmentLevel)}`)}
</Text>
</Group>
<Group gap="xs" wrap="nowrap">
<IconCalendar
size={18}
color="var(--mantine-color-gray-6)"
style={{ flexShrink: 0 }}
/>
<Text size="md" c="gray.7">
{asI18n(`${t('company.candidates.card.totalexperience')}: ${formatTotalExperienceDuration(candidate.totalExperienceMonths)}`)}
</Text>
</Group>
</Stack>
{recognitionBadge ? (
<Box
mt="sm"
pt="sm"
style={{
borderTop: '1px solid var(--mantine-color-gray-2)',
}}
>
<Group gap="xs" align="center">
<IconAward
size={18}
color="var(--mantine-color-gray-6)"
/>
<Text size="md" c="gray.7" fw={500}>
{asI18n(`${t('company.candidates.card.recognitionlikelihood' as any)}:`)}
</Text>
<Badge
size="sm"
color={recognitionBadge.color}
variant="light"
>
{asI18n(recognitionBadge.label)}
</Badge>
</Group>
</Box>
) : null}
</Card>
</Anchor>
)
})}
</SimpleGrid>
{filteredCandidates.length === 0 && (
<Center h={200}>
<Text c="dimmed" size="lg">
{t('company.candidates.nocandidates')}
</Text>
</Center>
)}
</Box>
</Group>
</Container>
</Box>
</>
)
}