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:30:11 +02:00
commit f0093328d8
370 changed files with 35601 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
import { LoginComponent } from '@/components/LoginComponent';
import { useBackOfficeLogin } from '@/hooks/backoffice/useBackOfficeLogin';
export default function BackOfficeLogin() {
const backofficeLogin = useBackOfficeLogin();
return (
<LoginComponent
namespace="backoffice"
mutation={backofficeLogin}
/>
);
}

View File

@@ -0,0 +1,373 @@
import { useEffect, useState } from 'react';
import { Container, Title, Text, Paper, Stack, Loader, Alert, Grid, Group, Flex, Card, Button, Switch, ActionIcon } from '@pikku/mantine/core';
import { IconExternalLink, IconEye } from '@tabler/icons-react';
import { EnumSelect } from '@/components/common/EnumSelect';
import dayjs from 'dayjs';
import { useParams } from '@tanstack/react-router';
import { useGetBackOfficeCandidate } from '@/hooks/backoffice/useGetBackOfficeCandidate';
import { useUpdateBackOfficeCandidate } from '@/hooks/backoffice/useUpdateBackOfficeCandidate';
import { useGetBackOfficeCandidateResult } from '@/hooks/backoffice/useGetBackOfficeCandidateResult';
import { EducationVerificationCard } from '@/components/backoffice/EducationVerificationCard';
import { LicenseVerificationCard } from '@/components/backoffice/LicenseVerificationCard';
import { LanguageVerificationCard } from '@/components/backoffice/LanguageVerificationCard';
import { ExperienceVerificationCard } from '@/components/backoffice/ExperienceVerificationCard';
import type { DB } from '@heygermany/sdk';
import {
NurseRecognitionGermanyValues,
NurseRecognitionHomeCountryValues,
RecognitionLikelihoodValues,
} from '@heygermany/sdk';
import { CandidateResult } from '@/components/jobs/CandidateResult';
import { QualificationAssessment } from '@/components/backoffice/QualificationAssessment';
import { BooleanSelectComponent } from '@/components/backoffice/DocumentFieldFactory';
import { useGetCandidateResults } from '@/hooks/useGetCandidateResult';
import { ApplicantStatusSelect } from '@/components/backoffice/ApplicantStatusSelect';
import { useGetCountries } from '@/hooks/useGetCountries';
import { LifecycleNotificationsCard } from '@/components/backoffice/LifecycleNotificationsCard';
import { asI18n } from '@pikku/react';
import { useI18n } from '@/context/i18n-provider';
const CandidateResultPreview: React.FunctionComponent<{ candidateId: string }> = ({ candidateId }) => {
const { data: candidateResults } = useGetCandidateResults(candidateId)
if (!candidateResults) return null
return <CandidateResult candidateResults={candidateResults} />
}
export default function BackOfficeCandidateDetailPage() {
const params = useParams({ strict: false });
const candidateId = params.candidateId as string;
const t = useI18n();
const { data: candidate, isLoading, error } = useGetBackOfficeCandidate(candidateId);
const { data: candidateResult } = useGetBackOfficeCandidateResult(candidateId);
const updateCandidateMutation = useUpdateBackOfficeCandidate(candidateId);
const { getCountryNameFromAbbreviation } = useGetCountries();
const [showResultsDrawer, setShowResultsDrawer] = useState(false);
const [verbose, setVerbose] = useState(false);
const [analysisIssuesDraft, setAnalysisIssuesDraft] = useState('');
const notAvailable = t('common.na');
const yesNo = (value: boolean | null | undefined) => {
if (value === true) return t('common.yes');
if (value === false) return t('common.no');
return notAvailable;
};
const currentAnalysisIssues = Array.isArray(candidate?.lifecycleNotifications?.analysis?.issues)
? candidate.lifecycleNotifications.analysis.issues.filter((issue: any): issue is string => typeof issue === 'string')
: [];
const currentAnalysisIssuesDraft = currentAnalysisIssues.join('\n');
useEffect(() => {
setAnalysisIssuesDraft(currentAnalysisIssuesDraft);
}, [candidateId, currentAnalysisIssuesDraft]);
const saveAnalysisIssues = () => {
const analysisIssues = analysisIssuesDraft
.split('\n')
.map((issue: string) => issue.trim())
.filter((issue: string) => issue.length > 0)
updateCandidateMutation.mutate({ analysisIssues })
}
if (isLoading) {
return (
<Flex justify="center" align="center" style={{ minHeight: 'calc(100vh - 60px)' }}>
<Loader size="xl" />
</Flex>
);
}
if (error || !candidate) {
return (
<Container size="xl">
<Alert color="red" title={t('common.error')}>
{t('backoffice.candidate.error.notfound')}
</Alert>
</Container>
);
}
return (
<>
<Flex>
<Container>
<Stack>
<Group justify="space-between" align="center" mb="lg">
<Title order={1}>
{asI18n(candidate.name)}
</Title>
<Group gap="sm">
<ApplicantStatusSelect
value={candidate.status}
onChange={(status) => {
updateCandidateMutation.mutate({ status: status! })
}}
isValid={candidateResult?.isValid ?? false}
/>
<Group gap="xs">
<Text size="sm" fw={500}>{t('backoffice.candidate.verbose')}</Text>
<Switch
checked={verbose}
onChange={(event) => setVerbose(event.currentTarget.checked)}
/>
</Group>
{candidateResult && (
<Text
size="sm"
c={candidateResult.isValid ? 'success' : 'error'}
fw={500}
>
{candidateResult.isValid ? t('backoffice.candidate.result.valid') : t('backoffice.candidate.result.invalid')}
</Text>
)}
<ActionIcon
size="lg"
variant="light"
disabled={!candidateResult?.isValid}
onClick={() => setShowResultsDrawer(!showResultsDrawer)}
title={t('backoffice.candidate.actions.showresultspanel')}
>
<IconEye size={18} />
</ActionIcon>
<Button
size="sm"
variant="filled"
disabled={!candidateResult?.isValid}
onClick={() => window.open(`/jobs/application?candidateId=${candidateId}`, '_blank')}
leftSection={<IconExternalLink size={16} />}
>
{t('backoffice.candidate.actions.resultpage')}
</Button>
<Button
size="sm"
variant="light"
disabled={!candidateResult?.isValid}
onClick={() => window.open(`/company/candidate/${candidateId}`, '_blank')}
leftSection={<IconExternalLink size={16} />}
>
{t('backoffice.candidate.actions.companyview')}
</Button>
</Group>
</Group>
{/* Qualification Result Summary */}
<QualificationAssessment candidateResult={candidateResult} />
{/* Manual Assessment Section */}
<Title order={3} mb="md">{t('backoffice.candidate.manualassessment')}</Title>
<Card shadow="sm" padding="lg" radius="md" withBorder mb="md">
<Grid gutter="md">
<Grid.Col span={6}>
<EnumSelect
enumObject={NurseRecognitionHomeCountryValues}
enumName="NurseRecognitionHomeCountry"
label={t('backoffice.candidate.fields.statushomecountry')}
placeholder={t('backoffice.candidate.fields.selectstatus')}
value={candidate.qualificationStatusHomeCountry || null}
onChange={(qualificationStatusHomeCountry) =>
updateCandidateMutation.mutate({
qualificationStatusHomeCountry: qualificationStatusHomeCountry! as DB.NurseRecognitionHomeCountry
})
}
/>
</Grid.Col>
<Grid.Col span={6}>
<EnumSelect
enumObject={NurseRecognitionGermanyValues}
enumName="NurseRecognitionGermany"
label={t('backoffice.candidate.fields.statusgermany')}
placeholder={t('backoffice.candidate.fields.selectstatus')}
value={candidate.qualificationStatusGermany || null}
onChange={(qualificationStatusGermany) =>
updateCandidateMutation.mutate({
qualificationStatusGermany: qualificationStatusGermany! as DB.NurseRecognitionGermany
})
}
/>
</Grid.Col>
<Grid.Col span={6}>
<BooleanSelectComponent
label={t('backoffice.candidate.fields.licensemandatory')}
value={candidate.licenseMandatory}
onChange={(licenseMandatory) =>
updateCandidateMutation.mutate({ licenseMandatory })}
/>
</Grid.Col>
<Grid.Col span={6}>
<BooleanSelectComponent
label={t('backoffice.candidate.fields.durationrequirementsmet')}
value={candidate.durationRequirementsMet}
onChange={(durationRequirementsMet) =>
updateCandidateMutation.mutate({ durationRequirementsMet })}
/>
</Grid.Col>
<Grid.Col span={6}>
<EnumSelect
enumObject={RecognitionLikelihoodValues}
enumName="RecognitionLikelihood"
label={t('backoffice.candidate.fields.recognitionlikelihood')}
placeholder={t('backoffice.candidate.fields.selectlikelihood')}
value={candidate.recognitionLikelihood || null}
onChange={(recognitionLikelihood) =>
updateCandidateMutation.mutate({
recognitionLikelihood: recognitionLikelihood! as DB.RecognitionLikelihood
})
}
/>
</Grid.Col>
</Grid>
</Card>
<Title order={2} mb="md">{t('backoffice.candidate.basicinfo')}</Title>
<Card shadow="sm" padding="lg" radius="md" withBorder>
<Stack gap="md">
<Grid>
<Grid.Col span={6}>
<Group gap="xs">
<Text fw={500} size="sm">{t('backoffice.candidate.fields.email')}</Text>
<Text size="sm">{candidate.email ? asI18n(candidate.email) : notAvailable}</Text>
</Group>
</Grid.Col>
<Grid.Col span={6}>
<Group gap="xs">
<Text fw={500} size="sm">{t('backoffice.candidate.fields.currentresidence')}</Text>
<Text size="sm">{asI18n(getCountryNameFromAbbreviation(candidate.currentResidence))}</Text>
</Group>
</Grid.Col>
<Grid.Col span={6}>
<Group gap="xs">
<Text fw={500} size="sm">{t('backoffice.candidate.fields.countryofeducation')}</Text>
<Text size="sm">{asI18n(getCountryNameFromAbbreviation(candidate.countryEducation))}</Text>
</Group>
</Grid.Col>
<Grid.Col span={6}>
<Group gap="xs">
<Text fw={500} size="sm">{t('backoffice.candidate.fields.eupassport')}</Text>
<Text size="sm">{yesNo(candidate.euPassport)}</Text>
</Group>
</Grid.Col>
<Grid.Col span={6}>
<Group gap="xs">
<Text fw={500} size="sm">{t('backoffice.candidate.fields.dateofbirthage')}</Text>
<Text size="sm">
{candidate.dateOfBirth ? (
<>
{dayjs(candidate.dateOfBirth).format('YYYY-MM-DD')}
{` (${dayjs().diff(dayjs(candidate.dateOfBirth), 'year')} ${t('backoffice.candidate.fields.years')})`}
</>
) : notAvailable}
</Text>
</Group>
</Grid.Col>
<Grid.Col span={6}>
<Group gap="xs">
<Text fw={500} size="sm">{t('backoffice.candidate.fields.livingingermany')}</Text>
<Text size="sm">{yesNo(candidate.livingInGermany)}</Text>
</Group>
</Grid.Col>
<Grid.Col span={6}>
<Group gap="xs">
<Text fw={500} size="sm">{t('backoffice.candidate.fields.jointalentpool')}</Text>
<Text size="sm">{yesNo(candidate.joinTalentPool)}</Text>
</Group>
</Grid.Col>
<Grid.Col span={6}>
<Group gap="xs">
<Text fw={500} size="sm">{t('backoffice.candidate.fields.worklocation')}</Text>
<Text size="sm">{asI18n(candidate.stateWorkPreference !== null ? candidate.stateWorkPreference.join(', ') : notAvailable)}</Text>
</Group>
</Grid.Col>
<Grid.Col span={6}>
<Group gap="xs">
<Text fw={500} size="sm">{t('backoffice.candidate.fields.submittedat')}</Text>
<Text size="sm">{candidate.submittedAt ? asI18n(new Date(candidate.submittedAt).toLocaleDateString()) : notAvailable}</Text>
</Group>
</Grid.Col>
</Grid>
</Stack>
</Card>
<Stack gap="xl">
{/* Education Section */}
<Paper my="md">
<Title order={2} mb="md">{t('enums.candidatedocumenttype.education')}</Title>
<Stack gap="md">
<EducationVerificationCard
candidateId={candidateId}
education={candidate.analysis?.education || {}}
documents={candidate.documents?.education || []}
verbose={verbose}
/>
</Stack>
</Paper>
{/* License Section */}
<Paper my="md">
<Title order={2} mb="md">{t('enums.candidatedocumenttype.license')}</Title>
<Stack gap="md">
<LicenseVerificationCard
candidateId={candidateId}
license={candidate.analysis?.license || {}}
documents={candidate.documents?.license || []}
verbose={verbose}
/>
</Stack>
</Paper>
{/* Language Section */}
<Paper my="md">
<Title order={2} mb="md">{t('enums.candidatedocumenttype.language')}</Title>
<Stack gap="md">
<LanguageVerificationCard
candidateId={candidateId}
language={candidate.analysis?.language || {}}
documents={candidate.documents?.language || []}
verbose={verbose}
/>
</Stack>
</Paper>
{/* Experience Section */}
<Paper my="md">
<Title order={2} mb="md">{t('enums.candidatedocumenttype.work_experience')}</Title>
<Stack gap="md">
<ExperienceVerificationCard
candidateId={candidateId}
experience={candidate.analysis?.experience || {}}
documents={candidate.documents?.experience || []}
verbose={verbose}
/>
</Stack>
</Paper>
</Stack>
<LifecycleNotificationsCard
lifecycleNotifications={candidate.lifecycleNotifications}
analysisIssuesDraft={analysisIssuesDraft}
onAnalysisIssuesDraftChange={setAnalysisIssuesDraft}
onSaveAnalysisIssues={saveAnalysisIssues}
isSaving={updateCandidateMutation.isPending}
/>
</Stack>
</Container>
{/* Results Drawer */}
{showResultsDrawer && <Paper ml='lg' withBorder><Stack>
<CandidateResultPreview candidateId={candidateId} />
</Stack></Paper>}
</Flex>
</>
);
}

