chore: heygermany customer project
Some checks failed
Main / Setup and Test (push) Has been cancelled
Main / Build Website (push) Has been cancelled

This commit is contained in:
e2e
2026-07-11 11:03:59 +02:00
commit 36f1b746bd
370 changed files with 35585 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
import { Box, Container, rem, Stack, Text } from "@pikku/mantine/core"
import { CTASection } from "./results/CTASection"
import NextSteps from "./results/NextSteps"
import { QualificationsSection } from "./results/QualificationsSection"
import React from "react"
import { CandidateResults } from "@heygermany/sdk"
import { Emphasize } from "../ui/Emphasize"
import { useI18n } from "@/context/i18n-provider"
export const CandidateResult: React.FunctionComponent<{
candidateResults: CandidateResults
ctaPublicMode?: boolean
}> = ({ candidateResults, ctaPublicMode = false }) => {
const { textContent } = candidateResults
const t = useI18n()
if (!textContent.renderPath) {
return <div>{t('jobs.result.noeligibletemplate')}</div>
}
const isHelper = textContent.renderPath.startsWith("helper.")
const afterExplanationParaNotes = textContent.notes.afterExplanation
return (
<>
{/* Hero Section */}
<Box bg="white" py="xl">
<Container size="lg" ta="center">
<Emphasize text={textContent.hero} tt="capitalize" size="xxxl" fw={700} mt="xl" mb="md" />
<Emphasize text={textContent.result} size="xxl" fw={700} mt="xl" mb="xl" />
<Container maw={rem(800)} size="md" mb="xl">
<Stack gap="md">
{/* First paragraph */}
{textContent.explanation[0] && <Emphasize size="lgxl" text={textContent.explanation[0]} />}
{/* Remaining paragraphs */}
{textContent.explanation.slice(1).map((p: string, i: number) => <Emphasize key={i} size="lgxl" text={p} />)}
{/* Visa note(s) after explanation */}
{afterExplanationParaNotes.map((n: string, idx: number) => (
<Emphasize key={`note-${idx}`} size="lgxl" text={n} />
))}
</Stack>
</Container>
</Container>
</Box>
{/* Qualifications Section */}
<QualificationsSection candidateResults={candidateResults} />
{/* What does this mean — render only if not Helper */}
{!isHelper && textContent.recognition?.length ? (
<Container maw={rem(800)} size="md" my="xl">
<Text fz="xxxl" fw={700} mb='lg' ta='center'>
{t('jobs.nextsteps.whatdoesthismean')}
</Text>
<Stack gap='sm' c="dimmed" mx="auto" align='center'>
<Emphasize primary={false} text={textContent.recognition[0]} ta='center' fz="xl" fw={700} mb='lg' />
{textContent.recognition.slice(1).map((line: string, idx: number) => (
<Emphasize
primary={false}
renderInlineStyles={false}
key={idx}
ta='center'
text={line}
fz='xl'
fw={500}
/>
))}
</Stack>
</Container>
) : null}
{/* NextSteps Section */}
<NextSteps joinTalentPool={candidateResults.joinTalentPool!} nextSteps={candidateResults.nextSteps} isHelper={isHelper} />
{/* CTA Section */}
<CTASection joinTalentPool={candidateResults.joinTalentPool!} publicMode={ctaPublicMode} />
</>
)
}

View File

@@ -0,0 +1,163 @@
import type { CandidateResults } from '@heygermany/sdk'
import { useI18n } from '@/context/i18n-provider'
import { useLocation } from '@tanstack/react-router'
import { CandidateResult } from './CandidateResult'
function buildDemoCandidateResults(
t: ReturnType<typeof useI18n>,
candidateName: string
): CandidateResults {
const currentGermanLevel = 'B1'
return {
education: {
title: t('jobs.qualifications.education.match.title'),
description: t('jobs.qualifications.education.match.description'),
status: 'completed',
requirements: [
t('jobs.qualifications.education.match.requirements.degree'),
t('jobs.qualifications.education.match.requirements.accredited'),
t('jobs.qualifications.education.match.requirements.structured'),
],
},
license: {
title: 'Nursing License',
description: 'Missing nursing license or registration from your home country.',
status: 'required',
requirements: [
'Nursing license from your home country required for job recognition',
],
steps: ['Obtain your license from your home country'],
},
language: {
title: 'German Proficiency',
description: 'Demonstrate required German language skills.',
status: 'in-progress',
progress: 75,
requirements: [
'German level B2',
'Language certificate (Goethe-Institut, telc, TestDaF, or ECL)',
],
steps: [
`Improve your German level from ${currentGermanLevel} to B2`,
'Get a language certificate from Goethe-Institut, telc, TestDaF, or ECL',
],
},
experience: {
title: 'Work Experience',
description: 'Upload your CV to our platform to connect with employers.',
status: 'optional',
requirements: [
'Curriculum Vitae (CV)',
],
steps: [
'Create your CV',
'Upload it to your profile',
],
},
copies: {
title: 'Copies',
description: 'Provide notarized or certified copies of required documents.',
status: 'required',
requirements: [
'Passport copy',
'Education documents copies',
'Police clearance certificate copy',
'Certificate of good standing copy',
],
steps: [
'Prepare document copies',
'Translate by sworn translator if required',
],
},
originals: {
title: 'Originals',
description: 'Prepare required originals for submission to the authority.',
status: 'required',
requirements: [
'Completed application form',
'CV (signed and dated)',
'Medical fitness certificate',
'Certificate of good conduct',
],
steps: [
'Prepare originals',
'Submit to the authority',
],
},
joinTalentPool: false,
nextSteps: {
gethelp: {
title: 'Get Help',
subtitle: 'Get free support for your application in Germany. Many services help internationally trained professionals like you succeed in job recognition. Seek help before you start the process — it can make all the difference.',
costs: 'Cost: Free',
button: 'Find a counseling service',
},
connect: {
title: 'Connect with Employers',
subtitle: 'Engaging with a German employer early can boost your chances of success. They can guide you through requirements, provide documents, and support your recognition process from start to finish. Well help you connect with your future employer.',
costs: 'Cost: Free',
buttonjoin: 'Join our talent pool',
buttonimprove: 'Improve your profile',
},
documents: {
title: 'Prepare Your Documents',
subtitle: "Gather all necessary documents you will need for your job recognition application. Some must be in original, others as copies, and in most cases, certified translations are required",
costs: 'Cost: 200-300€ for translated documents',
button: 'Check required documents',
},
submit: {
title: 'Submit Your Application',
subtitle: `To begin the recognition process, you'll need to submit an application for permission to use the professional title "Registered Nurse" (Pflegefachfrau or Pflegefachmann) to the relevant authority (LaGeSo). Make sure you have all required documents before you start the process. Incomplete submissions can slow down your recognition process unnecessarily`,
costs: 'Cost: 250-350€ for recognition fee',
button: 'Learn how to apply',
},
compensatory: {
title: 'Complete a Compensatory Measure',
subtitle: 'After you submit your recognition application, youll receive a Deficit Notice from LaGeSo. It shows where your qualification differs from the German nursing standard and outlines the compensatory measures you can take to achieve full recognition. You can choose either an Adaptation Program or a Knowledge Exam.',
costs: 'Cost: Varies',
button: 'Explore compensatory measures',
},
german: {
title: 'Improve Your German',
subtitle: `You're currently at ${currentGermanLevel} — almost there! To gain full recognition and work as a nurse in Berlin, you'll need to improve your German to B2 level. There are several options available to help fund your language courses.`,
costs: 'Cost: Varies (see tips)',
button: 'Find courses and funding options',
},
},
textContent: {
renderPath: 'nurse.missingLicense',
hero: `Good News ${candidateName}`,
result: 'You May Qualify as a Nurse in Berlin',
explanation: [
'Based on your documents, you already fulfill some of the requirements to work as a <strong>Nurse (Pflegefachkraft)</strong> in Berlin. To move forward, you still need to provide some important documents for your qualification to be recognized in Germany— most importantly, your Nursing <strong>License</strong> or Nursing Registration from your home country.',
"This means you're already on the right track! Please make sure you have all the required documents for job recognition ready once you start the process. There are many free advisory services available to support you along the way. Please keep in mind that this is an early and independent assessment to support your journey. Final recognition is granted by the official German authorities and includes additional steps. This assessment is not legally binding.",
],
recognition: [
'🔍 Likely Outcome of Job Recognition: Partial Job Recognition',
"Your qualification aligns <i>partially</i> with German nursing standards — that's a strong foundation!",
'To achieve full recognition and be able to work as a nurse you may need to complete a <strong>compensatory measure</strong> and reach <strong>B2</strong> in German.',
"Please note: Your professional <strong>license</strong> wasn't uploaded yet. It's a required document for recognition, so make sure you have it available when starting your application.",
],
notes: {
afterExplanation: [
'Please note that you need a B1 German language certificate in order to obtain a visa for Germany.',
],
},
},
}
}
export function DemoCandidateResultPage() {
const t = useI18n()
const searchParams = useLocation({ select: (l) => new URLSearchParams(l.searchStr) })
const candidateName = searchParams.get('name') || 'Maria Santos'
const candidateResults = buildDemoCandidateResults(t, candidateName)
return (
<CandidateResult
candidateResults={candidateResults}
ctaPublicMode={true}
/>
)
}

View File

