chore: heygermany customer project
This commit is contained in:
587
apps/website/views/[lang]/(app)/company/candidates/page.tsx
Normal file
587
apps/website/views/[lang]/(app)/company/candidates/page.tsx
Normal file
@@ -0,0 +1,587 @@
|
||||
'use client'
|
||||
|
||||
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 '@/framework/navigation'
|
||||
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()
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user