chore: heygermany customer project
This commit is contained in:
229
apps/website/components/jobs/results/ApplicantProfile.tsx
Normal file
229
apps/website/components/jobs/results/ApplicantProfile.tsx
Normal 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
|
||||
139
apps/website/components/jobs/results/CTASection.tsx
Normal file
139
apps/website/components/jobs/results/CTASection.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
99
apps/website/components/jobs/results/DocumentsChecklist.tsx
Normal file
99
apps/website/components/jobs/results/DocumentsChecklist.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
apps/website/components/jobs/results/GermanLanguageTips.tsx
Normal file
65
apps/website/components/jobs/results/GermanLanguageTips.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
204
apps/website/components/jobs/results/GetHelp.tsx
Normal file
204
apps/website/components/jobs/results/GetHelp.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
apps/website/components/jobs/results/HowToApply.tsx
Normal file
56
apps/website/components/jobs/results/HowToApply.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
231
apps/website/components/jobs/results/NextSteps.tsx
Normal file
231
apps/website/components/jobs/results/NextSteps.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
150
apps/website/components/jobs/results/Qualification.tsx
Normal file
150
apps/website/components/jobs/results/Qualification.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
187
apps/website/components/jobs/results/QualificationsSection.tsx
Normal file
187
apps/website/components/jobs/results/QualificationsSection.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user