@@ -0,0 +1,191 @@
import React from 'react'
import { Controller, useFormContext, useWatch } from 'react-hook-form'
import { GermanStates } from '@heygermany/sdk'
import { MultiSelect, Radio, Stack, Title, Group, Box, Input, Alert } from '@pikku/mantine/core'
import { DateInput } from '@mantine/dates'
import { IconInfoCircle } from '@tabler/icons-react'
import { useI18n } from '@/context/i18n-provider'
import { CountrySearch } from './CountrySearch'
import { SUPPORTED_COUNTRIES } from '@/config'
export const BasicInfo: React.FunctionComponent = () => {
const t = useI18n()
const { control, formState: { errors } } = useFormContext()
const countryEducation = useWatch({ control, name: 'countryEducation' })
const isUnsupportedCountry = countryEducation && !SUPPORTED_COUNTRIES.includes(countryEducation)
const germanStates = Object.values(GermanStates)
return (
<Stack gap="lg">
<Title order={2}>{t('jobs.apply.steps.basicinfo')}</Title>
{/* German States Multi-Select */}
<Box>
<Controller
name="stateWorkPreference"
control={control}
rules={{ required: t('jobs.basicinfo.validation.staterequired') }}
render={({ field }) => (
<MultiSelect
label={t('jobs.basicinfo.worklocation.question')}
value={field.value || []}
onChange={(newValue) => {
if (newValue.includes('ALL')) {
// If ALL is selected, select all states
if (!field.value?.includes('ALL')) {
field.onChange(germanStates)
} else {
// If ALL is already selected and user clicks it again, deselect all
field.onChange([])
}
} else {
field.onChange(newValue)
}
}}
data={[
{ value: 'ALL', label: t('jobs.basicinfo.worklocation.selectall')},
...germanStates.map(state => ({ value: state, label: state }))
]}
placeholder={t('jobs.basicinfo.worklocation.placeholder')}
searchable
clearable
error={errors.stateWorkPreference?.message as string}
w="100%"
/>
)}
/>
</Box>
{/* Open to Work */}
<Box>
<Input.Wrapper label={t('jobs.basicinfo.jointalentpool.question')}>
<Controller
name="joinTalentPool"
control={control}
rules={{
validate: (value) => (value === true || value === false) || t('jobs.basicinfo.validation.optionrequired')
}}
render={({ field }) => (
<Radio.Group
value={field.value?.toString()}
onChange={(value) => field.onChange(value === 'true' ? true : false)}
error={errors.joinTalentPool?.message as string}
>
<Group gap="lg" mb="xs">
<Radio value="true" label={t('jobs.basicinfo.yes')} />
<Radio value="false" label={t('jobs.basicinfo.no')} />
</Group>
</Radio.Group>
)}
/>
</Input.Wrapper>
</Box>
{/* Living in Germany */}
<Box>
<Input.Wrapper label={t('jobs.basicinfo.livingingermany.question')}>
<Controller
name="livingInGermany"
control={control}
rules={{
validate: (value) => (value === true || value === false) || t('jobs.basicinfo.validation.optionrequired')
}}
render={({ field }) => (
<Radio.Group
value={field.value?.toString()}
onChange={(value) => field.onChange(value === 'true' ? true : false)}
error={errors.livingInGermany?.message as string}
>
<Group gap="lg" mb="xs">
<Radio value="true" label={t('jobs.basicinfo.yes')} />
<Radio value="false" label={t('jobs.basicinfo.no')} />
</Group>
</Radio.Group>
)}
/>
</Input.Wrapper>
</Box>
{/* EU Passport */}
<Box>
<Input.Wrapper label={t('jobs.basicinfo.eupassport.question')}>
<Controller
name="euPassport"
control={control}
rules={{
validate: (value) => (value === true || value === false) || t('jobs.basicinfo.validation.optionrequired')
}}
render={({ field }) => (
<Radio.Group
value={field.value?.toString()}
onChange={(value) => field.onChange(value === 'true' ? true : false)}
error={errors.euPassport?.message as string}
>
<Group gap="lg" mb="xs">
<Radio value="true" label={t('jobs.basicinfo.yes')} />
<Radio value="false" label={t('jobs.basicinfo.no')} />
</Group>
</Radio.Group>
)}
/>
</Input.Wrapper>
</Box>
{/* Date of Birth */}
<Box>
<Controller
name="dateOfBirth"
control={control}
rules={{
required: t('jobs.basicinfo.validation.dateofbirthrequired')
}}
render={({ field }) => (
<DateInput
label={t('jobs.basicinfo.dateofbirth.question')}
placeholder={t('jobs.basicinfo.dateofbirth.placeholder')}
value={field.value ? new Date(field.value) : null}
onChange={field.onChange}
error={errors.dateOfBirth?.message as string}
clearable
weekendDays={[]}
/>
)}
/>
</Box>
{/* Education Country */}
<Box>
<Controller
name="countryEducation"
control={control}
rules={{
required: t('jobs.basicinfo.validation.countryrequired'),
}}
render={({ field }) => (
<CountrySearch
value={field.value}
onChange={field.onChange}
label={t('jobs.basicinfo.education.question')}
placeholder={t('jobs.basicinfo.education.placeholder')}
error={errors.countryEducation?.message as string}
/>
)}
/>
{isUnsupportedCountry && (
<Alert
icon={<IconInfoCircle size={16} />}
title={t('jobs.basicinfo.unsupportedcountry.title')}
color="blue"
mt="sm"
>
{t('jobs.basicinfo.unsupportedcountry.message')}
</Alert>
)}
</Box>
</Stack>
)
}

View File

@@ -0,0 +1,37 @@
import React from 'react'
import { Box, Stack, Title, Text, Flex, Container } from '@pikku/mantine/core'
import { asI18n } from '@pikku/react'
interface CandidateSectionProps {
title: string
example: string
description: string
children: React.ReactNode
}
export const CandidateSection: React.FunctionComponent<CandidateSectionProps> = ({
title,
example,
description,
children
}) => {
return (
<Flex gap="md" direction={{ base: 'column', md: 'row' }}>
<Stack flex={1}>
<Title order={2}>{asI18n(title)}</Title>
<Text size="xl" mb="md">
{asI18n(example)}
</Text>
<Text>
{asI18n(description)}
</Text>
</Stack>
<Box flex={1}>
{children}
</Box>
</Flex>
)
}

View File

@@ -0,0 +1,41 @@
import React, { useMemo } from 'react'
import { Select } from '@pikku/mantine/core'
import { useGetCountries } from '@/hooks/useGetCountries'
interface CountrySearchProps {
value: string
onChange: (value: string) => void
error?: string
label?: any
placeholder?: any
}
export const CountrySearch: React.FunctionComponent<CountrySearchProps> = ({
value,
onChange,
error,
label,
placeholder
}) => {
const { data: countriesMap = {}, isLoading } = useGetCountries()
const countryOptions = useMemo(() => {
return Object.entries(countriesMap).map(([iso3, name]) => ({
value: iso3,
label: name
}))
}, [countriesMap])
return (
<Select
label={label as any}
value={value}
data={countryOptions}
onChange={(selectedValue) => onChange(selectedValue || '')}
placeholder={placeholder as any}
searchable
error={error as any}
disabled={isLoading && countryOptions.length === 0}
/>
)
}

View File

@@ -0,0 +1,19 @@
import { Stack, Title, Text, Container, Paper } from "@pikku/mantine/core"
import { useI18n } from '@/context/i18n-provider'
export const Done: React.FunctionComponent<{ name: string }> = ({ name }) => {
const t = useI18n()
return (
<Container p={0} size='md' mt='10%'>
<Paper p={{ base: '4rem', lg: '8rem' }} withBorder radius='lg'>
<Stack>
<Title order={2}>
{t('jobs.done.title', { name })}
</Title>
<Text size="xl" lineBreaks>{t('jobs.done.body')}</Text>
</Stack>
</Paper>
</Container>
)
}

View File

@@ -0,0 +1,142 @@
import { Controller, useFormContext } from 'react-hook-form'
import { Checkbox, Box, Text, Group, Stack, Title } from '@pikku/mantine/core'
import type { DB } from '@heygermany/sdk'
import {
ApplicationCandidateData,
ApplicationCandidateLanguage,
CandidateDocumentTypes,
LanguageLevels,
} from '@heygermany/sdk'
import { FileUpload } from '../../ui/FileUpload'
import { useUploadCandidateFiles } from '@/hooks/useUploadCandidateFiles'
import { useEffect } from 'react'
import { CandidateSection } from './CandidateSection'
import { useI18n } from '@/context/i18n-provider'
import { EnumSelect } from '@/components/common/EnumSelect'
import { asI18n } from '@pikku/react'
export const GermanLanguage: React.FunctionComponent<{ candidate: ApplicationCandidateData }> = ({ candidate }) => {
const t = useI18n()
const { control, trigger, clearErrors, setValue, formState: { errors }, watch } = useFormContext<ApplicationCandidateLanguage>()
const languageCertificateProvided = watch('languageCertificateProvided')
const selfAssessment = watch('languageSelfAssessmentLevel')
useEffect(() => {
if (languageCertificateProvided === null) {
setValue('languageCertificateProvided', true)
} else if (languageCertificateProvided === true) {
setValue('languageSelfAssessmentLevel', null)
}
clearErrors()
}, [languageCertificateProvided])
useEffect(() => {
if (selfAssessment) {
clearErrors()
}
}, [selfAssessment])
const { uploadFiles, deleteFile } = useUploadCandidateFiles(candidate.candidateId, CandidateDocumentTypes.LANGUAGE, trigger)
const validateLanguages = (files?: any[], selfAssessment?: DB.LanguageLevel | null, certificateProvided?: boolean | null): boolean | string => {
if (certificateProvided) {
if (!files || files.length === 0) {
return t('jobs.germanlanguage.validation.certificaterequired')
}
if (files.length > 5) {
return t('jobs.germanlanguage.validation.maxfiles')
}
} else {
if (!selfAssessment) {
return t('jobs.germanlanguage.validation.selfassessmentrequired')
}
}
return true
}
return (
<CandidateSection
title={t('jobs.germanlanguage.title')}
example={t('jobs.germanlanguage.example')}
description={t('jobs.germanlanguage.description')}
>
<Stack gap="lg">
<Controller
name="languageCertificateProvided"
control={control}
render={({ field }) => (
<Group gap="xs" mb="sm">
<Checkbox
id="language-certificate-not-provided"
checked={field.value !== true}
onChange={() => field.onChange(!field.value)}
/>
<Text
size="sm"
style={{ cursor: 'pointer' }}
component="label"
htmlFor="language-certificate-not-provided"
>
{t('jobs.germanlanguage.nocertificate')}
</Text>
</Group>
)}
/>
{languageCertificateProvided && (
<Controller
name="languageCertificates"
control={control}
rules={{
validate: () => validateLanguages(candidate.languageCertificates, undefined, languageCertificateProvided)
}}
render={() => (
<FileUpload
files={candidate.languageCertificates.map((file: DB.CandidateDocument) => ({ id: file.documentId, fileName: file.fileName }))}
uploadFiles={uploadFiles}
deleteFile={deleteFile}
/>
)}
/>
)}
{!languageCertificateProvided && (
<Box>
<Title order={3} size="lg" mb="md">{t('jobs.germanlanguage.selfassessment.title')}</Title>
<Text size="sm" c="dimmed" mb="md">
{t('jobs.germanlanguage.selfassessment.description')}
</Text>
<Controller
name="languageSelfAssessmentLevel"
control={control}
rules={{
validate: (value) => validateLanguages(undefined, value, languageCertificateProvided)
}}
render={({ field }) => (
<Box w="fit-content">
<EnumSelect
label={t('jobs.germanlanguage.selfassessment.levellabel')}
value={field.value}
onChange={field.onChange}
enumName='LanguageLevel'
enumObject={LanguageLevels}
placeholder={t('jobs.germanlanguage.selfassessment.placeholder')}
error={errors.languageSelfAssessmentLevel?.message}
/>
</Box>
)}
/>
</Box>
)}
{errors.languageCertificates && (
<Text size="sm" c="error">
{asI18n(errors.languageCertificates?.message ?? '')}
</Text>
)}
</Stack>
</CandidateSection>
)
}

