chore: heygermany customer project
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
302
apps/website/views/[lang]/(app)/backoffice/candidates/page.tsx
Normal file
302
apps/website/views/[lang]/(app)/backoffice/candidates/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
84
apps/website/views/[lang]/(app)/backoffice/layout.tsx
Normal file
84
apps/website/views/[lang]/(app)/backoffice/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
apps/website/views/[lang]/(app)/backoffice/page.tsx
Normal file
13
apps/website/views/[lang]/(app)/backoffice/page.tsx
Normal 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
|
||||
}
|
||||
200
apps/website/views/[lang]/(app)/backoffice/translations/page.tsx
Normal file
200
apps/website/views/[lang]/(app)/backoffice/translations/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user