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 09:28:50 +02:00
commit aae77ea31e
398 changed files with 38345 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
'use client';
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,375 @@
'use client';
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 '@/framework/navigation';
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();
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,302 @@
'use client';
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,22 @@
'use client';
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,18 @@
'use client'
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,84 @@
'use client';
import { AppShell, Flex, Button, Box, Container } from '@pikku/mantine/core';
import '@/styles/datatable.css';
import Link from '@/framework/link';
import { usePathname, useRouter, useParams } from '@/framework/navigation';
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 = usePathname();
const router = useRouter();
const params = useParams();
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={() => router.push(`/${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}
href={`/${lang}/backoffice/candidates`}
variant={pathname.includes('/backoffice/candidates') ? 'filled' : 'default'}
>
{asI18n('Candidates')}
</Button>
<Button
component={Link}
href={`/${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,13 @@
'use client';
import { useParams, useRouter } from '@/framework/navigation';
import { useEffect } from 'react';
export default function BackOfficeDashboardPage() {
const router = useRouter()
const { lang } = useParams<{ lang?: string }>()
useEffect(() => {
router.replace(`/${lang || 'en'}/backoffice/candidates`)
}, [lang, router])
return null
}

View File

@@ -0,0 +1,200 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Link from '@/framework/link'
import { useParams } from '@/framework/navigation'
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()
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} href={`/${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,16 @@
'use client';
import { LoginComponent, LoginFormData } from '@/components/LoginComponent';
import { useCompanyLogin } from '@/hooks/company/useCompanyLogin';
import { useRouter } from '@/framework/navigation';
export default function CompanyLogin() {
const login = useCompanyLogin();
return (
<LoginComponent
namespace="company"
mutation={login}
/>
);
}

View File

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

View File

@@ -0,0 +1,67 @@
'use client';
import { AppShell, Flex, Text, Container, Button, Box } from '@pikku/mantine/core';
import { usePathname, useRouter, useParams } from '@/framework/navigation';
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 = usePathname();
const router = useRouter();
const params = useParams();
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={() => router.push(`/${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,15 @@
'use client';
import { useEffect } from 'react';
import { useRouter, useParams } from '@/framework/navigation';
export default function CompanyPage() {
const router = useRouter();
const { lang } = useParams();
useEffect(() => {
router.replace(`/${lang}/company/candidates`);
}, [router, lang]);
return null;
}

View File

@@ -0,0 +1,64 @@
'use client';
import { useEffect } from 'react';
import { useParams, useRouter } from '@/framework/navigation';
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 router = useRouter();
const { lang } = useParams<{ lang?: string }>()
const logout = useMutation({
mutationFn: async () => {
await signOut()
}
})
useEffect(() => {
const performLogout = async () => {
await new Promise((resolve) => setTimeout(resolve, 3000))
await logout.mutateAsync()
router.replace(lang ? `/${lang}` : '/')
}
void performLogout()
}, [lang, router])
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>
);
}

View File

@@ -0,0 +1,95 @@
'use client'
import { Container, Stack, Title, Text, Grid, ThemeIcon, List, Box } from '@pikku/mantine/core'
import { useI18n } from "@/context/i18n-provider"
const AboutPage: React.FunctionComponent = () => {
const t = useI18n()
return (
<Container size="md" py="xl">
<Title order={1} size="h1" mb="xl" ta="center">{t('website.about.title')}</Title>
<Stack gap="xl">
<Text size="lg" c="dimmed">
{t('website.about.description')}
</Text>
<Stack gap="md">
<Title order={2} size="h2" mt="xl" mb="md">{t('website.about.mission.title')}</Title>
<Text c="dimmed">
{t('website.about.mission.description')}
</Text>
</Stack>
<Stack gap="md">
<Title order={2} size="h2" mt="xl" mb="md">{t('website.about.howitworks.title')}</Title>
<Grid mt="md">
<Grid.Col span={{ base: 12, md: 4 }}>
<Stack align="center" ta="center" gap="md">
<ThemeIcon size="xl" radius="xl" variant="light" color="blue">
<Text size="xl" fw={700}>{1}</Text>
</ThemeIcon>
<Title order={3} size="lg" mb="xs">{t('website.about.howitworks.step1.title')}</Title>
<Text size="sm" c="dimmed">
{t('website.about.howitworks.step1.description')}
</Text>
</Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<Stack align="center" ta="center" gap="md">
<ThemeIcon size="xl" radius="xl" variant="light" color="blue">
<Text size="xl" fw={700}>{2}</Text>
</ThemeIcon>
<Title order={3} size="lg" mb="xs">{t('website.about.howitworks.step2.title')}</Title>
<Text size="sm" c="dimmed">
{t('website.about.howitworks.step2.description')}
</Text>
</Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<Stack align="center" ta="center" gap="md">
<ThemeIcon size="xl" radius="xl" variant="light" color="blue">
<Text size="xl" fw={700}>{3}</Text>
</ThemeIcon>
<Title order={3} size="lg" mb="xs">{t('website.about.howitworks.step3.title')}</Title>
<Text size="sm" c="dimmed">
{t('website.about.howitworks.step3.description')}
</Text>
</Stack>
</Grid.Col>
</Grid>
</Stack>
<Stack gap="md">
<Title order={2} size="h2" mt="xl" mb="md">{t('website.about.whychoose.title')}</Title>
<List spacing="xs" c="dimmed">
<List.Item>
{t('website.about.whychoose.fast.summary')}
</List.Item>
<List.Item>
{t('website.about.whychoose.free.summary')}
</List.Item>
<List.Item>
{t('website.about.whychoose.secure.summary')}
</List.Item>
<List.Item>
{t('website.about.whychoose.support.summary')}
</List.Item>
</List>
</Stack>
<Box bg="blue.0" p="xl" style={{ borderRadius: 'var(--mantine-radius-lg)' }} mt="xl">
<Title order={3} size="xl" mb="md">{t('website.about.cta.title')}</Title>
<Text c="dimmed" mb="md">
{t('website.about.cta.description')}
</Text>
</Box>
</Stack>
</Container>
)
}
export default AboutPage

View File

@@ -0,0 +1,100 @@
'use client'
import { Container, Stack, Title, Text, Button, Loader, Center } from '@pikku/mantine/core'
import { useI18n } from "@/context/i18n-provider"
import { useEffect, useState } from 'react'
import { useSearchParams, useRouter, useParams } from '@/framework/navigation'
import { verifyMagicLink } from '@/lib/auth'
import { asI18n } from '@pikku/react'
const MagicLinkPage: React.FunctionComponent = () => {
const t = useI18n()
const searchParams = useSearchParams()
const router = useRouter()
const { lang } = useParams<{ lang: string }>()
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const [errorMessage, setErrorMessage] = useState<string>('')
useEffect(() => {
const token = searchParams.get('token')
const redirect = searchParams.get('redirect') || '/jobs/application'
if (!token) {
setStatus('error')
setErrorMessage(t('magiclink.error.notoken'))
return
}
const completeMagicLinkSignIn = async () => {
try {
await verifyMagicLink(token)
setStatus('success')
setTimeout(() => {
router.push(redirect)
}, 1000)
} catch (error: any) {
setStatus('error')
setErrorMessage(error.message || t('magiclink.error.verificationfailed'))
}
}
completeMagicLinkSignIn()
}, [searchParams, router, t])
return (
<Container size="sm" py="xl" my='auto'>
<Stack gap="xl" align="center">
{status === 'loading' && (
<>
<Center>
<Loader size="xl" />
</Center>
<Title order={1} ta="center">
{t('magiclink.loading.title')}
</Title>
</>
)}
{status === 'success' && (
<>
<Stack gap="md" align="center">
<Title order={1} ta="center">
{t('magiclink.success.title')}
</Title>
<Text c="dimmed" ta="center">
{t('magiclink.success.message')}
</Text>
</Stack>
</>
)}
{status === 'error' && (
<>
<Stack gap="md" align="center">
<Title order={1} ta="center">
{t('magiclink.error.title')}
</Title>
<Text c="dimmed" ta="center">
{t('magiclink.error.message')}
</Text>
{errorMessage && (
<Text size="sm" c="red" ta="center">
{asI18n(errorMessage)}
</Text>
)}
<Button
size="lg"
mt="md"
onClick={() => router.push(`/${lang}`)}
>
{t('magiclink.error.button')}
</Button>
</Stack>
</>
)}
</Stack>
</Container>
)
}
export default MagicLinkPage

View File

@@ -0,0 +1,220 @@
'use client'
import React, { startTransition, useState } from 'react'
import { FormProvider } from 'react-hook-form'
import { Progress, Card, Button, Stepper, Box, Flex, Text, Paper, Container, Portal } from "@pikku/mantine/core"
import { BasicInfo } from "@/components/jobs/form/BasicInfo"
import { TrainingCertificates } from "@/components/jobs/form/TrainingCertificates"
import { License } from "@/components/jobs/form/License"
import { GermanLanguage } from "@/components/jobs/form/GermanLanguage"
import { ProfessionalExperience } from "@/components/jobs/form/ProfessionalExperience"
import { ApplicationCandidateData } from '@heygermany/sdk'
import { useCandidateForm } from '@/hooks/candidate/useCandidateForm'
import { useGetCandidate } from '@/hooks/candidate/useGetCandidate'
import { pikku } from '@/pikku/http'
import { useQueryClient } from '@tanstack/react-query'
import { Done } from '@/components/jobs/form/Done'
import { Invalid } from '@/components/jobs/form/Invalid'
import { useI18n } from '@/context/i18n-provider'
import { ApplicantStatuses } from '@heygermany/sdk'
import { CandidateResult } from '@/components/jobs/CandidateResult'
import { useGetCandidateResults } from '@/hooks/useGetCandidateResult'
import { IconCheck } from '@tabler/icons-react'
import { HelpAffix } from '@/components/jobs/form/HelpAffix'
import { useSearchParams } from '@/framework/navigation'
import { asI18n } from '@pikku/react'
const Form: React.FunctionComponent<{ candidate: ApplicationCandidateData }> = ({ candidate }) => {
const t = useI18n()
const steps = [
{ title: t('jobs.apply.steps.basicinfo'), component: BasicInfo },
{ title: t('jobs.apply.steps.educationdocuments' as any), component: TrainingCertificates },
{ title: t('jobs.apply.steps.license'), component: License },
{ title: t('jobs.apply.steps.germanlanguage'), component: GermanLanguage },
{ title: t('jobs.apply.steps.professionalexperience'), component: ProfessionalExperience },
]
const [currentStep, setCurrentStep] = useState(0)
// Single form instance for the entire application
const form = useCandidateForm(candidate)
const progressPercentage = (currentStep / steps.length) * 100
const queryClient = useQueryClient()
// Return a Date (not an ISO string): the pikku fetch client serializes Date
// values over the wire, and the /candidate input type expects Date fields.
const serializeDate = (value: Date | string | null | undefined) => {
if (!value) return null
const date = value instanceof Date ? value : new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
const handleNext = async () => {
// Validate current form state before proceeding
const isValid = await form.trigger()
if (isValid) {
// Perform partial update - save current form data
const { education, licenses, languageCertificates, workExperience, ...formData } = form.getValues()
const currentData = {
...formData,
candidateId: candidate.candidateId,
dateOfBirth: serializeDate(formData.dateOfBirth),
}
if (currentStep < steps.length - 1) {
startTransition(() => {
setCurrentStep((step) => step + 1)
})
void pikku().patch('/candidate/:candidateId', currentData).catch((error: unknown) => {
console.error('Error saving application data:', error)
})
return
}
try {
// Final submission
;(window as any).fbq?.('track', 'Lead', {
content_name: 'HeyGermany application',
content_category: 'Application'
})
await pikku().patch('/candidate/:candidateId', {
...currentData,
submittedAt: new Date(),
})
} catch (error) {
console.error('Error saving application data:', error)
}
queryClient.invalidateQueries({ queryKey: ['candidate'] })
}
}
const handleBack = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1)
}
}
const renderStepContent = () => {
const currentStepConfig = steps[currentStep]
if (!currentStepConfig) return null
const StepComponent = currentStepConfig.component
return <StepComponent candidate={candidate} />
}
return (
<FormProvider {...form}>
{/* Mobile Progress Bar */}
<Box px="md" py="xs" w="100%" bg="white" pos="sticky" top="4rem" display={{ base: 'block', lg: 'none' }} style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}>
<Flex align="center" gap="xs" mb="xs">
<Text size="sm" fw={500}>{t('jobs.apply.stepprogress', { current: currentStep + 1, total: steps.length })}</Text>
</Flex>
<Progress value={progressPercentage} h="0.5rem" />
</Box>
<Container size='xl' display={{ base: 'none', lg: 'block' }}>
{/* Desktop Stepper */}
<Box my="xl">
<Stepper
active={currentStep}
onStepClick={setCurrentStep}
allowNextStepsSelect={false}
wrap={false}
>
{steps.map((step, index) => <Stepper.Step key={index} label={step.title} completedIcon={<IconCheck color='#eab308' size={18} />} />)}
</Stepper>
</Box>
</Container>
<Container size='lg' maw='4xl' w='100%'>
<Flex w="100%" direction="column" align="center" justify="center" p="lg">
{/* Render the current step once to avoid duplicate form controls across breakpoints */}
<Paper w="100%" shadow='xl' withBorder p={{ base: 'md', md: 'xl' }}>
{renderStepContent()}
</Paper>
{/* Navigation Buttons */}
<Flex justify="center" gap="md" mt="lg">
<Button
variant="outline"
onClick={handleBack}
disabled={form.formState.isSubmitting}
>
{t('jobs.apply.buttons.back')}
</Button>
<Button
onClick={handleNext}
disabled={form.formState.isSubmitting}
>
{currentStep === steps.length - 1 ? t('jobs.apply.buttons.submit') : t('jobs.apply.buttons.next')}
</Button>
</Flex>
</Flex>
</Container>
<HelpAffix />
</FormProvider >
)
}
const Result: React.FunctionComponent<{ candidateId: string }> = ({ candidateId }) => {
const { data: candidateResults, isLoading: resultLoading, error } = useGetCandidateResults(candidateId)
if (resultLoading) {
return null
}
if (error) {
return (
<Container size="lg" py="xl">
<Card shadow="sm" p="lg" radius="md" withBorder>
<Text size="xl" fw={700} mb="md" c="red">
{asI18n('Error Loading Qualification Results')}
</Text>
<Text size="md" c="dimmed">
{asI18n('We encountered an error while checking your qualifications. Please try again later or contact support if the issue persists.')}
</Text>
</Card>
</Container>
)
}
if (!candidateResults) {
return null
}
return <CandidateResult candidateResults={candidateResults} />
}
const ApplicationPage: React.FunctionComponent = () => {
const searchParams = useSearchParams()
const candidateIdFromParams = searchParams.get('candidateId')
const { data: candidate, isLoading: candidateLoading } = useGetCandidate(candidateIdFromParams || undefined)
if (candidateLoading || !candidate) {
return null
}
if (candidate.status === ApplicantStatuses.PENDING) {
return <Done name={candidate.name} />
}
if (candidate.status === ApplicantStatuses.INVALID) {
return <Invalid name={candidate.name} candidateId={candidate.candidateId} />
}
if (candidate.status === ApplicantStatuses.COMPLETE) {
return <Result candidateId={candidate.candidateId} />
}
return <div style={{ position: 'relative' }}>
<Form candidate={candidate} />
</div>
}
export default ApplicationPage