View File

@@ -0,0 +1,25 @@
import { ActionIcon, Tooltip } from '@pikku/mantine/core'
import { IconMailQuestion } from '@tabler/icons-react'
import { asI18n } from '@pikku/react'
export const HelpAffix: React.FunctionComponent = () => {
return (
<div style={{ position: 'absolute', bottom: 20, right: 20 }} >
<Tooltip label={asI18n('Need help? Contact us')} position="left">
<ActionIcon
component="a"
href="mailto:info@hey-germany.com"
size={50}
radius="xl"
variant="filled"
color="primary"
style={{
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
}}
>
<IconMailQuestion size={28} stroke={2} color="white" />
</ActionIcon>
</Tooltip>
</div>
)
}

View File

@@ -0,0 +1,73 @@
import { Stack, Title, Container, Paper, Button, Text, List } from "@pikku/mantine/core"
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { pikku } from '@/pikku/http'
import { ApplicantStatuses } from '@heygermany/sdk'
import { asI18n } from '@pikku/react'
export const Invalid: React.FunctionComponent<{ name: string, candidateId: string }> = ({ name, candidateId }) => {
const queryClient = useQueryClient()
const resetApplicationMutation = useMutation({
mutationFn: async () => {
await pikku().patch('/candidate/:candidateId', {
candidateId,
status: ApplicantStatuses.INITIAL,
submittedAt: null
})
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['candidate'] })
}
})
return (
<Container p={0} size='md' mt={{ base: '2rem', md: '3rem' }}>
<Paper p={{ base: '1.5rem', md: '2rem', lg: '2.5rem' }} withBorder radius='lg' bg='gray.0'>
<Stack gap='lg'>
<Title order={2} c='gray.7' lh={1.2}>
{asI18n(`Hi ${name},`)}
</Title>
<Stack gap='xs'>
<Text size='lg' c='gray.7' lh={1.35}>
{asI18n('Thanks for uploading your documents!')}
</Text>
<Text size='lg' c='gray.7' lh={1.35}>
{asI18n('We&apos;ve reviewed your files, but unfortunately, one or more of them couldn&apos;t be processed for your personal evaluation.')}
</Text>
</Stack>
<Stack gap='xs'>
<Text size='lg' fw={600} c='gray.7' lh={1.3}>
{asI18n('This can happen if:')}
</Text>
<List spacing={2} size='lg' c='gray.7' withPadding style={{ lineHeight: 1.3 }}>
<List.Item>{asI18n('The document is incomplete or blurry')}</List.Item>
<List.Item>{asI18n('The content is unclear or unreadable')}</List.Item>
<List.Item>{asI18n('The file you uploaded is not related to the document we requested')}</List.Item>
</List>
</Stack>
<Text size='lg' c='gray.7' lh={1.35}>
{asI18n('Please upload a clear and complete version of the missing or invalid document(s) so we can continue your evaluation.')}
</Text>
<Button
onClick={() => resetApplicationMutation.mutate()}
loading={resetApplicationMutation.isPending}
disabled={resetApplicationMutation.isPending}
size="md"
mt='xs'
maw={280}
mx='auto'
variant='filled'
color='yellow.5'
c='black'
>
{asI18n('Upload documents again')}
</Button>
</Stack>
</Paper>
</Container>
)
}

View File

@@ -0,0 +1,93 @@
import { Controller, useFormContext } from 'react-hook-form'
import { Checkbox, Box, Text, Group } from '@pikku/mantine/core'
import type { DB } from '@heygermany/sdk'
import { ApplicationCandidateData, ApplicationCandidateLicense, CandidateDocumentTypes } from '@heygermany/sdk'
import { FileUpload } from '../../ui/FileUpload'
import { useUploadCandidateFiles } from '@/hooks/useUploadCandidateFiles'
import { useEffect } from 'react'
import { CandidateSection } from './CandidateSection'
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
export const License: React.FunctionComponent<{ candidate: ApplicationCandidateData }> = ({ candidate }) => {
const t = useI18n()
const { control, trigger, formState: { errors }, watch, setValue } = useFormContext<ApplicationCandidateLicense>()
const licenseProvided = watch('licenseProvided')
const { uploadFiles, deleteFile } = useUploadCandidateFiles(candidate.candidateId, CandidateDocumentTypes.LICENSE, trigger)
const validateLicenses = (files: any[]): boolean | string => {
if (!files || files.length === 0) {
return t('jobs.license.validation.required')
}
if (files.length > 5) {
return t('jobs.license.validation.maxfiles')
}
return true
}
useEffect(() => {
if (licenseProvided === null) {
setValue('licenseProvided', true)
}
}, [licenseProvided])
useEffect(() => {
if (licenseProvided === false) {
trigger()
}
}, [licenseProvided])
return (
<CandidateSection
title={t('jobs.license.title')}
example={t('jobs.license.example')}
description={t('jobs.license.description')}
>
<Box>
<Controller
name="licenseProvided"
control={control}
render={({ field }) => (
<Group gap="xs" mb="lg">
<Checkbox
id="license-not-provided"
checked={field.value === false}
onChange={(e) => field.onChange(!e.currentTarget.checked)}
/>
<Text size="sm" style={{ cursor: 'pointer' }} component="label" htmlFor="license-not-provided">
{t('jobs.license.nolicense')}
</Text>
</Group>
)}
/>
{licenseProvided && (
<Controller
name="licenses"
control={control}
rules={{
validate: () => validateLicenses(candidate.licenses)
}}
render={() => (
<FileUpload
files={candidate.licenses.map((file: DB.CandidateDocument) => ({ id: file.documentId, fileName: file.fileName }))}
uploadFiles={uploadFiles}
deleteFile={deleteFile}
/>
)}
/>
)}
{errors.licenses && (
<Text size="sm" c="error" mt="xs">
{asI18n(errors.licenses?.message ?? '')}
</Text>
)}
</Box>
</CandidateSection>
)
}

View File

@@ -0,0 +1,56 @@
import React from 'react'
import { Controller, useFormContext } from 'react-hook-form'
import type { DB } from '@heygermany/sdk'
import { ApplicationCandidateData, ApplicationCandidateWorkExperience, CandidateDocumentTypes } from '@heygermany/sdk'
import { FileUpload } from '../../ui/FileUpload'
import { useUploadCandidateFiles } from '@/hooks/useUploadCandidateFiles'
import { CandidateSection } from './CandidateSection'
import { Box, Text } from '@pikku/mantine/core'
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
export const ProfessionalExperience: React.FunctionComponent<{ candidate: ApplicationCandidateData }> = ({ candidate }) => {
const t = useI18n()
const { control, trigger, formState: { errors } } = useFormContext<ApplicationCandidateWorkExperience>()
const { uploadFiles, deleteFile } = useUploadCandidateFiles(candidate.candidateId, CandidateDocumentTypes.WORK_EXPERIENCE, trigger)
const validateWorkExperience = (files: any[]): boolean | string => {
if (files && files.length > 5) {
return t('jobs.professionalexperience.validation.maxfiles')
}
return true
}
return (
<CandidateSection
title={t('jobs.professionalexperience.title')}
example={t('jobs.professionalexperience.example')}
description={t('jobs.professionalexperience.description')}
>
<Box>
<Controller
name="workExperience"
control={control}
rules={{
validate: () => validateWorkExperience(candidate.workExperience)
}}
render={() => (
<Box mt="xs">
<FileUpload
files={candidate.workExperience.map((file: DB.CandidateDocument) => ({ id: file.documentId, fileName: file.fileName }))}
uploadFiles={uploadFiles}
deleteFile={deleteFile}
/>
</Box>
)}
/>
{errors.workExperience && (
<Text size="sm" c="dimmed" mt="xs">
{asI18n(errors.workExperience?.message ?? '')}
</Text>
)}
</Box>
</CandidateSection>
)
}

View File

