chore: heygermany customer project
Some checks failed
Main / Setup and Test (push) Has been cancelled
Main / Build Website (push) Has been cancelled

This commit is contained in:
e2e
2026-07-11 11:13:08 +02:00
commit 2c4174b44b
370 changed files with 35601 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
import { LoginComponent, LoginFormData } from '@/components/LoginComponent';
import { useCompanyLogin } from '@/hooks/company/useCompanyLogin';
export default function CompanyLogin() {
const login = useCompanyLogin();
return (
<LoginComponent
namespace="company"
mutation={login}
/>
);
}

View File

@@ -0,0 +1,770 @@
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>
)
}

View File

@@ -0,0 +1,585 @@
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>
</>
)
}

View File

@@ -0,0 +1,65 @@
import { AppShell, Flex, Text, Container, Button, Box } from '@pikku/mantine/core';
import { useLocation, useNavigate, useParams } from '@tanstack/react-router';
import { IconArrowLeft } from '@tabler/icons-react';
import LanguageSelector from '@/components/ui/LanguageSelector';
import Logo from '@/components/ui/Logo';
import { useI18n } from '@/context/i18n-provider';
interface CompanyLayoutProps {
children: React.ReactNode;
}
export default function CompanyLayout({ children }: CompanyLayoutProps) {
const pathname = useLocation({ select: (l) => l.pathname });
const navigate = useNavigate();
const params = useParams({ strict: false });
const t = useI18n();
const lang = params.lang as string;
const isDetailPage = pathname.includes('/company/candidate/');
return (
<AppShell
padding={0}
header={{ height: 80 }}
styles={{
main: {
backgroundColor: 'var(--mantine-color-gray-0)',
}
}}
>
<AppShell.Header>
{isDetailPage ? (
<Box style={{ borderBottom: '1px solid var(--mantine-color-gray-2)', height: '100%' }}>
<Container size="xl" h="100%">
<Flex align="center" justify="space-between" h="100%">
<Button
variant="default"
leftSection={<IconArrowLeft size={16} />}
onClick={() => navigate({ to: `/${lang}/company/candidates` })}
>
{t('company.navigation.backtosearch')}
</Button>
<LanguageSelector />
</Flex>
</Container>
</Box>
) : (
<Flex align="center" justify="space-between" h="100%" px="xl">
<div>
<Logo size="sm" />
<Text size="sm" c="dimmed" mt={2}>
{t('company.navigation.tagline')}
</Text>
</div>
<Flex gap="sm" align="center">
<LanguageSelector />
</Flex>
</Flex>
)}
</AppShell.Header>
<AppShell.Main>{children}</AppShell.Main>
</AppShell>
);
}

View File

@@ -0,0 +1,13 @@
import { useEffect } from 'react';
import { useNavigate, useParams } from '@tanstack/react-router';
export default function CompanyPage() {
const navigate = useNavigate();
const { lang } = useParams({ strict: false });
useEffect(() => {
navigate({ to: `/${lang}/company/candidates`, replace: true });
}, [navigate, lang]);
return null;
}