View File

@@ -0,0 +1,300 @@
import { Container, Title, Text, TextInput, ActionIcon, Group, Button, Badge, Select } from '@pikku/mantine/core';
import { DataTable, type DataTableSortStatus } from 'mantine-datatable';
import { useDebouncedValue } from '@mantine/hooks';
import { IconSearch, IconX } from '@tabler/icons-react';
import sortBy from 'lodash/sortBy';
import dayjs from 'dayjs';
import { useGetBackOfficeCandidates } from '@/hooks/backoffice/useGetBackOfficeCandidates';
import { useI18n } from '@/context/i18n-provider';
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { pikku } from '@/pikku/http';
import { CountriesMap } from '@heygermany/sdk';
import { CountrySearch } from '@/components/jobs/form/CountrySearch';
import { LanguageLevelSelectComponent, NurseRecognitionGermanySelectComponent, ApplicantStatusSelectComponent } from '@/components/backoffice/DocumentFieldFactory';
export default function BackOfficeCandidates() {
const { isLoading, data: candidates } = useGetBackOfficeCandidates()
const t = useI18n();
const notAvailable = t('common.na');
// Fetch countries map
const { data: countriesMap = {} } = useQuery({
queryKey: ['countries', t.locale],
queryFn: async (): Promise<CountriesMap> => {
return await pikku().get('/countries', { locale: t.locale })
},
staleTime: 60 * 60 * 1000, // 1 hour
});
// Filter states
const [nameQuery, setNameQuery] = useState('');
const [debouncedNameQuery] = useDebouncedValue(nameQuery, 200);
const [countryFilter, setCountryFilter] = useState('');
const [germanLevelFilter, setGermanLevelFilter] = useState<string | null>(null);
const [recognitionStatusFilter, setRecognitionStatusFilter] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [reuploadedAfterInvalidFilter, setReuploadedAfterInvalidFilter] = useState<string | null>(null);
// Sort state
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<any>>({
columnAccessor: 'createdAt',
direction: 'desc',
});
// Records state
const [records, setRecords] = useState<any[]>([]);
const hasReuploadedAfterInvalid = (candidate: any) => {
if (!candidate.reuploadedAt || !candidate.invalidatedAt) {
return false;
}
return new Date(candidate.reuploadedAt).getTime() > new Date(candidate.invalidatedAt).getTime();
};
// Apply filters
useEffect(() => {
if (!candidates?.data) {
setRecords([]);
return;
}
const filtered = candidates.data.filter((candidate: any) => {
// Name filter
if (debouncedNameQuery !== '' &&
!candidate.name?.toLowerCase().includes(debouncedNameQuery.trim().toLowerCase())) {
return false;
}
// Country filter
if (countryFilter && candidate.countryEducation !== countryFilter) {
return false;
}
// German level filter
if (germanLevelFilter && candidate.languageSelfAssessmentLevel !== germanLevelFilter) {
return false;
}
// Recognition status filter
if (recognitionStatusFilter && candidate.qualificationStatusGermany !== recognitionStatusFilter) {
return false;
}
// Status filter
if (statusFilter && candidate.status !== statusFilter) {
return false;
}
if (reuploadedAfterInvalidFilter === 'yes' && !hasReuploadedAfterInvalid(candidate)) {
return false;
}
if (reuploadedAfterInvalidFilter === 'no' && hasReuploadedAfterInvalid(candidate)) {
return false;
}
return true;
});
setRecords(filtered);
}, [candidates?.data, debouncedNameQuery, countryFilter, germanLevelFilter, recognitionStatusFilter, statusFilter, reuploadedAfterInvalidFilter]);
// Apply sorting
useEffect(() => {
if (records.length === 0) return;
let sortedData = [...records];
if (sortStatus.columnAccessor === 'createdAt') {
sortedData = sortBy(sortedData, (item) => item.createdAt ? new Date(item.createdAt).getTime() : 0);
} else if (sortStatus.columnAccessor === 'fullName') {
sortedData = sortBy(sortedData, (item) => item.name || '');
} else if (sortStatus.columnAccessor === 'countryEducation') {
sortedData = sortBy(sortedData, (item) => item.countryEducation || '');
} else if (sortStatus.columnAccessor === 'germanLevel') {
sortedData = sortBy(sortedData, (item) => item.languageSelfAssessmentLevel || '');
} else if (sortStatus.columnAccessor === 'recognitionStatus') {
sortedData = sortBy(sortedData, (item) => item.qualificationStatusGermany || '');
} else if (sortStatus.columnAccessor === 'status') {
sortedData = sortBy(sortedData, (item) => item.status || '');
}
setRecords(sortStatus.direction === 'desc' ? sortedData.reverse() : sortedData);
}, [sortStatus]);
const clearFilters = () => {
setNameQuery('');
setCountryFilter('');
setGermanLevelFilter(null);
setRecognitionStatusFilter(null);
setStatusFilter(null);
setReuploadedAfterInvalidFilter(null);
};
const hasActiveFilters =
nameQuery !== '' ||
countryFilter !== '' ||
germanLevelFilter !== null ||
recognitionStatusFilter !== null ||
statusFilter !== null ||
reuploadedAfterInvalidFilter !== null;
return (
<Container size="xl">
<Title order={1}>
{t('backoffice.candidates.title')}
</Title>
<Group justify="space-between" mt="sm" mb="lg">
<Text size="lg" c="dimmed">
{t('backoffice.candidates.description')}
</Text>
<Group gap="md">
<Text size="sm" c="dimmed" fw={500}>
{hasActiveFilters
? t('backoffice.candidates.count.filtered', {
filtered: records.length,
total: candidates?.total || 0,
})
: t('backoffice.candidates.count.total', {
total: candidates?.total || 0,
})}
</Text>
{hasActiveFilters && (
<Button variant="light" onClick={clearFilters}>
{t('backoffice.candidates.clearfilters')}
</Button>
)}
</Group>
</Group>
<DataTable
withTableBorder
borderRadius="md"
striped
highlightOnHover
height='calc(100vh - 220px)'
minHeight={200}
fetching={isLoading}
records={records}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
onRowClick={({ record }) =>
window.open(`/backoffice/candidate/${record.candidateId}`, '_blank', 'noopener,noreferrer')
}
columns={[
{
accessor: 'fullName',
title: t('backoffice.candidates.columns.fullname'),
sortable: true,
render: (candidate) => candidate.name,
filter: (
<TextInput
label={t('backoffice.candidates.filters.name.label')}
description={t('backoffice.candidates.filters.name.description')}
placeholder={t('backoffice.candidates.filters.name.placeholder')}
leftSection={<IconSearch size={16} />}
rightSection={
<ActionIcon size="sm" variant="transparent" c="dimmed" onClick={() => setNameQuery('')}>
<IconX size={14} />
</ActionIcon>
}
value={nameQuery}
onChange={(e) => setNameQuery(e.currentTarget.value)}
/>
),
filtering: nameQuery !== '',
},
{
accessor: 'countryEducation',
title: t('backoffice.candidates.columns.countryofeducation'),
sortable: true,
render: (candidate) => candidate.countryEducation ? (countriesMap[candidate.countryEducation] || candidate.countryEducation) : notAvailable,
filter: (
<CountrySearch
label={t('backoffice.candidates.filters.country.label')}
placeholder={t('backoffice.candidates.filters.country.placeholder')}
value={countryFilter}
onChange={setCountryFilter}
/>
),
filtering: countryFilter !== '',
},
{
accessor: 'germanLevel',
title: t('backoffice.candidates.columns.germanlevel'),
sortable: true,
render: (candidate) => candidate.languageSelfAssessmentLevel ? t(`enums.languagelevel.${candidate.languageSelfAssessmentLevel.toLowerCase()}` as any) : notAvailable,
filter: (
<LanguageLevelSelectComponent
label={t('backoffice.candidates.filters.germanlevel.label')}
placeholder={t('backoffice.candidates.filters.germanlevel.placeholder')}
value={germanLevelFilter}
onChange={setGermanLevelFilter}
/>
),
filtering: germanLevelFilter !== null,
},
{
accessor: 'recognitionStatus',
title: t('backoffice.candidates.columns.recognitionstatus'),
sortable: true,
render: (candidate) => candidate.qualificationStatusGermany ? t(`enums.nurserecognitiongermany.${candidate.qualificationStatusGermany.toLowerCase()}` as any) : notAvailable,
filter: (
<NurseRecognitionGermanySelectComponent
label={t('backoffice.candidates.filters.recognitionstatus.label')}
placeholder={t('backoffice.candidates.filters.recognitionstatus.placeholder')}
value={recognitionStatusFilter}
onChange={setRecognitionStatusFilter}
/>
),
filtering: recognitionStatusFilter !== null,
},
{
accessor: 'createdAt',
title: t('backoffice.candidates.columns.creationdate'),
sortable: true,
render: (candidate) => dayjs(candidate.createdAt).format('YYYY-MM-DD')
},
{
accessor: 'status',
title: t('backoffice.candidates.columns.status'),
sortable: true,
render: (candidate) => candidate.status ? t(`enums.applicantstatus.${candidate.status.toLowerCase()}` as any) : notAvailable,
filter: (
<ApplicantStatusSelectComponent
label={t('backoffice.candidates.filters.status.label')}
placeholder={t('backoffice.candidates.filters.status.placeholder')}
value={statusFilter}
onChange={setStatusFilter}
/>
),
filtering: statusFilter !== null,
},
{
accessor: 'reuploadedAfterInvalid',
title: t('backoffice.candidates.columns.reuploaded'),
render: (candidate) => hasReuploadedAfterInvalid(candidate)
? <Badge color="yellow" variant="light">{t('backoffice.candidates.reuploaded.badge')}</Badge>
: <Text size="sm" c="dimmed">{t('common.no')}</Text>,
filter: (
<Select
label={t('backoffice.candidates.filters.reuploaded.label')}
placeholder={t('backoffice.candidates.filters.reuploaded.placeholder')}
value={reuploadedAfterInvalidFilter}
onChange={setReuploadedAfterInvalidFilter}
data={[
{ value: 'yes', label: t('common.yes') },
{ value: 'no', label: t('common.no') },
]}
clearable
/>
),
filtering: reuploadedAfterInvalidFilter !== null,
}
]}
/>
</Container>
);
}