@@ -0,0 +1,179 @@
import { Link, useNavigate, useParams } from '@tanstack/react-router'
import { Button, Checkbox, Stack, TextInput, Text, Anchor, Alert } from "@pikku/mantine/core"
import { Controller, useForm } from "react-hook-form"
import { requestMagicLink } from "@/lib/auth"
import { pikku } from "@/pikku/http"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useI18n } from "@/context/i18n-provider"
import { useState } from "react"
import { asI18n } from "@pikku/react"
export const StartApplication: React.FunctionComponent<{ initializing: boolean }> = ({ initializing }) => {
const navigate = useNavigate()
const params = useParams({ strict: false })
const lang = params.lang as string
const t = useI18n()
const [emailExists, setEmailExists] = useState(false)
const [existingEmail, setExistingEmail] = useState('')
const [hasError, setHasError] = useState(false)
const {
control,
handleSubmit,
formState: { errors },
} = useForm({
defaultValues: {
email: '',
fullName: '',
termsAndPrivacyAccepted: false
}
})
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: async (data: any) => {
if (data.termsAndPrivacyAccepted && data.fullName && data.email) {
try {
await pikku().post('/candidate', {
name: data.fullName,
email: data.email
});
(window as any).fbq?.('trackCustom', 'StartApplication', {
source: 'check_my_qualification_button',
page: '/en/jobs/apply'
});
queryClient.invalidateQueries({ queryKey: ['candidate'] });
navigate({ to: `/${lang}/jobs/application` });
} catch (error: any) {
if (error.status === 409) {
setEmailExists(true)
setExistingEmail(data.email)
setHasError(false)
throw error
}
setHasError(true)
setEmailExists(false)
throw error
}
}
}
})
const sendMagicLinkMutation = useMutation({
mutationFn: async () => {
await requestMagicLink(existingEmail)
}
})
return (
<Stack component="form" gap="md" onSubmit={handleSubmit(data => mutation.mutateAsync(data))}>
<Controller
name="fullName"
control={control}
rules={{ required: t('jobs.startapplication.validation.fullnamerequired') }}
disabled={initializing}
render={({ field }) => (
<TextInput
id="fullName"
label={t('jobs.startapplication.fullname')}
error={errors.fullName?.message}
{...field}
/>
)}
/>
<Controller
name="email"
control={control}
rules={{
required: t('jobs.startapplication.validation.emailrequired'),
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: t('jobs.startapplication.validation.emailinvalid')
}
}}
disabled={initializing}
render={({ field }) => (
<TextInput
id="email"
type="email"
label={t('jobs.startapplication.email')}
autoComplete="email"
error={errors.email?.message}
{...field}
/>
)}
/>
<Controller
name="termsAndPrivacyAccepted"
control={control}
disabled={initializing}
rules={{ required: t('jobs.startapplication.validation.termsrequired') }}
render={({ field }) => (
<Checkbox
id="terms"
checked={field.value}
onChange={field.onChange}
disabled={field.disabled}
error={errors.termsAndPrivacyAccepted?.message}
label={
<Text size="sm">
{t('jobs.startapplication.termstext.prefix')}{asI18n(' ')}
<Anchor component={Link} to={`/${lang}/legal/terms-and-conditions`} size="sm">
{t('jobs.startapplication.termstext.terms')}
</Anchor>{asI18n(' ')}
{t('jobs.startapplication.termstext.and')}{asI18n(' ')}
<Anchor component={Link} to={`/${lang}/legal/privacy`} size="sm">
{t('jobs.startapplication.termstext.privacy')}
</Anchor>{asI18n(' ')}
{t('jobs.startapplication.termstext.suffix')}
</Text>
}
/>
)}
/>
{hasError && (
<Alert color="red" title={t('jobs.startapplication.error.title')}>
<Text size="sm">
{t('jobs.startapplication.error.message')}
</Text>
</Alert>
)}
{emailExists && (
<Alert color="blue" title={t('jobs.startapplication.emailexists.title')}>
<Text size="sm" mb="md">
{t('jobs.startapplication.emailexists.message')}
</Text>
<Button
size="sm"
variant="light"
onClick={() => sendMagicLinkMutation.mutate()}
loading={sendMagicLinkMutation.isPending}
disabled={sendMagicLinkMutation.isSuccess}
>
{sendMagicLinkMutation.isSuccess
? t('jobs.startapplication.emailexists.sent')
: t('jobs.startapplication.emailexists.button')}
</Button>
</Alert>
)}
<Stack gap="xs">
<Button
size="lg"
type="submit"
loading={mutation.isPending}
disabled={initializing || mutation.isPending}
px="xl"
>
{t('jobs.startapplication.checkqualification')}
</Button>
<Text size="sm" c="dimmed" ta="center">
{t('jobs.startapplication.freetext')}
</Text>
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,95 @@
import { Controller, useFormContext } from 'react-hook-form'
import type { DB } from '@heygermany/sdk'
import { ApplicationCandidateData, ApplicationCandidateEducation, CandidateDocumentTypes } from '@heygermany/sdk'
import { FileUpload } from '../../ui/FileUpload'
import { useUploadCandidateFiles } from '@/hooks/useUploadCandidateFiles'
import { CandidateSection } from './CandidateSection'
import { Alert, Box, List, Text, ThemeIcon } from '@pikku/mantine/core'
import { useI18n } from '@/context/i18n-provider'
import { IconCheck, IconInfoCircle } from '@tabler/icons-react'
import { asI18n } from '@pikku/react'
export const TrainingCertificates: React.FunctionComponent<{ candidate: ApplicationCandidateData }> = ({ candidate }) => {
const t = useI18n()
const { control, trigger, formState: { errors } } = useFormContext<ApplicationCandidateEducation>()
const { uploadFiles, deleteFile } = useUploadCandidateFiles(candidate.candidateId, CandidateDocumentTypes.EDUCATION, trigger)
const uploadedEducationDocuments = [...candidate.education]
const i18n = (key: string) => t(key as any)
const educationCopy = {
title: i18n('jobs.educationdocuments.title'),
example: i18n('jobs.educationdocuments.example'),
description: i18n('jobs.educationdocuments.description'),
validationRequired: i18n('jobs.educationdocuments.validation.required'),
validationMaxFiles: i18n('jobs.educationdocuments.validation.maxfiles'),
checklistTitle: i18n('jobs.educationdocuments.checklist.title'),
checklist: [
i18n('jobs.educationdocuments.checklist.item1'),
i18n('jobs.educationdocuments.checklist.item2'),
i18n('jobs.educationdocuments.checklist.item3')
]
}
const validateEducation = (files: DB.CandidateDocument[]): string | boolean => {
if (!files || files.length === 0) {
return educationCopy.validationRequired
} else if (files.length > 10) {
return educationCopy.validationMaxFiles
}
return true
}
return (
<CandidateSection
title={educationCopy.title}
example={educationCopy.example}
description={educationCopy.description}
>
<Box>
{educationCopy.checklist.length > 0 && (
<Alert
color="blue"
mb="md"
title={asI18n(educationCopy.checklistTitle)}
icon={<IconInfoCircle size={16} />}
>
<List size="sm" spacing="xs">
{educationCopy.checklist.map((item) => (
<List.Item
key={item}
icon={
<ThemeIcon size={18} radius="xl" color="blue.6">
<IconCheck size={12} />
</ThemeIcon>
}
>
{asI18n(item)}
</List.Item>
))}
</List>
</Alert>
)}
<Controller
name="education"
control={control}
rules={{
validate: () => validateEducation(uploadedEducationDocuments)
}}
render={() => (
<Box mt="xs">
<FileUpload
files={uploadedEducationDocuments.map((file: DB.CandidateDocument) => ({ id: file.documentId, fileName: file.fileName }))}
uploadFiles={uploadFiles}
deleteFile={deleteFile}
/>
</Box>
)}
/>
{errors.education && (
<Text size="sm" c="error" mt="xs">{asI18n(errors.education.message ?? '')}</Text>
)}
</Box>
</CandidateSection>
)
}

View File

