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 08:26:01 +02:00
commit 9c39da4d3f
398 changed files with 38331 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>
)
}