chore: heygermany customer project
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import { LoginComponent } from '@/components/LoginComponent';
|
||||
import { useBackOfficeLogin } from '@/hooks/backoffice/useBackOfficeLogin';
|
||||
|
||||
export default function BackOfficeLogin() {
|
||||
const backofficeLogin = useBackOfficeLogin();
|
||||
|
||||
return (
|
||||
<LoginComponent
|
||||
namespace="backoffice"
|
||||
mutation={backofficeLogin}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Container, Title, Text, Paper, Stack, Loader, Alert, Grid, Group, Flex, Card, Button, Switch, ActionIcon } from '@pikku/mantine/core';
|
||||
import { IconExternalLink, IconEye } from '@tabler/icons-react';
|
||||
import { EnumSelect } from '@/components/common/EnumSelect';
|
||||
import dayjs from 'dayjs';
|
||||
import { useParams } from '@tanstack/react-router';
|
||||
import { useGetBackOfficeCandidate } from '@/hooks/backoffice/useGetBackOfficeCandidate';
|
||||
import { useUpdateBackOfficeCandidate } from '@/hooks/backoffice/useUpdateBackOfficeCandidate';
|
||||
import { useGetBackOfficeCandidateResult } from '@/hooks/backoffice/useGetBackOfficeCandidateResult';
|
||||
import { EducationVerificationCard } from '@/components/backoffice/EducationVerificationCard';
|
||||
import { LicenseVerificationCard } from '@/components/backoffice/LicenseVerificationCard';
|
||||
import { LanguageVerificationCard } from '@/components/backoffice/LanguageVerificationCard';
|
||||
import { ExperienceVerificationCard } from '@/components/backoffice/ExperienceVerificationCard';
|
||||
import type { DB } from '@heygermany/sdk';
|
||||
import {
|
||||
NurseRecognitionGermanyValues,
|
||||
NurseRecognitionHomeCountryValues,
|
||||
RecognitionLikelihoodValues,
|
||||
} from '@heygermany/sdk';
|
||||
import { CandidateResult } from '@/components/jobs/CandidateResult';
|
||||
import { QualificationAssessment } from '@/components/backoffice/QualificationAssessment';
|
||||
import { BooleanSelectComponent } from '@/components/backoffice/DocumentFieldFactory';
|
||||
import { useGetCandidateResults } from '@/hooks/useGetCandidateResult';
|
||||
import { ApplicantStatusSelect } from '@/components/backoffice/ApplicantStatusSelect';
|
||||
import { useGetCountries } from '@/hooks/useGetCountries';
|
||||
import { LifecycleNotificationsCard } from '@/components/backoffice/LifecycleNotificationsCard';
|
||||
import { asI18n } from '@pikku/react';
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
|
||||
const CandidateResultPreview: React.FunctionComponent<{ candidateId: string }> = ({ candidateId }) => {
|
||||
const { data: candidateResults } = useGetCandidateResults(candidateId)
|
||||
if (!candidateResults) return null
|
||||
return <CandidateResult candidateResults={candidateResults} />
|
||||
}
|
||||
|
||||
export default function BackOfficeCandidateDetailPage() {
|
||||
const params = useParams({ strict: false });
|
||||
const candidateId = params.candidateId as string;
|
||||
const t = useI18n();
|
||||
|
||||
const { data: candidate, isLoading, error } = useGetBackOfficeCandidate(candidateId);
|
||||
const { data: candidateResult } = useGetBackOfficeCandidateResult(candidateId);
|
||||
const updateCandidateMutation = useUpdateBackOfficeCandidate(candidateId);
|
||||
const { getCountryNameFromAbbreviation } = useGetCountries();
|
||||
const [showResultsDrawer, setShowResultsDrawer] = useState(false);
|
||||
const [verbose, setVerbose] = useState(false);
|
||||
const [analysisIssuesDraft, setAnalysisIssuesDraft] = useState('');
|
||||
const notAvailable = t('common.na');
|
||||
const yesNo = (value: boolean | null | undefined) => {
|
||||
if (value === true) return t('common.yes');
|
||||
if (value === false) return t('common.no');
|
||||
return notAvailable;
|
||||
};
|
||||
|
||||
const currentAnalysisIssues = Array.isArray(candidate?.lifecycleNotifications?.analysis?.issues)
|
||||
? candidate.lifecycleNotifications.analysis.issues.filter((issue: any): issue is string => typeof issue === 'string')
|
||||
: [];
|
||||
const currentAnalysisIssuesDraft = currentAnalysisIssues.join('\n');
|
||||
|
||||
useEffect(() => {
|
||||
setAnalysisIssuesDraft(currentAnalysisIssuesDraft);
|
||||
}, [candidateId, currentAnalysisIssuesDraft]);
|
||||
|
||||
const saveAnalysisIssues = () => {
|
||||
const analysisIssues = analysisIssuesDraft
|
||||
.split('\n')
|
||||
.map((issue: string) => issue.trim())
|
||||
.filter((issue: string) => issue.length > 0)
|
||||
|
||||
updateCandidateMutation.mutate({ analysisIssues })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Flex justify="center" align="center" style={{ minHeight: 'calc(100vh - 60px)' }}>
|
||||
<Loader size="xl" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !candidate) {
|
||||
return (
|
||||
<Container size="xl">
|
||||
<Alert color="red" title={t('common.error')}>
|
||||
{t('backoffice.candidate.error.notfound')}
|
||||
</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex>
|
||||
<Container>
|
||||
<Stack>
|
||||
<Group justify="space-between" align="center" mb="lg">
|
||||
<Title order={1}>
|
||||
{asI18n(candidate.name)}
|
||||
</Title>
|
||||
<Group gap="sm">
|
||||
<ApplicantStatusSelect
|
||||
value={candidate.status}
|
||||
onChange={(status) => {
|
||||
updateCandidateMutation.mutate({ status: status! })
|
||||
}}
|
||||
isValid={candidateResult?.isValid ?? false}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={500}>{t('backoffice.candidate.verbose')}</Text>
|
||||
<Switch
|
||||
checked={verbose}
|
||||
onChange={(event) => setVerbose(event.currentTarget.checked)}
|
||||
/>
|
||||
</Group>
|
||||
{candidateResult && (
|
||||
<Text
|
||||
size="sm"
|
||||
c={candidateResult.isValid ? 'success' : 'error'}
|
||||
fw={500}
|
||||
>
|
||||
{candidateResult.isValid ? t('backoffice.candidate.result.valid') : t('backoffice.candidate.result.invalid')}
|
||||
</Text>
|
||||
)}
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="light"
|
||||
disabled={!candidateResult?.isValid}
|
||||
onClick={() => setShowResultsDrawer(!showResultsDrawer)}
|
||||
title={t('backoffice.candidate.actions.showresultspanel')}
|
||||
>
|
||||
<IconEye size={18} />
|
||||
</ActionIcon>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="filled"
|
||||
disabled={!candidateResult?.isValid}
|
||||
onClick={() => window.open(`/jobs/application?candidateId=${candidateId}`, '_blank')}
|
||||
leftSection={<IconExternalLink size={16} />}
|
||||
>
|
||||
{t('backoffice.candidate.actions.resultpage')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
disabled={!candidateResult?.isValid}
|
||||
onClick={() => window.open(`/company/candidate/${candidateId}`, '_blank')}
|
||||
leftSection={<IconExternalLink size={16} />}
|
||||
>
|
||||
{t('backoffice.candidate.actions.companyview')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Qualification Result Summary */}
|
||||
<QualificationAssessment candidateResult={candidateResult} />
|
||||
|
||||
{/* Manual Assessment Section */}
|
||||
<Title order={3} mb="md">{t('backoffice.candidate.manualassessment')}</Title>
|
||||
<Card shadow="sm" padding="lg" radius="md" withBorder mb="md">
|
||||
<Grid gutter="md">
|
||||
<Grid.Col span={6}>
|
||||
<EnumSelect
|
||||
enumObject={NurseRecognitionHomeCountryValues}
|
||||
enumName="NurseRecognitionHomeCountry"
|
||||
label={t('backoffice.candidate.fields.statushomecountry')}
|
||||
placeholder={t('backoffice.candidate.fields.selectstatus')}
|
||||
value={candidate.qualificationStatusHomeCountry || null}
|
||||
onChange={(qualificationStatusHomeCountry) =>
|
||||
updateCandidateMutation.mutate({
|
||||
qualificationStatusHomeCountry: qualificationStatusHomeCountry! as DB.NurseRecognitionHomeCountry
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<EnumSelect
|
||||
enumObject={NurseRecognitionGermanyValues}
|
||||
enumName="NurseRecognitionGermany"
|
||||
label={t('backoffice.candidate.fields.statusgermany')}
|
||||
placeholder={t('backoffice.candidate.fields.selectstatus')}
|
||||
value={candidate.qualificationStatusGermany || null}
|
||||
onChange={(qualificationStatusGermany) =>
|
||||
updateCandidateMutation.mutate({
|
||||
qualificationStatusGermany: qualificationStatusGermany! as DB.NurseRecognitionGermany
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<BooleanSelectComponent
|
||||
label={t('backoffice.candidate.fields.licensemandatory')}
|
||||
value={candidate.licenseMandatory}
|
||||
onChange={(licenseMandatory) =>
|
||||
updateCandidateMutation.mutate({ licenseMandatory })}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<BooleanSelectComponent
|
||||
label={t('backoffice.candidate.fields.durationrequirementsmet')}
|
||||
value={candidate.durationRequirementsMet}
|
||||
onChange={(durationRequirementsMet) =>
|
||||
updateCandidateMutation.mutate({ durationRequirementsMet })}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<EnumSelect
|
||||
enumObject={RecognitionLikelihoodValues}
|
||||
enumName="RecognitionLikelihood"
|
||||
label={t('backoffice.candidate.fields.recognitionlikelihood')}
|
||||
placeholder={t('backoffice.candidate.fields.selectlikelihood')}
|
||||
value={candidate.recognitionLikelihood || null}
|
||||
onChange={(recognitionLikelihood) =>
|
||||
updateCandidateMutation.mutate({
|
||||
recognitionLikelihood: recognitionLikelihood! as DB.RecognitionLikelihood
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Card>
|
||||
<Title order={2} mb="md">{t('backoffice.candidate.basicinfo')}</Title>
|
||||
|
||||
<Card shadow="sm" padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} size="sm">{t('backoffice.candidate.fields.email')}</Text>
|
||||
<Text size="sm">{candidate.email ? asI18n(candidate.email) : notAvailable}</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} size="sm">{t('backoffice.candidate.fields.currentresidence')}</Text>
|
||||
<Text size="sm">{asI18n(getCountryNameFromAbbreviation(candidate.currentResidence))}</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} size="sm">{t('backoffice.candidate.fields.countryofeducation')}</Text>
|
||||
<Text size="sm">{asI18n(getCountryNameFromAbbreviation(candidate.countryEducation))}</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} size="sm">{t('backoffice.candidate.fields.eupassport')}</Text>
|
||||
<Text size="sm">{yesNo(candidate.euPassport)}</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} size="sm">{t('backoffice.candidate.fields.dateofbirthage')}</Text>
|
||||
<Text size="sm">
|
||||
{candidate.dateOfBirth ? (
|
||||
<>
|
||||
{dayjs(candidate.dateOfBirth).format('YYYY-MM-DD')}
|
||||
{` (${dayjs().diff(dayjs(candidate.dateOfBirth), 'year')} ${t('backoffice.candidate.fields.years')})`}
|
||||
</>
|
||||
) : notAvailable}
|
||||
</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} size="sm">{t('backoffice.candidate.fields.livingingermany')}</Text>
|
||||
<Text size="sm">{yesNo(candidate.livingInGermany)}</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} size="sm">{t('backoffice.candidate.fields.jointalentpool')}</Text>
|
||||
<Text size="sm">{yesNo(candidate.joinTalentPool)}</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} size="sm">{t('backoffice.candidate.fields.worklocation')}</Text>
|
||||
<Text size="sm">{asI18n(candidate.stateWorkPreference !== null ? candidate.stateWorkPreference.join(', ') : notAvailable)}</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} size="sm">{t('backoffice.candidate.fields.submittedat')}</Text>
|
||||
<Text size="sm">{candidate.submittedAt ? asI18n(new Date(candidate.submittedAt).toLocaleDateString()) : notAvailable}</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Stack gap="xl">
|
||||
{/* Education Section */}
|
||||
<Paper my="md">
|
||||
<Title order={2} mb="md">{t('enums.candidatedocumenttype.education')}</Title>
|
||||
<Stack gap="md">
|
||||
<EducationVerificationCard
|
||||
candidateId={candidateId}
|
||||
education={candidate.analysis?.education || {}}
|
||||
documents={candidate.documents?.education || []}
|
||||
verbose={verbose}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* License Section */}
|
||||
<Paper my="md">
|
||||
<Title order={2} mb="md">{t('enums.candidatedocumenttype.license')}</Title>
|
||||
<Stack gap="md">
|
||||
<LicenseVerificationCard
|
||||
candidateId={candidateId}
|
||||
license={candidate.analysis?.license || {}}
|
||||
documents={candidate.documents?.license || []}
|
||||
verbose={verbose}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Language Section */}
|
||||
<Paper my="md">
|
||||
<Title order={2} mb="md">{t('enums.candidatedocumenttype.language')}</Title>
|
||||
<Stack gap="md">
|
||||
<LanguageVerificationCard
|
||||
candidateId={candidateId}
|
||||
language={candidate.analysis?.language || {}}
|
||||
documents={candidate.documents?.language || []}
|
||||
verbose={verbose}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Experience Section */}
|
||||
<Paper my="md">
|
||||
<Title order={2} mb="md">{t('enums.candidatedocumenttype.work_experience')}</Title>
|
||||
<Stack gap="md">
|
||||
<ExperienceVerificationCard
|
||||
candidateId={candidateId}
|
||||
experience={candidate.analysis?.experience || {}}
|
||||
documents={candidate.documents?.experience || []}
|
||||
verbose={verbose}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<LifecycleNotificationsCard
|
||||
lifecycleNotifications={candidate.lifecycleNotifications}
|
||||
analysisIssuesDraft={analysisIssuesDraft}
|
||||
onAnalysisIssuesDraftChange={setAnalysisIssuesDraft}
|
||||
onSaveAnalysisIssues={saveAnalysisIssues}
|
||||
isSaving={updateCandidateMutation.isPending}
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
|
||||
{/* Results Drawer */}
|
||||
{showResultsDrawer && <Paper ml='lg' withBorder><Stack>
|
||||
<CandidateResultPreview candidateId={candidateId} />
|
||||
</Stack></Paper>}
|
||||
</Flex>
|
||||
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
300
apps/website/views/localized/app/backoffice/candidates/page.tsx
Normal file
300
apps/website/views/localized/app/backoffice/candidates/page.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { Container, Title, Text, TextInput, ActionIcon, Group, Button, Badge, Select } from '@pikku/mantine/core';
|
||||
import { DataTable, type DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { IconSearch, IconX } from '@tabler/icons-react';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import dayjs from 'dayjs';
|
||||
import { useGetBackOfficeCandidates } from '@/hooks/backoffice/useGetBackOfficeCandidates';
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { pikku } from '@/pikku/http';
|
||||
import { CountriesMap } from '@heygermany/sdk';
|
||||
import { CountrySearch } from '@/components/jobs/form/CountrySearch';
|
||||
import { LanguageLevelSelectComponent, NurseRecognitionGermanySelectComponent, ApplicantStatusSelectComponent } from '@/components/backoffice/DocumentFieldFactory';
|
||||
|
||||
export default function BackOfficeCandidates() {
|
||||
const { isLoading, data: candidates } = useGetBackOfficeCandidates()
|
||||
const t = useI18n();
|
||||
const notAvailable = t('common.na');
|
||||
|
||||
// Fetch countries map
|
||||
const { data: countriesMap = {} } = useQuery({
|
||||
queryKey: ['countries', t.locale],
|
||||
queryFn: async (): Promise<CountriesMap> => {
|
||||
return await pikku().get('/countries', { locale: t.locale })
|
||||
},
|
||||
staleTime: 60 * 60 * 1000, // 1 hour
|
||||
});
|
||||
|
||||
// Filter states
|
||||
const [nameQuery, setNameQuery] = useState('');
|
||||
const [debouncedNameQuery] = useDebouncedValue(nameQuery, 200);
|
||||
const [countryFilter, setCountryFilter] = useState('');
|
||||
const [germanLevelFilter, setGermanLevelFilter] = useState<string | null>(null);
|
||||
const [recognitionStatusFilter, setRecognitionStatusFilter] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [reuploadedAfterInvalidFilter, setReuploadedAfterInvalidFilter] = useState<string | null>(null);
|
||||
|
||||
// Sort state
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<any>>({
|
||||
columnAccessor: 'createdAt',
|
||||
direction: 'desc',
|
||||
});
|
||||
|
||||
// Records state
|
||||
const [records, setRecords] = useState<any[]>([]);
|
||||
|
||||
const hasReuploadedAfterInvalid = (candidate: any) => {
|
||||
if (!candidate.reuploadedAt || !candidate.invalidatedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new Date(candidate.reuploadedAt).getTime() > new Date(candidate.invalidatedAt).getTime();
|
||||
};
|
||||
|
||||
// Apply filters
|
||||
useEffect(() => {
|
||||
if (!candidates?.data) {
|
||||
setRecords([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = candidates.data.filter((candidate: any) => {
|
||||
// Name filter
|
||||
if (debouncedNameQuery !== '' &&
|
||||
!candidate.name?.toLowerCase().includes(debouncedNameQuery.trim().toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Country filter
|
||||
if (countryFilter && candidate.countryEducation !== countryFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// German level filter
|
||||
if (germanLevelFilter && candidate.languageSelfAssessmentLevel !== germanLevelFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Recognition status filter
|
||||
if (recognitionStatusFilter && candidate.qualificationStatusGermany !== recognitionStatusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (statusFilter && candidate.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (reuploadedAfterInvalidFilter === 'yes' && !hasReuploadedAfterInvalid(candidate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (reuploadedAfterInvalidFilter === 'no' && hasReuploadedAfterInvalid(candidate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
setRecords(filtered);
|
||||
}, [candidates?.data, debouncedNameQuery, countryFilter, germanLevelFilter, recognitionStatusFilter, statusFilter, reuploadedAfterInvalidFilter]);
|
||||
|
||||
// Apply sorting
|
||||
useEffect(() => {
|
||||
if (records.length === 0) return;
|
||||
|
||||
let sortedData = [...records];
|
||||
|
||||
if (sortStatus.columnAccessor === 'createdAt') {
|
||||
sortedData = sortBy(sortedData, (item) => item.createdAt ? new Date(item.createdAt).getTime() : 0);
|
||||
} else if (sortStatus.columnAccessor === 'fullName') {
|
||||
sortedData = sortBy(sortedData, (item) => item.name || '');
|
||||
} else if (sortStatus.columnAccessor === 'countryEducation') {
|
||||
sortedData = sortBy(sortedData, (item) => item.countryEducation || '');
|
||||
} else if (sortStatus.columnAccessor === 'germanLevel') {
|
||||
sortedData = sortBy(sortedData, (item) => item.languageSelfAssessmentLevel || '');
|
||||
} else if (sortStatus.columnAccessor === 'recognitionStatus') {
|
||||
sortedData = sortBy(sortedData, (item) => item.qualificationStatusGermany || '');
|
||||
} else if (sortStatus.columnAccessor === 'status') {
|
||||
sortedData = sortBy(sortedData, (item) => item.status || '');
|
||||
}
|
||||
|
||||
setRecords(sortStatus.direction === 'desc' ? sortedData.reverse() : sortedData);
|
||||
}, [sortStatus]);
|
||||
|
||||
const clearFilters = () => {
|
||||
setNameQuery('');
|
||||
setCountryFilter('');
|
||||
setGermanLevelFilter(null);
|
||||
setRecognitionStatusFilter(null);
|
||||
setStatusFilter(null);
|
||||
setReuploadedAfterInvalidFilter(null);
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
nameQuery !== '' ||
|
||||
countryFilter !== '' ||
|
||||
germanLevelFilter !== null ||
|
||||
recognitionStatusFilter !== null ||
|
||||
statusFilter !== null ||
|
||||
reuploadedAfterInvalidFilter !== null;
|
||||
|
||||
return (
|
||||
<Container size="xl">
|
||||
<Title order={1}>
|
||||
{t('backoffice.candidates.title')}
|
||||
</Title>
|
||||
<Group justify="space-between" mt="sm" mb="lg">
|
||||
<Text size="lg" c="dimmed">
|
||||
{t('backoffice.candidates.description')}
|
||||
</Text>
|
||||
<Group gap="md">
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{hasActiveFilters
|
||||
? t('backoffice.candidates.count.filtered', {
|
||||
filtered: records.length,
|
||||
total: candidates?.total || 0,
|
||||
})
|
||||
: t('backoffice.candidates.count.total', {
|
||||
total: candidates?.total || 0,
|
||||
})}
|
||||
</Text>
|
||||
{hasActiveFilters && (
|
||||
<Button variant="light" onClick={clearFilters}>
|
||||
{t('backoffice.candidates.clearfilters')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<DataTable
|
||||
withTableBorder
|
||||
borderRadius="md"
|
||||
striped
|
||||
highlightOnHover
|
||||
height='calc(100vh - 220px)'
|
||||
minHeight={200}
|
||||
fetching={isLoading}
|
||||
records={records}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
onRowClick={({ record }) =>
|
||||
window.open(`/backoffice/candidate/${record.candidateId}`, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
accessor: 'fullName',
|
||||
title: t('backoffice.candidates.columns.fullname'),
|
||||
sortable: true,
|
||||
render: (candidate) => candidate.name,
|
||||
filter: (
|
||||
<TextInput
|
||||
label={t('backoffice.candidates.filters.name.label')}
|
||||
description={t('backoffice.candidates.filters.name.description')}
|
||||
placeholder={t('backoffice.candidates.filters.name.placeholder')}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
rightSection={
|
||||
<ActionIcon size="sm" variant="transparent" c="dimmed" onClick={() => setNameQuery('')}>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
}
|
||||
value={nameQuery}
|
||||
onChange={(e) => setNameQuery(e.currentTarget.value)}
|
||||
/>
|
||||
),
|
||||
filtering: nameQuery !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'countryEducation',
|
||||
title: t('backoffice.candidates.columns.countryofeducation'),
|
||||
sortable: true,
|
||||
render: (candidate) => candidate.countryEducation ? (countriesMap[candidate.countryEducation] || candidate.countryEducation) : notAvailable,
|
||||
filter: (
|
||||
<CountrySearch
|
||||
label={t('backoffice.candidates.filters.country.label')}
|
||||
placeholder={t('backoffice.candidates.filters.country.placeholder')}
|
||||
value={countryFilter}
|
||||
onChange={setCountryFilter}
|
||||
/>
|
||||
),
|
||||
filtering: countryFilter !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'germanLevel',
|
||||
title: t('backoffice.candidates.columns.germanlevel'),
|
||||
sortable: true,
|
||||
render: (candidate) => candidate.languageSelfAssessmentLevel ? t(`enums.languagelevel.${candidate.languageSelfAssessmentLevel.toLowerCase()}` as any) : notAvailable,
|
||||
filter: (
|
||||
<LanguageLevelSelectComponent
|
||||
label={t('backoffice.candidates.filters.germanlevel.label')}
|
||||
placeholder={t('backoffice.candidates.filters.germanlevel.placeholder')}
|
||||
value={germanLevelFilter}
|
||||
onChange={setGermanLevelFilter}
|
||||
/>
|
||||
),
|
||||
filtering: germanLevelFilter !== null,
|
||||
},
|
||||
{
|
||||
accessor: 'recognitionStatus',
|
||||
title: t('backoffice.candidates.columns.recognitionstatus'),
|
||||
sortable: true,
|
||||
render: (candidate) => candidate.qualificationStatusGermany ? t(`enums.nurserecognitiongermany.${candidate.qualificationStatusGermany.toLowerCase()}` as any) : notAvailable,
|
||||
filter: (
|
||||
<NurseRecognitionGermanySelectComponent
|
||||
label={t('backoffice.candidates.filters.recognitionstatus.label')}
|
||||
placeholder={t('backoffice.candidates.filters.recognitionstatus.placeholder')}
|
||||
value={recognitionStatusFilter}
|
||||
onChange={setRecognitionStatusFilter}
|
||||
/>
|
||||
),
|
||||
filtering: recognitionStatusFilter !== null,
|
||||
},
|
||||
{
|
||||
accessor: 'createdAt',
|
||||
title: t('backoffice.candidates.columns.creationdate'),
|
||||
sortable: true,
|
||||
render: (candidate) => dayjs(candidate.createdAt).format('YYYY-MM-DD')
|
||||
},
|
||||
{
|
||||
accessor: 'status',
|
||||
title: t('backoffice.candidates.columns.status'),
|
||||
sortable: true,
|
||||
render: (candidate) => candidate.status ? t(`enums.applicantstatus.${candidate.status.toLowerCase()}` as any) : notAvailable,
|
||||
filter: (
|
||||
<ApplicantStatusSelectComponent
|
||||
label={t('backoffice.candidates.filters.status.label')}
|
||||
placeholder={t('backoffice.candidates.filters.status.placeholder')}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
/>
|
||||
),
|
||||
filtering: statusFilter !== null,
|
||||
},
|
||||
{
|
||||
accessor: 'reuploadedAfterInvalid',
|
||||
title: t('backoffice.candidates.columns.reuploaded'),
|
||||
render: (candidate) => hasReuploadedAfterInvalid(candidate)
|
||||
? <Badge color="yellow" variant="light">{t('backoffice.candidates.reuploaded.badge')}</Badge>
|
||||
: <Text size="sm" c="dimmed">{t('common.no')}</Text>,
|
||||
filter: (
|
||||
<Select
|
||||
label={t('backoffice.candidates.filters.reuploaded.label')}
|
||||
placeholder={t('backoffice.candidates.filters.reuploaded.placeholder')}
|
||||
value={reuploadedAfterInvalidFilter}
|
||||
onChange={setReuploadedAfterInvalidFilter}
|
||||
data={[
|
||||
{ value: 'yes', label: t('common.yes') },
|
||||
{ value: 'no', label: t('common.no') },
|
||||
]}
|
||||
clearable
|
||||
/>
|
||||
),
|
||||
filtering: reuploadedAfterInvalidFilter !== null,
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Container, Title, Text, Stack } from '@pikku/mantine/core';
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
|
||||
export default function BackOfficeDashboardPage() {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Container size="xl">
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={1} mb="sm">
|
||||
{t('backoffice.dashboard.title')}
|
||||
</Title>
|
||||
<Text c="dimmed">
|
||||
{t('backoffice.dashboard.description')}
|
||||
</Text>
|
||||
</div>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -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,16 @@
|
||||
import Content from './content.mdx'
|
||||
import { useMDXComponents } from '@/mdx-components'
|
||||
import { MDXProvider } from '@mdx-js/react'
|
||||
import { Container } from '@pikku/mantine/core'
|
||||
|
||||
export default function NextStepsHelpPage() {
|
||||
const components = useMDXComponents({})
|
||||
|
||||
return (
|
||||
<Container size="xl" mt="xl">
|
||||
<MDXProvider components={components}>
|
||||
<Content />
|
||||
</MDXProvider>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
81
apps/website/views/localized/app/backoffice/layout.tsx
Normal file
81
apps/website/views/localized/app/backoffice/layout.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { AppShell, Flex, Button, Box, Container } from '@pikku/mantine/core';
|
||||
import '@/styles/datatable.css';
|
||||
import { Link, useLocation, useNavigate, useParams } from '@tanstack/react-router';
|
||||
import { IconArrowLeft } from '@tabler/icons-react';
|
||||
import LanguageSelector from '@/components/ui/LanguageSelector';
|
||||
import Logo from '@/components/ui/Logo';
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
import { asI18n } from '@pikku/react';
|
||||
|
||||
interface BackOfficeLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function BackOfficeLayout({ children }: BackOfficeLayoutProps) {
|
||||
const t = useI18n();
|
||||
const pathname = useLocation({ select: (l) => l.pathname });
|
||||
const navigate = useNavigate();
|
||||
const params = useParams({ strict: false });
|
||||
const lang = params.lang as string;
|
||||
const isDetailPage = pathname.includes('/backoffice/candidate/');
|
||||
|
||||
// Don't apply AppShell to auth pages
|
||||
if (pathname.includes('/backoffice/auth')) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
padding="md"
|
||||
header={{ height: 80 }}
|
||||
styles={{
|
||||
main: {
|
||||
backgroundColor: 'var(--mantine-color-gray-0)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AppShell.Header>
|
||||
{isDetailPage ? (
|
||||
<Box style={{ borderBottom: '1px solid var(--mantine-color-gray-2)', height: '100%' }}>
|
||||
<Container size="xl" h="100%">
|
||||
<Flex align="center" justify="space-between" h="100%">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconArrowLeft size={16} />}
|
||||
onClick={() => navigate({ to: `/${lang}/backoffice/candidates` })}
|
||||
>
|
||||
{t('backoffice.navigation.backtocandidates')}
|
||||
</Button>
|
||||
<LanguageSelector />
|
||||
</Flex>
|
||||
</Container>
|
||||
</Box>
|
||||
) : (
|
||||
<Flex align="center" justify="space-between" h="100%" px="md">
|
||||
<Logo size="sm" type={t('backoffice.navigation.adminbadge')} />
|
||||
|
||||
<Flex gap="sm" align="center">
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/${lang}/backoffice/candidates`}
|
||||
variant={pathname.includes('/backoffice/candidates') ? 'filled' : 'default'}
|
||||
>
|
||||
{asI18n('Candidates')}
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/${lang}/backoffice/translations`}
|
||||
variant={pathname.includes('/backoffice/translations') ? 'filled' : 'default'}
|
||||
>
|
||||
{asI18n('Translations')}
|
||||
</Button>
|
||||
<LanguageSelector />
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Main>{children}</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
11
apps/website/views/localized/app/backoffice/page.tsx
Normal file
11
apps/website/views/localized/app/backoffice/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useNavigate, useParams } from '@tanstack/react-router';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function BackOfficeDashboardPage() {
|
||||
const navigate = useNavigate()
|
||||
const { lang } = useParams({ strict: false }) as { lang?: string }
|
||||
useEffect(() => {
|
||||
navigate({ to: `/${lang || 'en'}/backoffice/candidates`, replace: true })
|
||||
}, [lang, navigate])
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams } from '@tanstack/react-router'
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@pikku/mantine/core'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import type {
|
||||
BackendTranslationBundle,
|
||||
BackendTranslationNamespace,
|
||||
} from '@heygermany/sdk'
|
||||
import { invokeRPC } from '@/pikku/rpc'
|
||||
|
||||
function toPrettyJson(value: Record<string, unknown>) {
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export default function BackOfficeTranslationsPage() {
|
||||
const params = useParams({ strict: false })
|
||||
const lang = params.lang as string
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedLocale, setSelectedLocale] = useState<string | null>(null)
|
||||
const [selectedNamespace, setSelectedNamespace] =
|
||||
useState<BackendTranslationNamespace | null>(null)
|
||||
const [editorValue, setEditorValue] = useState('')
|
||||
const [editorError, setEditorError] = useState<string | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['backoffice', 'translations'],
|
||||
queryFn: async () =>
|
||||
await invokeRPC<BackendTranslationBundle[]>('getBackOfficeTranslations', {}),
|
||||
})
|
||||
|
||||
const rows = query.data ?? []
|
||||
|
||||
const localeOptions = useMemo(
|
||||
() => Array.from(new Set(rows.map((row) => row.locale))).map((locale) => ({
|
||||
value: locale,
|
||||
label: locale,
|
||||
})),
|
||||
[rows]
|
||||
)
|
||||
|
||||
const namespaceOptions = useMemo(
|
||||
() => Array.from(new Set(rows.map((row) => row.namespace))).map((namespace) => ({
|
||||
value: namespace,
|
||||
label: namespace,
|
||||
})),
|
||||
[rows]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (rows.length === 0) return
|
||||
|
||||
if (!selectedLocale) {
|
||||
setSelectedLocale(rows.find((row) => Object.keys(row.content).length > 0)?.locale ?? rows[0].locale)
|
||||
}
|
||||
|
||||
if (!selectedNamespace) {
|
||||
setSelectedNamespace(
|
||||
rows.find((row) => Object.keys(row.content).length > 0)?.namespace ?? rows[0].namespace
|
||||
)
|
||||
}
|
||||
}, [rows, selectedLocale, selectedNamespace])
|
||||
|
||||
const selectedRow = useMemo(
|
||||
() =>
|
||||
rows.find(
|
||||
(row) =>
|
||||
row.locale === selectedLocale && row.namespace === selectedNamespace
|
||||
) ?? null,
|
||||
[rows, selectedLocale, selectedNamespace]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRow) return
|
||||
setEditorValue(toPrettyJson(selectedRow.content))
|
||||
setEditorError(null)
|
||||
}, [selectedRow])
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedLocale || !selectedNamespace) {
|
||||
throw new Error('Select a locale and namespace first')
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(editorValue)
|
||||
if (!isRecord(parsed)) {
|
||||
throw new Error('Translation bundle must be a JSON object')
|
||||
}
|
||||
|
||||
return await invokeRPC<BackendTranslationBundle>('updateBackOfficeTranslation', {
|
||||
locale: selectedLocale,
|
||||
namespace: selectedNamespace,
|
||||
content: parsed,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setEditorError(null)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['backoffice', 'translations'],
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
setEditorError(error instanceof Error ? error.message : String(error))
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Container size="xl">
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Stack gap={4}>
|
||||
<Title order={1}>{asI18n('Backend translations')}</Title>
|
||||
<Text c="dimmed">
|
||||
{asI18n('Edit DB-backed candidate result copy. Changes apply live to backend result rendering.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button component={Link} to={`/${lang}/backoffice/candidates`} variant="light">
|
||||
{asI18n('Back to candidates')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Stack gap="md">
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label={asI18n('Locale')}
|
||||
data={localeOptions}
|
||||
value={selectedLocale}
|
||||
onChange={(value) => setSelectedLocale(value)}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label={asI18n('Namespace')}
|
||||
data={namespaceOptions}
|
||||
value={selectedNamespace}
|
||||
onChange={(value) =>
|
||||
setSelectedNamespace(value as BackendTranslationNamespace | null)
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Textarea
|
||||
label={asI18n('Translation JSON')}
|
||||
autosize
|
||||
minRows={24}
|
||||
maxRows={40}
|
||||
value={editorValue}
|
||||
onChange={(event) => setEditorValue(event.currentTarget.value)}
|
||||
placeholder={asI18n('{ }')}
|
||||
/>
|
||||
|
||||
{selectedRow ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{asI18n(`Last updated: ${new Date(selectedRow.updatedAt).toLocaleString()}`)}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{editorError ? (
|
||||
<Text size="sm" c="red">
|
||||
{asI18n(editorError)}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{query.isLoading
|
||||
? asI18n('Loading translations...')
|
||||
: asI18n(`${rows.length} translation bundles available`)}
|
||||
</Text>
|
||||
<Button
|
||||
loading={saveMutation.isPending}
|
||||
disabled={!selectedRow}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
>
|
||||
{asI18n('Save translation bundle')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
13
apps/website/views/localized/app/company/auth/login/page.tsx
Normal file
13
apps/website/views/localized/app/company/auth/login/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { LoginComponent, LoginFormData } from '@/components/LoginComponent';
|
||||
import { useCompanyLogin } from '@/hooks/company/useCompanyLogin';
|
||||
|
||||
export default function CompanyLogin() {
|
||||
const login = useCompanyLogin();
|
||||
|
||||
return (
|
||||
<LoginComponent
|
||||
namespace="company"
|
||||
mutation={login}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Text,
|
||||
Card,
|
||||
Badge,
|
||||
Group,
|
||||
Stack,
|
||||
Button,
|
||||
Box,
|
||||
Grid,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
} from '@pikku/mantine/core'
|
||||
import {
|
||||
IconMapPin,
|
||||
IconLanguage,
|
||||
IconCalendar,
|
||||
IconBriefcase,
|
||||
IconAward,
|
||||
IconMail,
|
||||
IconInfoCircle,
|
||||
IconSchool,
|
||||
IconCheck,
|
||||
} from '@tabler/icons-react'
|
||||
import { useI18n } from '@/context/i18n-provider'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { useGetCompanyCandidate } from '@/hooks/company/useGetCompanyCandidate'
|
||||
import { useGetCountries } from '@/hooks/useGetCountries'
|
||||
import { CandidateProfileAvatar } from '@/components/company/CandidateProfileAvatar'
|
||||
|
||||
function SectionHeader({
|
||||
icon,
|
||||
title,
|
||||
meta,
|
||||
titleOrder = 3,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
meta?: ReactNode
|
||||
titleOrder?: 3 | 4
|
||||
}) {
|
||||
return (
|
||||
<Group justify="space-between" align="center" mb="lg">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{icon}
|
||||
<Title order={titleOrder}>{asI18n(title)}</Title>
|
||||
</Group>
|
||||
{meta}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CandidateDetailPage() {
|
||||
const params = useParams({ strict: false })
|
||||
const candidateId = params.candidateId as string
|
||||
const t = useI18n()
|
||||
const { getCountryNameFromAbbreviation } = useGetCountries()
|
||||
const { isLoading, data: candidate } = useGetCompanyCandidate(candidateId)
|
||||
const locale = t.locale || 'en'
|
||||
|
||||
// Format language level for display
|
||||
const formatLanguageLevel = (level: string | null) => {
|
||||
if (!level) return t('enums.languagelevel.none' as any)
|
||||
return t(`enums.languagelevel.${level.toLowerCase()}` as any)
|
||||
}
|
||||
|
||||
// Format profession for display
|
||||
const formatProfession = (profession: string | null) => {
|
||||
if (!profession) return t('common.na')
|
||||
return t(`enums.nurserecognitiongermany.${profession.toLowerCase()}` as any)
|
||||
}
|
||||
|
||||
const getRecognitionLikelihoodColor = (
|
||||
likelihood: string | null | undefined
|
||||
) => {
|
||||
if (likelihood === 'high') return 'green'
|
||||
if (likelihood === 'medium') return 'yellow'
|
||||
if (likelihood === 'low') return 'red'
|
||||
return 'gray'
|
||||
}
|
||||
|
||||
const formatExperienceDate = (date: Date | string | null | undefined) => {
|
||||
if (!date) return null
|
||||
|
||||
const parsedDate = new Date(date)
|
||||
if (Number.isNaN(parsedDate.getTime())) return null
|
||||
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(parsedDate)
|
||||
}
|
||||
|
||||
const getExperienceMonthIndex = (
|
||||
date: Date | string | null | undefined
|
||||
): number | null => {
|
||||
if (!date) return null
|
||||
|
||||
const parsedDate = new Date(date)
|
||||
if (Number.isNaN(parsedDate.getTime())) return null
|
||||
|
||||
return parsedDate.getUTCFullYear() * 12 + parsedDate.getUTCMonth()
|
||||
}
|
||||
|
||||
const getExperienceDurationMonths = (
|
||||
startDate: Date | string | null | undefined,
|
||||
endDate: Date | string | null | undefined
|
||||
): number | null => {
|
||||
const startMonth = getExperienceMonthIndex(startDate)
|
||||
const endMonth = getExperienceMonthIndex(endDate || new Date())
|
||||
|
||||
if (
|
||||
startMonth === null ||
|
||||
endMonth === null ||
|
||||
endMonth < startMonth
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return endMonth - startMonth + 1
|
||||
}
|
||||
|
||||
const formatExperienceDuration = (
|
||||
startDate: Date | string | null | undefined,
|
||||
endDate: Date | string | null | undefined
|
||||
) => {
|
||||
const totalMonths = getExperienceDurationMonths(startDate, endDate)
|
||||
if (totalMonths === null) return null
|
||||
|
||||
const years = Math.floor(totalMonths / 12)
|
||||
const months = totalMonths % 12
|
||||
const parts: string[] = []
|
||||
|
||||
if (years > 0) {
|
||||
parts.push(
|
||||
`${years} ${t(
|
||||
years === 1
|
||||
? 'company.candidates.duration.year'
|
||||
: 'company.candidates.duration.years'
|
||||
)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (months > 0) {
|
||||
parts.push(
|
||||
`${months} ${t(
|
||||
months === 1
|
||||
? 'company.candidates.duration.month'
|
||||
: 'company.candidates.duration.months'
|
||||
)}`
|
||||
)
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(' ') : null
|
||||
}
|
||||
|
||||
const formatExperienceTimeline = (
|
||||
startDate: Date | string | null | undefined,
|
||||
endDate: Date | string | null | undefined
|
||||
) => {
|
||||
const start = formatExperienceDate(startDate)
|
||||
const end = endDate
|
||||
? formatExperienceDate(endDate)
|
||||
: start
|
||||
? t('company.candidate.experience.present' as any)
|
||||
: null
|
||||
|
||||
if (start && end) return `${start} - ${end}`
|
||||
return start || end || t('common.na')
|
||||
}
|
||||
|
||||
const formatExperiencePeriod = (
|
||||
startDate: Date | string | null | undefined,
|
||||
endDate: Date | string | null | undefined
|
||||
) => {
|
||||
const timeline = formatExperienceTimeline(startDate, endDate)
|
||||
const duration = formatExperienceDuration(startDate, endDate)
|
||||
|
||||
return duration ? `${timeline} · ${duration}` : timeline
|
||||
}
|
||||
|
||||
const formatFacilityType = (facilityType: string | null | undefined) => {
|
||||
if (!facilityType) return t('common.na')
|
||||
|
||||
return facilityType
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const getExperienceLocation = (
|
||||
city: string | null | undefined,
|
||||
country: string | null | undefined
|
||||
) => {
|
||||
const locationParts = [
|
||||
city,
|
||||
country
|
||||
? getCountryNameFromAbbreviation(country) || country
|
||||
: null,
|
||||
].filter(Boolean)
|
||||
|
||||
return locationParts.length > 0
|
||||
? locationParts.join(', ')
|
||||
: null
|
||||
}
|
||||
|
||||
const formatExperienceSummary = (
|
||||
city: string | null | undefined,
|
||||
country: string | null | undefined,
|
||||
facilityType: string | null | undefined
|
||||
) => {
|
||||
const parts = [
|
||||
getExperienceLocation(city, country),
|
||||
facilityType ? formatFacilityType(facilityType) : null,
|
||||
].filter(Boolean)
|
||||
|
||||
return parts.length > 0 ? parts.join(' · ') : t('common.na')
|
||||
}
|
||||
|
||||
const formatExperienceDepartments = (
|
||||
department: string | null | undefined
|
||||
) => {
|
||||
if (!department) return null
|
||||
|
||||
const departments = department
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
|
||||
return departments.length > 0 ? departments.join(', ') : null
|
||||
}
|
||||
|
||||
const formatHomeCountryQualification = (qualification: string | null) => {
|
||||
if (!qualification) return t('common.na')
|
||||
return t(
|
||||
`enums.nurserecognitionhomecountry.${qualification.toLowerCase()}` as any
|
||||
)
|
||||
}
|
||||
|
||||
const formatEducationSummary = (
|
||||
program: string | null | undefined,
|
||||
institution: string | null | undefined
|
||||
) => {
|
||||
const educationParts = [program, institution].filter(Boolean)
|
||||
return educationParts.length > 0
|
||||
? educationParts.join(' · ')
|
||||
: t('common.na')
|
||||
}
|
||||
|
||||
const formatEducationCountry = (country: string | null | undefined) =>
|
||||
country ? getCountryNameFromAbbreviation(country) || country : t('common.na')
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={400}>
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (!candidate) {
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Alert color="red" title={t('company.candidate.notfound.title')}>
|
||||
{t('company.candidate.notfound.message')}
|
||||
</Alert>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const experiences = candidate.experience.filter((experience: any) =>
|
||||
Boolean(
|
||||
experience.roleTitle ||
|
||||
experience.employerName ||
|
||||
experience.department ||
|
||||
experience.country ||
|
||||
experience.city ||
|
||||
experience.facilityType ||
|
||||
experience.startDate ||
|
||||
experience.endDate
|
||||
)
|
||||
)
|
||||
const education = candidate.education
|
||||
const hasExperienceDetails = experiences.length > 0
|
||||
const hasEducationDetails = Boolean(
|
||||
candidate.qualificationStatusHomeCountry ||
|
||||
education?.program ||
|
||||
education?.institution ||
|
||||
education?.country ||
|
||||
typeof education?.nursingRelatedDegree === 'boolean'
|
||||
)
|
||||
const sectionCardStyle = {
|
||||
borderColor: 'var(--mantine-color-gray-2)',
|
||||
}
|
||||
const contactEmailHref = `mailto:info@hey-germany.com?subject=${encodeURIComponent(
|
||||
`${t('company.candidate.contact.emailsubject')}: ${candidate.name} (${candidate.candidateId})`
|
||||
)}&body=${encodeURIComponent(
|
||||
`${t('company.candidate.contact.emailbody')}\n\n${t('company.candidates.candidate')}: ${candidate.name}\n${t('company.candidate.contact.emailcandidateid')}: ${candidate.candidateId}\n`
|
||||
)}`
|
||||
|
||||
return (
|
||||
<Box style={{ minHeight: 'calc(100vh - 80px)' }} suppressHydrationWarning>
|
||||
<Container size="xl" py="xl" suppressHydrationWarning>
|
||||
{/* Profile Header Card */}
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="xl"
|
||||
radius="md"
|
||||
withBorder
|
||||
mb="xl"
|
||||
style={sectionCardStyle}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<Group gap="xl" wrap="nowrap" align="flex-start">
|
||||
<CandidateProfileAvatar
|
||||
countryCode={candidate.countryEducation}
|
||||
imageBorderWidth={4}
|
||||
badgeBorderWidth={4}
|
||||
badgeOffset={-8}
|
||||
badgeSize={48}
|
||||
iconSize={64}
|
||||
name={candidate.name}
|
||||
profileImageUrl={candidate.profileImageUrl}
|
||||
size={128}
|
||||
/>
|
||||
|
||||
{/* Info */}
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Title order={1} mb="xs">
|
||||
{asI18n(candidate.name)}
|
||||
</Title>
|
||||
<Text size="lg" c="gray.7" mb="md">
|
||||
{formatProfession(candidate.qualificationStatusGermany)}
|
||||
</Text>
|
||||
{candidate.recognitionLikelihood && (
|
||||
<Badge
|
||||
size="md"
|
||||
color={getRecognitionLikelihoodColor(
|
||||
candidate.recognitionLikelihood
|
||||
)}
|
||||
variant="light"
|
||||
mb="lg"
|
||||
>
|
||||
{asI18n(`${t(
|
||||
`enums.recognitionlikelihood.${candidate.recognitionLikelihood}` as any
|
||||
)} ${t('company.candidate.badge.recognitionlikelihood')}`)}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Grid gutter="md">
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Group gap="xs">
|
||||
<IconMapPin size={18} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="sm" c="gray.7">
|
||||
{asI18n(`${getCountryNameFromAbbreviation(
|
||||
candidate.countryEducation
|
||||
) || t('common.na')}${candidate.livingInGermany ? ` (${t('company.candidates.badge.ingermany')})` : ''}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Group gap="xs">
|
||||
<IconSchool size={18} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="sm" c="gray.7">
|
||||
{asI18n(education?.program || t('common.na'))}
|
||||
</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Group gap="xs">
|
||||
<IconLanguage
|
||||
size={18}
|
||||
color="var(--mantine-color-gray-6)"
|
||||
/>
|
||||
<Text size="sm" c="gray.7">
|
||||
{asI18n(`${t('company.candidates.german')} ${formatLanguageLevel(
|
||||
candidate.languageSelfAssessmentLevel
|
||||
)}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Group gap="xs">
|
||||
<IconCalendar
|
||||
size={18}
|
||||
color="var(--mantine-color-gray-6)"
|
||||
/>
|
||||
<Text size="sm" c="gray.7">
|
||||
{candidate.age
|
||||
? asI18n(`${candidate.age} ${t('company.candidate.yearsold')}`)
|
||||
: t('common.na')}
|
||||
</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<Grid gutter="xl">
|
||||
{/* Left Column */}
|
||||
<Grid.Col span={{ base: 12, md: 8 }}>
|
||||
<Stack gap="xl">
|
||||
{/* Education Card */}
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={sectionCardStyle}
|
||||
>
|
||||
<SectionHeader
|
||||
icon={<IconSchool size={18} />}
|
||||
title={t('company.candidate.section.education')}
|
||||
/>
|
||||
|
||||
{hasEducationDetails ? (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" c="gray.6" mb={4}>
|
||||
{t('company.candidate.field.qualificationlevel')}
|
||||
</Text>
|
||||
<Text fw={500}>
|
||||
{asI18n(formatHomeCountryQualification(
|
||||
candidate.qualificationStatusHomeCountry
|
||||
))}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="gray.6" mb={4}>
|
||||
{t('company.candidate.field.educationdegree')}
|
||||
</Text>
|
||||
<Text fw={500}>
|
||||
{asI18n(formatEducationSummary(
|
||||
education?.program,
|
||||
education?.institution
|
||||
))}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="gray.6" mb={4}>
|
||||
{t('company.candidate.field.countryofeducation')}
|
||||
</Text>
|
||||
<Text fw={500}>
|
||||
{asI18n(formatEducationCountry(
|
||||
education?.country || candidate.countryEducation
|
||||
))}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="gray.6" mb={4}>
|
||||
{t('company.candidate.field.nursinglicense')}
|
||||
</Text>
|
||||
{candidate.hasVerifiedLicense ? (
|
||||
<Group gap="xs">
|
||||
<Text fw={500}>
|
||||
{t('company.candidate.license.verified')}
|
||||
</Text>
|
||||
<IconCheck
|
||||
size={18}
|
||||
color="var(--mantine-color-green-6)"
|
||||
/>
|
||||
</Group>
|
||||
) : (
|
||||
<Text fw={500}>
|
||||
{t('company.candidate.license.notprovided')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
) : (
|
||||
<Text c="dimmed">{t('company.candidate.education.nodetails')}</Text>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Languages Card */}
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={sectionCardStyle}
|
||||
>
|
||||
<SectionHeader
|
||||
icon={<IconLanguage size={18} />}
|
||||
title={t('company.candidate.section.languages')}
|
||||
/>
|
||||
{candidate.languagesSpoken &&
|
||||
candidate.languagesSpoken.length > 0 && (
|
||||
<Group gap="xs" mb="md">
|
||||
{candidate.languagesSpoken.map((lang: any, index: number) => (
|
||||
<Badge key={index} variant="outline" size="md">
|
||||
{t(`enums.languagecode.${lang.toLowerCase()}` as any)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
<Box
|
||||
pt="md"
|
||||
style={{ borderTop: '1px solid var(--mantine-color-gray-2)' }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" c="gray.6" mb={4}>
|
||||
{t('company.candidate.field.germanlevel')}
|
||||
</Text>
|
||||
<Text fw={500}>
|
||||
{asI18n(formatLanguageLevel(
|
||||
candidate.languageSelfAssessmentLevel
|
||||
))}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="gray.6" mb={4}>
|
||||
{t('company.candidate.field.assessmenttype')}
|
||||
</Text>
|
||||
<Text fw={500}>
|
||||
{candidate.languageCertificateProvided
|
||||
? t('company.candidate.assessmenttype.certificate')
|
||||
: t('company.candidate.assessmenttype.selfassessment')}
|
||||
</Text>
|
||||
</div>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={sectionCardStyle}
|
||||
>
|
||||
<SectionHeader
|
||||
icon={<IconBriefcase size={18} />}
|
||||
title={t('company.candidate.section.workexperience')}
|
||||
/>
|
||||
|
||||
{hasExperienceDetails ? (
|
||||
<Stack gap={0}>
|
||||
{experiences.map((experience: any, index: number) => (
|
||||
<Box
|
||||
key={`${experience.roleTitle || 'role'}-${experience.employerName || 'employer'}-${experience.startDate || index}`}
|
||||
style={{
|
||||
paddingTop: index === 0 ? 0 : 20,
|
||||
paddingBottom:
|
||||
index === experiences.length - 1 ? 0 : 20,
|
||||
borderTop:
|
||||
index === 0
|
||||
? 'none'
|
||||
: '1px solid var(--mantine-color-gray-2)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
paddingLeft: 20,
|
||||
marginLeft: 8,
|
||||
borderLeft: '2px solid var(--mantine-color-gray-3)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: -7,
|
||||
top: 6,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--mantine-color-dark-9)',
|
||||
}}
|
||||
/>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="lg" c="dark.9">
|
||||
{asI18n(experience.roleTitle || t('common.na'))}
|
||||
</Text>
|
||||
{experience.employerName ? (
|
||||
<Text size="md" c="dark.7">
|
||||
{asI18n(experience.employerName)}
|
||||
</Text>
|
||||
) : null}
|
||||
{experience.department ? (
|
||||
<Text size="sm" c="gray.6">
|
||||
{asI18n(formatExperienceDepartments(
|
||||
experience.department
|
||||
) || t('common.na'))}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="sm" c="gray.6">
|
||||
{asI18n(formatExperiencePeriod(
|
||||
experience.startDate,
|
||||
experience.endDate
|
||||
) || t('common.na'))}
|
||||
</Text>
|
||||
<Text size="sm" c="gray.6">
|
||||
{asI18n(formatExperienceSummary(
|
||||
experience.city,
|
||||
experience.country,
|
||||
experience.facilityType
|
||||
) || t('common.na'))}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text c="dimmed">{t('company.candidate.experience.nodetails' as any)}</Text>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Recognition Status Card */}
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={sectionCardStyle}
|
||||
>
|
||||
<SectionHeader
|
||||
icon={<IconAward size={18} />}
|
||||
title={t('company.candidate.section.recognitionstatus')}
|
||||
/>
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Group gap="xs" mb={4}>
|
||||
<Text size="sm" c="gray.6">
|
||||
{t('company.candidate.field.referenceprofession')}
|
||||
</Text>
|
||||
<IconInfoCircle
|
||||
size={16}
|
||||
color="var(--mantine-color-gray-6)"
|
||||
/>
|
||||
</Group>
|
||||
<Text fw={500}>
|
||||
{asI18n(formatProfession(candidate.qualificationStatusGermany))}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="gray.6" mb={4}>
|
||||
{t('company.candidate.field.recognitionlikelihood')}
|
||||
</Text>
|
||||
{candidate.recognitionLikelihood ? (
|
||||
<Badge
|
||||
size="md"
|
||||
color={getRecognitionLikelihoodColor(
|
||||
candidate.recognitionLikelihood
|
||||
)}
|
||||
variant="light"
|
||||
>
|
||||
{t(
|
||||
`enums.recognitionlikelihood.${candidate.recognitionLikelihood}` as any
|
||||
)}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text fw={500}>{t('common.na')}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
{/* Right Column */}
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<Stack gap="xl">
|
||||
{/* Personal Information Card */}
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={sectionCardStyle}
|
||||
>
|
||||
<SectionHeader
|
||||
icon={<IconInfoCircle size={18} />}
|
||||
title={t('company.candidate.section.personalinformation')}
|
||||
titleOrder={4}
|
||||
/>
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" c="gray.6" mb={4}>
|
||||
{t('company.candidate.field.nationality')}
|
||||
</Text>
|
||||
<Text fw={500}>
|
||||
{asI18n(getCountryNameFromAbbreviation(
|
||||
candidate.nationality || candidate.countryEducation
|
||||
) || t('common.na'))}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" c="gray.6" mb={4}>
|
||||
{t('company.candidate.field.countryofresidence')}
|
||||
</Text>
|
||||
<Text fw={500}>
|
||||
{asI18n(getCountryNameFromAbbreviation(
|
||||
candidate.currentResidence
|
||||
) || t('common.na'))}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{candidate.stateWorkPreference &&
|
||||
candidate.stateWorkPreference.length > 0 && (
|
||||
<div>
|
||||
<Text size="sm" c="gray.6" mb={4}>
|
||||
{t('company.candidate.field.preferredlocations')}
|
||||
</Text>
|
||||
<Group gap="xs" mt="xs">
|
||||
{candidate.stateWorkPreference.map((state: any, index: number) => (
|
||||
<Badge key={index} variant="outline" size="sm">
|
||||
{t(
|
||||
`enums.germanstate.${state.toLowerCase().replace(/_/g, '-')}` as any
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Contact Information Card */}
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={sectionCardStyle}
|
||||
>
|
||||
<SectionHeader
|
||||
icon={<IconMail size={18} />}
|
||||
title={t('company.candidate.section.contactinformation')}
|
||||
titleOrder={4}
|
||||
/>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="gray.7">
|
||||
{t('company.candidate.contact.emaildescription')}
|
||||
</Text>
|
||||
<Button
|
||||
component="a"
|
||||
href={contactEmailHref}
|
||||
variant="default"
|
||||
fullWidth
|
||||
leftSection={<IconMail size={18} />}
|
||||
styles={{
|
||||
root: {
|
||||
height: 48,
|
||||
borderRadius: 10,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t('company.candidate.contact.emailcta')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Container>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
585
apps/website/views/localized/app/company/candidates/page.tsx
Normal file
585
apps/website/views/localized/app/company/candidates/page.tsx
Normal file
@@ -0,0 +1,585 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Text,
|
||||
SimpleGrid,
|
||||
Card,
|
||||
Badge,
|
||||
Group,
|
||||
Stack,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
ActionIcon,
|
||||
Loader,
|
||||
Center,
|
||||
Anchor,
|
||||
Box,
|
||||
Popover,
|
||||
} from '@pikku/mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import {
|
||||
IconAward,
|
||||
IconBriefcase,
|
||||
IconCalendar,
|
||||
IconFilter,
|
||||
IconInfoCircle,
|
||||
IconLanguage,
|
||||
IconSchool,
|
||||
} from '@tabler/icons-react'
|
||||
import { useI18n } from '@/context/i18n-provider'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { useGetCompanyCandidates } from '@/hooks/company/useGetCompanyCandidates'
|
||||
import { useGetCountries } from '@/hooks/useGetCountries'
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { SUPPORTED_COUNTRIES } from '@heygermany/sdk/config'
|
||||
import {
|
||||
LanguageLevels,
|
||||
NurseRecognitionGermanyValues,
|
||||
RecognitionLikelihoodValues,
|
||||
type LanguageLevel,
|
||||
} from '@heygermany/sdk'
|
||||
import { CandidateProfileAvatar } from '@/components/company/CandidateProfileAvatar'
|
||||
|
||||
const LANGUAGE_LEVEL_SORT_RANK: Record<LanguageLevel, number> = {
|
||||
[LanguageLevels.NONE]: 0,
|
||||
[LanguageLevels.A_ONE]: 1,
|
||||
[LanguageLevels.A_TWO]: 2,
|
||||
[LanguageLevels.B_ONE]: 3,
|
||||
[LanguageLevels.B_TWO]: 4,
|
||||
[LanguageLevels.C_ONE]: 5,
|
||||
[LanguageLevels.C_TWO]: 6,
|
||||
}
|
||||
|
||||
const getLanguageLevelSortRank = (
|
||||
level: LanguageLevel | null | undefined
|
||||
): number => {
|
||||
if (!level) return -1
|
||||
return LANGUAGE_LEVEL_SORT_RANK[level] ?? -1
|
||||
}
|
||||
|
||||
const getCreatedAtTimestamp = (
|
||||
createdAt: string | Date | null | undefined
|
||||
): number => {
|
||||
if (!createdAt) return 0
|
||||
return new Date(createdAt).getTime()
|
||||
}
|
||||
|
||||
export default function CompanyCandidates() {
|
||||
const { isLoading, data: response } = useGetCompanyCandidates()
|
||||
const candidates = response?.data || []
|
||||
const t = useI18n()
|
||||
const { lang } = useParams({ strict: false })
|
||||
const { data: countriesData, getCountryNameFromAbbreviation } =
|
||||
useGetCountries()
|
||||
const [drawerOpened, { open: openDrawer, close: closeDrawer }] =
|
||||
useDisclosure(false)
|
||||
|
||||
const [selectedProfessions, setSelectedProfessions] = useState<string[]>([])
|
||||
const [selectedLanguageLevels, setSelectedLanguageLevels] = useState<
|
||||
string[]
|
||||
>([])
|
||||
const [selectedCountries, setSelectedCountries] = useState<string[]>([])
|
||||
const [selectedResidency, setSelectedResidency] = useState<string[]>([])
|
||||
|
||||
const candidateSourceCountries = SUPPORTED_COUNTRIES.filter(
|
||||
(code) => code !== 'DEU'
|
||||
)
|
||||
|
||||
const allCountries = useMemo(() => {
|
||||
if (!countriesData) return []
|
||||
|
||||
return candidateSourceCountries
|
||||
.map((code) => ({
|
||||
code,
|
||||
name: countriesData[code] || code,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [countriesData])
|
||||
|
||||
const filteredCandidates = useMemo(() => {
|
||||
return candidates
|
||||
.filter((candidate: any) => {
|
||||
if (
|
||||
selectedProfessions.length > 0 &&
|
||||
!selectedProfessions.includes(
|
||||
candidate.qualificationStatusGermany || ''
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
selectedLanguageLevels.length > 0 &&
|
||||
!selectedLanguageLevels.includes(
|
||||
candidate.languageSelfAssessmentLevel || ''
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
selectedCountries.length > 0 &&
|
||||
!selectedCountries.includes(candidate.countryEducation || '')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (selectedResidency.length > 0) {
|
||||
const isInGermany = candidate.livingInGermany
|
||||
? 'in-germany'
|
||||
: 'not-in-germany'
|
||||
|
||||
if (!selectedResidency.includes(isInGermany)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
.sort((a: any, b: any) => {
|
||||
const languageLevelDifference =
|
||||
getLanguageLevelSortRank(b.languageSelfAssessmentLevel) -
|
||||
getLanguageLevelSortRank(a.languageSelfAssessmentLevel)
|
||||
|
||||
if (languageLevelDifference !== 0) {
|
||||
return languageLevelDifference
|
||||
}
|
||||
|
||||
return (
|
||||
getCreatedAtTimestamp(b.createdAt) -
|
||||
getCreatedAtTimestamp(a.createdAt)
|
||||
)
|
||||
})
|
||||
}, [
|
||||
candidates,
|
||||
selectedProfessions,
|
||||
selectedLanguageLevels,
|
||||
selectedCountries,
|
||||
selectedResidency,
|
||||
])
|
||||
|
||||
const languageLevelOptions = useMemo(
|
||||
() => t.getEnumOptions(LanguageLevels, 'languagelevel'),
|
||||
[t]
|
||||
)
|
||||
|
||||
const professionOptions = useMemo(
|
||||
() => t.getEnumOptions(NurseRecognitionGermanyValues, 'nurserecognitiongermany'),
|
||||
[t]
|
||||
)
|
||||
|
||||
const formatLanguageLevel = (level: string | null) => {
|
||||
if (!level) return t('enums.languagelevel.none' as any)
|
||||
const option = languageLevelOptions.find((opt) => opt.value === level)
|
||||
return option?.label || level
|
||||
}
|
||||
|
||||
const formatProfession = (profession: string | null) => {
|
||||
if (!profession) return t('company.candidates.profession.na' as any)
|
||||
const option = professionOptions.find((opt) => opt.value === profession)
|
||||
return option?.label || profession
|
||||
}
|
||||
|
||||
const formatEducationSummary = (program: string | null | undefined) =>
|
||||
program || t('common.na')
|
||||
|
||||
const formatTotalExperienceDuration = (
|
||||
totalExperienceMonths: number | null | undefined
|
||||
) => {
|
||||
if (!totalExperienceMonths) return t('common.na')
|
||||
|
||||
const years = Math.floor(totalExperienceMonths / 12)
|
||||
const months = totalExperienceMonths % 12
|
||||
const parts: string[] = []
|
||||
|
||||
if (years > 0) {
|
||||
parts.push(
|
||||
`${years} ${t(
|
||||
years === 1
|
||||
? 'company.candidates.duration.year'
|
||||
: 'company.candidates.duration.years'
|
||||
)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (months > 0 || parts.length === 0) {
|
||||
parts.push(
|
||||
`${months} ${t(
|
||||
months === 1
|
||||
? 'company.candidates.duration.month'
|
||||
: 'company.candidates.duration.months'
|
||||
)}`
|
||||
)
|
||||
}
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const getRecognitionLikelihoodBadge = (likelihood: string | null) => {
|
||||
if (!likelihood) return null
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
[RecognitionLikelihoodValues.HIGH]: 'green',
|
||||
[RecognitionLikelihoodValues.MEDIUM]: 'yellow',
|
||||
[RecognitionLikelihoodValues.LOW]: 'red',
|
||||
}
|
||||
|
||||
return {
|
||||
color: colorMap[likelihood] || 'gray',
|
||||
label: t(`enums.recognitionlikelihood.${likelihood}` as any),
|
||||
}
|
||||
}
|
||||
|
||||
const FilterContent = () => (
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Group gap={6} mb="sm">
|
||||
<Text fw={500} size="sm" c="dark.7">
|
||||
{t('company.candidates.filters.profession')}
|
||||
</Text>
|
||||
<Popover width={320} position="right" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<ActionIcon variant="subtle" size="xs" color="gray">
|
||||
<IconInfoCircle size={14} />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Text size="sm" style={{ whiteSpace: 'pre-line', lineHeight: 1.6 }}>
|
||||
{t('company.candidates.filters.profession.tooltip' as any)}
|
||||
</Text>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
<Checkbox.Group
|
||||
value={selectedProfessions}
|
||||
onChange={setSelectedProfessions}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{professionOptions.map((option) => (
|
||||
<Checkbox
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
label={asI18n(option.label)}
|
||||
size="sm"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fw={500} size="sm" mb="sm" c="dark.7">
|
||||
{t('company.candidates.filters.language')}
|
||||
</Text>
|
||||
<Checkbox.Group
|
||||
value={selectedLanguageLevels}
|
||||
onChange={setSelectedLanguageLevels}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{languageLevelOptions.map((option) => (
|
||||
<Checkbox
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
label={asI18n(option.label)}
|
||||
size="sm"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fw={500} size="sm" mb="sm" c="dark.7">
|
||||
{t('company.candidates.filters.country')}
|
||||
</Text>
|
||||
<Checkbox.Group
|
||||
value={selectedCountries}
|
||||
onChange={setSelectedCountries}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{allCountries.map(({ code, name }) => (
|
||||
<Checkbox key={code} value={code} label={asI18n(name)} size="sm" />
|
||||
))}
|
||||
</Stack>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fw={500} size="sm" mb="sm" c="dark.7">
|
||||
{t('company.candidates.filters.residency')}
|
||||
</Text>
|
||||
<Checkbox.Group
|
||||
value={selectedResidency}
|
||||
onChange={setSelectedResidency}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Checkbox
|
||||
value="in-germany"
|
||||
label={t('company.candidates.filters.iningermany')}
|
||||
size="sm"
|
||||
/>
|
||||
<Checkbox
|
||||
value="not-in-germany"
|
||||
label={t('company.candidates.filters.notingermany')}
|
||||
size="sm"
|
||||
/>
|
||||
</Stack>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
</Stack>
|
||||
)
|
||||
|
||||
const filterPanelScrollStyle = {
|
||||
minHeight: 0,
|
||||
overflowY: 'auto' as const,
|
||||
paddingRight: 4,
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={400}>
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
opened={drawerOpened}
|
||||
onClose={closeDrawer}
|
||||
title={
|
||||
<Text fw={600} size="lg">
|
||||
{t('company.candidates.filters.title')}
|
||||
</Text>
|
||||
}
|
||||
padding="lg"
|
||||
size="sm"
|
||||
hiddenFrom="lg"
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
...filterPanelScrollStyle,
|
||||
maxHeight: 'calc(100dvh - 140px)',
|
||||
}}
|
||||
>
|
||||
<FilterContent />
|
||||
</Box>
|
||||
</Drawer>
|
||||
|
||||
<Box style={{ minHeight: 'calc(100vh - 80px)' }}>
|
||||
<Container size="xl" py="xl">
|
||||
<Group align="flex-start" gap="xl">
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
w={280}
|
||||
visibleFrom="lg"
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 100,
|
||||
maxHeight: 'calc(100dvh - 120px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Text fw={600} size="lg" mb="lg">
|
||||
{t('company.candidates.filters.title')}
|
||||
</Text>
|
||||
<Box style={filterPanelScrollStyle}>
|
||||
<FilterContent />
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Group justify="space-between" mb="xl">
|
||||
<Title order={2}>
|
||||
{t('company.candidates.candidatesfound' as any, {
|
||||
count: filteredCandidates.length,
|
||||
})}
|
||||
</Title>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
onClick={openDrawer}
|
||||
hiddenFrom="lg"
|
||||
>
|
||||
<IconFilter size={20} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
|
||||
{filteredCandidates.map((candidate: any) => {
|
||||
const recognitionBadge = getRecognitionLikelihoodBadge(
|
||||
candidate.recognitionLikelihood
|
||||
)
|
||||
|
||||
return (
|
||||
<Anchor
|
||||
key={candidate.candidateId}
|
||||
href={`/${lang}/company/candidate/${candidate.candidateId}`}
|
||||
underline="never"
|
||||
style={{ display: 'block', height: '100%' }}
|
||||
>
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="xl"
|
||||
radius="md"
|
||||
withBorder
|
||||
h="100%"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
position: 'relative',
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
'&:hover': {
|
||||
boxShadow: '0 12px 24px rgba(0, 0, 0, 0.12)',
|
||||
transform: 'translateY(-4px)',
|
||||
borderColor: 'var(--mantine-color-blue-5)',
|
||||
backgroundColor: 'var(--mantine-color-gray-0)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{candidate.livingInGermany && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="black" tt="uppercase" fw={500}>
|
||||
{t('company.candidates.card.in_germany')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Group gap="md" mb="md" wrap="nowrap" align="flex-start">
|
||||
<CandidateProfileAvatar
|
||||
countryCode={candidate.countryEducation}
|
||||
imageBorderWidth={2}
|
||||
badgeBorderWidth={2}
|
||||
badgeOffset={-2}
|
||||
badgeSize={28}
|
||||
iconSize={28}
|
||||
name={candidate.name}
|
||||
profileImageUrl={candidate.profileImageUrl}
|
||||
size={56}
|
||||
/>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={600} size="lg" mb={4} c="dark.9">
|
||||
{candidate.name}
|
||||
</Text>
|
||||
<Text size="md" c="gray.6">
|
||||
{asI18n(getCountryNameFromAbbreviation(
|
||||
candidate.countryEducation
|
||||
) || t('common.na'))}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<IconBriefcase
|
||||
size={18}
|
||||
color="var(--mantine-color-gray-6)"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Text size="md" c="gray.7" lineClamp={1}>
|
||||
{asI18n(formatProfession(
|
||||
candidate.qualificationStatusGermany
|
||||
))}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||
<IconSchool
|
||||
size={18}
|
||||
color="var(--mantine-color-gray-6)"
|
||||
style={{ flexShrink: 0, marginTop: 2 }}
|
||||
/>
|
||||
<Text size="md" c="gray.7" lineClamp={2}>
|
||||
{asI18n(formatEducationSummary(
|
||||
candidate.education?.program
|
||||
))}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<IconLanguage
|
||||
size={18}
|
||||
color="var(--mantine-color-gray-6)"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Text size="md" c="gray.7">
|
||||
{asI18n(`${t('company.candidates.card.germanlevel' as any)}: ${formatLanguageLevel(candidate.languageSelfAssessmentLevel)}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<IconCalendar
|
||||
size={18}
|
||||
color="var(--mantine-color-gray-6)"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Text size="md" c="gray.7">
|
||||
{asI18n(`${t('company.candidates.card.totalexperience')}: ${formatTotalExperienceDuration(candidate.totalExperienceMonths)}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{recognitionBadge ? (
|
||||
<Box
|
||||
mt="sm"
|
||||
pt="sm"
|
||||
style={{
|
||||
borderTop: '1px solid var(--mantine-color-gray-2)',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" align="center">
|
||||
<IconAward
|
||||
size={18}
|
||||
color="var(--mantine-color-gray-6)"
|
||||
/>
|
||||
<Text size="md" c="gray.7" fw={500}>
|
||||
{asI18n(`${t('company.candidates.card.recognitionlikelihood' as any)}:`)}
|
||||
</Text>
|
||||
<Badge
|
||||
size="sm"
|
||||
color={recognitionBadge.color}
|
||||
variant="light"
|
||||
>
|
||||
{asI18n(recognitionBadge.label)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Box>
|
||||
) : null}
|
||||
</Card>
|
||||
</Anchor>
|
||||
)
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
{filteredCandidates.length === 0 && (
|
||||
<Center h={200}>
|
||||
<Text c="dimmed" size="lg">
|
||||
{t('company.candidates.nocandidates')}
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
</Container>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
65
apps/website/views/localized/app/company/layout.tsx
Normal file
65
apps/website/views/localized/app/company/layout.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { AppShell, Flex, Text, Container, Button, Box } from '@pikku/mantine/core';
|
||||
import { useLocation, useNavigate, useParams } from '@tanstack/react-router';
|
||||
import { IconArrowLeft } from '@tabler/icons-react';
|
||||
import LanguageSelector from '@/components/ui/LanguageSelector';
|
||||
import Logo from '@/components/ui/Logo';
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
|
||||
interface CompanyLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function CompanyLayout({ children }: CompanyLayoutProps) {
|
||||
const pathname = useLocation({ select: (l) => l.pathname });
|
||||
const navigate = useNavigate();
|
||||
const params = useParams({ strict: false });
|
||||
const t = useI18n();
|
||||
const lang = params.lang as string;
|
||||
const isDetailPage = pathname.includes('/company/candidate/');
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
padding={0}
|
||||
header={{ height: 80 }}
|
||||
styles={{
|
||||
main: {
|
||||
backgroundColor: 'var(--mantine-color-gray-0)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AppShell.Header>
|
||||
{isDetailPage ? (
|
||||
<Box style={{ borderBottom: '1px solid var(--mantine-color-gray-2)', height: '100%' }}>
|
||||
<Container size="xl" h="100%">
|
||||
<Flex align="center" justify="space-between" h="100%">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconArrowLeft size={16} />}
|
||||
onClick={() => navigate({ to: `/${lang}/company/candidates` })}
|
||||
>
|
||||
{t('company.navigation.backtosearch')}
|
||||
</Button>
|
||||
<LanguageSelector />
|
||||
</Flex>
|
||||
</Container>
|
||||
</Box>
|
||||
) : (
|
||||
<Flex align="center" justify="space-between" h="100%" px="xl">
|
||||
<div>
|
||||
<Logo size="sm" />
|
||||
<Text size="sm" c="dimmed" mt={2}>
|
||||
{t('company.navigation.tagline')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Flex gap="sm" align="center">
|
||||
<LanguageSelector />
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Main>{children}</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
13
apps/website/views/localized/app/company/page.tsx
Normal file
13
apps/website/views/localized/app/company/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useParams } from '@tanstack/react-router';
|
||||
|
||||
export default function CompanyPage() {
|
||||
const navigate = useNavigate();
|
||||
const { lang } = useParams({ strict: false });
|
||||
|
||||
useEffect(() => {
|
||||
navigate({ to: `/${lang}/company/candidates`, replace: true });
|
||||
}, [navigate, lang]);
|
||||
|
||||
return null;
|
||||
}
|
||||
62
apps/website/views/localized/app/logout/page.tsx
Normal file
62
apps/website/views/localized/app/logout/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useParams } from '@tanstack/react-router';
|
||||
import { Paper, Text, Title, Stack, Loader, Flex } from '@pikku/mantine/core';
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { signOut } from '@/lib/auth';
|
||||
import { asI18n } from '@pikku/react';
|
||||
|
||||
export default function LogoutPage() {
|
||||
const t = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const { lang } = useParams({ strict: false }) as { lang?: string }
|
||||
const logout = useMutation({
|
||||
mutationFn: async () => {
|
||||
await signOut()
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const performLogout = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
await logout.mutateAsync()
|
||||
navigate({ to: lang ? `/${lang}` : '/', replace: true })
|
||||
}
|
||||
|
||||
void performLogout()
|
||||
}, [lang, navigate])
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
mih="100vh"
|
||||
p="md"
|
||||
>
|
||||
<Paper p="xl" radius="md" shadow="sm" w="100%" maw="400">
|
||||
<Stack align="center" gap="lg">
|
||||
<Title order={2} ta="center">
|
||||
{t('logout.title')}
|
||||
</Title>
|
||||
|
||||
{logout.isPending ? (
|
||||
<>
|
||||
<Loader size="md" />
|
||||
<Text ta="center" c="dimmed">
|
||||
{t('logout.loggingout')}
|
||||
</Text>
|
||||
</>
|
||||
) : logout.isError ? (
|
||||
<Text ta="center" c="red">
|
||||
{asI18n(`${t('logout.error')}: ${logout.error?.message || 'Unknown error'}`)}
|
||||
</Text>
|
||||
) : logout.isSuccess ? (
|
||||
<Text ta="center" c="green">
|
||||
{t('logout.success')}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user