@@ -0,0 +1,229 @@
import React, { useMemo, useState } from 'react'
import { Controller, useForm } from 'react-hook-form'
import { Box, Button, Checkbox, SimpleGrid, Stack, Text } from '@pikku/mantine/core'
import type { DB } from '@heygermany/sdk'
import {
ApplicationCandidateData,
CandidateDocumentTypes,
LanguageCodes,
ProfessionalRecognitionStatuses,
} from '@heygermany/sdk'
import { useI18n } from '@/context/i18n-provider'
import { pikku } from '@/pikku/http'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { EnumSelect } from '@/components/common/EnumSelect'
import { FileUpload } from '@/components/ui/FileUpload'
import { useUploadCandidateFiles } from '@/hooks/useUploadCandidateFiles'
import { useLocation, useParams } from '@tanstack/react-router'
import { CountrySearch } from '../form/CountrySearch'
interface ApplicantProfileForm {
nationality: string
currentResidence: string
professionalRecognitionStatus: DB.ProfessionalRecognitionStatus | null
qualificationStatusGermany: DB.NurseRecognitionGermany | null
recognitionLikelihood: DB.RecognitionLikelihood | null
languagesSpoken: string[]
}
export const ApplicantProfile: React.FunctionComponent = () => {
const params = useParams({ strict: false })
const searchParams = useLocation({ select: (l) => new URLSearchParams(l.searchStr) })
const candidateIdFromUrl = params.candidateId as string | undefined
const candidateIdFromQuery = searchParams.get('candidateId')
const candidateId = candidateIdFromUrl || candidateIdFromQuery || undefined
// Fetch existing candidate data
const { data: candidate } = useQuery({
queryKey: ['candidate', candidateId],
queryFn: async () => {
if (candidateId) {
return await pikku().get('/candidate/:candidateId', { candidateId })
}
return await pikku().get('/candidate')
},
})
if (!candidate) {
return null
}
return <ApplicantProfileInner candidate={candidate} />
}
const ApplicantProfileInner: React.FunctionComponent<{ candidate: ApplicationCandidateData }> = ({ candidate }) => {
const t = useI18n()
const queryClient = useQueryClient()
const [isSaving, setIsSaving] = useState(false)
// Update mutation
const updateMutation = useMutation({
mutationFn: async (data: ApplicantProfileForm) => {
if (!candidate?.candidateId) {
throw new Error('Candidate ID not found')
}
setIsSaving(true)
// Create a minimum delay promise (2 seconds)
const minDelay = new Promise(resolve => setTimeout(resolve, 2000))
// Update candidate profile
const updatePromise = pikku().patch(`/candidate/:candidateId`, {
candidateId: candidate.candidateId,
...data,
languagesSpoken: data.languagesSpoken as DB.LanguageCode[],
})
// Wait for both the update and minimum delay
await Promise.all([updatePromise, minDelay])
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['candidate'] })
setIsSaving(false)
// Show success notification or redirect
},
onError: () => {
setIsSaving(false)
},
})
const { control, handleSubmit, trigger, formState: { errors } } = useForm<ApplicantProfileForm>({
defaultValues: {
nationality: candidate.nationality || '',
currentResidence: candidate.currentResidence || '',
professionalRecognitionStatus: candidate.professionalRecognitionStatus || null,
qualificationStatusGermany: candidate.qualificationStatusGermany || null,
recognitionLikelihood: candidate.recognitionLikelihood || null,
languagesSpoken: candidate.languagesSpoken || [],
},
})
// File upload for CV (using WORK_EXPERIENCE document type)
const { uploadFiles, deleteFile } = useUploadCandidateFiles(
candidate.candidateId,
CandidateDocumentTypes.WORK_EXPERIENCE,
trigger
)
// Available languages (sorted alphabetically, English first)
const selectEnums = t.getEnumOptions(LanguageCodes, 'languagecode')
const availableLanguages = useMemo(() => {
// English first, then sort rest alphabetically by label
const english = selectEnums.find(l => l.value === 'en')!
const others = selectEnums.filter(l => l.value !== 'en').sort((a, b) => a.label.localeCompare(b.label))
return [english, ...others]
}, [t, selectEnums])
return (
<>
<Text size="sm" c="dimmed" mb="lg">
{t('jobs.profile.description')}
</Text>
<form onSubmit={handleSubmit((data) => updateMutation.mutate(data))}>
<Stack gap="md">
{/* Nationality */}
<Controller
name="nationality"
control={control}
render={({ field }) => (
<CountrySearch
label={t('jobs.profile.nationality.label')}
placeholder={t('jobs.profile.nationality.placeholder')}
value={field.value}
onChange={field.onChange}
error={errors.nationality?.message}
/>
)}
/>
{/* Current Residence */}
<Controller
name="currentResidence"
control={control}
render={({ field }) => (
<CountrySearch
label={t('jobs.profile.residence.label')}
placeholder={t('jobs.profile.residence.placeholder')}
value={field.value}
onChange={field.onChange}
error={errors.currentResidence?.message}
/>
)}
/>
{/* Professional Recognition Status */}
<Controller
name="professionalRecognitionStatus"
control={control}
render={({ field }) => (
<EnumSelect
label={t('jobs.profile.recognitionstatus.label')}
enumObject={ProfessionalRecognitionStatuses}
enumName='professionalRecognitionStatus'
value={field.value}
onChange={field.onChange}
error={errors.professionalRecognitionStatus?.message} />
)}
/>
{/* Languages Spoken */}
<Box>
<Text size="sm" fw={500} mb="xs">{t('jobs.profile.languages.label')}</Text>
<Controller
name="languagesSpoken"
control={control}
render={({ field }) => (
<Checkbox.Group
value={field.value}
onChange={field.onChange}
error={errors.languagesSpoken?.message}
>
<SimpleGrid cols={3} spacing="sm">
{availableLanguages.map((lang) => (
<Checkbox
key={lang.value}
value={lang.value}
label={lang.label}
/>
))}
</SimpleGrid>
</Checkbox.Group>
)}
/>
</Box>
{/* CV Upload */}
<Box>
<Text size="sm" fw={500} mb="xs">{t('jobs.profile.cv.label')}</Text>
<Text size="sm" c="dimmed" mb="sm">
{t('jobs.profile.cv.description')}
</Text>
<FileUpload
files={candidate?.workExperience?.map((file: DB.CandidateDocument) => ({
id: file.documentId,
fileName: file.fileName
})) || []}
uploadFiles={uploadFiles}
deleteFile={deleteFile}
/>
</Box>
{/* Submit Button */}
<Button
type="submit"
size="lg"
w='100%'
mt="md"
loading={isSaving}
>
{t('jobs.profile.savebutton')}
</Button>
</Stack>
</form>
</>
)
}
export default ApplicantProfile

View File

@@ -0,0 +1,139 @@
import { useState } from 'react'
import {
Box,
Container,
Grid,
Stack,
Text,
Group,
Button,
rem,
Drawer,
Alert,
} from '@pikku/mantine/core'
import { IconInfoCircle } from '@tabler/icons-react'
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
import GetHelp from './GetHelp'
import ApplicantProfile from './ApplicantProfile'
import ConnectWithEmployers from './ConnectWithEmployers'
interface CTASectionProps {
joinTalentPool: boolean
publicMode?: boolean
}
export const CTASection: React.FunctionComponent<CTASectionProps> = ({
joinTalentPool,
publicMode = false,
}) => {
const t = useI18n()
const [drawerOpened, setDrawerOpened] = useState(false)
const [drawerTitle, setDrawerTitle] = useState('')
const [drawerContent, setDrawerContent] = useState<'getHelp' | 'connect'>('connect')
const openDrawer = (contentType: 'getHelp' | 'connect', title: string) => {
setDrawerTitle(title)
setDrawerContent(contentType)
setDrawerOpened(true)
}
const renderDrawerContent = () => {
switch (drawerContent) {
case 'getHelp':
return <GetHelp />
case 'connect':
if (publicMode) {
return <ConnectWithEmployers />
}
return (
<Stack gap="md">
{!joinTalentPool && (
<Alert variant="light" color="blue" icon={<IconInfoCircle />}>
<Text size="sm" fw={500}>
{t('jobs.improveyourprofile.optin.title')}
</Text>
<Text size="sm" mt="xs">
{t('jobs.improveyourprofile.optin.description')}
</Text>
</Alert>
)}
<ApplicantProfile />
</Stack>
)
default:
return null
}
}
return (
<>
<Box bg="white" py="xl">
<Container size="xl" p={0}>
<Grid gutter={0}>
<Grid.Col span={{ base: 12, lg: 6 }}>
<Stack gap="xl" p="xl" me={rem(40)}>
<Stack gap="xs">
<Text size="xxxl" fw={700} lh={1.2}>
{t('jobs.ctasection.ready.prefix')}
</Text>
<Text size="xxxl" fw={700} c="primary" lh={1.2}>
{t('jobs.ctasection.ready.suffix')}
</Text>
</Stack>
<Text size="xl" maw={rem(500)}>
{t('jobs.ctasection.description')}
</Text>
<Group>
<Button
size="md"
color="primary"
onClick={() => openDrawer('connect', t('jobs.ctasection.buttons.connect'))}
>
{t('jobs.ctasection.buttons.connect')}
</Button>
<Button
size="md"
variant="outline"
color="gray"
onClick={() => openDrawer('getHelp', t('jobs.ctasection.buttons.contact'))}
>
{t('jobs.ctasection.buttons.contact')}
</Button>
</Group>
</Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 6 }}>
<Box pos="relative" h={{ base: '300px', lg: '100%' }}>
<img
src="https://media.istockphoto.com/id/998313080/photo/smiling-medical-team-standing-together-outside-a-hospital.jpg?s=612x612&w=0&k=20&c=fXzbjAoStQ_8jTM4TQxbHBEjhETI3vq5_7d_JL19eCA="
alt="Smiling medical team standing together outside a hospital"
style={{
height: '100%',
width: '100%',
objectFit: 'cover',
objectPosition: 'center',
clipPath: 'polygon(12% 0, 100% 0%, 100% 100%, 0 100%)',
}}
/>
</Box>
</Grid.Col>
</Grid>
</Container>
</Box>
<Drawer
opened={drawerOpened}
onClose={() => setDrawerOpened(false)}
title={asI18n(drawerTitle)}
size="lg"
position="right"
>
{renderDrawerContent()}
</Drawer>
</>
)
}

View File

@@ -0,0 +1,61 @@
import { Stack, Text, List, Divider } from '@pikku/mantine/core'
import { useI18n } from '@/context/i18n-provider'
import { mKey } from '@/i18n/messages'
import { compareRecognitionOptionKeys } from '@/lib/content/compare-recognition-options'
export default function CompareOptionsRecognition() {
const t = useI18n()
// Structure (which options, ordering) is data; text comes from messages.
const options = compareRecognitionOptionKeys.map((key) => ({
key,
name: mKey(`jobs.compareoptionsrecognition.options.${key}.name`),
description: mKey(`jobs.compareoptionsrecognition.options.${key}.description`),
duration: mKey(`jobs.compareoptionsrecognition.options.${key}.duration`),
}))
return (
<Stack gap="md">
<Text mt="md" mb="md">
{t('jobs.compareoptionsrecognition.context')}
</Text>
<Text fw="bold" mb="xs">
{t('jobs.compareoptionsrecognition.choose')}
</Text>
<List withPadding spacing="xs" mb="md">
{options.map((option: any) => (
<List.Item key={option.key}>
<Text fw="bold">{option.name}:</Text> {option.description}
</List.Item>
))}
</List>
<Divider />
<Text fw="bold" mt="md" mb="xs">
{t('jobs.compareoptionsrecognition.duration')}
</Text>
<List withPadding spacing="xs" mb="md">
{options.map((option: any) => (
<List.Item key={option.key + '-duration'}>
<Text fw="bold">{option.name}:</Text> {option.duration}
</List.Item>
))}
</List>
<Text mt="md">
{t('jobs.compareoptionsrecognition.requirementsaftercompletion')}
</Text>
<Text fw="bold" mt="md">
{t('jobs.compareoptionsrecognition.professionaltitleintro')}
</Text>
<Text fw="bold" fz="md">
{t('jobs.compareoptionsrecognition.professionaltitle')}
</Text>
<Text mt="sm" lineBreaks>{t('jobs.compareoptionsrecognition.outcome')}</Text>
</Stack>
)
}

View File

@@ -0,0 +1,40 @@
import { useState, useEffect } from 'react'
import { Checkbox, Stack, Text, Group } from '@pikku/mantine/core'
import { useI18n } from '@/context/i18n-provider'
export default function ConnectWithEmployers() {
const t = useI18n()
const [joinTalentPool, setJoinTalentPool] = useState(false)
useEffect(() => {
fetch('/json/user.json')
.then((response) => response.json())
.then((data) => {
setJoinTalentPool(data.joinTalentPool ?? false)
})
.catch(() => {
console.error('Failed to load user data, using default values.')
})
}, [])
const handleCheckboxChange = (checked: boolean) => {
setJoinTalentPool(checked)
localStorage.setItem('joinTalentPool', JSON.stringify(checked))
}
return (
<Stack gap="md">
<Text mb="md" lineBreaks>{t('jobs.connectwithemployers.description')}</Text>
<Group gap="xs" align="center" mt="md">
<Checkbox
id="joinTalentPool"
checked={joinTalentPool}
onChange={(e) => handleCheckboxChange(e.target.checked)}
label={t('jobs.connectwithemployers.jointalentpool')}
size="md"
/>
</Group>
</Stack>
)
}