View File

@@ -0,0 +1,20 @@
import { Container, Title, Text, Stack } from '@pikku/mantine/core';
import { useI18n } from '@/context/i18n-provider';
export default function BackOfficeDashboardPage() {
const t = useI18n();
return (
<Container size="xl">
<Stack gap="lg">
<div>
<Title order={1} mb="sm">
{t('backoffice.dashboard.title')}
</Title>
<Text c="dimmed">
{t('backoffice.dashboard.description')}
</Text>
</div>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,351 @@
# Translation System - CSV Guide
## What is this?
This guide explains how the translation CSV file works for the "Next Steps" section on candidate results pages. The system uses **translation tokens** to manage multilingual content efficiently.
---
## Understanding the CSV Structure
The translation CSV file has this structure:
| Token | EN | DE | UK | ... |
|-------|----|----|----|----|
| `default.getHelp.title` | Get Help | Hilfe erhalten | Отримати допомогу | ... |
| `default.getHelp.subtitle` | Get free support... | Lassen Sie sich... | Отримайте... | ... |
**Key parts:**
- **Token** (Column 1): The unique identifier for each piece of text
- **EN, DE, UK, etc.** (Remaining columns): Translations for each language
---
## Token Naming Convention
Tokens follow a specific pattern: `{layer}.{step}.{property}`
### Structure Breakdown
```
default.getHelp.title
↓ ↓ ↓
Layer Step Property
```
**1. Layer** - Which candidate group this applies to:
- `default` - Base layer for everyone
- `nurse.default` - All nurses
- `assistant.default` - All assistants
- `nurse.allmet` - Specific nurse situation
- `assistant.missinglicense` - Specific assistant situation
**2. Step** - Which of the 6 steps this text belongs to:
- `getHelp` - Get Help step
- `connect` - Connect with Employers step
- `documents` - Prepare Your Documents step
- `submit` - Submit Your Application step
- `compensatory` - Complete a Compensatory Measure step
- `german` - Improve Your German step
**3. Property** - What part of the step this is:
- `title` - The step heading
- `subtitle` - The description text
- `costs` - Cost information text
- `button` - Button text
- `buttonImprove` / `buttonJoin` - Special buttons for connect step
---
## How the Layer System Works
The system uses **3 layers** that override each other, like stacking transparent sheets:
### Layer 1: `default.*` (Base Layer)
This is the foundation. Every step should have complete translations here.
**Example tokens:**
```
default.submit.title
default.submit.subtitle
default.submit.costs
default.submit.button
```
**When to use:** These apply to ALL candidates unless overridden by a more specific layer.
---
### Layer 2: `{role}.default.*` (Role Layer)
Changes text for **all candidates of a specific role** (nurse/assistant/helper).
**Example tokens:**
```
assistant.default.submit.subtitle
assistant.default.submit.costs
```
**What happens:**
- `assistant.default.submit.subtitle` replaces `default.submit.subtitle` for ALL assistants
- But `default.submit.title` still shows (not overridden)
- But `default.submit.button` still shows (not overridden)
---
### Layer 3: `{role}.{situation}.*` (Specific Situation Layer)
Changes text for **one specific candidate situation** only.
**Example tokens:**
```
assistant.missinglicense.submit.subtitle
```
**What happens:**
- `assistant.missinglicense.submit.subtitle` replaces the subtitle for assistants missing their license
- `assistant.default.submit.costs` still applies (from Layer 2)
- `default.submit.title` still applies (from Layer 1)
- `default.submit.button` still applies (from Layer 1)
---
## Real Example: How Tokens Stack
**Scenario:** A "Nursing Assistant without a license" views the "Submit Your Application" step.
**The system looks for tokens in this order:**
1. **Check Layer 3** (most specific):
- `assistant.missinglicense.submit.title` → Not found ❌
- `assistant.missinglicense.submit.subtitle` → Found ✅ "First upload your license..."
- `assistant.missinglicense.submit.costs` → Not found ❌
- `assistant.missinglicense.submit.button` → Not found ❌
2. **Check Layer 2** (role-specific):
- `assistant.default.submit.title` → Not found ❌
- `assistant.default.submit.subtitle` → Not needed (found in Layer 3)
- `assistant.default.submit.costs` → Found ✅ "Cost: 150-250€"
- `assistant.default.submit.button` → Not found ❌
3. **Check Layer 1** (default):
- `default.submit.title` → Found ✅ "Submit Your Application"
- `default.submit.subtitle` → Not needed (found in Layer 3)
- `default.submit.costs` → Not needed (found in Layer 2)
- `default.submit.button` → Found ✅ "Learn how to apply"
**Final Result:**
- Title: "Submit Your Application" ← from `default.submit.title`
- Subtitle: "First upload your license..." ← from `assistant.missinglicense.submit.subtitle`
- Costs: "Cost: 150-250€" ← from `assistant.default.submit.costs`
- Button: "Learn how to apply" ← from `default.submit.button`
---
## Available Steps & Properties
### The 6 Steps
Every next-steps journey can have these steps (use camelCase in tokens):
| Token Name | Display Name |
|------------|--------------|
| `getHelp` | Get Help |
| `connect` | Connect with Employers |
| `documents` | Prepare Your Documents |
| `submit` | Submit Your Application |
| `compensatory` | Complete a Compensatory Measure |
| `german` | Improve Your German |
### Properties for Each Step
| Property | What it is | Example Token |
|----------|-----------|---------------|
| `title` | Step heading | `default.getHelp.title` |
| `subtitle` | Description text | `default.getHelp.subtitle` |
| `costs` | Cost information | `default.getHelp.costs` |
| `button` | Button text | `default.getHelp.button` |
| `buttonImprove` | Special button (connect only) | `default.connect.buttonImprove` |
| `buttonJoin` | Special button (connect only) | `default.connect.buttonJoin` |
---
## Available Layers (All Valid Token Prefixes)
### Global Layer
- `default` - Base for everyone
### Role Layers
- `nurse.default` - Shared by all nurse situations
- `assistant.default` - Shared by all assistant situations
- `helper.default` - Shared by all helper situations
### Specific Situation Layers
**Nurses:**
- `nurse.allmet`
- `nurse.missinglicense`
- `nurse.missinglicenseandaccreditation`
**Assistants:**
- `assistant.allmet`
- `assistant.missinglicense`
- `assistant.missinglicenseandaccreditation`
**Helpers:**
- Only uses `helper.default` (no specific situations)
---
## Dynamic Placeholders
Some translations can include placeholders that get replaced with actual data:
| Placeholder | Replaced with | Example |
|-------------|---------------|---------|
| `{currentLevel}` | Candidate's current German level | A1, B1, B2, etc. |
| `{requiredLevel}` | Required German level | B1, B2, etc. |
**Example translation:**
```
default.german.subtitle = "You're currently at {currentLevel} — almost there! To gain full recognition, you'll need to improve your German to {requiredLevel} level."
```
**Becomes:**
```
"You're currently at A2 — almost there! To gain full recognition, you'll need to improve your German to B2 level."
```
---
## Rules for Adding Translations
### 1. Always Fill Out the `default.*` Layer Completely
The base layer should have ALL properties for ALL steps. This ensures there's always a fallback.
**Required tokens for each step:**
```
default.{stepName}.title
default.{stepName}.subtitle
default.{stepName}.costs
default.{stepName}.button
```
### 2. Only Add Role/Situation Tokens When You Need to Change Something
If assistant costs are different, add:
```
assistant.default.submit.costs
```
Don't add:
```
assistant.default.submit.title ← Not needed if same as default
assistant.default.submit.button ← Not needed if same as default
```
### 3. Use Consistent Naming
- Steps use camelCase: `getHelp`, `connect`, `documents`
- Properties use camelCase: `title`, `subtitle`, `buttonImprove`
- Layers use lowercase with dots: `assistant.default`, `nurse.allmet`
### 4. Add All Languages for Each Token
Every token row must have translations for all supported languages (EN, DE, UK, TL, TR, BS, SQ, VI, ES, HI, AR).
---
## Common Scenarios
### Scenario 1: Change just the subtitle for all assistants
**Add one token:**
```
assistant.default.submit.subtitle
```
All other properties (title, costs, button) will fall through from `default.*`
---
### Scenario 2: Change subtitle for one specific situation
**Add one token:**
```
nurse.missinglicense.submit.subtitle
```
Everything else comes from `nurse.default.*` (if exists) or `default.*`
---
### Scenario 3: Assistants have different costs and different process
**Add two tokens:**
```
assistant.default.submit.subtitle ← Changed process description
assistant.default.submit.costs ← Different price
```
Title and button stay from `default.*`
---
### Scenario 4: One situation needs urgent warning
**Add one token:**
```
assistant.missinglicenseandaccreditation.submit.subtitle
```
This overrides even `assistant.default.submit.subtitle` for this specific situation.
---
## Why This System?
### Without this system:
- 7 candidate situations × 6 steps × 4 properties = **168 translations per language**
- Lots of duplication
- Hard to make global changes
### With this system:
- Base layer: ~24 translations (6 steps × 4 properties)
- Role overrides: ~10 translations (only what's different)
- Situation overrides: ~5 translations (only what's specific)
- **Total: ~39 translations per language** (77% less!)
---
## Quick Reference: Token Format
```
{layer}.{step}.{property}
↓ ↓ ↓
(role. (camel (camel
situation) Case) Case)
Examples:
default.getHelp.title
assistant.default.submit.costs
nurse.missinglicense.documents.subtitle
```
---
## Checking Your Work
To verify a token will work correctly:
1. **Check the layer exists** in the "Available Layers" section above
2. **Check the step name** is one of the 6 available steps (camelCase)
3. **Check the property** is one of: title, subtitle, costs, button, buttonImprove, buttonJoin
4. **Ensure all languages have translations** for that token
**Valid token:** ✅ `assistant.default.submit.costs`
**Invalid token:** ❌ `assistant.submit.costs` (missing `.default` or situation)
**Invalid token:** ❌ `default.Submit.costs` (should be `submit` not `Submit`)
**Invalid token:** ❌ `default.submit.price` (should be `costs` not `price`)

View File

@@ -0,0 +1,16 @@
import Content from './content.mdx'
import { useMDXComponents } from '@/mdx-components'
import { MDXProvider } from '@mdx-js/react'
import { Container } from '@pikku/mantine/core'
export default function NextStepsHelpPage() {
const components = useMDXComponents({})
return (
<Container size="xl" mt="xl">
<MDXProvider components={components}>
<Content />
</MDXProvider>
</Container>
)
}

View File

@@ -0,0 +1,81 @@
import { AppShell, Flex, Button, Box, Container } from '@pikku/mantine/core';
import '@/styles/datatable.css';
import { Link, 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';
import { asI18n } from '@pikku/react';
interface BackOfficeLayoutProps {
children: React.ReactNode;
}
export default function BackOfficeLayout({ children }: BackOfficeLayoutProps) {
const t = useI18n();
const pathname = useLocation({ select: (l) => l.pathname });
const navigate = useNavigate();
const params = useParams({ strict: false });
const lang = params.lang as string;
const isDetailPage = pathname.includes('/backoffice/candidate/');
// Don't apply AppShell to auth pages
if (pathname.includes('/backoffice/auth')) {
return <>{children}</>;
}
return (
<AppShell
padding="md"
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}/backoffice/candidates` })}
>
{t('backoffice.navigation.backtocandidates')}
</Button>
<LanguageSelector />
</Flex>
</Container>
</Box>
) : (
<Flex align="center" justify="space-between" h="100%" px="md">
<Logo size="sm" type={t('backoffice.navigation.adminbadge')} />
<Flex gap="sm" align="center">
<Button
component={Link}
to={`/${lang}/backoffice/candidates`}
variant={pathname.includes('/backoffice/candidates') ? 'filled' : 'default'}
>
{asI18n('Candidates')}
</Button>
<Button
component={Link}
to={`/${lang}/backoffice/translations`}
variant={pathname.includes('/backoffice/translations') ? 'filled' : 'default'}
>
{asI18n('Translations')}
</Button>
<LanguageSelector />
</Flex>
</Flex>
)}
</AppShell.Header>
<AppShell.Main>{children}</AppShell.Main>
</AppShell>
);
}

View File

@@ -0,0 +1,11 @@
import { useNavigate, useParams } from '@tanstack/react-router';
import { useEffect } from 'react';
export default function BackOfficeDashboardPage() {
const navigate = useNavigate()
const { lang } = useParams({ strict: false }) as { lang?: string }
useEffect(() => {
navigate({ to: `/${lang || 'en'}/backoffice/candidates`, replace: true })
}, [lang, navigate])
return null
}

View File

@@ -0,0 +1,197 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from '@tanstack/react-router'
import {
Button,
Container,
Group,
Paper,
Select,
Stack,
Text,
Textarea,
Title,
} from '@pikku/mantine/core'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { asI18n } from '@pikku/react'
import type {
BackendTranslationBundle,
BackendTranslationNamespace,
} from '@heygermany/sdk'
import { invokeRPC } from '@/pikku/rpc'
function toPrettyJson(value: Record<string, unknown>) {
return JSON.stringify(value, null, 2)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
export default function BackOfficeTranslationsPage() {
const params = useParams({ strict: false })
const lang = params.lang as string
const queryClient = useQueryClient()
const [selectedLocale, setSelectedLocale] = useState<string | null>(null)
const [selectedNamespace, setSelectedNamespace] =
useState<BackendTranslationNamespace | null>(null)
const [editorValue, setEditorValue] = useState('')
const [editorError, setEditorError] = useState<string | null>(null)
const query = useQuery({
queryKey: ['backoffice', 'translations'],
queryFn: async () =>
await invokeRPC<BackendTranslationBundle[]>('getBackOfficeTranslations', {}),
})
const rows = query.data ?? []
const localeOptions = useMemo(
() => Array.from(new Set(rows.map((row) => row.locale))).map((locale) => ({
value: locale,
label: locale,
})),
[rows]
)
const namespaceOptions = useMemo(
() => Array.from(new Set(rows.map((row) => row.namespace))).map((namespace) => ({
value: namespace,
label: namespace,
})),
[rows]
)
useEffect(() => {
if (rows.length === 0) return
if (!selectedLocale) {
setSelectedLocale(rows.find((row) => Object.keys(row.content).length > 0)?.locale ?? rows[0].locale)
}
if (!selectedNamespace) {
setSelectedNamespace(
rows.find((row) => Object.keys(row.content).length > 0)?.namespace ?? rows[0].namespace
)
}
}, [rows, selectedLocale, selectedNamespace])
const selectedRow = useMemo(
() =>
rows.find(
(row) =>
row.locale === selectedLocale && row.namespace === selectedNamespace
) ?? null,
[rows, selectedLocale, selectedNamespace]
)
useEffect(() => {
if (!selectedRow) return
setEditorValue(toPrettyJson(selectedRow.content))
setEditorError(null)
}, [selectedRow])
const saveMutation = useMutation({
mutationFn: async () => {
if (!selectedLocale || !selectedNamespace) {
throw new Error('Select a locale and namespace first')
}
const parsed = JSON.parse(editorValue)
if (!isRecord(parsed)) {
throw new Error('Translation bundle must be a JSON object')
}
return await invokeRPC<BackendTranslationBundle>('updateBackOfficeTranslation', {
locale: selectedLocale,
namespace: selectedNamespace,
content: parsed,
})
},
onSuccess: async () => {
setEditorError(null)
await queryClient.invalidateQueries({
queryKey: ['backoffice', 'translations'],
})
},
onError: (error) => {
setEditorError(error instanceof Error ? error.message : String(error))
},
})
return (
<Container size="xl">
<Stack gap="lg">
<Group justify="space-between" align="flex-start">
<Stack gap={4}>
<Title order={1}>{asI18n('Backend translations')}</Title>
<Text c="dimmed">
{asI18n('Edit DB-backed candidate result copy. Changes apply live to backend result rendering.')}
</Text>
</Stack>
<Button component={Link} to={`/${lang}/backoffice/candidates`} variant="light">
{asI18n('Back to candidates')}
</Button>
</Group>
<Paper withBorder radius="md" p="lg">
<Stack gap="md">
<Group grow align="flex-start">
<Select
label={asI18n('Locale')}
data={localeOptions}
value={selectedLocale}
onChange={(value) => setSelectedLocale(value)}
searchable
/>
<Select
label={asI18n('Namespace')}
data={namespaceOptions}
value={selectedNamespace}
onChange={(value) =>
setSelectedNamespace(value as BackendTranslationNamespace | null)
}
/>
</Group>
<Textarea
label={asI18n('Translation JSON')}
autosize
minRows={24}
maxRows={40}
value={editorValue}
onChange={(event) => setEditorValue(event.currentTarget.value)}
placeholder={asI18n('{ }')}
/>
{selectedRow ? (
<Text size="sm" c="dimmed">
{asI18n(`Last updated: ${new Date(selectedRow.updatedAt).toLocaleString()}`)}
</Text>
) : null}
{editorError ? (
<Text size="sm" c="red">
{asI18n(editorError)}
</Text>
) : null}
<Group justify="space-between">
<Text size="sm" c="dimmed">
{query.isLoading
? asI18n('Loading translations...')
: asI18n(`${rows.length} translation bundles available`)}
</Text>
<Button
loading={saveMutation.isPending}
disabled={!selectedRow}
onClick={() => saveMutation.mutate()}
>
{asI18n('Save translation bundle')}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Container>
)
}

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;
}

View File

@@ -0,0 +1,62 @@
import { useEffect } from 'react';
import { useNavigate, useParams } from '@tanstack/react-router';
import { Paper, Text, Title, Stack, Loader, Flex } from '@pikku/mantine/core';
import { useI18n } from '@/context/i18n-provider';
import { useMutation } from '@tanstack/react-query';
import { signOut } from '@/lib/auth';
import { asI18n } from '@pikku/react';
export default function LogoutPage() {
const t = useI18n();
const navigate = useNavigate();
const { lang } = useParams({ strict: false }) as { lang?: string }
const logout = useMutation({
mutationFn: async () => {
await signOut()
}
})
useEffect(() => {
const performLogout = async () => {
await new Promise((resolve) => setTimeout(resolve, 3000))
await logout.mutateAsync()
navigate({ to: lang ? `/${lang}` : '/', replace: true })
}
void performLogout()
}, [lang, navigate])
return (
<Flex
align="center"
justify="center"
mih="100vh"
p="md"
>
<Paper p="xl" radius="md" shadow="sm" w="100%" maw="400">
<Stack align="center" gap="lg">
<Title order={2} ta="center">
{t('logout.title')}
</Title>
{logout.isPending ? (
<>
<Loader size="md" />
<Text ta="center" c="dimmed">
{t('logout.loggingout')}
</Text>
</>
) : logout.isError ? (
<Text ta="center" c="red">
{asI18n(`${t('logout.error')}: ${logout.error?.message || 'Unknown error'}`)}
</Text>
) : logout.isSuccess ? (
<Text ta="center" c="green">
{t('logout.success')}
</Text>
) : null}
</Stack>
</Paper>
</Flex>
);
}