View File

@@ -0,0 +1,72 @@
'use client'
import { StartApplication } from '@/components/jobs/form/StartApplication'
import { useI18n } from '@/context/i18n-provider'
import { Container, Box, Stack, Title, Text, List, Grid } from "@pikku/mantine/core"
const ApplyPage: React.FunctionComponent = () => {
const t = useI18n()
return (
<Box component="section" py="xl" pos="relative" flex={1}>
<Container size="xl" pt="xl">
<Grid gutter="xl" align="stretch">
{/* Who is This For Section */}
<Grid.Col span={{ base: 12, md: 4 }}>
<Stack gap="xl">
<Box>
<Title order={2} mb="md">{t('jobs.apply.whoisthisfor.title')}</Title>
<Text size="sm">
{t('jobs.apply.whoisthisfor.description')}
</Text>
</Box>
<Box>
<Title order={2} mb="md">{t('jobs.apply.whatyoullget.title')}</Title>
<List size="sm" spacing="xs">
<List.Item>{t('jobs.apply.whatyoullget.item1')}</List.Item>
<List.Item>{t('jobs.apply.whatyoullget.item2')}</List.Item>
<List.Item>{t('jobs.apply.whatyoullget.item3')}</List.Item>
</List>
</Box>
<Box>
<Title order={2} mb="md">{t('jobs.apply.whatyoullneed.title')}</Title>
<List size="sm" spacing="xs">
<List.Item>{t('jobs.apply.whatyoullneed.item1')}</List.Item>
<List.Item>{t('jobs.apply.whatyoullneed.item2')}</List.Item>
<List.Item>
{t('jobs.apply.whatyoullneed.item3.title')}
<br />
{t('jobs.apply.whatyoullneed.item3.note')}
</List.Item>
<List.Item>
{t('jobs.apply.whatyoullneed.item4.title')}
<br />
{t('jobs.apply.whatyoullneed.item4.note')}
</List.Item>
</List>
</Box>
</Stack>
</Grid.Col>
{/* Get Your Results Section */}
<Grid.Col span={{ base: 12, md: 4 }}>
<Stack gap="lg">
<Box>
<Title order={2} mb="md">{t('jobs.apply.getyourresults.title')}</Title>
<Text size="sm" mt="md">
{t('jobs.apply.getyourresults.description')}
</Text>
</Box>
<StartApplication initializing={false} />
</Stack>
</Grid.Col>
</Grid>
</Container>
</Box>
)
}
export default ApplyPage