View File

@@ -0,0 +1,99 @@
import {
Stack,
Text,
List,
Divider,
Anchor,
Card,
Title,
} from '@pikku/mantine/core'
import { useI18n } from '@/context/i18n-provider'
import { mList } from '@/i18n/messages'
import { asI18n } from '@pikku/react'
interface DocumentsChecklistProps {
openGetHelpDrawer: () => void
}
export default function DocumentsChecklist({
openGetHelpDrawer,
}: DocumentsChecklistProps) {
const t = useI18n()
return (
<Stack gap="lg" mt="md">
<Text lineBreaks>{t('jobs.documentschecklist.intro.part1')}</Text>
<Text lineBreaks>{t('jobs.documentschecklist.intro.part2')}</Text>
<Card withBorder padding="md" radius="sm">
<Text size="sm">
{t('jobs.documentschecklist.intro.help')}
<Anchor
c="primary"
fw={700}
td="underline"
onClick={() => {
openGetHelpDrawer()
}}
>
{asI18n('here')}
</Anchor>
</Text>
</Card>
<Divider />
<Title order={4}>
{t('jobs.documentschecklist.submitoriginal.title')}
</Title>
<List withPadding spacing="xs">
{mList('jobs.documentschecklist.submitoriginal.items').map(
(item, idx) => (
<List.Item key={'original-' + idx}>{item}</List.Item>
)
)}
</List>
<Divider />
<Title order={4}>{t('jobs.documentschecklist.submitcopies.title')}</Title>
<List withPadding spacing="xs">
{mList('jobs.documentschecklist.submitcopies.items').map(
(item, idx) => (
<List.Item key={'copy-' + idx}>{item}</List.Item>
)
)}
</List>
<Card withBorder padding="md" radius="sm">
<Text size="sm" lineBreaks>{t('jobs.documentschecklist.translationinfo')}</Text>
</Card>
<Divider />
<Title order={4}>{t('jobs.documentschecklist.advice.title')}</Title>
<Text lineBreaks>{t('jobs.documentschecklist.advice.part1')}</Text>
<Text lineBreaks>{t('jobs.documentschecklist.advice.part2')}</Text>
<Divider />
<Title order={4}>{t('jobs.documentschecklist.contact.title')}</Title>
<Text>
<strong>{asI18n('Address:')}</strong>
<br />
{t('jobs.documentschecklist.contact.address')}
</Text>
<Text>
<strong>{asI18n('Email:')}</strong>{asI18n(' ')}
<Anchor
href={`mailto:${t('jobs.documentschecklist.contact.email')}`}
c="primary"
fw={700}
>
{t('jobs.documentschecklist.contact.email')}
</Anchor>
</Text>
<Text>
<strong>{asI18n('Phone:')}</strong>{asI18n(' ')}{t('jobs.documentschecklist.contact.phone')}
</Text>
<Text>
<em>{t('jobs.documentschecklist.contact.hours')}</em>
</Text>
</Stack>
)
}

View File

@@ -0,0 +1,65 @@
import {
Stack,
Text,
Title,
List,
Anchor,
Table,
Divider,
} from '@pikku/mantine/core'
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
import { paidCourseProviders as providers } from '@/lib/content/paid-course-providers'
import { freeCourseLinks as freeLinks } from '@/lib/content/free-course-links'
export default function GermanLanguageTips() {
const t = useI18n()
return (
<Stack gap="md" mt="md">
<Text lineBreaks>{t('jobs.germanlanguagetips.tip')}</Text>
<Title order={3}>{t('jobs.germanlanguagetips.freecourses.title')}</Title>
<List>
{freeLinks.map((link, idx) => (
<List.Item key={idx}>
<Anchor href={link.url} target="_blank" rel="noopener noreferrer">
{asI18n(link.name)}
</Anchor>
</List.Item>
))}
</List>
<Text lineBreaks>{t('jobs.germanlanguagetips.b1congrats')}</Text>
<Text lineBreaks>{t('jobs.germanlanguagetips.b2requirement')}</Text>
<Divider />
<Title order={3}>{t('jobs.germanlanguagetips.paidcourses.title')}</Title>
<Table withTableBorder withColumnBorders striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Provider</Table.Th>
<Table.Th>Levels</Table.Th>
<Table.Th>Format</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{providers.map((course, idx) => (
<Table.Tr key={idx}>
<Table.Td>{course.provider}</Table.Td>
<Table.Td>{course.levels}</Table.Td>
<Table.Td>{course.format}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Text lineBreaks>{t('jobs.germanlanguagetips.paidcourses.fundingintro')}</Text>
{/* TODO(i18n): the `jobs.germanlanguagetips.fundingoptions` source cell is
corrupt JSON in the Google Sheet (truncated, unescaped quotes), so this
section has rendered empty in all locales. Restore as a TS content
module + messages once the source content is recovered. */}
</Stack>
)
}

View File

@@ -0,0 +1,204 @@
import { IconExternalLink } from '@tabler/icons-react'
import { Stack, Text, List, Divider, Group, Anchor } from '@pikku/mantine/core'
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
export default function GetHelp() {
const t = useI18n()
return (
<Stack gap="md">
<Text mt="md" mb="md">
{t('jobs.gethelp.description')}
</Text>
<List withPadding spacing="xs" mb="md">
<List.Item>{t('jobs.gethelp.services.explainprocess')}</List.Item>
<List.Item>{t('jobs.gethelp.services.helpdocuments')}</List.Item>
<List.Item>{t('jobs.gethelp.services.reviewapplication')}</List.Item>
<List.Item>{t('jobs.gethelp.services.supportcommunication')}</List.Item>
<List.Item>{t('jobs.gethelp.services.adviserequirements')}</List.Item>
</List>
<Text mb="md" lineBreaks>{t('jobs.gethelp.support')}</Text>
<Stack gap="md">
<Stack
gap="xs"
pb="md"
style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}
>
<Text fw="bold">{t('jobs.gethelp.organizations.dare.name')}</Text>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.dare.phone')}: +49 (0)175 2264572`)}</Text>
<Text lineBreaks>{asI18n('Whatsapp: +49-17163442306')}</Text>
<Text size="sm">
{asI18n(`${t('jobs.gethelp.organizations.dare.email')}: `)}
<Anchor href="mailto:info@dareconsulting.de" c="primary" fw={700}>
{asI18n('info@dareconsulting.de')}
</Anchor>
</Text>
<Group gap="xs" align="center">
<IconExternalLink size={16} />
<Anchor
href="https://dareconsulting.de/de/services/#BBeFaP"
target="_blank"
rel="noopener noreferrer"
size="sm"
c="primary"
fw={700}
>
{t('jobs.gethelp.organizations.bamf.website')}
</Anchor>
</Group>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.dare.languages')}: English, German, Spanish`)}</Text>
</Stack>
<Stack
gap="xs"
pb="md"
style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}
>
<Text fw="bold">{t('jobs.gethelp.organizations.bamf.name')}</Text>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.bamf.phone')}: +49 (0) 30 1815 1111`)}</Text>
<Group gap="xs" align="center">
<IconExternalLink size={16} />
<Anchor
href="https://www.anerkennung-in-deutschland.de/en/contact/recognition"
target="_blank"
rel="noopener noreferrer"
size="sm"
c="primary"
fw={700}
>
{t('jobs.gethelp.organizations.bamf.contactform')}
</Anchor>
</Group>
<Group gap="xs" align="center">
<IconExternalLink size={16} />
<Anchor
href="https://www.make-it-in-germany.com/en/"
target="_blank"
rel="noopener noreferrer"
size="sm"
c="primary"
fw={700}
>
{t('jobs.gethelp.organizations.bamf.website')}
</Anchor>
</Group>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.bamf.languages')}: English, German`)}</Text>
</Stack>
<Stack
gap="xs"
pb="md"
style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}
>
<Text fw="bold">{t('jobs.gethelp.organizations.gesbit.name')}</Text>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.gesbit.phone')}: +49 (0)30 / 31510900`)}</Text>
<Group gap="xs" align="center">
<IconExternalLink size={16} />
<Anchor
href="https://gesbit.de/arbeitsmarkt-und-beschaeftigung/hotline-anerkennung-beruflicher-qualifikationen/#c2712"
target="_blank"
rel="noopener noreferrer"
size="sm"
c="primary"
fw={700}
>
{t('jobs.gethelp.organizations.gesbit.website')}
</Anchor>
</Group>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.gesbit.languages')}: Turkish, Ukrainian, English, German`)}</Text>
</Stack>
<Stack
gap="xs"
pb="md"
style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}
>
<Text fw="bold">{t('jobs.gethelp.organizations.clubdialog.name')}</Text>
<Text size="sm">
{asI18n(`${t('jobs.gethelp.organizations.clubdialog.email')}: `)}
<Anchor
href="mailto:anerkennung@club-dialog.de"
c="primary"
fw={700}
>
{asI18n('anerkennung@club-dialog.de')}
</Anchor>
</Text>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.clubdialog.phone')}: +49 (0)30 / 263 476 05`)}</Text>
<Group gap="xs" align="center">
<IconExternalLink size={16} />
<Anchor
href="https://anerkennung-berufsqualifikationen-ua.de/"
target="_blank"
rel="noopener noreferrer"
size="sm"
c="primary"
fw={700}
>
{t('jobs.gethelp.organizations.clubdialog.website')}
</Anchor>
</Group>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.clubdialog.languages')}: Russian, Polish, Ukrainian, English, German`)}</Text>
</Stack>
<Stack
gap="xs"
pb="md"
style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}
>
<Text fw="bold">{t('jobs.gethelp.organizations.lared.name')}</Text>
<Text size="sm">
{asI18n(`${t('jobs.gethelp.organizations.lared.email')}: `)}
<Anchor href="mailto:anerkennung@la-red.eu" c="primary" fw={700}>
{asI18n('anerkennung@la-red.eu')}
</Anchor>
</Text>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.lared.phone')}: +49 (0)30 / 457 989 555`)}</Text>
<Group gap="xs" align="center">
<IconExternalLink size={16} />
<Anchor
href="https://la-red.eu/lara-assessment-and-recognition-of-foreign-qualifications/"
target="_blank"
rel="noopener noreferrer"
size="sm"
c="primary"
fw={700}
>
{t('jobs.gethelp.organizations.lared.website')}
</Anchor>
</Group>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.lared.languages')}: Spanish, French, Italian, Arabic, English, German`)}</Text>
</Stack>
<Stack gap="xs">
<Text fw="bold">{t('jobs.gethelp.organizations.tbb.name')}</Text>
<Text size="sm">
{asI18n(`${t('jobs.gethelp.organizations.tbb.email')}: `)}
<Anchor href="mailto:diploma@tbb-berlin.de" c="primary" fw={700}>
{asI18n('diploma@tbb-berlin.de')}
</Anchor>
</Text>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.tbb.phone')}: +49 (0)30 / 236 233 25`)}</Text>
<Group gap="xs" align="center">
<IconExternalLink size={16} />
<Anchor
href="https://www.tbb-berlin.de/beratungsangebote/consulting-services/"
target="_blank"
rel="noopener noreferrer"
size="sm"
c="primary"
fw={700}
>
{t('jobs.gethelp.organizations.tbb.website')}
</Anchor>
</Group>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.tbb.languages')}: Turkish, Arabic, Russian, English, German`)}</Text>
</Stack>
</Stack>
</Stack>
)
}

