586 lines
18 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|