View File

@@ -0,0 +1,5 @@
import { DemoCandidateResultPage } from '@/components/jobs/DemoCandidateResultPage'
export default function DemoResultPage() {
return <DemoCandidateResultPage />
}

View File

@@ -0,0 +1,19 @@
import TopBar from '@/components/ui/TopBar'
import FooterBar from '@/components/ui/FooterBar'
import { Flex } from '@pikku/mantine/core'
interface MainLayoutProps {
children: React.ReactNode
}
export default function MainLayout({ children }: MainLayoutProps) {
return (
<Flex direction="column" mih="100vh">
<TopBar />
<Flex component="main" direction="column" flex="1">
{children}
</Flex>
<FooterBar />
</Flex>
)
}

View File

@@ -0,0 +1,5 @@
import { HomePage } from '@/components/home/HomePage'
export default function LocalizedHomePage() {
return <HomePage />
}

View File

@@ -0,0 +1,71 @@
'use client'
import { Container, Stack, Title, Text, Button } from '@pikku/mantine/core'
import { useI18n } from "@/context/i18n-provider"
import { useMemo } from 'react'
import { useSearchParams, useRouter, useParams } from '@/framework/navigation'
const VerifyEmailPage: React.FunctionComponent = () => {
const t = useI18n()
const searchParams = useSearchParams()
const router = useRouter()
const { lang } = useParams<{ lang: string }>()
const status = useMemo<'success' | 'error'>(() => {
const error = searchParams.get('error')
const verified = searchParams.get('verified')
if (error || verified !== '1') {
return 'error'
}
return 'success'
}, [searchParams])
return (
<Container size="sm" py="xl" my='auto'>
<Stack gap="xl" align="center">
{status === 'success' && (
<>
<Stack gap="md" align="center">
<Title order={1} ta="center">
{t('verifyemail.success.title')}
</Title>
<Text c="dimmed" ta="center">
{t('verifyemail.success.message')}
</Text>
<Button
size="lg"
mt="md"
onClick={() => router.push(`/${lang}`)}
>
{t('verifyemail.success.button')}
</Button>
</Stack>
</>
)}
{status === 'error' && (
<>
<Stack gap="md" align="center">
<Title order={1} ta="center">
{t('verifyemail.error.title')}
</Title>
<Text c="dimmed" ta="center">
{t('verifyemail.error.message')}
</Text>
<Button
size="lg"
mt="md"
onClick={() => router.push(`/${lang}`)}
>
{t('verifyemail.error.button')}
</Button>
</Stack>
</>
)}
</Stack>
</Container>
)
}
export default VerifyEmailPage