View File

@@ -0,0 +1,56 @@
import { Stack, Text, Divider, Anchor, Title, Card } from '@pikku/mantine/core'
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
export default function HowToApply() {
const t = useI18n()
return (
<Stack gap="md" mt="md">
<Text lineBreaks>{t('jobs.howtoapply.intro1')}</Text>
<Text>
{asI18n(`${t('jobs.howtoapply.applicationform')} `)}
<Anchor
href="/pdf/Application_Form_Nurse.pdf"
target="_blank"
rel="noopener noreferrer"
c="primary"
td="underline"
fw={700}
>
{asI18n('here')}
</Anchor>
</Text>
<Text lineBreaks>{t('jobs.howtoapply.reminder')}</Text>
<Text lineBreaks>{t('jobs.howtoapply.submission')}</Text>
<Divider />
<Title order={4}>{t('jobs.howtoapply.contacttitle')}</Title>
<Text lineBreaks>{t('jobs.howtoapply.contact.address')}</Text>
<Text>
{asI18n('Email: ')}
<Anchor
href={`mailto:${t('jobs.howtoapply.contact.email')}`}
c="primary"
fw={700}
>
{t('jobs.howtoapply.contact.email')}
</Anchor>
</Text>
<Text>{asI18n(`Phone: ${t('jobs.howtoapply.contact.phone')}`)}</Text>
<Text lineBreaks>{t('jobs.howtoapply.contact.hours')}</Text>
<Card withBorder padding="md" radius="sm" mt="md">
<Text lineBreaks>{t('jobs.howtoapply.tip')}</Text>
</Card>
<Text lineBreaks>{t('jobs.howtoapply.waiver')}</Text>
<Divider />
<Text lineBreaks>{t('jobs.howtoapply.counseling')}</Text>
</Stack>
)
}

View File

@@ -0,0 +1,231 @@
import { useState } from 'react'
import {
Button,
Text,
Drawer,
Stack,
} from '@pikku/mantine/core'
import {
IconInfoCircle,
IconBriefcase,
IconFile,
IconCircleCheck,
IconLanguage,
IconExternalLink,
} from '@tabler/icons-react'
import GetHelp from './GetHelp'
import ConnectWithEmployers from './ConnectWithEmployers'
import CompareOptionsRecognition from './CompareOptionsRecognition'
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
import DocumentsChecklist from './DocumentsChecklist'
import HowToApply from './HowToApply'
import GermanLanguageTips from './GermanLanguageTips'
import ApplicantProfile from './ApplicantProfile'
import { ExpandableSteps, type ExpandableStep } from '@/components/common/ExpandableSteps'
import { useMantineTheme } from '@pikku/mantine/core'
import { NextSteps as NextStepsType } from '@heygermany/sdk'
interface NextStepsProps {
joinTalentPool: boolean
nextSteps: NextStepsType | null
isHelper?: boolean
}
interface NextStepContentProps {
subtitle?: string
costs?: string
textButton?: string
onButtonClick?: () => void
}
function NextStepContent({ subtitle, costs, textButton, onButtonClick }: NextStepContentProps) {
const theme = useMantineTheme()
return (
<Stack justify='center' align='center' ta='start'>
{subtitle && (
<Text
c="dimmed"
fz="lg"
mt="md"
>
{asI18n(subtitle)}
</Text>
)}
{costs && (
<Text
c={`${theme.colors.gray[8]}`}
fw={400}
fz="md"
mt="md"
mb="lg"
>
{asI18n(costs)}
</Text>
)}
{textButton && onButtonClick && (
<Button
leftSection={<IconExternalLink size={24} />}
size="md"
onClick={(e) => {
e.stopPropagation()
onButtonClick()
}}
>
{asI18n(textButton)}
</Button>
)}
</Stack>
)
}
export default function NextSteps({ joinTalentPool, nextSteps, isHelper = false }: NextStepsProps) {
const t = useI18n()
const [drawerOpened, setDrawerOpened] = useState(false)
const [drawerTitle, setDrawerTitle] = useState('')
const [drawerContent, setDrawerContent] = useState<
'getHelp' | 'connect' | 'documents' | 'apply' | 'compensatory' | 'language'
>('getHelp')
// Handle missing next steps data
if (!nextSteps) {
console.error('[NextSteps] No next steps data provided from backend')
return null
}
const openDrawer = (contentType: 'getHelp' | 'connect' | 'documents' | 'apply' | 'compensatory' | 'language', title: string) => {
setDrawerTitle(title)
setDrawerContent(contentType)
setDrawerOpened(true)
}
// For helpers, only show: gethelp, connect, and conditionally german
// For others, show all available steps
const allSteps: ExpandableStep[] = [
{
title: nextSteps.gethelp.title,
icon: <IconInfoCircle size={38} />,
content: () => (
<NextStepContent
subtitle={nextSteps.gethelp.subtitle}
costs={nextSteps.gethelp.costs}
textButton={nextSteps.gethelp.button}
onButtonClick={() => openDrawer('getHelp', nextSteps.gethelp.title)}
/>
),
},
{
title: nextSteps.connect.title,
icon: <IconBriefcase size={38} />,
content: () => (
<NextStepContent
subtitle={nextSteps.connect.subtitle}
costs={nextSteps.connect.costs}
textButton={joinTalentPool
? nextSteps.connect.buttonimprove
: nextSteps.connect.buttonjoin}
onButtonClick={() => openDrawer('connect', nextSteps.connect.title)}
/>
),
},
...(!isHelper && nextSteps.documents ? [{
title: nextSteps.documents.title,
icon: <IconFile size={38} />,
content: () => (
<NextStepContent
subtitle={nextSteps.documents?.subtitle}
costs={nextSteps.documents?.costs}
textButton={nextSteps.documents?.button}
onButtonClick={() => openDrawer('documents', nextSteps.documents!.title)}
/>
),
}] : []),
...(!isHelper && nextSteps.submit ? [{
title: nextSteps.submit.title,
icon: <IconFile size={38} />,
content: () => (
<NextStepContent
subtitle={nextSteps.submit?.subtitle}
costs={nextSteps.submit?.costs}
textButton={nextSteps.submit?.button}
onButtonClick={() => openDrawer('apply', nextSteps.submit!.title)}
/>
),
}] : []),
...(!isHelper && nextSteps.compensatory ? [{
title: nextSteps.compensatory.title,
icon: <IconCircleCheck size={38} />,
content: () => (
<NextStepContent
subtitle={nextSteps.compensatory?.subtitle}
costs={nextSteps.compensatory?.costs}
textButton={nextSteps.compensatory?.button}
onButtonClick={() => openDrawer('compensatory', nextSteps.compensatory!.title)}
/>
),
}] : []),
...(nextSteps.german ? [{
title: nextSteps.german.title,
icon: <IconLanguage size={36} />,
content: () => (
<NextStepContent
subtitle={nextSteps.german?.subtitle}
costs={nextSteps.german?.costs}
textButton={nextSteps.german?.button}
onButtonClick={() => openDrawer('language', nextSteps.german!.title)}
/>
),
}] : []),
]
const renderDrawerContent = () => {
switch (drawerContent) {
case 'getHelp':
return <GetHelp />
case 'connect':
return joinTalentPool ? <ApplicantProfile /> : <ConnectWithEmployers />
case 'documents':
return (
<DocumentsChecklist
openGetHelpDrawer={() => openDrawer('getHelp', nextSteps.gethelp.title)}
/>
)
case 'apply':
return <HowToApply />
case 'compensatory':
return <CompareOptionsRecognition />
case 'language':
return <GermanLanguageTips />
default:
console.error(`[NextSteps] Unknown drawer content type: ${drawerContent}`)
return (
<Text c="dimmed" p="md">
{asI18n('Content not available')}
</Text>
)
}
}
return (
<>
<ExpandableSteps
steps={allSteps}
defaultExpanded={[0]}
singleExpand={true}
showTitle={true}
title={t('jobs.nextsteps.title')}
/>
<Drawer
opened={drawerOpened}
onClose={() => setDrawerOpened(false)}
title={asI18n(drawerTitle)}
size="lg"
position="right"
>
{renderDrawerContent()}
</Drawer>
</>
)
}

View File

