chore: heygermany customer project
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { LoginComponent } from '@/components/LoginComponent';
|
||||
import { useBackOfficeLogin } from '@/hooks/backoffice/useBackOfficeLogin';
|
||||
|
||||
export default function BackOfficeLogin() {
|
||||
const backofficeLogin = useBackOfficeLogin();
|
||||
|
||||
return (
|
||||
<LoginComponent
|
||||
namespace="backoffice"
|
||||
mutation={backofficeLogin}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Container, Title, Text, Paper, Stack, Loader, Alert, Grid, Group, Flex, Card, Button, Switch, ActionIcon } from '@pikku/mantine/core';
|
||||
import { IconExternalLink, IconEye } from '@tabler/icons-react';
|
||||
import { EnumSelect } from '@/components/common/EnumSelect';
|
||||
import dayjs from 'dayjs';
|
||||
import { useParams } from '@/framework/navigation';
|
||||
import { useGetBackOfficeCandidate } from '@/hooks/backoffice/useGetBackOfficeCandidate';
|
||||
import { useUpdateBackOfficeCandidate } from '@/hooks/backoffice/useUpdateBackOfficeCandidate';
|
||||
import { useGetBackOfficeCandidateResult } from '@/hooks/backoffice/useGetBackOfficeCandidateResult';
|
||||
import { EducationVerificationCard } from '@/components/backoffice/EducationVerificationCard';
|
||||
import { LicenseVerificationCard } from '@/components/backoffice/LicenseVerificationCard';
|
||||
import { LanguageVerificationCard } from '@/components/backoffice/LanguageVerificationCard';
|
||||
import { ExperienceVerificationCard } from '@/components/backoffice/ExperienceVerificationCard';
|
||||
import type { DB } from '@heygermany/sdk';
|
||||
import {
|
||||
NurseRecognitionGermanyValues,
|
||||
NurseRecognitionHomeCountryValues,
|
||||
RecognitionLikelihoodValues,
|
||||
} from '@heygermany/sdk';
|
||||
import { CandidateResult } from '@/components/jobs/CandidateResult';
|
||||
import { QualificationAssessment } from '@/components/backoffice/QualificationAssessment';
|
||||
import { BooleanSelectComponent } from '@/components/backoffice/DocumentFieldFactory';
|
||||
import { useGetCandidateResults } from '@/hooks/useGetCandidateResult';
|
||||
import { ApplicantStatusSelect } from '@/components/backoffice/ApplicantStatusSelect';
|
||||
import { useGetCountries } from '@/hooks/useGetCountries';
|
||||
import { LifecycleNotificationsCard } from '@/components/backoffice/LifecycleNotificationsCard';
|
||||
import { asI18n } from '@pikku/react';
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
|
||||
const CandidateResultPreview: React.FunctionComponent<{ candidateId: string }> = ({ candidateId }) => {
|
||||
const { data: candidateResults } = useGetCandidateResults(candidateId)
|
||||
if (!candidateResults) return null
|
||||
return <CandidateResult candidateResults={candidateResults} />
|
||||
}
|
||||
|
||||
export default function BackOfficeCandidateDetailPage() {
|
||||
const params = useParams();
|
||||
const candidateId = params.candidateId as string;
|
||||
const t = useI18n();
|
||||
|
||||
const { data: candidate, isLoading, error } = useGetBackOfficeCandidate(candidateId);
|
||||
const { data: candidateResult } = useGetBackOfficeCandidateResult(candidateId);
|
||||
const updateCandidateMutation = useUpdateBackOfficeCandidate(candidateId);
|
||||
const { getCountryNameFromAbbreviation } = useGetCountries();
|
||||
const [showResultsDrawer, setShowResultsDrawer] = useState(false);
|
||||
const [verbose, setVerbose] = useState(false);
|
||||
const [analysisIssuesDraft, setAnalysisIssuesDraft] = useState('');
|
||||
const notAvailable = t('common.na');
|
||||
const yesNo = (value: boolean | null | undefined) => {
|
||||
if (value === true) return t('common.yes');
|
||||
if (value === false) return t('common.no');
|
||||
return notAvailable;
|
||||
};
|
||||
|
||||
const currentAnalysisIssues = Array.isArray(candidate?.lifecycleNotifications?.analysis?.issues)
|
||||
? candidate.lifecycleNotifications.analysis.issues.filter((issue: any): issue is string => typeof issue === 'string')
|
||||
: [];
|
||||
const currentAnalysisIssuesDraft = currentAnalysisIssues.join('\n');
|
||||
|
||||
useEffect(() => {
|
||||
setAnalysisIssuesDraft(currentAnalysisIssuesDraft);
|
||||
}, [candidateId, currentAnalysisIssuesDraft]);
|
||||
|
||||
const saveAnalysisIssues = () => {
|
||||
const analysisIssues = analysisIssuesDraft
|
||||
.split('\n')
|
||||
.map((issue: string) => issue.trim())
|
||||
.filter((issue: string) => issue.length > 0)
|
||||
|
||||
updateCandidateMutation.mutate({ analysisIssues })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Flex justify="center" align="center" style={{ minHeight: 'calc(100vh - 60px)' }}>
|
||||
<Loader size="xl" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !candidate) {
|
||||
return (
|
||||
<Container size="xl">
|
||||
<Alert color="red" title={t('common.error')}>
|
||||
{t('backoffice.candidate.error.notfound')}
|
||||
</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex>
|
||||
<Container>
|
||||
<Stack>
|
||||
<Group justify="space-between" align="center" mb="lg">
|
||||
<Title order={1}>
|
||||
{asI18n(candidate.name)}
|
||||
</Title>
|
||||
<Group gap="sm">
|
||||
<ApplicantStatusSelect
|
||||
value={candidate.status}
|
||||
onChange={(status) => {
|
||||
updateCandidateMutation.mutate({ status: status! })
|
||||
}}
|
||||
isValid={candidateResult?.isValid ?? false}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={500}>{t('backoffice.candidate.verbose')}</Text>
|
||||
<Switch
|
||||
checked={verbose}
|
||||
onChange={(event) => setVerbose(event.currentTarget.checked)}
|
||||
/>
|
||||
</Group>
|
||||
{candidateResult && (
|
||||
<Text
|
||||
size="sm"
|
||||
c={candidateResult.isValid ? 'success' : 'error'}
|
||||
fw={500}
|
||||
>
|
||||
{candidateResult.isValid ? t('backoffice.candidate.result.valid') : t('backoffice.candidate.result.invalid')}
|
||||
</Text>
|
||||
)}
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="light"
|
||||
disabled={!candidateResult?.isValid}
|
||||
onClick={() => setShowResultsDrawer(!showResultsDrawer)}
|
||||
title={t('backoffice.candidate.actions.showresultspanel')}
|
||||
>
|
||||
<IconEye size={18} />
|
||||
</ActionIcon>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="filled"
|
||||
disabled={!candidateResult?.isValid}
|
||||
onClick={() => window.open(`/jobs/application?candidateId=${candidateId}`, '_blank')}
|
||||
leftSection={<IconExternalLink size={16} />}
|
||||
>
|
||||
{t('backoffice.candidate.actions.resultpage')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
disabled={!candidateResult?.isValid}
|
||||
onClick={() => window.open(`/company/candidate/${candidateId}`, '_blank')}
|
||||
leftSection={<IconExternalLink size={16} />}
|
||||
>
|
||||
{t('backoffice.candidate.actions.companyview')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Qualification Result Summary */}
|
||||
<QualificationAssessment candidateResult={candidateResult} />
|
||||
|
||||
{/* Manual Assessment Section */}
|
||||
<Title order={3} mb="md">{t('backoffice.candidate.manualassessment')}</Title>
|
||||
<Card shadow="sm" padding="lg" radius="md" withBorder mb="md">
|
||||
<Grid gutter="md">
|
||||
<Grid.Col span={6}>
|
||||
<EnumSelect
|
||||
enumObject={NurseRecognitionHomeCountryValues}
|
||||
enumName="NurseRecognitionHomeCountry"
|
||||
label={t('backoffice.candidate.fields.statushomecountry')}
|
||||
placeholder={t('backoffice.candidate.fields.selectstatus')}
|
||||
value={candidate.qualificationStatusHomeCountry || null}
|
||||
onChange={(qualificationStatusHomeCountry) =>
|
||||
updateCandidateMutation.mutate({
|
||||
qualificationStatusHomeCountry: qualificationStatusHomeCountry! as DB.NurseRecognitionHomeCountry
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<EnumSelect
|
||||
enumObject={NurseRecognitionGermanyValues}
|
||||
enumName="NurseRecognitionGermany"
|
||||
label={t('backoffice.candidate.fields.statusgermany')}
|
||||
placeholder={t('backoffice.candidate.fields.selectstatus')}
|
||||
value={candidate.qualificationStatusGermany || null}
|
||||
onChange={(qualificationStatusGermany) =>
|
||||
updateCandidateMutation.mutate({
|
||||
qualificationStatusGermany: qualificationStatusGermany! as DB.NurseRecognitionGermany
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<BooleanSelectComponent
|
||||
label={t('backoffice.candidate.fields.licensemandatory')}
|
||||
value={candidate.licenseMandatory}
|
||||
onChange={(licenseMandatory) =>
|
||||
updateCandidateMutation.mutate({ licenseMandatory })}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<BooleanSelectComponent
|
||||
label={t('backoffice.candidate.fields.durationrequirementsmet')}
|
||||
value={candidate.durationRequirementsMet}
|
||||
onChange={(durationRequirementsMet) =>
|
||||
updateCandidateMutation.mutate({ durationRequirementsMet })}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<EnumSelect
|
||||
enumObject={RecognitionLikelihoodValues}
|
||||
enumName="RecognitionLikelihood"
|
||||
label={t('backoffice.candidate.fields.recognitionlikelihood')}
|
||||
placeholder={t('backoffice.candidate.fields.selectlikelihood')}
|
||||
value={candidate.recognitionLikelihood || null}
|
||||
onChange={(recognitionLikelihood) =>
|
||||
updateCandidateMutation.mutate({
|
||||
recognitionLikelihood: recognitionLikelihood! as DB.RecognitionLikelihood
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Card>
|
||||
<Title order={2} mb="md">{t('backoffice.candidate.basicinfo')}</Title>
|
||||
|
||||
<Card shadow="sm" padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} size="sm">{t('backoffice.candidate.fields.email')}</Text>
|
||||
<Text size="sm">{candidate.email ? asI18n(candidate.email) : notAvailable}</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} size="sm">{t('backoffice.candidate.fields.currentresidence')}</Text>
|
||||
<Text size="sm">{asI18n(getCountryNameFromAbbreviation(candidate.currentResidence))}</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} size="sm">{t('backoffice.candidate.fields.countryofeducation')}</Text>
|
||||
<Text size="sm">{asI18n(getCountryNameFromAbbreviation(candidate.countryEducation))}</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} size="sm">{t('backoffice.candidate.fields.eupassport')}</Text>
|
||||
<Text size="sm">{yesNo(candidate.euPassport)}</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} size="sm">{t('backoffice.candidate.fields.dateofbirthage')}</Text>
|
||||
<Text size="sm">
|
||||
{candidate.dateOfBirth ? (
|
||||
<>
|
||||
{dayjs(candidate.dateOfBirth).format('YYYY-MM-DD')}
|
||||
{` (${dayjs().diff(dayjs(candidate.dateOfBirth), 'year')} ${t('backoffice.candidate.fields.years')})`}
|
||||
</>
|
||||
) : notAvailable}
|
||||
</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} size="sm">{t('backoffice.candidate.fields.livingingermany')}</Text>
|
||||
<Text size="sm">{yesNo(candidate.livingInGermany)}</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} size="sm">{t('backoffice.candidate.fields.jointalentpool')}</Text>
|
||||
<Text size="sm">{yesNo(candidate.joinTalentPool)}</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} size="sm">{t('backoffice.candidate.fields.worklocation')}</Text>
|
||||
<Text size="sm">{asI18n(candidate.stateWorkPreference !== null ? candidate.stateWorkPreference.join(', ') : notAvailable)}</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} size="sm">{t('backoffice.candidate.fields.submittedat')}</Text>
|
||||
<Text size="sm">{candidate.submittedAt ? asI18n(new Date(candidate.submittedAt).toLocaleDateString()) : notAvailable}</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Stack gap="xl">
|
||||
{/* Education Section */}
|
||||
<Paper my="md">
|
||||
<Title order={2} mb="md">{t('enums.candidatedocumenttype.education')}</Title>
|
||||
<Stack gap="md">
|
||||
<EducationVerificationCard
|
||||
candidateId={candidateId}
|
||||
education={candidate.analysis?.education || {}}
|
||||
documents={candidate.documents?.education || []}
|
||||
verbose={verbose}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* License Section */}
|
||||
<Paper my="md">
|
||||
<Title order={2} mb="md">{t('enums.candidatedocumenttype.license')}</Title>
|
||||
<Stack gap="md">
|
||||
<LicenseVerificationCard
|
||||
candidateId={candidateId}
|
||||
license={candidate.analysis?.license || {}}
|
||||
documents={candidate.documents?.license || []}
|
||||
verbose={verbose}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Language Section */}
|
||||
<Paper my="md">
|
||||
<Title order={2} mb="md">{t('enums.candidatedocumenttype.language')}</Title>
|
||||
<Stack gap="md">
|
||||
<LanguageVerificationCard
|
||||
candidateId={candidateId}
|
||||
language={candidate.analysis?.language || {}}
|
||||
documents={candidate.documents?.language || []}
|
||||
verbose={verbose}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Experience Section */}
|
||||
<Paper my="md">
|
||||
<Title order={2} mb="md">{t('enums.candidatedocumenttype.work_experience')}</Title>
|
||||
<Stack gap="md">
|
||||
<ExperienceVerificationCard
|
||||
candidateId={candidateId}
|
||||
experience={candidate.analysis?.experience || {}}
|
||||
documents={candidate.documents?.experience || []}
|
||||
verbose={verbose}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<LifecycleNotificationsCard
|
||||
lifecycleNotifications={candidate.lifecycleNotifications}
|
||||
analysisIssuesDraft={analysisIssuesDraft}
|
||||
onAnalysisIssuesDraftChange={setAnalysisIssuesDraft}
|
||||
onSaveAnalysisIssues={saveAnalysisIssues}
|
||||
isSaving={updateCandidateMutation.isPending}
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
|
||||
{/* Results Drawer */}
|
||||
{showResultsDrawer && <Paper ml='lg' withBorder><Stack>
|
||||
<CandidateResultPreview candidateId={candidateId} />
|
||||
</Stack></Paper>}
|
||||
</Flex>
|
||||
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
302
apps/website/views/[lang]/(app)/backoffice/candidates/page.tsx
Normal file
302
apps/website/views/[lang]/(app)/backoffice/candidates/page.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
'use client';
|
||||
|
||||
import { Container, Title, Text, TextInput, ActionIcon, Group, Button, Badge, Select } from '@pikku/mantine/core';
|
||||
import { DataTable, type DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { IconSearch, IconX } from '@tabler/icons-react';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import dayjs from 'dayjs';
|
||||
import { useGetBackOfficeCandidates } from '@/hooks/backoffice/useGetBackOfficeCandidates';
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { pikku } from '@/pikku/http';
|
||||
import { CountriesMap } from '@heygermany/sdk';
|
||||
import { CountrySearch } from '@/components/jobs/form/CountrySearch';
|
||||
import { LanguageLevelSelectComponent, NurseRecognitionGermanySelectComponent, ApplicantStatusSelectComponent } from '@/components/backoffice/DocumentFieldFactory';
|
||||
|
||||
export default function BackOfficeCandidates() {
|
||||
const { isLoading, data: candidates } = useGetBackOfficeCandidates()
|
||||
const t = useI18n();
|
||||
const notAvailable = t('common.na');
|
||||
|
||||
// Fetch countries map
|
||||
const { data: countriesMap = {} } = useQuery({
|
||||
queryKey: ['countries', t.locale],
|
||||
queryFn: async (): Promise<CountriesMap> => {
|
||||
return await pikku().get('/countries', { locale: t.locale })
|
||||
},
|
||||
staleTime: 60 * 60 * 1000, // 1 hour
|
||||
});
|
||||
|
||||
// Filter states
|
||||
const [nameQuery, setNameQuery] = useState('');
|
||||
const [debouncedNameQuery] = useDebouncedValue(nameQuery, 200);
|
||||
const [countryFilter, setCountryFilter] = useState('');
|
||||
const [germanLevelFilter, setGermanLevelFilter] = useState<string | null>(null);
|
||||
const [recognitionStatusFilter, setRecognitionStatusFilter] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [reuploadedAfterInvalidFilter, setReuploadedAfterInvalidFilter] = useState<string | null>(null);
|
||||
|
||||
// Sort state
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<any>>({
|
||||
columnAccessor: 'createdAt',
|
||||
direction: 'desc',
|
||||
});
|
||||
|
||||
// Records state
|
||||
const [records, setRecords] = useState<any[]>([]);
|
||||
|
||||
const hasReuploadedAfterInvalid = (candidate: any) => {
|
||||
if (!candidate.reuploadedAt || !candidate.invalidatedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new Date(candidate.reuploadedAt).getTime() > new Date(candidate.invalidatedAt).getTime();
|
||||
};
|
||||
|
||||
// Apply filters
|
||||
useEffect(() => {
|
||||
if (!candidates?.data) {
|
||||
setRecords([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = candidates.data.filter((candidate: any) => {
|
||||
// Name filter
|
||||
if (debouncedNameQuery !== '' &&
|
||||
!candidate.name?.toLowerCase().includes(debouncedNameQuery.trim().toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Country filter
|
||||
if (countryFilter && candidate.countryEducation !== countryFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// German level filter
|
||||
if (germanLevelFilter && candidate.languageSelfAssessmentLevel !== germanLevelFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Recognition status filter
|
||||
if (recognitionStatusFilter && candidate.qualificationStatusGermany !== recognitionStatusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (statusFilter && candidate.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (reuploadedAfterInvalidFilter === 'yes' && !hasReuploadedAfterInvalid(candidate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (reuploadedAfterInvalidFilter === 'no' && hasReuploadedAfterInvalid(candidate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
setRecords(filtered);
|
||||
}, [candidates?.data, debouncedNameQuery, countryFilter, germanLevelFilter, recognitionStatusFilter, statusFilter, reuploadedAfterInvalidFilter]);
|
||||
|
||||
// Apply sorting
|
||||
useEffect(() => {
|
||||
if (records.length === 0) return;
|
||||
|
||||
let sortedData = [...records];
|
||||
|
||||
if (sortStatus.columnAccessor === 'createdAt') {
|
||||
sortedData = sortBy(sortedData, (item) => item.createdAt ? new Date(item.createdAt).getTime() : 0);
|
||||
} else if (sortStatus.columnAccessor === 'fullName') {
|
||||
sortedData = sortBy(sortedData, (item) => item.name || '');
|
||||
} else if (sortStatus.columnAccessor === 'countryEducation') {
|
||||
sortedData = sortBy(sortedData, (item) => item.countryEducation || '');
|
||||
} else if (sortStatus.columnAccessor === 'germanLevel') {
|
||||
sortedData = sortBy(sortedData, (item) => item.languageSelfAssessmentLevel || '');
|
||||
} else if (sortStatus.columnAccessor === 'recognitionStatus') {
|
||||
sortedData = sortBy(sortedData, (item) => item.qualificationStatusGermany || '');
|
||||
} else if (sortStatus.columnAccessor === 'status') {
|
||||
sortedData = sortBy(sortedData, (item) => item.status || '');
|
||||
}
|
||||
|
||||
setRecords(sortStatus.direction === 'desc' ? sortedData.reverse() : sortedData);
|
||||
}, [sortStatus]);
|
||||
|
||||
const clearFilters = () => {
|
||||
setNameQuery('');
|
||||
setCountryFilter('');
|
||||
setGermanLevelFilter(null);
|
||||
setRecognitionStatusFilter(null);
|
||||
setStatusFilter(null);
|
||||
setReuploadedAfterInvalidFilter(null);
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
nameQuery !== '' ||
|
||||
countryFilter !== '' ||
|
||||
germanLevelFilter !== null ||
|
||||
recognitionStatusFilter !== null ||
|
||||
statusFilter !== null ||
|
||||
reuploadedAfterInvalidFilter !== null;
|
||||
|
||||
return (
|
||||
<Container size="xl">
|
||||
<Title order={1}>
|
||||
{t('backoffice.candidates.title')}
|
||||
</Title>
|
||||
<Group justify="space-between" mt="sm" mb="lg">
|
||||
<Text size="lg" c="dimmed">
|
||||
{t('backoffice.candidates.description')}
|
||||
</Text>
|
||||
<Group gap="md">
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{hasActiveFilters
|
||||
? t('backoffice.candidates.count.filtered', {
|
||||
filtered: records.length,
|
||||
total: candidates?.total || 0,
|
||||
})
|
||||
: t('backoffice.candidates.count.total', {
|
||||
total: candidates?.total || 0,
|
||||
})}
|
||||
</Text>
|
||||
{hasActiveFilters && (
|
||||
<Button variant="light" onClick={clearFilters}>
|
||||
{t('backoffice.candidates.clearfilters')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<DataTable
|
||||
withTableBorder
|
||||
borderRadius="md"
|
||||
striped
|
||||
highlightOnHover
|
||||
height='calc(100vh - 220px)'
|
||||
minHeight={200}
|
||||
fetching={isLoading}
|
||||
records={records}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
onRowClick={({ record }) =>
|
||||
window.open(`/backoffice/candidate/${record.candidateId}`, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
accessor: 'fullName',
|
||||
title: t('backoffice.candidates.columns.fullname'),
|
||||
sortable: true,
|
||||
render: (candidate) => candidate.name,
|
||||
filter: (
|
||||
<TextInput
|
||||
label={t('backoffice.candidates.filters.name.label')}
|
||||
description={t('backoffice.candidates.filters.name.description')}
|
||||
placeholder={t('backoffice.candidates.filters.name.placeholder')}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
rightSection={
|
||||
<ActionIcon size="sm" variant="transparent" c="dimmed" onClick={() => setNameQuery('')}>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
}
|
||||
value={nameQuery}
|
||||
onChange={(e) => setNameQuery(e.currentTarget.value)}
|
||||
/>
|
||||
),
|
||||
filtering: nameQuery !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'countryEducation',
|
||||
title: t('backoffice.candidates.columns.countryofeducation'),
|
||||
sortable: true,
|
||||
render: (candidate) => candidate.countryEducation ? (countriesMap[candidate.countryEducation] || candidate.countryEducation) : notAvailable,
|
||||
filter: (
|
||||
<CountrySearch
|
||||
label={t('backoffice.candidates.filters.country.label')}
|
||||
placeholder={t('backoffice.candidates.filters.country.placeholder')}
|
||||
value={countryFilter}
|
||||
onChange={setCountryFilter}
|
||||
/>
|
||||
),
|
||||
filtering: countryFilter !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'germanLevel',
|
||||
title: t('backoffice.candidates.columns.germanlevel'),
|
||||
sortable: true,
|
||||
render: (candidate) => candidate.languageSelfAssessmentLevel ? t(`enums.languagelevel.${candidate.languageSelfAssessmentLevel.toLowerCase()}` as any) : notAvailable,
|
||||
filter: (
|
||||
<LanguageLevelSelectComponent
|
||||
label={t('backoffice.candidates.filters.germanlevel.label')}
|
||||
placeholder={t('backoffice.candidates.filters.germanlevel.placeholder')}
|
||||
value={germanLevelFilter}
|
||||
onChange={setGermanLevelFilter}
|
||||
/>
|
||||
),
|
||||
filtering: germanLevelFilter !== null,
|
||||
},
|
||||
{
|
||||
accessor: 'recognitionStatus',
|
||||
title: t('backoffice.candidates.columns.recognitionstatus'),
|
||||
sortable: true,
|
||||
render: (candidate) => candidate.qualificationStatusGermany ? t(`enums.nurserecognitiongermany.${candidate.qualificationStatusGermany.toLowerCase()}` as any) : notAvailable,
|
||||
filter: (
|
||||
<NurseRecognitionGermanySelectComponent
|
||||
label={t('backoffice.candidates.filters.recognitionstatus.label')}
|
||||
placeholder={t('backoffice.candidates.filters.recognitionstatus.placeholder')}
|
||||
value={recognitionStatusFilter}
|
||||
onChange={setRecognitionStatusFilter}
|
||||
/>
|
||||
),
|
||||
filtering: recognitionStatusFilter !== null,
|
||||
},
|
||||
{
|
||||
accessor: 'createdAt',
|
||||
title: t('backoffice.candidates.columns.creationdate'),
|
||||
sortable: true,
|
||||
render: (candidate) => dayjs(candidate.createdAt).format('YYYY-MM-DD')
|
||||
},
|
||||
{
|
||||
accessor: 'status',
|
||||
title: t('backoffice.candidates.columns.status'),
|
||||
sortable: true,
|
||||
render: (candidate) => candidate.status ? t(`enums.applicantstatus.${candidate.status.toLowerCase()}` as any) : notAvailable,
|
||||
filter: (
|
||||
<ApplicantStatusSelectComponent
|
||||
label={t('backoffice.candidates.filters.status.label')}
|
||||
placeholder={t('backoffice.candidates.filters.status.placeholder')}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
/>
|
||||
),
|
||||
filtering: statusFilter !== null,
|
||||
},
|
||||
{
|
||||
accessor: 'reuploadedAfterInvalid',
|
||||
title: t('backoffice.candidates.columns.reuploaded'),
|
||||
render: (candidate) => hasReuploadedAfterInvalid(candidate)
|
||||
? <Badge color="yellow" variant="light">{t('backoffice.candidates.reuploaded.badge')}</Badge>
|
||||
: <Text size="sm" c="dimmed">{t('common.no')}</Text>,
|
||||
filter: (
|
||||
<Select
|
||||
label={t('backoffice.candidates.filters.reuploaded.label')}
|
||||
placeholder={t('backoffice.candidates.filters.reuploaded.placeholder')}
|
||||
value={reuploadedAfterInvalidFilter}
|
||||
onChange={setReuploadedAfterInvalidFilter}
|
||||
data={[
|
||||
{ value: 'yes', label: t('common.yes') },
|
||||
{ value: 'no', label: t('common.no') },
|
||||
]}
|
||||
clearable
|
||||
/>
|
||||
),
|
||||
filtering: reuploadedAfterInvalidFilter !== null,
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { Container, Title, Text, Stack } from '@pikku/mantine/core';
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
|
||||
export default function BackOfficeDashboardPage() {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Container size="xl">
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={1} mb="sm">
|
||||
{t('backoffice.dashboard.title')}
|
||||
</Title>
|
||||
<Text c="dimmed">
|
||||
{t('backoffice.dashboard.description')}
|
||||
</Text>
|
||||
</div>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
# Translation System - CSV Guide
|
||||
|
||||
## What is this?
|
||||
|
||||
This guide explains how the translation CSV file works for the "Next Steps" section on candidate results pages. The system uses **translation tokens** to manage multilingual content efficiently.
|
||||
|
||||
---
|
||||
|
||||
## Understanding the CSV Structure
|
||||
|
||||
The translation CSV file has this structure:
|
||||
|
||||
| Token | EN | DE | UK | ... |
|
||||
|-------|----|----|----|----|
|
||||
| `default.getHelp.title` | Get Help | Hilfe erhalten | Отримати допомогу | ... |
|
||||
| `default.getHelp.subtitle` | Get free support... | Lassen Sie sich... | Отримайте... | ... |
|
||||
|
||||
**Key parts:**
|
||||
- **Token** (Column 1): The unique identifier for each piece of text
|
||||
- **EN, DE, UK, etc.** (Remaining columns): Translations for each language
|
||||
|
||||
---
|
||||
|
||||
## Token Naming Convention
|
||||
|
||||
Tokens follow a specific pattern: `{layer}.{step}.{property}`
|
||||
|
||||
### Structure Breakdown
|
||||
|
||||
```
|
||||
default.getHelp.title
|
||||
↓ ↓ ↓
|
||||
Layer Step Property
|
||||
```
|
||||
|
||||
**1. Layer** - Which candidate group this applies to:
|
||||
- `default` - Base layer for everyone
|
||||
- `nurse.default` - All nurses
|
||||
- `assistant.default` - All assistants
|
||||
- `nurse.allmet` - Specific nurse situation
|
||||
- `assistant.missinglicense` - Specific assistant situation
|
||||
|
||||
**2. Step** - Which of the 6 steps this text belongs to:
|
||||
- `getHelp` - Get Help step
|
||||
- `connect` - Connect with Employers step
|
||||
- `documents` - Prepare Your Documents step
|
||||
- `submit` - Submit Your Application step
|
||||
- `compensatory` - Complete a Compensatory Measure step
|
||||
- `german` - Improve Your German step
|
||||
|
||||
**3. Property** - What part of the step this is:
|
||||
- `title` - The step heading
|
||||
- `subtitle` - The description text
|
||||
- `costs` - Cost information text
|
||||
- `button` - Button text
|
||||
- `buttonImprove` / `buttonJoin` - Special buttons for connect step
|
||||
|
||||
---
|
||||
|
||||
## How the Layer System Works
|
||||
|
||||
The system uses **3 layers** that override each other, like stacking transparent sheets:
|
||||
|
||||
### Layer 1: `default.*` (Base Layer)
|
||||
|
||||
This is the foundation. Every step should have complete translations here.
|
||||
|
||||
**Example tokens:**
|
||||
```
|
||||
default.submit.title
|
||||
default.submit.subtitle
|
||||
default.submit.costs
|
||||
default.submit.button
|
||||
```
|
||||
|
||||
**When to use:** These apply to ALL candidates unless overridden by a more specific layer.
|
||||
|
||||
---
|
||||
|
||||
### Layer 2: `{role}.default.*` (Role Layer)
|
||||
|
||||
Changes text for **all candidates of a specific role** (nurse/assistant/helper).
|
||||
|
||||
**Example tokens:**
|
||||
```
|
||||
assistant.default.submit.subtitle
|
||||
assistant.default.submit.costs
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
- `assistant.default.submit.subtitle` replaces `default.submit.subtitle` for ALL assistants
|
||||
- But `default.submit.title` still shows (not overridden)
|
||||
- But `default.submit.button` still shows (not overridden)
|
||||
|
||||
---
|
||||
|
||||
### Layer 3: `{role}.{situation}.*` (Specific Situation Layer)
|
||||
|
||||
Changes text for **one specific candidate situation** only.
|
||||
|
||||
**Example tokens:**
|
||||
```
|
||||
assistant.missinglicense.submit.subtitle
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
- `assistant.missinglicense.submit.subtitle` replaces the subtitle for assistants missing their license
|
||||
- `assistant.default.submit.costs` still applies (from Layer 2)
|
||||
- `default.submit.title` still applies (from Layer 1)
|
||||
- `default.submit.button` still applies (from Layer 1)
|
||||
|
||||
---
|
||||
|
||||
## Real Example: How Tokens Stack
|
||||
|
||||
**Scenario:** A "Nursing Assistant without a license" views the "Submit Your Application" step.
|
||||
|
||||
**The system looks for tokens in this order:**
|
||||
|
||||
1. **Check Layer 3** (most specific):
|
||||
- `assistant.missinglicense.submit.title` → Not found ❌
|
||||
- `assistant.missinglicense.submit.subtitle` → Found ✅ "First upload your license..."
|
||||
- `assistant.missinglicense.submit.costs` → Not found ❌
|
||||
- `assistant.missinglicense.submit.button` → Not found ❌
|
||||
|
||||
2. **Check Layer 2** (role-specific):
|
||||
- `assistant.default.submit.title` → Not found ❌
|
||||
- `assistant.default.submit.subtitle` → Not needed (found in Layer 3)
|
||||
- `assistant.default.submit.costs` → Found ✅ "Cost: 150-250€"
|
||||
- `assistant.default.submit.button` → Not found ❌
|
||||
|
||||
3. **Check Layer 1** (default):
|
||||
- `default.submit.title` → Found ✅ "Submit Your Application"
|
||||
- `default.submit.subtitle` → Not needed (found in Layer 3)
|
||||
- `default.submit.costs` → Not needed (found in Layer 2)
|
||||
- `default.submit.button` → Found ✅ "Learn how to apply"
|
||||
|
||||
**Final Result:**
|
||||
- Title: "Submit Your Application" ← from `default.submit.title`
|
||||
- Subtitle: "First upload your license..." ← from `assistant.missinglicense.submit.subtitle`
|
||||
- Costs: "Cost: 150-250€" ← from `assistant.default.submit.costs`
|
||||
- Button: "Learn how to apply" ← from `default.submit.button`
|
||||
|
||||
---
|
||||
|
||||
## Available Steps & Properties
|
||||
|
||||
### The 6 Steps
|
||||
|
||||
Every next-steps journey can have these steps (use camelCase in tokens):
|
||||
|
||||
| Token Name | Display Name |
|
||||
|------------|--------------|
|
||||
| `getHelp` | Get Help |
|
||||
| `connect` | Connect with Employers |
|
||||
| `documents` | Prepare Your Documents |
|
||||
| `submit` | Submit Your Application |
|
||||
| `compensatory` | Complete a Compensatory Measure |
|
||||
| `german` | Improve Your German |
|
||||
|
||||
### Properties for Each Step
|
||||
|
||||
| Property | What it is | Example Token |
|
||||
|----------|-----------|---------------|
|
||||
| `title` | Step heading | `default.getHelp.title` |
|
||||
| `subtitle` | Description text | `default.getHelp.subtitle` |
|
||||
| `costs` | Cost information | `default.getHelp.costs` |
|
||||
| `button` | Button text | `default.getHelp.button` |
|
||||
| `buttonImprove` | Special button (connect only) | `default.connect.buttonImprove` |
|
||||
| `buttonJoin` | Special button (connect only) | `default.connect.buttonJoin` |
|
||||
|
||||
---
|
||||
|
||||
## Available Layers (All Valid Token Prefixes)
|
||||
|
||||
### Global Layer
|
||||
- `default` - Base for everyone
|
||||
|
||||
### Role Layers
|
||||
- `nurse.default` - Shared by all nurse situations
|
||||
- `assistant.default` - Shared by all assistant situations
|
||||
- `helper.default` - Shared by all helper situations
|
||||
|
||||
### Specific Situation Layers
|
||||
|
||||
**Nurses:**
|
||||
- `nurse.allmet`
|
||||
- `nurse.missinglicense`
|
||||
- `nurse.missinglicenseandaccreditation`
|
||||
|
||||
**Assistants:**
|
||||
- `assistant.allmet`
|
||||
- `assistant.missinglicense`
|
||||
- `assistant.missinglicenseandaccreditation`
|
||||
|
||||
**Helpers:**
|
||||
- Only uses `helper.default` (no specific situations)
|
||||
|
||||
---
|
||||
|
||||
## Dynamic Placeholders
|
||||
|
||||
Some translations can include placeholders that get replaced with actual data:
|
||||
|
||||
| Placeholder | Replaced with | Example |
|
||||
|-------------|---------------|---------|
|
||||
| `{currentLevel}` | Candidate's current German level | A1, B1, B2, etc. |
|
||||
| `{requiredLevel}` | Required German level | B1, B2, etc. |
|
||||
|
||||
**Example translation:**
|
||||
```
|
||||
default.german.subtitle = "You're currently at {currentLevel} — almost there! To gain full recognition, you'll need to improve your German to {requiredLevel} level."
|
||||
```
|
||||
|
||||
**Becomes:**
|
||||
```
|
||||
"You're currently at A2 — almost there! To gain full recognition, you'll need to improve your German to B2 level."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rules for Adding Translations
|
||||
|
||||
### 1. Always Fill Out the `default.*` Layer Completely
|
||||
|
||||
The base layer should have ALL properties for ALL steps. This ensures there's always a fallback.
|
||||
|
||||
**Required tokens for each step:**
|
||||
```
|
||||
default.{stepName}.title
|
||||
default.{stepName}.subtitle
|
||||
default.{stepName}.costs
|
||||
default.{stepName}.button
|
||||
```
|
||||
|
||||
### 2. Only Add Role/Situation Tokens When You Need to Change Something
|
||||
|
||||
If assistant costs are different, add:
|
||||
```
|
||||
assistant.default.submit.costs
|
||||
```
|
||||
|
||||
Don't add:
|
||||
```
|
||||
assistant.default.submit.title ← Not needed if same as default
|
||||
assistant.default.submit.button ← Not needed if same as default
|
||||
```
|
||||
|
||||
### 3. Use Consistent Naming
|
||||
|
||||
- Steps use camelCase: `getHelp`, `connect`, `documents`
|
||||
- Properties use camelCase: `title`, `subtitle`, `buttonImprove`
|
||||
- Layers use lowercase with dots: `assistant.default`, `nurse.allmet`
|
||||
|
||||
### 4. Add All Languages for Each Token
|
||||
|
||||
Every token row must have translations for all supported languages (EN, DE, UK, TL, TR, BS, SQ, VI, ES, HI, AR).
|
||||
|
||||
---
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### Scenario 1: Change just the subtitle for all assistants
|
||||
|
||||
**Add one token:**
|
||||
```
|
||||
assistant.default.submit.subtitle
|
||||
```
|
||||
|
||||
All other properties (title, costs, button) will fall through from `default.*`
|
||||
|
||||
---
|
||||
|
||||
### Scenario 2: Change subtitle for one specific situation
|
||||
|
||||
**Add one token:**
|
||||
```
|
||||
nurse.missinglicense.submit.subtitle
|
||||
```
|
||||
|
||||
Everything else comes from `nurse.default.*` (if exists) or `default.*`
|
||||
|
||||
---
|
||||
|
||||
### Scenario 3: Assistants have different costs and different process
|
||||
|
||||
**Add two tokens:**
|
||||
```
|
||||
assistant.default.submit.subtitle ← Changed process description
|
||||
assistant.default.submit.costs ← Different price
|
||||
```
|
||||
|
||||
Title and button stay from `default.*`
|
||||
|
||||
---
|
||||
|
||||
### Scenario 4: One situation needs urgent warning
|
||||
|
||||
**Add one token:**
|
||||
```
|
||||
assistant.missinglicenseandaccreditation.submit.subtitle
|
||||
```
|
||||
|
||||
This overrides even `assistant.default.submit.subtitle` for this specific situation.
|
||||
|
||||
---
|
||||
|
||||
## Why This System?
|
||||
|
||||
### Without this system:
|
||||
- 7 candidate situations × 6 steps × 4 properties = **168 translations per language**
|
||||
- Lots of duplication
|
||||
- Hard to make global changes
|
||||
|
||||
### With this system:
|
||||
- Base layer: ~24 translations (6 steps × 4 properties)
|
||||
- Role overrides: ~10 translations (only what's different)
|
||||
- Situation overrides: ~5 translations (only what's specific)
|
||||
- **Total: ~39 translations per language** (77% less!)
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Token Format
|
||||
|
||||
```
|
||||
{layer}.{step}.{property}
|
||||
↓ ↓ ↓
|
||||
(role. (camel (camel
|
||||
situation) Case) Case)
|
||||
|
||||
Examples:
|
||||
default.getHelp.title
|
||||
assistant.default.submit.costs
|
||||
nurse.missinglicense.documents.subtitle
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checking Your Work
|
||||
|
||||
To verify a token will work correctly:
|
||||
|
||||
1. **Check the layer exists** in the "Available Layers" section above
|
||||
2. **Check the step name** is one of the 6 available steps (camelCase)
|
||||
3. **Check the property** is one of: title, subtitle, costs, button, buttonImprove, buttonJoin
|
||||
4. **Ensure all languages have translations** for that token
|
||||
|
||||
**Valid token:** ✅ `assistant.default.submit.costs`
|
||||
**Invalid token:** ❌ `assistant.submit.costs` (missing `.default` or situation)
|
||||
**Invalid token:** ❌ `default.Submit.costs` (should be `submit` not `Submit`)
|
||||
**Invalid token:** ❌ `default.submit.price` (should be `costs` not `price`)
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import Content from './content.mdx'
|
||||
import { useMDXComponents } from '@/mdx-components'
|
||||
import { MDXProvider } from '@mdx-js/react'
|
||||
import { Container } from '@pikku/mantine/core'
|
||||
|
||||
export default function NextStepsHelpPage() {
|
||||
const components = useMDXComponents({})
|
||||
|
||||
return (
|
||||
<Container size="xl" mt="xl">
|
||||
<MDXProvider components={components}>
|
||||
<Content />
|
||||
</MDXProvider>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
84
apps/website/views/[lang]/(app)/backoffice/layout.tsx
Normal file
84
apps/website/views/[lang]/(app)/backoffice/layout.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { AppShell, Flex, Button, Box, Container } from '@pikku/mantine/core';
|
||||
import '@/styles/datatable.css';
|
||||
import Link from '@/framework/link';
|
||||
import { usePathname, useRouter, useParams } from '@/framework/navigation';
|
||||
import { IconArrowLeft } from '@tabler/icons-react';
|
||||
import LanguageSelector from '@/components/ui/LanguageSelector';
|
||||
import Logo from '@/components/ui/Logo';
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
import { asI18n } from '@pikku/react';
|
||||
|
||||
interface BackOfficeLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function BackOfficeLayout({ children }: BackOfficeLayoutProps) {
|
||||
const t = useI18n();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const lang = params.lang as string;
|
||||
const isDetailPage = pathname.includes('/backoffice/candidate/');
|
||||
|
||||
// Don't apply AppShell to auth pages
|
||||
if (pathname.includes('/backoffice/auth')) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
padding="md"
|
||||
header={{ height: 80 }}
|
||||
styles={{
|
||||
main: {
|
||||
backgroundColor: 'var(--mantine-color-gray-0)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AppShell.Header>
|
||||
{isDetailPage ? (
|
||||
<Box style={{ borderBottom: '1px solid var(--mantine-color-gray-2)', height: '100%' }}>
|
||||
<Container size="xl" h="100%">
|
||||
<Flex align="center" justify="space-between" h="100%">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconArrowLeft size={16} />}
|
||||
onClick={() => router.push(`/${lang}/backoffice/candidates`)}
|
||||
>
|
||||
{t('backoffice.navigation.backtocandidates')}
|
||||
</Button>
|
||||
<LanguageSelector />
|
||||
</Flex>
|
||||
</Container>
|
||||
</Box>
|
||||
) : (
|
||||
<Flex align="center" justify="space-between" h="100%" px="md">
|
||||
<Logo size="sm" type={t('backoffice.navigation.adminbadge')} />
|
||||
|
||||
<Flex gap="sm" align="center">
|
||||
<Button
|
||||
component={Link}
|
||||
href={`/${lang}/backoffice/candidates`}
|
||||
variant={pathname.includes('/backoffice/candidates') ? 'filled' : 'default'}
|
||||
>
|
||||
{asI18n('Candidates')}
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
href={`/${lang}/backoffice/translations`}
|
||||
variant={pathname.includes('/backoffice/translations') ? 'filled' : 'default'}
|
||||
>
|
||||
{asI18n('Translations')}
|
||||
</Button>
|
||||
<LanguageSelector />
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Main>{children}</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
13
apps/website/views/[lang]/(app)/backoffice/page.tsx
Normal file
13
apps/website/views/[lang]/(app)/backoffice/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useParams, useRouter } from '@/framework/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function BackOfficeDashboardPage() {
|
||||
const router = useRouter()
|
||||
const { lang } = useParams<{ lang?: string }>()
|
||||
useEffect(() => {
|
||||
router.replace(`/${lang || 'en'}/backoffice/candidates`)
|
||||
}, [lang, router])
|
||||
return null
|
||||
}
|
||||
200
apps/website/views/[lang]/(app)/backoffice/translations/page.tsx
Normal file
200
apps/website/views/[lang]/(app)/backoffice/translations/page.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Link from '@/framework/link'
|
||||
import { useParams } from '@/framework/navigation'
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@pikku/mantine/core'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import type {
|
||||
BackendTranslationBundle,
|
||||
BackendTranslationNamespace,
|
||||
} from '@heygermany/sdk'
|
||||
import { invokeRPC } from '@/pikku/rpc'
|
||||
|
||||
function toPrettyJson(value: Record<string, unknown>) {
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export default function BackOfficeTranslationsPage() {
|
||||
const params = useParams()
|
||||
const lang = params.lang as string
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedLocale, setSelectedLocale] = useState<string | null>(null)
|
||||
const [selectedNamespace, setSelectedNamespace] =
|
||||
useState<BackendTranslationNamespace | null>(null)
|
||||
const [editorValue, setEditorValue] = useState('')
|
||||
const [editorError, setEditorError] = useState<string | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['backoffice', 'translations'],
|
||||
queryFn: async () =>
|
||||
await invokeRPC<BackendTranslationBundle[]>('getBackOfficeTranslations', {}),
|
||||
})
|
||||
|
||||
const rows = query.data ?? []
|
||||
|
||||
const localeOptions = useMemo(
|
||||
() => Array.from(new Set(rows.map((row) => row.locale))).map((locale) => ({
|
||||
value: locale,
|
||||
label: locale,
|
||||
})),
|
||||
[rows]
|
||||
)
|
||||
|
||||
const namespaceOptions = useMemo(
|
||||
() => Array.from(new Set(rows.map((row) => row.namespace))).map((namespace) => ({
|
||||
value: namespace,
|
||||
label: namespace,
|
||||
})),
|
||||
[rows]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (rows.length === 0) return
|
||||
|
||||
if (!selectedLocale) {
|
||||
setSelectedLocale(rows.find((row) => Object.keys(row.content).length > 0)?.locale ?? rows[0].locale)
|
||||
}
|
||||
|
||||
if (!selectedNamespace) {
|
||||
setSelectedNamespace(
|
||||
rows.find((row) => Object.keys(row.content).length > 0)?.namespace ?? rows[0].namespace
|
||||
)
|
||||
}
|
||||
}, [rows, selectedLocale, selectedNamespace])
|
||||
|
||||
const selectedRow = useMemo(
|
||||
() =>
|
||||
rows.find(
|
||||
(row) =>
|
||||
row.locale === selectedLocale && row.namespace === selectedNamespace
|
||||
) ?? null,
|
||||
[rows, selectedLocale, selectedNamespace]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRow) return
|
||||
setEditorValue(toPrettyJson(selectedRow.content))
|
||||
setEditorError(null)
|
||||
}, [selectedRow])
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedLocale || !selectedNamespace) {
|
||||
throw new Error('Select a locale and namespace first')
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(editorValue)
|
||||
if (!isRecord(parsed)) {
|
||||
throw new Error('Translation bundle must be a JSON object')
|
||||
}
|
||||
|
||||
return await invokeRPC<BackendTranslationBundle>('updateBackOfficeTranslation', {
|
||||
locale: selectedLocale,
|
||||
namespace: selectedNamespace,
|
||||
content: parsed,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setEditorError(null)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['backoffice', 'translations'],
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
setEditorError(error instanceof Error ? error.message : String(error))
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Container size="xl">
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Stack gap={4}>
|
||||
<Title order={1}>{asI18n('Backend translations')}</Title>
|
||||
<Text c="dimmed">
|
||||
{asI18n('Edit DB-backed candidate result copy. Changes apply live to backend result rendering.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button component={Link} href={`/${lang}/backoffice/candidates`} variant="light">
|
||||
{asI18n('Back to candidates')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Stack gap="md">
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label={asI18n('Locale')}
|
||||
data={localeOptions}
|
||||
value={selectedLocale}
|
||||
onChange={(value) => setSelectedLocale(value)}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label={asI18n('Namespace')}
|
||||
data={namespaceOptions}
|
||||
value={selectedNamespace}
|
||||
onChange={(value) =>
|
||||
setSelectedNamespace(value as BackendTranslationNamespace | null)
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Textarea
|
||||
label={asI18n('Translation JSON')}
|
||||
autosize
|
||||
minRows={24}
|
||||
maxRows={40}
|
||||
value={editorValue}
|
||||
onChange={(event) => setEditorValue(event.currentTarget.value)}
|
||||
placeholder={asI18n('{ }')}
|
||||
/>
|
||||
|
||||
{selectedRow ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{asI18n(`Last updated: ${new Date(selectedRow.updatedAt).toLocaleString()}`)}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{editorError ? (
|
||||
<Text size="sm" c="red">
|
||||
{asI18n(editorError)}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{query.isLoading
|
||||
? asI18n('Loading translations...')
|
||||
: asI18n(`${rows.length} translation bundles available`)}
|
||||
</Text>
|
||||
<Button
|
||||
loading={saveMutation.isPending}
|
||||
disabled={!selectedRow}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
>
|
||||
{asI18n('Save translation bundle')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
16
apps/website/views/[lang]/(app)/company/auth/login/page.tsx
Normal file
16
apps/website/views/[lang]/(app)/company/auth/login/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { LoginComponent, LoginFormData } from '@/components/LoginComponent';
|
||||
import { useCompanyLogin } from '@/hooks/company/useCompanyLogin';
|
||||
import { useRouter } from '@/framework/navigation';
|
||||
|
||||
export default function CompanyLogin() {
|
||||
const login = useCompanyLogin();
|
||||
|
||||
return (
|
||||
<LoginComponent
|
||||
namespace="company"
|
||||
mutation={login}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,772 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useParams } from '@/framework/navigation'
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Text,
|
||||
Card,
|
||||
Badge,
|
||||
Group,
|
||||
Stack,
|
||||
Button,
|
||||
Box,
|
||||
Grid,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
} from '@pikku/mantine/core'
|
||||
import {
|
||||
IconMapPin,
|
||||
IconLanguage,
|
||||
IconCalendar,
|
||||
IconBriefcase,
|
||||
IconAward,
|
||||
IconMail,
|
||||
IconInfoCircle,
|
||||
IconSchool,
|
||||
IconCheck,
|
||||
} from '@tabler/icons-react'
|
||||
import { useI18n } from '@/context/i18n-provider'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { useGetCompanyCandidate } from '@/hooks/company/useGetCompanyCandidate'
|
||||
import { useGetCountries } from '@/hooks/useGetCountries'
|
||||
import { CandidateProfileAvatar } from '@/components/company/CandidateProfileAvatar'
|
||||
|
||||
function SectionHeader({
|
||||
icon,
|
||||
title,
|
||||
meta,
|
||||
titleOrder = 3,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
meta?: ReactNode
|
||||
titleOrder?: 3 | 4
|
||||
}) {
|
||||
return (
|
||||
<Group justify="space-between" align="center" mb="lg">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{icon}
|
||||
<Title order={titleOrder}>{asI18n(title)}</Title>
|
||||
</Group>
|
||||
{meta}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CandidateDetailPage() {
|
||||
const params = useParams()
|
||||
const candidateId = params.candidateId as string
|
||||
const t = useI18n()
|
||||
const { getCountryNameFromAbbreviation } = useGetCountries()
|
||||
const { isLoading, data: candidate } = useGetCompanyCandidate(candidateId)
|
||||
const locale = t.locale || 'en'
|
||||
|
||||
// Format language level for display
|
||||
const formatLanguageLevel = (level: string | null) => {
|
||||
if (!level) return t('enums.languagelevel.none' as any)
|
||||
return t(`enums.languagelevel.${level.toLowerCase()}` as any)
|
||||
}
|
||||
|
||||
// Format profession for display
|
||||
const formatProfession = (profession: string | null) => {
|
||||
if (!profession) return t('common.na')
|
||||
return t(`enums.nurserecognitiongermany.${profession.toLowerCase()}` as any)
|
||||
}
|
||||
|
||||
const getRecognitionLikelihoodColor = (
|
||||
likelihood: string | null | undefined
|
||||
) => {
|
||||
if (likelihood === 'high') return 'green'
|
||||
if (likelihood === 'medium') return 'yellow'
|
||||
if (likelihood === 'low') return 'red'
|
||||
return 'gray'
|
||||
}
|
||||
|
||||
const formatExperienceDate = (date: Date | string | null | undefined) => {
|
||||
if (!date) return null
|
||||
|
||||
const parsedDate = new Date(date)
|
||||
if (Number.isNaN(parsedDate.getTime())) return null
|
||||
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(parsedDate)
|
||||
}
|
||||
|
||||
const getExperienceMonthIndex = (
|
||||
date: Date | string | null | undefined
|
||||
): number | null => {
|
||||
if (!date) return null
|
||||
|
||||
const parsedDate = new Date(date)
|
||||
if (Number.isNaN(parsedDate.getTime())) return null
|
||||
|
||||
return parsedDate.getUTCFullYear() * 12 + parsedDate.getUTCMonth()
|
||||
}
|
||||
|
||||
const getExperienceDurationMonths = (
|
||||
startDate: Date | string | null | undefined,
|
||||
endDate: Date | string | null | undefined
|
||||
): number | null => {
|
||||
const startMonth = getExperienceMonthIndex(startDate)
|
||||
const endMonth = getExperienceMonthIndex(endDate || new Date())
|
||||
|
||||
if (
|
||||
startMonth === null ||
|
||||
endMonth === null ||
|
||||
endMonth < startMonth
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return endMonth - startMonth + 1
|
||||
}
|
||||
|
||||
const formatExperienceDuration = (
|
||||
startDate: Date | string | null | undefined,
|
||||
endDate: Date | string | null | undefined
|
||||
) => {
|
||||
const totalMonths = getExperienceDurationMonths(startDate, endDate)
|
||||
if (totalMonths === null) return null
|
||||
|
||||
const years = Math.floor(totalMonths / 12)
|
||||
const months = totalMonths % 12
|
||||
const parts: string[] = []
|
||||
|
||||
if (years > 0) {
|
||||
parts.push(
|
||||
`${years} ${t(
|
||||
years === 1
|
||||
? 'company.candidates.duration.year'
|
||||
: 'company.candidates.duration.years'
|
||||
)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (months > 0) {
|
||||
parts.push(
|
||||
`${months} ${t(
|
||||
months === 1
|
||||
? 'company.candidates.duration.month'
|
||||
: 'company.candidates.duration.months'
|
||||
)}`
|
||||
)
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(' ') : null
|
||||
}
|
||||
|
||||
const formatExperienceTimeline = (
|
||||
startDate: Date | string | null | undefined,
|
||||
endDate: Date | string | null | undefined
|
||||
) => {
|
||||
const start = formatExperienceDate(startDate)
|
||||
const end = endDate
|
||||
? formatExperienceDate(endDate)
|
||||
: start
|
||||
? t('company.candidate.experience.present' as any)
|
||||
: null
|
||||
|
||||
if (start && end) return `${start} - ${end}`
|
||||
return start || end || t('common.na')
|
||||
}
|
||||
|
||||
const formatExperiencePeriod = (
|
||||
startDate: Date | string | null | undefined,
|
||||
endDate: Date | string | null | undefined
|
||||
) => {
|
||||
const timeline = formatExperienceTimeline(startDate, endDate)
|
||||
const duration = formatExperienceDuration(startDate, endDate)
|
||||
|
||||
return duration ? `${timeline} · ${duration}` : timeline
|
||||
}
|
||||
|
||||
const formatFacilityType = (facilityType: string | null | undefined) => {
|
||||
if (!facilityType) return t('common.na')
|
||||
|
||||
return facilityType
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const getExperienceLocation = (
|
||||
city: string | null | undefined,
|
||||
country: string | null | undefined
|
||||
) => {
|
||||
const locationParts = [
|
||||
city,
|
||||
country
|
||||
? getCountryNameFromAbbreviation(country) || country
|
||||
: null,
|
||||
].filter(Boolean)
|
||||
|
||||
return locationParts.length > 0
|
||||
? locationParts.join(', ')
|
||||
: null
|
||||
}
|
||||
|
||||
const formatExperienceSummary = (
|
||||
city: string | null | undefined,
|
||||
country: string | null | undefined,
|
||||
facilityType: string | null | undefined
|
||||
) => {
|
||||
const parts = [
|
||||
getExperienceLocation(city, country),
|
||||
facilityType ? formatFacilityType(facilityType) : null,
|
||||
].filter(Boolean)
|
||||
|
||||
return parts.length > 0 ? parts.join(' · ') : t('common.na')
|
||||
}
|
||||
|
||||
const formatExperienceDepartments = (
|
||||
department: string | null | undefined
|
||||
) => {
|
||||
if (!department) return null
|
||||
|
||||
const departments = department
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
|
||||
return departments.length > 0 ? departments.join(', ') : null
|
||||
}
|
||||
|
||||
const formatHomeCountryQualification = (qualification: string | null) => {
|
||||
if (!qualification) return t('common.na')
|
||||
return t(
|
||||
`enums.nurserecognitionhomecountry.${qualification.toLowerCase()}` as any
|
||||
)
|
||||
}
|
||||
|
||||
const formatEducationSummary = (
|
||||
program: string | null | undefined,
|
||||
institution: string | null | undefined
|
||||
) => {
|
||||
const educationParts = [program, institution].filter(Boolean)
|
||||
return educationParts.length > 0
|
||||
? educationParts.join(' · ')
|
||||
: t('common.na')
|
||||
}
|
||||
|
||||
const formatEducationCountry = (country: string | null | undefined) =>
|
||||
country ? getCountryNameFromAbbreviation(country) || country : t('common.na')
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={400}>
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (!candidate) {
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Alert color="red" title={t('company.candidate.notfound.title')}>
|
||||
{t('company.candidate.notfound.message')}
|
||||
</Alert>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const experiences = candidate.experience.filter((experience: any) =>
|
||||
Boolean(
|
||||
experience.roleTitle ||
|
||||
experience.employerName ||
|
||||
experience.department ||
|
||||
experience.country ||
|
||||
experience.city ||
|
||||
experience.facilityType ||
|
||||
experience.startDate ||
|
||||
experience.endDate
|
||||
)
|
||||
)
|
||||
const education = candidate.education
|
||||
const hasExperienceDetails = experiences.length > 0
|
||||
const hasEducationDetails = Boolean(
|
||||
candidate.qualificationStatusHomeCountry ||
|
||||
education?.program ||
|
||||
education?.institution ||
|
||||
education?.country ||
|
||||
typeof education?.nursingRelatedDegree === 'boolean'
|
||||
)
|
||||
const sectionCardStyle = {
|
||||
borderColor: 'var(--mantine-color-gray-2)',
|
||||
}
|
||||
const contactEmailHref = `mailto:info@hey-germany.com?subject=${encodeURIComponent(
|
||||
`${t('company.candidate.contact.emailsubject')}: ${candidate.name} (${candidate.candidateId})`
|
||||
)}&body=${encodeURIComponent(
|
||||
`${t('company.candidate.contact.emailbody')}\n\n${t('company.candidates.candidate')}: ${candidate.name}\n${t('company.candidate.contact.emailcandidateid')}: ${candidate.candidateId}\n`
|
||||
)}`
|
||||
|
||||
return (
|
||||
<Box style={{ minHeight: 'calc(100vh - 80px)' }} suppressHydrationWarning>
|
||||
<Container size="xl" py="xl" suppressHydrationWarning>
|
||||
{/* Profile Header Card */}
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="xl"
|
||||
radius="md"
|
||||
withBorder
|
||||
mb="xl"
|
||||
style={sectionCardStyle}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<Group gap="xl" wrap="nowrap" align="flex-start">
|
||||
<CandidateProfileAvatar
|
||||
countryCode={candidate.countryEducation}
|
||||
imageBorderWidth={4}
|
||||
badgeBorderWidth={4}
|
||||
badgeOffset={-8}
|
||||
badgeSize={48}
|
||||
iconSize={64}
|
||||
name={candidate.name}
|
||||
profileImageUrl={candidate.profileImageUrl}
|
||||
size={128}
|
||||
/>
|
||||
|
||||
{/* Info */}
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Title order={1} mb="xs">
|
||||
{asI18n(candidate.name)}
|
||||
</Title>
|
||||
<Text size="lg" c="gray.7" mb="md">
|
||||
{formatProfession(candidate.qualificationStatusGermany)}
|
||||
</Text>
|
||||
{candidate.recognitionLikelihood && (
|
||||
<Badge
|
||||
size="md"
|
||||
color={getRecognitionLikelihoodColor(
|
||||
candidate.recognitionLikelihood
|
||||
)}
|
||||
variant="light"
|
||||
mb="lg"
|
||||
>
|
||||
{asI18n(`${t(
|
||||
`enums.recognitionlikelihood.${candidate.recognitionLikelihood}` as any
|
||||
)} ${t('company.candidate.badge.recognitionlikelihood')}`)}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Grid gutter="md">
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Group gap="xs">
|
||||
<IconMapPin size={18} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="sm" c="gray.7">
|
||||
{asI18n(`${getCountryNameFromAbbreviation(
|
||||
candidate.countryEducation
|
||||
) || t('common.na')}${candidate.livingInGermany ? ` (${t('company.candidates.badge.ingermany')})` : ''}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Group gap="xs">
|
||||
<IconSchool size={18} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="sm" c="gray.7">
|
||||
{asI18n(education?.program || t('common.na'))}
|
||||
</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Group gap="xs">
|
||||
<IconLanguage
|
||||
size={18}
|
||||
color="var(--mantine-color-gray-6)"
|
||||
/>
|
||||
<Text size="sm" c="gray.7">
|
||||
{asI18n(`${t('company.candidates.german')} ${formatLanguageLevel(
|
||||
candidate.languageSelfAssessmentLevel
|
||||
)}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Group gap="xs">
|
||||
<IconCalendar
|
||||
size={18}
|
||||
color="var(--mantine-color-gray-6)"
|
||||
/>
|
||||
<Text size="sm" c="gray.7">
|
||||
{candidate.age
|
||||
? asI18n(`${candidate.age} ${t('company.candidate.yearsold')}`)
|
||||
: t('common.na')}
|
||||
</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<Grid gutter="xl">
|
||||
{/* Left Column */}
|
||||
<Grid.Col span={{ base: 12, md: 8 }}>
|
||||
<Stack gap="xl">
|
||||
{/* Education Card */}
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={sectionCardStyle}
|
||||
>
|
||||
<SectionHeader
|
||||
icon={<IconSchool size={18} />}
|
||||
title={t('company.candidate.section.education')}
|
||||
/>
|
||||
|
||||
{hasEducationDetails ? (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" c="gray.6" mb={4}>
|
||||
{t('company.candidate.field.qualificationlevel')}
|
||||
</Text>
|
||||
<Text fw={500}>
|
||||
{asI18n(formatHomeCountryQualification(
|
||||
candidate.qualificationStatusHomeCountry
|
||||
))}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="gray.6" mb={4}>
|
||||
{t('company.candidate.field.educationdegree')}
|
||||
</Text>
|
||||
<Text fw={500}>
|
||||
{asI18n(formatEducationSummary(
|
||||
education?.program,
|
||||
education?.institution
|
||||
))}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="gray.6" mb={4}>
|
||||
{t('company.candidate.field.countryofeducation')}
|
||||
</Text>
|
||||
<Text fw={500}>
|
||||
{asI18n(formatEducationCountry(
|
||||
education?.country || candidate.countryEducation
|
||||
))}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="gray.6" mb={4}>
|
||||
{t('company.candidate.field.nursinglicense')}
|
||||
</Text>
|
||||
{candidate.hasVerifiedLicense ? (
|
||||
<Group gap="xs">
|
||||
<Text fw={500}>
|
||||
{t('company.candidate.license.verified')}
|
||||
</Text>
|
||||
<IconCheck
|
||||
size={18}
|
||||
color="var(--mantine-color-green-6)"
|
||||
/>
|
||||
</Group>
|
||||
) : (
|
||||
<Text fw={500}>
|
||||
{t('company.candidate.license.notprovided')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
) : (
|
||||
<Text c="dimmed">{t('company.candidate.education.nodetails')}</Text>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Languages Card */}
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={sectionCardStyle}
|
||||
>
|
||||
<SectionHeader
|
||||
icon={<IconLanguage size={18} />}
|
||||
title={t('company.candidate.section.languages')}
|
||||
/>
|
||||
{candidate.languagesSpoken &&
|
||||
candidate.languagesSpoken.length > 0 && (
|
||||
<Group gap="xs" mb="md">
|
||||
{candidate.languagesSpoken.map((lang: any, index: number) => (
|
||||
<Badge key={index} variant="outline" size="md">
|
||||
{t(`enums.languagecode.${lang.toLowerCase()}` as any)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
<Box
|
||||
pt="md"
|
||||
style={{ borderTop: '1px solid var(--mantine-color-gray-2)' }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" c="gray.6" mb={4}>
|
||||
{t('company.candidate.field.germanlevel')}
|
||||
</Text>
|
||||
<Text fw={500}>
|
||||
{asI18n(formatLanguageLevel(
|
||||
candidate.languageSelfAssessmentLevel
|
||||
))}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="gray.6" mb={4}>
|
||||
{t('company.candidate.field.assessmenttype')}
|
||||
</Text>
|
||||
<Text fw={500}>
|
||||
{candidate.languageCertificateProvided
|
||||
? t('company.candidate.assessmenttype.certificate')
|
||||
: t('company.candidate.assessmenttype.selfassessment')}
|
||||
</Text>
|
||||
</div>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={sectionCardStyle}
|
||||
>
|
||||
<SectionHeader
|
||||
icon={<IconBriefcase size={18} />}
|
||||
title={t('company.candidate.section.workexperience')}
|
||||
/>
|
||||
|
||||
{hasExperienceDetails ? (
|
||||
<Stack gap={0}>
|
||||
{experiences.map((experience: any, index: number) => (
|
||||
<Box
|
||||
key={`${experience.roleTitle || 'role'}-${experience.employerName || 'employer'}-${experience.startDate || index}`}
|
||||
style={{
|
||||
paddingTop: index === 0 ? 0 : 20,
|
||||
paddingBottom:
|
||||
index === experiences.length - 1 ? 0 : 20,
|
||||
borderTop:
|
||||
index === 0
|
||||
? 'none'
|
||||
: '1px solid var(--mantine-color-gray-2)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
paddingLeft: 20,
|
||||
marginLeft: 8,
|
||||
borderLeft: '2px solid var(--mantine-color-gray-3)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: -7,
|
||||
top: 6,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--mantine-color-dark-9)',
|
||||
}}
|
||||
/>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="lg" c="dark.9">
|
||||
{asI18n(experience.roleTitle || t('common.na'))}
|
||||
</Text>
|
||||
{experience.employerName ? (
|
||||
<Text size="md" c="dark.7">
|
||||
{asI18n(experience.employerName)}
|
||||
</Text>
|
||||
) : null}
|
||||
{experience.department ? (
|
||||
<Text size="sm" c="gray.6">
|
||||
{asI18n(formatExperienceDepartments(
|
||||
experience.department
|
||||
) || t('common.na'))}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="sm" c="gray.6">
|
||||
{asI18n(formatExperiencePeriod(
|
||||
experience.startDate,
|
||||
experience.endDate
|
||||
) || t('common.na'))}
|
||||
</Text>
|
||||
<Text size="sm" c="gray.6">
|
||||
{asI18n(formatExperienceSummary(
|
||||
experience.city,
|
||||
experience.country,
|
||||
experience.facilityType
|
||||
) || t('common.na'))}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text c="dimmed">{t('company.candidate.experience.nodetails' as any)}</Text>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Recognition Status Card */}
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={sectionCardStyle}
|
||||
>
|
||||
<SectionHeader
|
||||
icon={<IconAward size={18} />}
|
||||
title={t('company.candidate.section.recognitionstatus')}
|
||||
/>
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Group gap="xs" mb={4}>
|
||||
<Text size="sm" c="gray.6">
|
||||
{t('company.candidate.field.referenceprofession')}
|
||||
</Text>
|
||||
<IconInfoCircle
|
||||
size={16}
|
||||
color="var(--mantine-color-gray-6)"
|
||||
/>
|
||||
</Group>
|
||||
<Text fw={500}>
|
||||
{asI18n(formatProfession(candidate.qualificationStatusGermany))}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="gray.6" mb={4}>
|
||||
{t('company.candidate.field.recognitionlikelihood')}
|
||||
</Text>
|
||||
{candidate.recognitionLikelihood ? (
|
||||
<Badge
|
||||
size="md"
|
||||
color={getRecognitionLikelihoodColor(
|
||||
candidate.recognitionLikelihood
|
||||
)}
|
||||
variant="light"
|
||||
>
|
||||
{t(
|
||||
`enums.recognitionlikelihood.${candidate.recognitionLikelihood}` as any
|
||||
)}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text fw={500}>{t('common.na')}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
{/* Right Column */}
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<Stack gap="xl">
|
||||
{/* Personal Information Card */}
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={sectionCardStyle}
|
||||
>
|
||||
<SectionHeader
|
||||
icon={<IconInfoCircle size={18} />}
|
||||
title={t('company.candidate.section.personalinformation')}
|
||||
titleOrder={4}
|
||||
/>
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" c="gray.6" mb={4}>
|
||||
{t('company.candidate.field.nationality')}
|
||||
</Text>
|
||||
<Text fw={500}>
|
||||
{asI18n(getCountryNameFromAbbreviation(
|
||||
candidate.nationality || candidate.countryEducation
|
||||
) || t('common.na'))}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" c="gray.6" mb={4}>
|
||||
{t('company.candidate.field.countryofresidence')}
|
||||
</Text>
|
||||
<Text fw={500}>
|
||||
{asI18n(getCountryNameFromAbbreviation(
|
||||
candidate.currentResidence
|
||||
) || t('common.na'))}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{candidate.stateWorkPreference &&
|
||||
candidate.stateWorkPreference.length > 0 && (
|
||||
<div>
|
||||
<Text size="sm" c="gray.6" mb={4}>
|
||||
{t('company.candidate.field.preferredlocations')}
|
||||
</Text>
|
||||
<Group gap="xs" mt="xs">
|
||||
{candidate.stateWorkPreference.map((state: any, index: number) => (
|
||||
<Badge key={index} variant="outline" size="sm">
|
||||
{t(
|
||||
`enums.germanstate.${state.toLowerCase().replace(/_/g, '-')}` as any
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Contact Information Card */}
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={sectionCardStyle}
|
||||
>
|
||||
<SectionHeader
|
||||
icon={<IconMail size={18} />}
|
||||
title={t('company.candidate.section.contactinformation')}
|
||||
titleOrder={4}
|
||||
/>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="gray.7">
|
||||
{t('company.candidate.contact.emaildescription')}
|
||||
</Text>
|
||||
<Button
|
||||
component="a"
|
||||
href={contactEmailHref}
|
||||
variant="default"
|
||||
fullWidth
|
||||
leftSection={<IconMail size={18} />}
|
||||
styles={{
|
||||
root: {
|
||||
height: 48,
|
||||
borderRadius: 10,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t('company.candidate.contact.emailcta')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Container>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
587
apps/website/views/[lang]/(app)/company/candidates/page.tsx
Normal file
587
apps/website/views/[lang]/(app)/company/candidates/page.tsx
Normal file
@@ -0,0 +1,587 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Text,
|
||||
SimpleGrid,
|
||||
Card,
|
||||
Badge,
|
||||
Group,
|
||||
Stack,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
ActionIcon,
|
||||
Loader,
|
||||
Center,
|
||||
Anchor,
|
||||
Box,
|
||||
Popover,
|
||||
} from '@pikku/mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import {
|
||||
IconAward,
|
||||
IconBriefcase,
|
||||
IconCalendar,
|
||||
IconFilter,
|
||||
IconInfoCircle,
|
||||
IconLanguage,
|
||||
IconSchool,
|
||||
} from '@tabler/icons-react'
|
||||
import { useI18n } from '@/context/i18n-provider'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { useGetCompanyCandidates } from '@/hooks/company/useGetCompanyCandidates'
|
||||
import { useGetCountries } from '@/hooks/useGetCountries'
|
||||
import { useParams } from '@/framework/navigation'
|
||||
import { SUPPORTED_COUNTRIES } from '@heygermany/sdk/config'
|
||||
import {
|
||||
LanguageLevels,
|
||||
NurseRecognitionGermanyValues,
|
||||
RecognitionLikelihoodValues,
|
||||
type LanguageLevel,
|
||||
} from '@heygermany/sdk'
|
||||
import { CandidateProfileAvatar } from '@/components/company/CandidateProfileAvatar'
|
||||
|
||||
const LANGUAGE_LEVEL_SORT_RANK: Record<LanguageLevel, number> = {
|
||||
[LanguageLevels.NONE]: 0,
|
||||
[LanguageLevels.A_ONE]: 1,
|
||||
[LanguageLevels.A_TWO]: 2,
|
||||
[LanguageLevels.B_ONE]: 3,
|
||||
[LanguageLevels.B_TWO]: 4,
|
||||
[LanguageLevels.C_ONE]: 5,
|
||||
[LanguageLevels.C_TWO]: 6,
|
||||
}
|
||||
|
||||
const getLanguageLevelSortRank = (
|
||||
level: LanguageLevel | null | undefined
|
||||
): number => {
|
||||
if (!level) return -1
|
||||
return LANGUAGE_LEVEL_SORT_RANK[level] ?? -1
|
||||
}
|
||||
|
||||
const getCreatedAtTimestamp = (
|
||||
createdAt: string | Date | null | undefined
|
||||
): number => {
|
||||
if (!createdAt) return 0
|
||||
return new Date(createdAt).getTime()
|
||||
}
|
||||
|
||||
export default function CompanyCandidates() {
|
||||
const { isLoading, data: response } = useGetCompanyCandidates()
|
||||
const candidates = response?.data || []
|
||||
const t = useI18n()
|
||||
const { lang } = useParams()
|
||||
const { data: countriesData, getCountryNameFromAbbreviation } =
|
||||
useGetCountries()
|
||||
const [drawerOpened, { open: openDrawer, close: closeDrawer }] =
|
||||
useDisclosure(false)
|
||||
|
||||
const [selectedProfessions, setSelectedProfessions] = useState<string[]>([])
|
||||
const [selectedLanguageLevels, setSelectedLanguageLevels] = useState<
|
||||
string[]
|
||||
>([])
|
||||
const [selectedCountries, setSelectedCountries] = useState<string[]>([])
|
||||
const [selectedResidency, setSelectedResidency] = useState<string[]>([])
|
||||
|
||||
const candidateSourceCountries = SUPPORTED_COUNTRIES.filter(
|
||||
(code) => code !== 'DEU'
|
||||
)
|
||||
|
||||
const allCountries = useMemo(() => {
|
||||
if (!countriesData) return []
|
||||
|
||||
return candidateSourceCountries
|
||||
.map((code) => ({
|
||||
code,
|
||||
name: countriesData[code] || code,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [countriesData])
|
||||
|
||||
const filteredCandidates = useMemo(() => {
|
||||
return candidates
|
||||
.filter((candidate: any) => {
|
||||
if (
|
||||
selectedProfessions.length > 0 &&
|
||||
!selectedProfessions.includes(
|
||||
candidate.qualificationStatusGermany || ''
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
selectedLanguageLevels.length > 0 &&
|
||||
!selectedLanguageLevels.includes(
|
||||
candidate.languageSelfAssessmentLevel || ''
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
selectedCountries.length > 0 &&
|
||||
!selectedCountries.includes(candidate.countryEducation || '')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (selectedResidency.length > 0) {
|
||||
const isInGermany = candidate.livingInGermany
|
||||
? 'in-germany'
|
||||
: 'not-in-germany'
|
||||
|
||||
if (!selectedResidency.includes(isInGermany)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
.sort((a: any, b: any) => {
|
||||
const languageLevelDifference =
|
||||
getLanguageLevelSortRank(b.languageSelfAssessmentLevel) -
|
||||
getLanguageLevelSortRank(a.languageSelfAssessmentLevel)
|
||||
|
||||
if (languageLevelDifference !== 0) {
|
||||
return languageLevelDifference
|
||||
}
|
||||
|
||||
return (
|
||||
getCreatedAtTimestamp(b.createdAt) -
|
||||
getCreatedAtTimestamp(a.createdAt)
|
||||
)
|
||||
})
|
||||
}, [
|
||||
candidates,
|
||||
selectedProfessions,
|
||||
selectedLanguageLevels,
|
||||
selectedCountries,
|
||||
selectedResidency,
|
||||
])
|
||||
|
||||
const languageLevelOptions = useMemo(
|
||||
() => t.getEnumOptions(LanguageLevels, 'languagelevel'),
|
||||
[t]
|
||||
)
|
||||
|
||||
const professionOptions = useMemo(
|
||||
() => t.getEnumOptions(NurseRecognitionGermanyValues, 'nurserecognitiongermany'),
|
||||
[t]
|
||||
)
|
||||
|
||||
const formatLanguageLevel = (level: string | null) => {
|
||||
if (!level) return t('enums.languagelevel.none' as any)
|
||||
const option = languageLevelOptions.find((opt) => opt.value === level)
|
||||
return option?.label || level
|
||||
}
|
||||
|
||||
const formatProfession = (profession: string | null) => {
|
||||
if (!profession) return t('company.candidates.profession.na' as any)
|
||||
const option = professionOptions.find((opt) => opt.value === profession)
|
||||
return option?.label || profession
|
||||
}
|
||||
|
||||
const formatEducationSummary = (program: string | null | undefined) =>
|
||||
program || t('common.na')
|
||||
|
||||
const formatTotalExperienceDuration = (
|
||||
totalExperienceMonths: number | null | undefined
|
||||
) => {
|
||||
if (!totalExperienceMonths) return t('common.na')
|
||||
|
||||
const years = Math.floor(totalExperienceMonths / 12)
|
||||
const months = totalExperienceMonths % 12
|
||||
const parts: string[] = []
|
||||
|
||||
if (years > 0) {
|
||||
parts.push(
|
||||
`${years} ${t(
|
||||
years === 1
|
||||
? 'company.candidates.duration.year'
|
||||
: 'company.candidates.duration.years'
|
||||
)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (months > 0 || parts.length === 0) {
|
||||
parts.push(
|
||||
`${months} ${t(
|
||||
months === 1
|
||||
? 'company.candidates.duration.month'
|
||||
: 'company.candidates.duration.months'
|
||||
)}`
|
||||
)
|
||||
}
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const getRecognitionLikelihoodBadge = (likelihood: string | null) => {
|
||||
if (!likelihood) return null
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
[RecognitionLikelihoodValues.HIGH]: 'green',
|
||||
[RecognitionLikelihoodValues.MEDIUM]: 'yellow',
|
||||
[RecognitionLikelihoodValues.LOW]: 'red',
|
||||
}
|
||||
|
||||
return {
|
||||
color: colorMap[likelihood] || 'gray',
|
||||
label: t(`enums.recognitionlikelihood.${likelihood}` as any),
|
||||
}
|
||||
}
|
||||
|
||||
const FilterContent = () => (
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Group gap={6} mb="sm">
|
||||
<Text fw={500} size="sm" c="dark.7">
|
||||
{t('company.candidates.filters.profession')}
|
||||
</Text>
|
||||
<Popover width={320} position="right" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<ActionIcon variant="subtle" size="xs" color="gray">
|
||||
<IconInfoCircle size={14} />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Text size="sm" style={{ whiteSpace: 'pre-line', lineHeight: 1.6 }}>
|
||||
{t('company.candidates.filters.profession.tooltip' as any)}
|
||||
</Text>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
<Checkbox.Group
|
||||
value={selectedProfessions}
|
||||
onChange={setSelectedProfessions}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{professionOptions.map((option) => (
|
||||
<Checkbox
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
label={asI18n(option.label)}
|
||||
size="sm"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fw={500} size="sm" mb="sm" c="dark.7">
|
||||
{t('company.candidates.filters.language')}
|
||||
</Text>
|
||||
<Checkbox.Group
|
||||
value={selectedLanguageLevels}
|
||||
onChange={setSelectedLanguageLevels}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{languageLevelOptions.map((option) => (
|
||||
<Checkbox
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
label={asI18n(option.label)}
|
||||
size="sm"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fw={500} size="sm" mb="sm" c="dark.7">
|
||||
{t('company.candidates.filters.country')}
|
||||
</Text>
|
||||
<Checkbox.Group
|
||||
value={selectedCountries}
|
||||
onChange={setSelectedCountries}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{allCountries.map(({ code, name }) => (
|
||||
<Checkbox key={code} value={code} label={asI18n(name)} size="sm" />
|
||||
))}
|
||||
</Stack>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fw={500} size="sm" mb="sm" c="dark.7">
|
||||
{t('company.candidates.filters.residency')}
|
||||
</Text>
|
||||
<Checkbox.Group
|
||||
value={selectedResidency}
|
||||
onChange={setSelectedResidency}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Checkbox
|
||||
value="in-germany"
|
||||
label={t('company.candidates.filters.iningermany')}
|
||||
size="sm"
|
||||
/>
|
||||
<Checkbox
|
||||
value="not-in-germany"
|
||||
label={t('company.candidates.filters.notingermany')}
|
||||
size="sm"
|
||||
/>
|
||||
</Stack>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
</Stack>
|
||||
)
|
||||
|
||||
const filterPanelScrollStyle = {
|
||||
minHeight: 0,
|
||||
overflowY: 'auto' as const,
|
||||
paddingRight: 4,
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={400}>
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
opened={drawerOpened}
|
||||
onClose={closeDrawer}
|
||||
title={
|
||||
<Text fw={600} size="lg">
|
||||
{t('company.candidates.filters.title')}
|
||||
</Text>
|
||||
}
|
||||
padding="lg"
|
||||
size="sm"
|
||||
hiddenFrom="lg"
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
...filterPanelScrollStyle,
|
||||
maxHeight: 'calc(100dvh - 140px)',
|
||||
}}
|
||||
>
|
||||
<FilterContent />
|
||||
</Box>
|
||||
</Drawer>
|
||||
|
||||
<Box style={{ minHeight: 'calc(100vh - 80px)' }}>
|
||||
<Container size="xl" py="xl">
|
||||
<Group align="flex-start" gap="xl">
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
w={280}
|
||||
visibleFrom="lg"
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 100,
|
||||
maxHeight: 'calc(100dvh - 120px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Text fw={600} size="lg" mb="lg">
|
||||
{t('company.candidates.filters.title')}
|
||||
</Text>
|
||||
<Box style={filterPanelScrollStyle}>
|
||||
<FilterContent />
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Group justify="space-between" mb="xl">
|
||||
<Title order={2}>
|
||||
{t('company.candidates.candidatesfound' as any, {
|
||||
count: filteredCandidates.length,
|
||||
})}
|
||||
</Title>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
onClick={openDrawer}
|
||||
hiddenFrom="lg"
|
||||
>
|
||||
<IconFilter size={20} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
|
||||
{filteredCandidates.map((candidate: any) => {
|
||||
const recognitionBadge = getRecognitionLikelihoodBadge(
|
||||
candidate.recognitionLikelihood
|
||||
)
|
||||
|
||||
return (
|
||||
<Anchor
|
||||
key={candidate.candidateId}
|
||||
href={`/${lang}/company/candidate/${candidate.candidateId}`}
|
||||
underline="never"
|
||||
style={{ display: 'block', height: '100%' }}
|
||||
>
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="xl"
|
||||
radius="md"
|
||||
withBorder
|
||||
h="100%"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
position: 'relative',
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
'&:hover': {
|
||||
boxShadow: '0 12px 24px rgba(0, 0, 0, 0.12)',
|
||||
transform: 'translateY(-4px)',
|
||||
borderColor: 'var(--mantine-color-blue-5)',
|
||||
backgroundColor: 'var(--mantine-color-gray-0)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{candidate.livingInGermany && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="black" tt="uppercase" fw={500}>
|
||||
{t('company.candidates.card.in_germany')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Group gap="md" mb="md" wrap="nowrap" align="flex-start">
|
||||
<CandidateProfileAvatar
|
||||
countryCode={candidate.countryEducation}
|
||||
imageBorderWidth={2}
|
||||
badgeBorderWidth={2}
|
||||
badgeOffset={-2}
|
||||
badgeSize={28}
|
||||
iconSize={28}
|
||||
name={candidate.name}
|
||||
profileImageUrl={candidate.profileImageUrl}
|
||||
size={56}
|
||||
/>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={600} size="lg" mb={4} c="dark.9">
|
||||
{candidate.name}
|
||||
</Text>
|
||||
<Text size="md" c="gray.6">
|
||||
{asI18n(getCountryNameFromAbbreviation(
|
||||
candidate.countryEducation
|
||||
) || t('common.na'))}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<IconBriefcase
|
||||
size={18}
|
||||
color="var(--mantine-color-gray-6)"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Text size="md" c="gray.7" lineClamp={1}>
|
||||
{asI18n(formatProfession(
|
||||
candidate.qualificationStatusGermany
|
||||
))}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||
<IconSchool
|
||||
size={18}
|
||||
color="var(--mantine-color-gray-6)"
|
||||
style={{ flexShrink: 0, marginTop: 2 }}
|
||||
/>
|
||||
<Text size="md" c="gray.7" lineClamp={2}>
|
||||
{asI18n(formatEducationSummary(
|
||||
candidate.education?.program
|
||||
))}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<IconLanguage
|
||||
size={18}
|
||||
color="var(--mantine-color-gray-6)"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Text size="md" c="gray.7">
|
||||
{asI18n(`${t('company.candidates.card.germanlevel' as any)}: ${formatLanguageLevel(candidate.languageSelfAssessmentLevel)}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<IconCalendar
|
||||
size={18}
|
||||
color="var(--mantine-color-gray-6)"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Text size="md" c="gray.7">
|
||||
{asI18n(`${t('company.candidates.card.totalexperience')}: ${formatTotalExperienceDuration(candidate.totalExperienceMonths)}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{recognitionBadge ? (
|
||||
<Box
|
||||
mt="sm"
|
||||
pt="sm"
|
||||
style={{
|
||||
borderTop: '1px solid var(--mantine-color-gray-2)',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" align="center">
|
||||
<IconAward
|
||||
size={18}
|
||||
color="var(--mantine-color-gray-6)"
|
||||
/>
|
||||
<Text size="md" c="gray.7" fw={500}>
|
||||
{asI18n(`${t('company.candidates.card.recognitionlikelihood' as any)}:`)}
|
||||
</Text>
|
||||
<Badge
|
||||
size="sm"
|
||||
color={recognitionBadge.color}
|
||||
variant="light"
|
||||
>
|
||||
{asI18n(recognitionBadge.label)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Box>
|
||||
) : null}
|
||||
</Card>
|
||||
</Anchor>
|
||||
)
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
{filteredCandidates.length === 0 && (
|
||||
<Center h={200}>
|
||||
<Text c="dimmed" size="lg">
|
||||
{t('company.candidates.nocandidates')}
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
</Container>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
67
apps/website/views/[lang]/(app)/company/layout.tsx
Normal file
67
apps/website/views/[lang]/(app)/company/layout.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { AppShell, Flex, Text, Container, Button, Box } from '@pikku/mantine/core';
|
||||
import { usePathname, useRouter, useParams } from '@/framework/navigation';
|
||||
import { IconArrowLeft } from '@tabler/icons-react';
|
||||
import LanguageSelector from '@/components/ui/LanguageSelector';
|
||||
import Logo from '@/components/ui/Logo';
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
|
||||
interface CompanyLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function CompanyLayout({ children }: CompanyLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const t = useI18n();
|
||||
const lang = params.lang as string;
|
||||
const isDetailPage = pathname.includes('/company/candidate/');
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
padding={0}
|
||||
header={{ height: 80 }}
|
||||
styles={{
|
||||
main: {
|
||||
backgroundColor: 'var(--mantine-color-gray-0)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AppShell.Header>
|
||||
{isDetailPage ? (
|
||||
<Box style={{ borderBottom: '1px solid var(--mantine-color-gray-2)', height: '100%' }}>
|
||||
<Container size="xl" h="100%">
|
||||
<Flex align="center" justify="space-between" h="100%">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconArrowLeft size={16} />}
|
||||
onClick={() => router.push(`/${lang}/company/candidates`)}
|
||||
>
|
||||
{t('company.navigation.backtosearch')}
|
||||
</Button>
|
||||
<LanguageSelector />
|
||||
</Flex>
|
||||
</Container>
|
||||
</Box>
|
||||
) : (
|
||||
<Flex align="center" justify="space-between" h="100%" px="xl">
|
||||
<div>
|
||||
<Logo size="sm" />
|
||||
<Text size="sm" c="dimmed" mt={2}>
|
||||
{t('company.navigation.tagline')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Flex gap="sm" align="center">
|
||||
<LanguageSelector />
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Main>{children}</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
15
apps/website/views/[lang]/(app)/company/page.tsx
Normal file
15
apps/website/views/[lang]/(app)/company/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter, useParams } from '@/framework/navigation';
|
||||
|
||||
export default function CompanyPage() {
|
||||
const router = useRouter();
|
||||
const { lang } = useParams();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(`/${lang}/company/candidates`);
|
||||
}, [router, lang]);
|
||||
|
||||
return null;
|
||||
}
|
||||
64
apps/website/views/[lang]/(app)/logout/page.tsx
Normal file
64
apps/website/views/[lang]/(app)/logout/page.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useParams, useRouter } from '@/framework/navigation';
|
||||
import { Paper, Text, Title, Stack, Loader, Flex } from '@pikku/mantine/core';
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { signOut } from '@/lib/auth';
|
||||
import { asI18n } from '@pikku/react';
|
||||
|
||||
export default function LogoutPage() {
|
||||
const t = useI18n();
|
||||
const router = useRouter();
|
||||
const { lang } = useParams<{ lang?: string }>()
|
||||
const logout = useMutation({
|
||||
mutationFn: async () => {
|
||||
await signOut()
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const performLogout = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
await logout.mutateAsync()
|
||||
router.replace(lang ? `/${lang}` : '/')
|
||||
}
|
||||
|
||||
void performLogout()
|
||||
}, [lang, router])
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
mih="100vh"
|
||||
p="md"
|
||||
>
|
||||
<Paper p="xl" radius="md" shadow="sm" w="100%" maw="400">
|
||||
<Stack align="center" gap="lg">
|
||||
<Title order={2} ta="center">
|
||||
{t('logout.title')}
|
||||
</Title>
|
||||
|
||||
{logout.isPending ? (
|
||||
<>
|
||||
<Loader size="md" />
|
||||
<Text ta="center" c="dimmed">
|
||||
{t('logout.loggingout')}
|
||||
</Text>
|
||||
</>
|
||||
) : logout.isError ? (
|
||||
<Text ta="center" c="red">
|
||||
{asI18n(`${t('logout.error')}: ${logout.error?.message || 'Unknown error'}`)}
|
||||
</Text>
|
||||
) : logout.isSuccess ? (
|
||||
<Text ta="center" c="green">
|
||||
{t('logout.success')}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
95
apps/website/views/[lang]/(main)/about/page.tsx
Normal file
95
apps/website/views/[lang]/(main)/about/page.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import { Container, Stack, Title, Text, Grid, ThemeIcon, List, Box } from '@pikku/mantine/core'
|
||||
import { useI18n } from "@/context/i18n-provider"
|
||||
|
||||
const AboutPage: React.FunctionComponent = () => {
|
||||
const t = useI18n()
|
||||
|
||||
return (
|
||||
<Container size="md" py="xl">
|
||||
<Title order={1} size="h1" mb="xl" ta="center">{t('website.about.title')}</Title>
|
||||
|
||||
<Stack gap="xl">
|
||||
<Text size="lg" c="dimmed">
|
||||
{t('website.about.description')}
|
||||
</Text>
|
||||
|
||||
<Stack gap="md">
|
||||
<Title order={2} size="h2" mt="xl" mb="md">{t('website.about.mission.title')}</Title>
|
||||
<Text c="dimmed">
|
||||
{t('website.about.mission.description')}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="md">
|
||||
<Title order={2} size="h2" mt="xl" mb="md">{t('website.about.howitworks.title')}</Title>
|
||||
<Grid mt="md">
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<Stack align="center" ta="center" gap="md">
|
||||
<ThemeIcon size="xl" radius="xl" variant="light" color="blue">
|
||||
<Text size="xl" fw={700}>{1}</Text>
|
||||
</ThemeIcon>
|
||||
<Title order={3} size="lg" mb="xs">{t('website.about.howitworks.step1.title')}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('website.about.howitworks.step1.description')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<Stack align="center" ta="center" gap="md">
|
||||
<ThemeIcon size="xl" radius="xl" variant="light" color="blue">
|
||||
<Text size="xl" fw={700}>{2}</Text>
|
||||
</ThemeIcon>
|
||||
<Title order={3} size="lg" mb="xs">{t('website.about.howitworks.step2.title')}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('website.about.howitworks.step2.description')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<Stack align="center" ta="center" gap="md">
|
||||
<ThemeIcon size="xl" radius="xl" variant="light" color="blue">
|
||||
<Text size="xl" fw={700}>{3}</Text>
|
||||
</ThemeIcon>
|
||||
<Title order={3} size="lg" mb="xs">{t('website.about.howitworks.step3.title')}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('website.about.howitworks.step3.description')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="md">
|
||||
<Title order={2} size="h2" mt="xl" mb="md">{t('website.about.whychoose.title')}</Title>
|
||||
<List spacing="xs" c="dimmed">
|
||||
<List.Item>
|
||||
{t('website.about.whychoose.fast.summary')}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t('website.about.whychoose.free.summary')}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t('website.about.whychoose.secure.summary')}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t('website.about.whychoose.support.summary')}
|
||||
</List.Item>
|
||||
</List>
|
||||
</Stack>
|
||||
|
||||
<Box bg="blue.0" p="xl" style={{ borderRadius: 'var(--mantine-radius-lg)' }} mt="xl">
|
||||
<Title order={3} size="xl" mb="md">{t('website.about.cta.title')}</Title>
|
||||
<Text c="dimmed" mb="md">
|
||||
{t('website.about.cta.description')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default AboutPage
|
||||
100
apps/website/views/[lang]/(main)/auth/magic-link/page.tsx
Normal file
100
apps/website/views/[lang]/(main)/auth/magic-link/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
'use client'
|
||||
|
||||
import { Container, Stack, Title, Text, Button, Loader, Center } from '@pikku/mantine/core'
|
||||
import { useI18n } from "@/context/i18n-provider"
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams, useRouter, useParams } from '@/framework/navigation'
|
||||
import { verifyMagicLink } from '@/lib/auth'
|
||||
import { asI18n } from '@pikku/react'
|
||||
|
||||
const MagicLinkPage: React.FunctionComponent = () => {
|
||||
const t = useI18n()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { lang } = useParams<{ lang: string }>()
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
|
||||
const [errorMessage, setErrorMessage] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get('token')
|
||||
const redirect = searchParams.get('redirect') || '/jobs/application'
|
||||
|
||||
if (!token) {
|
||||
setStatus('error')
|
||||
setErrorMessage(t('magiclink.error.notoken'))
|
||||
return
|
||||
}
|
||||
|
||||
const completeMagicLinkSignIn = async () => {
|
||||
try {
|
||||
await verifyMagicLink(token)
|
||||
setStatus('success')
|
||||
setTimeout(() => {
|
||||
router.push(redirect)
|
||||
}, 1000)
|
||||
} catch (error: any) {
|
||||
setStatus('error')
|
||||
setErrorMessage(error.message || t('magiclink.error.verificationfailed'))
|
||||
}
|
||||
}
|
||||
|
||||
completeMagicLinkSignIn()
|
||||
}, [searchParams, router, t])
|
||||
|
||||
return (
|
||||
<Container size="sm" py="xl" my='auto'>
|
||||
<Stack gap="xl" align="center">
|
||||
{status === 'loading' && (
|
||||
<>
|
||||
<Center>
|
||||
<Loader size="xl" />
|
||||
</Center>
|
||||
<Title order={1} ta="center">
|
||||
{t('magiclink.loading.title')}
|
||||
</Title>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<Stack gap="md" align="center">
|
||||
<Title order={1} ta="center">
|
||||
{t('magiclink.success.title')}
|
||||
</Title>
|
||||
<Text c="dimmed" ta="center">
|
||||
{t('magiclink.success.message')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<Stack gap="md" align="center">
|
||||
<Title order={1} ta="center">
|
||||
{t('magiclink.error.title')}
|
||||
</Title>
|
||||
<Text c="dimmed" ta="center">
|
||||
{t('magiclink.error.message')}
|
||||
</Text>
|
||||
{errorMessage && (
|
||||
<Text size="sm" c="red" ta="center">
|
||||
{asI18n(errorMessage)}
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
size="lg"
|
||||
mt="md"
|
||||
onClick={() => router.push(`/${lang}`)}
|
||||
>
|
||||
{t('magiclink.error.button')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default MagicLinkPage
|
||||
220
apps/website/views/[lang]/(main)/jobs/application/page.tsx
Normal file
220
apps/website/views/[lang]/(main)/jobs/application/page.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
'use client'
|
||||
|
||||
import React, { startTransition, useState } from 'react'
|
||||
import { FormProvider } from 'react-hook-form'
|
||||
import { Progress, Card, Button, Stepper, Box, Flex, Text, Paper, Container, Portal } from "@pikku/mantine/core"
|
||||
import { BasicInfo } from "@/components/jobs/form/BasicInfo"
|
||||
import { TrainingCertificates } from "@/components/jobs/form/TrainingCertificates"
|
||||
import { License } from "@/components/jobs/form/License"
|
||||
import { GermanLanguage } from "@/components/jobs/form/GermanLanguage"
|
||||
import { ProfessionalExperience } from "@/components/jobs/form/ProfessionalExperience"
|
||||
import { ApplicationCandidateData } from '@heygermany/sdk'
|
||||
import { useCandidateForm } from '@/hooks/candidate/useCandidateForm'
|
||||
import { useGetCandidate } from '@/hooks/candidate/useGetCandidate'
|
||||
import { pikku } from '@/pikku/http'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Done } from '@/components/jobs/form/Done'
|
||||
import { Invalid } from '@/components/jobs/form/Invalid'
|
||||
import { useI18n } from '@/context/i18n-provider'
|
||||
import { ApplicantStatuses } from '@heygermany/sdk'
|
||||
import { CandidateResult } from '@/components/jobs/CandidateResult'
|
||||
import { useGetCandidateResults } from '@/hooks/useGetCandidateResult'
|
||||
import { IconCheck } from '@tabler/icons-react'
|
||||
import { HelpAffix } from '@/components/jobs/form/HelpAffix'
|
||||
import { useSearchParams } from '@/framework/navigation'
|
||||
import { asI18n } from '@pikku/react'
|
||||
|
||||
const Form: React.FunctionComponent<{ candidate: ApplicationCandidateData }> = ({ candidate }) => {
|
||||
const t = useI18n()
|
||||
|
||||
const steps = [
|
||||
{ title: t('jobs.apply.steps.basicinfo'), component: BasicInfo },
|
||||
{ title: t('jobs.apply.steps.educationdocuments' as any), component: TrainingCertificates },
|
||||
{ title: t('jobs.apply.steps.license'), component: License },
|
||||
{ title: t('jobs.apply.steps.germanlanguage'), component: GermanLanguage },
|
||||
{ title: t('jobs.apply.steps.professionalexperience'), component: ProfessionalExperience },
|
||||
]
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
|
||||
// Single form instance for the entire application
|
||||
const form = useCandidateForm(candidate)
|
||||
|
||||
const progressPercentage = (currentStep / steps.length) * 100
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Return a Date (not an ISO string): the pikku fetch client serializes Date
|
||||
// values over the wire, and the /candidate input type expects Date fields.
|
||||
const serializeDate = (value: Date | string | null | undefined) => {
|
||||
if (!value) return null
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
const handleNext = async () => {
|
||||
// Validate current form state before proceeding
|
||||
const isValid = await form.trigger()
|
||||
|
||||
if (isValid) {
|
||||
// Perform partial update - save current form data
|
||||
const { education, licenses, languageCertificates, workExperience, ...formData } = form.getValues()
|
||||
const currentData = {
|
||||
...formData,
|
||||
candidateId: candidate.candidateId,
|
||||
dateOfBirth: serializeDate(formData.dateOfBirth),
|
||||
}
|
||||
|
||||
if (currentStep < steps.length - 1) {
|
||||
startTransition(() => {
|
||||
setCurrentStep((step) => step + 1)
|
||||
})
|
||||
void pikku().patch('/candidate/:candidateId', currentData).catch((error: unknown) => {
|
||||
console.error('Error saving application data:', error)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Final submission
|
||||
;(window as any).fbq?.('track', 'Lead', {
|
||||
content_name: 'HeyGermany application',
|
||||
content_category: 'Application'
|
||||
})
|
||||
await pikku().patch('/candidate/:candidateId', {
|
||||
...currentData,
|
||||
submittedAt: new Date(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error saving application data:', error)
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['candidate'] })
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(currentStep - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const renderStepContent = () => {
|
||||
const currentStepConfig = steps[currentStep]
|
||||
if (!currentStepConfig) return null
|
||||
const StepComponent = currentStepConfig.component
|
||||
return <StepComponent candidate={candidate} />
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
{/* Mobile Progress Bar */}
|
||||
<Box px="md" py="xs" w="100%" bg="white" pos="sticky" top="4rem" display={{ base: 'block', lg: 'none' }} style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}>
|
||||
<Flex align="center" gap="xs" mb="xs">
|
||||
<Text size="sm" fw={500}>{t('jobs.apply.stepprogress', { current: currentStep + 1, total: steps.length })}</Text>
|
||||
</Flex>
|
||||
<Progress value={progressPercentage} h="0.5rem" />
|
||||
</Box>
|
||||
|
||||
<Container size='xl' display={{ base: 'none', lg: 'block' }}>
|
||||
{/* Desktop Stepper */}
|
||||
<Box my="xl">
|
||||
<Stepper
|
||||
active={currentStep}
|
||||
onStepClick={setCurrentStep}
|
||||
allowNextStepsSelect={false}
|
||||
|
||||
wrap={false}
|
||||
>
|
||||
{steps.map((step, index) => <Stepper.Step key={index} label={step.title} completedIcon={<IconCheck color='#eab308' size={18} />} />)}
|
||||
</Stepper>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
<Container size='lg' maw='4xl' w='100%'>
|
||||
<Flex w="100%" direction="column" align="center" justify="center" p="lg">
|
||||
{/* Render the current step once to avoid duplicate form controls across breakpoints */}
|
||||
<Paper w="100%" shadow='xl' withBorder p={{ base: 'md', md: 'xl' }}>
|
||||
{renderStepContent()}
|
||||
</Paper>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<Flex justify="center" gap="md" mt="lg">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBack}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{t('jobs.apply.buttons.back')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{currentStep === steps.length - 1 ? t('jobs.apply.buttons.submit') : t('jobs.apply.buttons.next')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Container>
|
||||
|
||||
<HelpAffix />
|
||||
</FormProvider >
|
||||
)
|
||||
}
|
||||
|
||||
const Result: React.FunctionComponent<{ candidateId: string }> = ({ candidateId }) => {
|
||||
const { data: candidateResults, isLoading: resultLoading, error } = useGetCandidateResults(candidateId)
|
||||
|
||||
if (resultLoading) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<Card shadow="sm" p="lg" radius="md" withBorder>
|
||||
<Text size="xl" fw={700} mb="md" c="red">
|
||||
{asI18n('Error Loading Qualification Results')}
|
||||
</Text>
|
||||
<Text size="md" c="dimmed">
|
||||
{asI18n('We encountered an error while checking your qualifications. Please try again later or contact support if the issue persists.')}
|
||||
</Text>
|
||||
</Card>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
if (!candidateResults) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <CandidateResult candidateResults={candidateResults} />
|
||||
}
|
||||
|
||||
const ApplicationPage: React.FunctionComponent = () => {
|
||||
const searchParams = useSearchParams()
|
||||
const candidateIdFromParams = searchParams.get('candidateId')
|
||||
const { data: candidate, isLoading: candidateLoading } = useGetCandidate(candidateIdFromParams || undefined)
|
||||
|
||||
if (candidateLoading || !candidate) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (candidate.status === ApplicantStatuses.PENDING) {
|
||||
return <Done name={candidate.name} />
|
||||
}
|
||||
|
||||
if (candidate.status === ApplicantStatuses.INVALID) {
|
||||
return <Invalid name={candidate.name} candidateId={candidate.candidateId} />
|
||||
}
|
||||
|
||||
if (candidate.status === ApplicantStatuses.COMPLETE) {
|
||||
return <Result candidateId={candidate.candidateId} />
|
||||
}
|
||||
|
||||
return <div style={{ position: 'relative' }}>
|
||||
<Form candidate={candidate} />
|
||||
</div>
|
||||
}
|
||||
|
||||
export default ApplicationPage
|
||||
72
apps/website/views/[lang]/(main)/jobs/apply/page.tsx
Normal file
72
apps/website/views/[lang]/(main)/jobs/apply/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { StartApplication } from '@/components/jobs/form/StartApplication'
|
||||
import { useI18n } from '@/context/i18n-provider'
|
||||
import { Container, Box, Stack, Title, Text, List, Grid } from "@pikku/mantine/core"
|
||||
|
||||
const ApplyPage: React.FunctionComponent = () => {
|
||||
const t = useI18n()
|
||||
|
||||
return (
|
||||
<Box component="section" py="xl" pos="relative" flex={1}>
|
||||
<Container size="xl" pt="xl">
|
||||
<Grid gutter="xl" align="stretch">
|
||||
|
||||
{/* Who is This For Section */}
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<Stack gap="xl">
|
||||
<Box>
|
||||
<Title order={2} mb="md">{t('jobs.apply.whoisthisfor.title')}</Title>
|
||||
<Text size="sm">
|
||||
{t('jobs.apply.whoisthisfor.description')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Title order={2} mb="md">{t('jobs.apply.whatyoullget.title')}</Title>
|
||||
<List size="sm" spacing="xs">
|
||||
<List.Item>{t('jobs.apply.whatyoullget.item1')}</List.Item>
|
||||
<List.Item>{t('jobs.apply.whatyoullget.item2')}</List.Item>
|
||||
<List.Item>{t('jobs.apply.whatyoullget.item3')}</List.Item>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Title order={2} mb="md">{t('jobs.apply.whatyoullneed.title')}</Title>
|
||||
<List size="sm" spacing="xs">
|
||||
<List.Item>{t('jobs.apply.whatyoullneed.item1')}</List.Item>
|
||||
<List.Item>{t('jobs.apply.whatyoullneed.item2')}</List.Item>
|
||||
<List.Item>
|
||||
{t('jobs.apply.whatyoullneed.item3.title')}
|
||||
<br />
|
||||
{t('jobs.apply.whatyoullneed.item3.note')}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t('jobs.apply.whatyoullneed.item4.title')}
|
||||
<br />
|
||||
{t('jobs.apply.whatyoullneed.item4.note')}
|
||||
</List.Item>
|
||||
</List>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
{/* Get Your Results Section */}
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<Stack gap="lg">
|
||||
<Box>
|
||||
<Title order={2} mb="md">{t('jobs.apply.getyourresults.title')}</Title>
|
||||
<Text size="sm" mt="md">
|
||||
{t('jobs.apply.getyourresults.description')}
|
||||
</Text>
|
||||
</Box>
|
||||
<StartApplication initializing={false} />
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Container>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default ApplyPage
|
||||
@@ -0,0 +1,5 @@
|
||||
import { DemoCandidateResultPage } from '@/components/jobs/DemoCandidateResultPage'
|
||||
|
||||
export default function DemoResultPage() {
|
||||
return <DemoCandidateResultPage />
|
||||
}
|
||||
19
apps/website/views/[lang]/(main)/layout.tsx
Normal file
19
apps/website/views/[lang]/(main)/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import TopBar from '@/components/ui/TopBar'
|
||||
import FooterBar from '@/components/ui/FooterBar'
|
||||
import { Flex } from '@pikku/mantine/core'
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function MainLayout({ children }: MainLayoutProps) {
|
||||
return (
|
||||
<Flex direction="column" mih="100vh">
|
||||
<TopBar />
|
||||
<Flex component="main" direction="column" flex="1">
|
||||
{children}
|
||||
</Flex>
|
||||
<FooterBar />
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
5
apps/website/views/[lang]/(main)/page.tsx
Normal file
5
apps/website/views/[lang]/(main)/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { HomePage } from '@/components/home/HomePage'
|
||||
|
||||
export default function LocalizedHomePage() {
|
||||
return <HomePage />
|
||||
}
|
||||
71
apps/website/views/[lang]/(main)/verify-email/page.tsx
Normal file
71
apps/website/views/[lang]/(main)/verify-email/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import { Container, Stack, Title, Text, Button } from '@pikku/mantine/core'
|
||||
import { useI18n } from "@/context/i18n-provider"
|
||||
import { useMemo } from 'react'
|
||||
import { useSearchParams, useRouter, useParams } from '@/framework/navigation'
|
||||
|
||||
const VerifyEmailPage: React.FunctionComponent = () => {
|
||||
const t = useI18n()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { lang } = useParams<{ lang: string }>()
|
||||
const status = useMemo<'success' | 'error'>(() => {
|
||||
const error = searchParams.get('error')
|
||||
const verified = searchParams.get('verified')
|
||||
|
||||
if (error || verified !== '1') {
|
||||
return 'error'
|
||||
}
|
||||
|
||||
return 'success'
|
||||
}, [searchParams])
|
||||
|
||||
return (
|
||||
<Container size="sm" py="xl" my='auto'>
|
||||
<Stack gap="xl" align="center">
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<Stack gap="md" align="center">
|
||||
<Title order={1} ta="center">
|
||||
{t('verifyemail.success.title')}
|
||||
</Title>
|
||||
<Text c="dimmed" ta="center">
|
||||
{t('verifyemail.success.message')}
|
||||
</Text>
|
||||
<Button
|
||||
size="lg"
|
||||
mt="md"
|
||||
onClick={() => router.push(`/${lang}`)}
|
||||
>
|
||||
{t('verifyemail.success.button')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<Stack gap="md" align="center">
|
||||
<Title order={1} ta="center">
|
||||
{t('verifyemail.error.title')}
|
||||
</Title>
|
||||
<Text c="dimmed" ta="center">
|
||||
{t('verifyemail.error.message')}
|
||||
</Text>
|
||||
<Button
|
||||
size="lg"
|
||||
mt="md"
|
||||
onClick={() => router.push(`/${lang}`)}
|
||||
>
|
||||
{t('verifyemail.error.button')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default VerifyEmailPage
|
||||
15
apps/website/views/[lang]/globals.css
Normal file
15
apps/website/views/[lang]/globals.css
Normal file
@@ -0,0 +1,15 @@
|
||||
a {
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
/* Theme toggle button - swap Sun/Moon icons based on data-mantine-color-scheme attribute */
|
||||
/* Used to avoid ssr hydration issues with icon rendering */
|
||||
.theme-toggle .icon-sun,
|
||||
.theme-toggle .icon-moon { display: inline-block; }
|
||||
|
||||
/* Default (light) shows Sun, hides Moon */
|
||||
.theme-toggle .icon-moon { display: none; }
|
||||
|
||||
/* When Mantine flips the <html> attribute, CSS swaps icons */
|
||||
[data-mantine-color-scheme='dark'] .theme-toggle .icon-sun { display: none; }
|
||||
[data-mantine-color-scheme='dark'] .theme-toggle .icon-moon { display: inline-block; }
|
||||
26
apps/website/views/[lang]/legal/imprint/content.mdx
Normal file
26
apps/website/views/[lang]/legal/imprint/content.mdx
Normal file
@@ -0,0 +1,26 @@
|
||||
# Imprint
|
||||
|
||||
Information according to § 5 TMG (German Telemedia Act):
|
||||
|
||||
HeyGermany UG (haftungsbeschränkt)
|
||||
|
||||
Postal Address: Am Neuen Markt 9/E-F, 14467 Potsdam
|
||||
|
||||
Contact:
|
||||
Email: info@hey-germany.com
|
||||
|
||||
Represented by:
|
||||
Katharina Schultz-Ebert
|
||||
|
||||
Responsible for content according to § 55 Abs. 2 RStV: Katharina Schultz-Ebert, Rheinstr. 9, 12159 Berlin
|
||||
|
||||
We are neither willing nor obliged to participate in dispute resolution proceedings before a consumer arbitration board. EU Commission’s Online Dispute Resolution platform: [Click here](https://consumer-redress.ec.europa.eu/index_en)
|
||||
|
||||
**Liability for Contents**
|
||||
As a service provider, we are responsible for our own content on these pages in accordance with § 7 para. 1 TMG (German Telemedia Act) and general laws. According to §§ 8 to 10 TMG, however, we are not obliged to monitor transmitted or stored third-party information or to investigate circumstances that indicate illegal activity. Obligations to remove or block the use of information under general laws remain unaffected. Liability in this respect is, however, only possible from the time we become aware of a specific infringement. Upon notification of such violations, we will remove the content immediately.
|
||||
|
||||
**Liability for Links**
|
||||
Our website contains links to external websites of third parties, over whose content we have no influence. Therefore, we cannot accept any liability for these external contents. The respective provider or operator of the linked pages is always responsible for their content. The linked pages were checked for possible legal violations at the time of linking. No illegal content was identifiable at the time of linking. A permanent monitoring of the content of the linked pages is not reasonable without concrete evidence of a violation. If we become aware of any legal infringements, we will remove such links immediately.
|
||||
|
||||
**Copyright**
|
||||
The contents and works created by the site operators on these pages are subject to German copyright law. Any duplication, processing, distribution, or commercialization of such material beyond the scope of copyright law requires the prior written consent of the respective author or creator. Downloads and copies of this site are only permitted for private, non-commercial use. Insofar as the content on this site was not created by the operator, the copyrights of third parties are respected. In particular, third-party content is identified as such. Should you nevertheless become aware of a copyright infringement, please notify us. Upon becoming aware of such violations, we will remove the content immediately.
|
||||
11
apps/website/views/[lang]/legal/imprint/page.tsx
Normal file
11
apps/website/views/[lang]/legal/imprint/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import Content from './content.mdx'
|
||||
import { useMDXComponents } from '@/mdx-components'
|
||||
import { Container } from '@pikku/mantine/core'
|
||||
|
||||
export default function ImprintPage() {
|
||||
return (
|
||||
<Container size="xl" mt="xl">
|
||||
<Content components={useMDXComponents({})} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
18
apps/website/views/[lang]/legal/layout.tsx
Normal file
18
apps/website/views/[lang]/legal/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import TopBar from '@/components/ui/TopBar'
|
||||
import FooterBar from '@/components/ui/FooterBar'
|
||||
import { Flex } from '@pikku/mantine/core'
|
||||
import { I18nProvider } from '@/context/i18n-provider'
|
||||
|
||||
export default function LegalLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<I18nProvider lang="en">
|
||||
<Flex direction="column" mih="100vh">
|
||||
<TopBar />
|
||||
<Flex component="main" direction="column" flex="1">
|
||||
{children}
|
||||
</Flex>
|
||||
<FooterBar />
|
||||
</Flex>
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
129
apps/website/views/[lang]/legal/privacy/content.mdx
Normal file
129
apps/website/views/[lang]/legal/privacy/content.mdx
Normal file
@@ -0,0 +1,129 @@
|
||||
# Privacy Policy
|
||||
|
||||
Welcome to **HeyGermany** (“we,” “us,” or “our”).
|
||||
We are committed to protecting your personal data and respecting your privacy in full compliance with the **General Data Protection Regulation (GDPR)** and applicable German data protection laws.
|
||||
|
||||
HeyGermany provides a digital service that analyzes educational and professional documents — such as diplomas, transcripts of records, licenses, and language certificates — to assess your qualification level and recognition chances in Germany. If you choose to join our Talent Pool, we also help connect you with potential employers through anonymized professional profiles.
|
||||
|
||||
Your trust and data security are extremely important to us. This Privacy Policy explains how we collect, use, store, and share your information and what rights you have.
|
||||
|
||||
----------
|
||||
|
||||
**Data Controller**
|
||||
HeyGermany
|
||||
Katharina Schultz-Ebert
|
||||
Rheinstr. 9
|
||||
12159 Berlin
|
||||
Germany
|
||||
Email: info@hey-germany.de
|
||||
|
||||
----------
|
||||
|
||||
**What Data We Collect**
|
||||
We collect and process the following categories of data when you use our platform:
|
||||
|
||||
**a. Personal Identification Data**
|
||||
Name, email address, country of residency and country of education
|
||||
|
||||
**b. Educational and Professional Documents**
|
||||
Diplomas, transcripts of records, professional licenses, language certificates, and other qualification-related documents you upload
|
||||
|
||||
**c. Analytical and Assessment Data**
|
||||
Information derived from our analysis, such as German reference profession, qualification level, recognition potential
|
||||
|
||||
**d. Optional Talent Pool Data**
|
||||
If you voluntarily join our Talent Pool, we create an anonymized professional profile based on your qualifications (e.g., degree level, country of qualification, years of experience, and language proficiency).
|
||||
We never share data that can personally identify you (such as name, email, or contact details) with employers.
|
||||
|
||||
**e. Technical Usage Data**
|
||||
IP address, device type, browser information, and general usage statistics (collected via cookies and analytics tools)
|
||||
|
||||
----------
|
||||
|
||||
**Purpose and Legal Basis for Processing**
|
||||
We process your data for the following purposes and based on the legal grounds set out in Article 6 of the GDPR:
|
||||
|
||||
|
||||
| **Purpose** | **Legal Basis** |
|
||||
|-------------|-----------------|
|
||||
| To analyze your educational and professional documents and provide you with a qualification assessment | Art. 6(1)(b) – Performance of a contract |
|
||||
| To inform you about your recognition chances in Germany | Art. 6(1)(b) – Performance of a contract |
|
||||
| To improve and develop our algorithms and platform (using anonymized data only) | Art. 6(1)(f) – Legitimate interests |
|
||||
| To create and share anonymized professional profiles with potential employers (if you join the Talent Pool) | Art. 6(1)(a) – Consent |
|
||||
| To comply with legal obligations (e.g., data security, record keeping) | Art. 6(1)(c) – Legal obligation |
|
||||
|
||||
----------
|
||||
|
||||
**Data Retention**
|
||||
We only store your personal data as long as necessary for the purposes described above or as required by law.
|
||||
- Uploaded documents and related analysis results are stored for 2 years after your last activity or until you delete your account.
|
||||
- If you join the Talent Pool, your anonymized profile will remain active until you withdraw your consent or delete your profile.
|
||||
- After this period, all personal data will be securely deleted or permanently anonymized.
|
||||
|
||||
You may request deletion of your data at any time by contacting info@hey-germany.de.
|
||||
|
||||
----------
|
||||
|
||||
**Data Sharing and Disclosure**
|
||||
We do not sell or commercially share your personal data.
|
||||
|
||||
We may share your information with:
|
||||
- Trusted service providers (e.g., secure cloud storage, data processors) who operate under strict data protection agreements.
|
||||
- Potential employers, but only in anonymized form and only if you have explicitly consented to join the Talent Pool.
|
||||
|
||||
No personal information that could identify you (such as name, contact details, or document copies) will ever be shared with employers or third parties without your consent.
|
||||
|
||||
If we transfer data outside the EU/EEA (for example, through cloud hosting providers), we ensure compliance with GDPR safeguards, such as Standard Contractual Clauses or adequacy decisions.
|
||||
|
||||
----------
|
||||
|
||||
**Your Rights Under GDPR**
|
||||
You have the following rights regarding your personal data:
|
||||
- **Access:** Request a copy of the personal data we hold about you.
|
||||
- **Rectification:** Request correction of inaccurate or incomplete data.
|
||||
- **Erasure:** Request deletion of your personal data (“right to be forgotten”).
|
||||
- **Restriction:** Request limitation of how your data is processed.
|
||||
- **Portability:** Request a copy of your data in a structured, commonly used format.
|
||||
- **Objection:** Object to processing based on legitimate interests.
|
||||
- **Withdraw Consent:** Withdraw your consent at any time (e.g., leaving the Talent Pool).
|
||||
|
||||
To exercise your rights, contact us at info@hey-germany.de. We will respond within 14 days.
|
||||
|
||||
----------
|
||||
|
||||
**Data Security**
|
||||
We apply industry-standard technical and organizational security measures to protect your data, including:
|
||||
- End-to-end encryption during upload and storage
|
||||
- Secure cloud infrastructure hosted in the EU
|
||||
- Access control and role-based permissions for staff
|
||||
- Regular system monitoring and data backups
|
||||
- Continuous security and compliance audits
|
||||
|
||||
Your data is treated with strict confidentiality and stored in compliance with European data protection standards.
|
||||
|
||||
----------
|
||||
|
||||
**Cookies and Analytics**
|
||||
HeyGermany uses cookies and similar technologies to ensure platform functionality and improve user experience.
|
||||
Essential cookies are required for the platform to operate. Analytical cookies are only used with your consent.
|
||||
|
||||
You can manage or withdraw your cookie consent in your browser settings at any time.
|
||||
|
||||
----------
|
||||
|
||||
**Changes to This Policy**
|
||||
We may update this Privacy Policy occasionally to reflect legal, technical, or business developments. The latest version will always be available at www.hey-germany.com/privacy-policy
|
||||
|
||||
If significant changes occur, we will notify you by email or within your user account.
|
||||
|
||||
----------
|
||||
|
||||
**Contact Us**
|
||||
If you have any questions or concerns about this Privacy Policy or your personal data, please contact our data protection team:
|
||||
|
||||
HeyGermany
|
||||
Katharina Schultz-Ebert
|
||||
Rheinstr. 9
|
||||
12159 Berlin
|
||||
Germany
|
||||
Email: info@hey-germany.de
|
||||
11
apps/website/views/[lang]/legal/privacy/page.tsx
Normal file
11
apps/website/views/[lang]/legal/privacy/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import Content from './content.mdx'
|
||||
import { useMDXComponents } from '@/mdx-components'
|
||||
import { Container } from '@pikku/mantine/core'
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<Container size="xl" mt="xl">
|
||||
<Content components={useMDXComponents({})} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# Terms & Conditions
|
||||
|
||||
General Terms and Conditions for the Use of Services Accessible via the Platform **[www.hey-germany.com](http://www.hey-germany.com)**
|
||||
|
||||
**Services**
|
||||
HeyGermany operates the platform [www.hey-germany.com](http://www.hey-germany.com), which connects providers and seekers in the healthcare sector. Users have the opportunity to upload content to the platform and/or access third-party content.
|
||||
Our Qualification Recognition Analysis is provided free of charge and without any legally binding effect. We do not guarantee the completeness, accuracy, or legal validity of the analysis provided. The results are intended for informational purposes only and cannot replace the official recognition process with the competent authorities.
|
||||
|
||||
HeyGermany facilitates, among other things, job placements in healthcare institutions (hereinafter: "Clients") that are seeking candidates. This is done in the form of brokerage services by initiating employment contracts between candidates and Clients in Germany.
|
||||
|
||||
Users may choose to express interest in placement by joining our Talent Pool. Joining the Talent Pool is voluntary and can be revoked at any time. Access to the Talent Pool and the related placement services provided by HeyGermany are free of charge.
|
||||
|
||||
After a Client places a request, HeyGermany proposes candidates whose profiles match the Client’s requirements. HeyGermany does not guarantee a successful placement. Clients remain free to decide whether or not to engage with a candidate proposed by HeyGermany. Similarly, candidates are not obliged to accept offers from Clients. If a Client selects a candidate, HeyGermany will contact the candidate via email.
|
||||
|
||||
Before the candidate begins an assignment, an employment contract will be concluded between the Client and the candidate. HeyGermany is not a party to this contract.
|
||||
|
||||
----------
|
||||
|
||||
**User Responsibilities**
|
||||
- You agree to provide true and accurate information.
|
||||
- You remain responsible for the documents you upload.
|
||||
- You must ensure that you have the right to use and share the documents you submit.
|
||||
|
||||
----------
|
||||
|
||||
**Data Processing**
|
||||
Uploaded documents and personal data are processed exclusively in accordance with our Privacy Policy. By using our service, you consent to such processing.
|
||||
|
||||
----------
|
||||
|
||||
**Limitation of Liability**
|
||||
We are not liable for any damages resulting from the use of our services, unless caused by intentional or grossly negligent behavior.
|
||||
Despite careful review, HeyGermany assumes no liability for the content of external websites linked or embedded on our platform. Responsibility for the content and availability of such external websites lies solely with their operators.
|
||||
|
||||
----------
|
||||
|
||||
**Changes to the Terms**
|
||||
We reserve the right to update or modify these Terms & Conditions at any time. Changes will be published on this page.
|
||||
@@ -0,0 +1,11 @@
|
||||
import Content from './content.mdx'
|
||||
import { useMDXComponents } from '@/mdx-components'
|
||||
import { Container } from '@pikku/mantine/core'
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<Container size="xl" mt="xl">
|
||||
<Content components={useMDXComponents({})} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user