View File

@@ -0,0 +1,15 @@
a {
text-decoration: inherit;
}
/* Theme toggle button - swap Sun/Moon icons based on data-mantine-color-scheme attribute */
/* Used to avoid ssr hydration issues with icon rendering */
.theme-toggle .icon-sun,
.theme-toggle .icon-moon { display: inline-block; }
/* Default (light) shows Sun, hides Moon */
.theme-toggle .icon-moon { display: none; }
/* When Mantine flips the <html> attribute, CSS swaps icons */
[data-mantine-color-scheme='dark'] .theme-toggle .icon-sun { display: none; }
[data-mantine-color-scheme='dark'] .theme-toggle .icon-moon { display: inline-block; }

View File

@@ -0,0 +1,26 @@
# Imprint
Information according to § 5 TMG (German Telemedia Act):
HeyGermany UG (haftungsbeschränkt)
Postal Address: Am Neuen Markt 9/E-F, 14467 Potsdam
Contact:
Email: info@hey-germany.com
Represented by:
Katharina Schultz-Ebert
Responsible for content according to § 55 Abs. 2 RStV: Katharina Schultz-Ebert, Rheinstr. 9, 12159 Berlin
We are neither willing nor obliged to participate in dispute resolution proceedings before a consumer arbitration board. EU Commissions Online Dispute Resolution platform: [Click here](https://consumer-redress.ec.europa.eu/index_en)
**Liability for Contents**
As a service provider, we are responsible for our own content on these pages in accordance with § 7 para. 1 TMG (German Telemedia Act) and general laws. According to §§ 8 to 10 TMG, however, we are not obliged to monitor transmitted or stored third-party information or to investigate circumstances that indicate illegal activity. Obligations to remove or block the use of information under general laws remain unaffected. Liability in this respect is, however, only possible from the time we become aware of a specific infringement. Upon notification of such violations, we will remove the content immediately.
**Liability for Links**
Our website contains links to external websites of third parties, over whose content we have no influence. Therefore, we cannot accept any liability for these external contents. The respective provider or operator of the linked pages is always responsible for their content. The linked pages were checked for possible legal violations at the time of linking. No illegal content was identifiable at the time of linking. A permanent monitoring of the content of the linked pages is not reasonable without concrete evidence of a violation. If we become aware of any legal infringements, we will remove such links immediately.
**Copyright**
The contents and works created by the site operators on these pages are subject to German copyright law. Any duplication, processing, distribution, or commercialization of such material beyond the scope of copyright law requires the prior written consent of the respective author or creator. Downloads and copies of this site are only permitted for private, non-commercial use. Insofar as the content on this site was not created by the operator, the copyrights of third parties are respected. In particular, third-party content is identified as such. Should you nevertheless become aware of a copyright infringement, please notify us. Upon becoming aware of such violations, we will remove the content immediately.

View File

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

View File

@@ -0,0 +1,18 @@
import TopBar from '@/components/ui/TopBar'
import FooterBar from '@/components/ui/FooterBar'
import { Flex } from '@pikku/mantine/core'
import { I18nProvider } from '@/context/i18n-provider'
export default function LegalLayout({ children }: { children: React.ReactNode }) {
return (
<I18nProvider lang="en">
<Flex direction="column" mih="100vh">
<TopBar />
<Flex component="main" direction="column" flex="1">
{children}
</Flex>
<FooterBar />
</Flex>
</I18nProvider>
)
}

View File

@@ -0,0 +1,129 @@
# Privacy Policy
Welcome to **HeyGermany** (“we,” “us,” or “our”).
We are committed to protecting your personal data and respecting your privacy in full compliance with the **General Data Protection Regulation (GDPR)** and applicable German data protection laws.
HeyGermany provides a digital service that analyzes educational and professional documents — such as diplomas, transcripts of records, licenses, and language certificates — to assess your qualification level and recognition chances in Germany. If you choose to join our Talent Pool, we also help connect you with potential employers through anonymized professional profiles.
Your trust and data security are extremely important to us. This Privacy Policy explains how we collect, use, store, and share your information and what rights you have.
----------
**Data Controller**
HeyGermany
Katharina Schultz-Ebert
Rheinstr. 9
12159 Berlin
Germany
Email: info@hey-germany.de
----------
**What Data We Collect**
We collect and process the following categories of data when you use our platform:
**a. Personal Identification Data**
Name, email address, country of residency and country of education
**b. Educational and Professional Documents**
Diplomas, transcripts of records, professional licenses, language certificates, and other qualification-related documents you upload
**c. Analytical and Assessment Data**
Information derived from our analysis, such as German reference profession, qualification level, recognition potential
**d. Optional Talent Pool Data**
If you voluntarily join our Talent Pool, we create an anonymized professional profile based on your qualifications (e.g., degree level, country of qualification, years of experience, and language proficiency).
We never share data that can personally identify you (such as name, email, or contact details) with employers.
**e. Technical Usage Data**
IP address, device type, browser information, and general usage statistics (collected via cookies and analytics tools)
----------
**Purpose and Legal Basis for Processing**
We process your data for the following purposes and based on the legal grounds set out in Article 6 of the GDPR:
| **Purpose** | **Legal Basis** |
|-------------|-----------------|
| To analyze your educational and professional documents and provide you with a qualification assessment | Art. 6(1)(b) Performance of a contract |
| To inform you about your recognition chances in Germany | Art. 6(1)(b) Performance of a contract |
| To improve and develop our algorithms and platform (using anonymized data only) | Art. 6(1)(f) Legitimate interests |
| To create and share anonymized professional profiles with potential employers (if you join the Talent Pool) | Art. 6(1)(a) Consent |
| To comply with legal obligations (e.g., data security, record keeping) | Art. 6(1)(c) Legal obligation |
----------
**Data Retention**
We only store your personal data as long as necessary for the purposes described above or as required by law.
- Uploaded documents and related analysis results are stored for 2 years after your last activity or until you delete your account.
- If you join the Talent Pool, your anonymized profile will remain active until you withdraw your consent or delete your profile.
- After this period, all personal data will be securely deleted or permanently anonymized.
You may request deletion of your data at any time by contacting info@hey-germany.de.
----------
**Data Sharing and Disclosure**
We do not sell or commercially share your personal data.
We may share your information with:
- Trusted service providers (e.g., secure cloud storage, data processors) who operate under strict data protection agreements.
- Potential employers, but only in anonymized form and only if you have explicitly consented to join the Talent Pool.
No personal information that could identify you (such as name, contact details, or document copies) will ever be shared with employers or third parties without your consent.
If we transfer data outside the EU/EEA (for example, through cloud hosting providers), we ensure compliance with GDPR safeguards, such as Standard Contractual Clauses or adequacy decisions.
----------
**Your Rights Under GDPR**
You have the following rights regarding your personal data:
- **Access:** Request a copy of the personal data we hold about you.
- **Rectification:** Request correction of inaccurate or incomplete data.
- **Erasure:** Request deletion of your personal data (“right to be forgotten”).
- **Restriction:** Request limitation of how your data is processed.
- **Portability:** Request a copy of your data in a structured, commonly used format.
- **Objection:** Object to processing based on legitimate interests.
- **Withdraw Consent:** Withdraw your consent at any time (e.g., leaving the Talent Pool).
To exercise your rights, contact us at info@hey-germany.de. We will respond within 14 days.
----------
**Data Security**
We apply industry-standard technical and organizational security measures to protect your data, including:
- End-to-end encryption during upload and storage
- Secure cloud infrastructure hosted in the EU
- Access control and role-based permissions for staff
- Regular system monitoring and data backups
- Continuous security and compliance audits
Your data is treated with strict confidentiality and stored in compliance with European data protection standards.
----------
**Cookies and Analytics**
HeyGermany uses cookies and similar technologies to ensure platform functionality and improve user experience.
Essential cookies are required for the platform to operate. Analytical cookies are only used with your consent.
You can manage or withdraw your cookie consent in your browser settings at any time.
----------
**Changes to This Policy**
We may update this Privacy Policy occasionally to reflect legal, technical, or business developments. The latest version will always be available at www.hey-germany.com/privacy-policy
If significant changes occur, we will notify you by email or within your user account.
----------
**Contact Us**
If you have any questions or concerns about this Privacy Policy or your personal data, please contact our data protection team:
HeyGermany
Katharina Schultz-Ebert
Rheinstr. 9
12159 Berlin
Germany
Email: info@hey-germany.de

View File

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

View File

@@ -0,0 +1,38 @@
# Terms & Conditions
General Terms and Conditions for the Use of Services Accessible via the Platform **[www.hey-germany.com](http://www.hey-germany.com)**
**Services**
HeyGermany operates the platform [www.hey-germany.com](http://www.hey-germany.com), which connects providers and seekers in the healthcare sector. Users have the opportunity to upload content to the platform and/or access third-party content.
Our Qualification Recognition Analysis is provided free of charge and without any legally binding effect. We do not guarantee the completeness, accuracy, or legal validity of the analysis provided. The results are intended for informational purposes only and cannot replace the official recognition process with the competent authorities.
HeyGermany facilitates, among other things, job placements in healthcare institutions (hereinafter: "Clients") that are seeking candidates. This is done in the form of brokerage services by initiating employment contracts between candidates and Clients in Germany.
Users may choose to express interest in placement by joining our Talent Pool. Joining the Talent Pool is voluntary and can be revoked at any time. Access to the Talent Pool and the related placement services provided by HeyGermany are free of charge.
After a Client places a request, HeyGermany proposes candidates whose profiles match the Clients requirements. HeyGermany does not guarantee a successful placement. Clients remain free to decide whether or not to engage with a candidate proposed by HeyGermany. Similarly, candidates are not obliged to accept offers from Clients. If a Client selects a candidate, HeyGermany will contact the candidate via email.
Before the candidate begins an assignment, an employment contract will be concluded between the Client and the candidate. HeyGermany is not a party to this contract.
----------
**User Responsibilities**
- You agree to provide true and accurate information.
- You remain responsible for the documents you upload.
- You must ensure that you have the right to use and share the documents you submit.
----------
**Data Processing**
Uploaded documents and personal data are processed exclusively in accordance with our Privacy Policy. By using our service, you consent to such processing.
----------
**Limitation of Liability**
We are not liable for any damages resulting from the use of our services, unless caused by intentional or grossly negligent behavior.
Despite careful review, HeyGermany assumes no liability for the content of external websites linked or embedded on our platform. Responsibility for the content and availability of such external websites lies solely with their operators.
----------
**Changes to the Terms**
We reserve the right to update or modify these Terms & Conditions at any time. Changes will be published on this page.

View File

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