@@ -0,0 +1,150 @@
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
import { LanguageLevels } from '@heygermany/sdk'
import {
Stack,
Title,
Text,
Box,
Paper,
Progress,
List,
ThemeIcon,
Flex,
rem,
useMantineTheme,
} from '@pikku/mantine/core'
import {
IconCircleCheck,
IconExclamationCircle,
IconClock,
IconInfoCircle,
IconCircle,
} from '@tabler/icons-react'
interface QualificationProps {
title: string
description: string
status: string
requirements: string[]
steps?: string[]
progress?: number
fillHeight?: boolean
}
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'green'
case 'required':
return 'red'
case 'in-progress':
return 'orange'
case 'optional':
return 'blue'
default:
return 'gray'
}
}
const getStatusIcon = (status: string) => {
const iconProps = { size: 28, color: getStatusColor(status) }
switch (status) {
case 'completed':
return <IconCircleCheck {...iconProps} />
case 'required':
return <IconExclamationCircle {...iconProps} />
case 'in-progress':
return <IconClock {...iconProps} />
case 'optional':
return <IconInfoCircle {...iconProps} />
default:
return <IconCircle {...iconProps} />
}
}
export const Qualification: React.FunctionComponent<QualificationProps> = ({
title,
description,
status,
requirements,
steps,
progress,
fillHeight = true,
}) => {
const t = useI18n()
const theme = useMantineTheme()
function translateLevels(req: string): string {
for (const key of Object.keys(LanguageLevels)) {
req = req.replace(key, t(`enums.languagelevel.${key}`.toLowerCase() as any))
}
return req
}
return (
<Paper flex={1} withBorder p="lg" radius="lg" h={fillHeight ? '100%' : undefined}>
<Stack flex={1} gap={0}>
<Flex align="center" gap="xs" mb={5}>
<ThemeIcon variant="transparent" color={getStatusColor(status)}>
{getStatusIcon(status)}
</ThemeIcon>
<Text size="xl">{asI18n(title)}</Text>
</Flex>
<Flex mb="md" gap="sm" align="center">
<Text component="span" fw="600" c={getStatusColor(status)}>
{t(`enums.documentstatus.${status}` as any)}
</Text>
{!isNaN(progress!) && (
<Progress.Root flex={1} h="md" radius="xl">
<Progress.Section value={progress!}>
<Progress.Label c="dimmed" fz="sm" fw={700}>
{progress}%
</Progress.Label>
</Progress.Section>
</Progress.Root>
)}
</Flex>
<Text size="lg" c="dimmed" mb="md">
{asI18n(description)}
</Text>
{requirements && (
<Box mb="md">
<Text size="lg" fw={500} mb="xs">
{asI18n(`${t('jobs.qualification.requirements')}:`)}
</Text>
<List pl="md" size="md">
{requirements.map((req, reqIndex) => (
<List.Item key={reqIndex}>
<Text c={`${theme.colors.gray[7]}`} fz="md" fw={300}>
{asI18n(translateLevels(req))}
</Text>
</List.Item>
))}
</List>
</Box>
)}
{(steps?.length && steps.length > 0) ? (
<Box mb="md">
<Text component='h4' size="lg" fw={500} mb="xs">
{asI18n(`${t('jobs.qualification.nextsteps')}:`)}
</Text>
<List pl="md" type="ordered" size="md">
{steps.map((step, stepIndex) => (
<List.Item key={stepIndex}>
<Text c={`${theme.colors.gray[7]}`} fz="md" fw={300}>
{asI18n(translateLevels(step))}
</Text>
</List.Item>
))}
</List>
</Box>
) : null}
</Stack>
</Paper>
)
}

View File

@@ -0,0 +1,80 @@
import {
Paper,
Text,
Flex,
ThemeIcon,
rem,
useMantineTheme,
Stack,
} from '@pikku/mantine/core'
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
import {
IconCircleCheck,
IconExclamationCircle,
IconClock,
IconInfoCircle,
IconCircle,
} from '@tabler/icons-react'
interface QualificationSectionStatusCardProps {
label: string
status: 'completed' | 'in-progress' | 'required' | 'optional'
color: string
icon: React.ReactNode
}
const getStatusIcon = (
status: QualificationSectionStatusCardProps['status'],
color: string
) => {
const iconProps = { size: 20, color }
switch (status) {
case 'completed':
return <IconCircleCheck {...iconProps} />
case 'required':
return <IconExclamationCircle {...iconProps} />
case 'in-progress':
return <IconClock {...iconProps} />
case 'optional':
return <IconInfoCircle {...iconProps} />
default:
return <IconCircle {...iconProps} />
}
}
export const QualificationSectionStatusCard: React.FC<
QualificationSectionStatusCardProps
> = ({ label, status, color, icon }) => {
const theme = useMantineTheme()
const t = useI18n()
return (
<Paper withBorder shadow="xs" p="md" radius="md" mb="xl" w={rem(220)}>
<Stack>
<Flex justify='space-between'>
<Text size="lg" c="dimmed" mb="lg">
{asI18n(label.split(' ')[0] ?? '')}
</Text>
<ThemeIcon
size="lg"
radius="xl"
variant="outline"
bg={color}
color='white'
p={4}
>
{icon}
</ThemeIcon>
</Flex>
<Flex align="center">
<ThemeIcon variant="transparent" size="md" radius="md">
{getStatusIcon(status, `${theme.colors.gray[7]}`)}
</ThemeIcon>
<Text size="xl" fw={600}>
{t(`enums.documentstatus.${status}` as any)}
</Text>
</Flex>
</Stack>
</Paper>
)
}

View File

@@ -0,0 +1,187 @@
import {
Container,
Stack,
Group,
Paper,
ThemeIcon,
Box,
Grid,
Flex,
Text,
rem,
useMantineTheme,
} from '@pikku/mantine/core'
import {
IconIdBadge2,
IconWorld,
IconBriefcase,
IconSchool,
} from '@tabler/icons-react'
import { Qualification } from './Qualification'
import { QualificationSectionStatusCard } from './QualificationSectionStatusCard'
import { CandidateResults } from '@heygermany/sdk'
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
interface QualificationData {
title: string
description: string
status: string
requirements: string[]
steps?: string[]
progress?: number
}
type SectionOut = {
key: "education" | "license" | "language" | "additional"
title: string
color: "education" | "license" | "language" | "additional"
icon: string
qualifications: QualificationData[]
}
const ICONS: Record<string, React.ReactElement> = {
school: <IconSchool size={38} />,
"id-badge": <IconIdBadge2 size={38} />,
world: <IconWorld size={38} />,
briefcase: <IconBriefcase size={38} />,
};
const getQualificationSectionStatus = (
title: string,
qualifications: QualificationData[]
) => {
const statuses = qualifications.map((q) => q.status)
if (statuses.includes('in-progress')) return 'in-progress'
if (statuses.every((s) => s === 'completed')) return 'completed'
if (statuses.every((s) => s === 'required')) return 'required'
if (statuses.includes('optional')) return 'optional'
return 'required'
}
function buildSections(candidateResults: CandidateResults, t: any): SectionOut[] {
return [
{
key: "education",
title: t('enums.qualificationsections.education'),
color: "education",
icon: "school",
qualifications: [candidateResults.education],
},
{
key: "license",
title: t('enums.qualificationsections.license'),
color: "license",
icon: "id-badge",
qualifications: [candidateResults.license],
},
{
key: "language",
title: t('enums.qualificationsections.language'),
color: "language",
icon: "world",
qualifications: [candidateResults.language],
},
{
key: "additional",
title: t('enums.qualificationsections.additional'),
color: "additional",
icon: "briefcase",
qualifications: [candidateResults.experience, candidateResults.copies, candidateResults.originals],
},
]
}
export const QualificationsSection: React.FunctionComponent<{ candidateResults: CandidateResults }> = ({ candidateResults }) => {
const t = useI18n()
const sections = buildSections(candidateResults, t)
const theme = useMantineTheme()
const mainSections = sections.slice(0, 3) // Education, License, Language
const additionalSection = sections[3] // Additional Requirements
return (
<>
<Container size="xl" py="xl" maw="full">
<Stack gap="xl">
<Flex wrap="wrap" gap="md" justify="center">
{sections.map((section, i) => (
<QualificationSectionStatusCard
key={i}
label={section.title}
status={section.color === 'additional' ? 'required' : getQualificationSectionStatus(section.title, section.qualifications)}
color={`var(--mantine-color-${section.color}-6)`}
icon={ICONS[section.icon]}
/>
))}
</Flex>
</Stack>
</Container>
<Box bg={theme.colors.gray[0]}>
<Container bg={theme.colors.gray[0]} size="xl" py="xl" maw="full">
<Grid mt="xl" grow>
{mainSections.map((section, sectionIndex) => (
<Grid.Col key={sectionIndex} span={{ base: 12, md: 4 }} mt="xl">
<Group align="center" mb="lg">
<ThemeIcon variant="transparent" color={section.color} size="xl" radius="md">{ICONS[section.icon]}</ThemeIcon>
<Text c={`${theme.colors.gray[7]}`} size="xl" fw={700}>
{asI18n(section.title)}
</Text>
</Group>
<Flex
gap="md"
flex={1}
align="stretch"
h='full'
>
{section.qualifications.map((qualification, qualIndex) => (
<Qualification
key={qualIndex}
title={qualification.title}
description={qualification.description}
status={qualification.status}
requirements={qualification.requirements}
steps={qualification.steps}
progress={qualification.progress}
/>
))}
</Flex>
</Grid.Col>
))}
</Grid>
<Box mb="md" mt={rem(80)}>
<Group align="center" mb="lg" gap="sm">
{ICONS[additionalSection.icon]}
<Text fw={700} size="xl">
{asI18n(additionalSection.title)}
</Text>
</Group>
<Grid>
{additionalSection.qualifications.map(
(qualification, qualIndex) => (
<Grid.Col key={qualIndex} span={{ base: 12, md: 4 }}>
<Paper shadow="xs" radius="lg" h="100%">
<Qualification
title={qualification.title}
description={qualification.description}
status={qualification.status}
requirements={qualification.requirements}
steps={qualification.steps}
progress={qualification.progress}
/>
</Paper>
</Grid.Col>
)
)}
</Grid>
</Box>
</Container>
</Box>
</>
)
}