feat(i18n): localise application workspace

This commit is contained in:
cesnimda
2026-08-28 20:04:11 +02:00
parent cc01540446
commit a57b2fe58c
5 changed files with 372 additions and 124 deletions
@@ -85,6 +85,7 @@ function Shell({ title, subtitle, loading, error, children }: {
// ---------- CV ----------
export function ApplicationCvSection({ jobId }: { jobId: number }) {
const { t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<ApplicationCv>(
() => applicationAssetsApi.cv(jobId),
[jobId],
@@ -122,29 +123,28 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) {
return (
<Stack spacing={2}>
<Shell
title="CV"
subtitle="Which CV variant this application uses. Variants are lenses over your master career profile."
title={t("workspaceCv")}
subtitle={t("assetsCvSubtitle")}
loading={loading}
error={error}
>
<Stack spacing={2}>
{(data?.availableVariants.length ?? 0) === 0 ? (
<Alert severity="info" sx={{ borderRadius: 2 }}>
No CV variants yet. Build one in the CV builder it starts from your master career
profile, so you never retype your history.
{t("assetsNoCvVariants")}
</Alert>
) : (
<TextField
select
size="small"
fullWidth
label="Attached CV variant"
label={t("assetsAttachedCv")}
value={attached}
disabled={busy}
onChange={(e) => attach(e.target.value === "" ? null : Number(e.target.value))}
helperText="Changing this only re-points the application. The variant itself is untouched."
helperText={t("assetsAttachedCvHelp")}
>
<MenuItem value="">None</MenuItem>
<MenuItem value="">{t("assetsNone")}</MenuItem>
{(data?.availableVariants ?? []).map((v) => (
<MenuItem key={v.id} value={v.id}>
{v.name} · {v.themeId} · v{v.version}
@@ -159,8 +159,8 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) {
<Box>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{data.attachedVariantName}</Typography>
<Typography variant="caption" color="text.secondary">
Theme {data.attachedThemeId} · version {data.attachedVersion}
{data.attachedIsPublic ? " · public" : ""}
{t("assetsThemeVersion", { theme: data.attachedThemeId ?? "—", version: data.attachedVersion ?? "—" })}
{data.attachedIsPublic ? ` · ${t("assetsPublic")}` : ""}
</Typography>
</Box>
<Stack direction="row" spacing={1}>
@@ -172,23 +172,23 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) {
// server-side, so there is no second CV loading path here.
href={`/career/builder/${data.attachedVariantId}`}
>
Edit, preview and export
{t("assetsEditPreviewExport")}
</Button>
<Button size="small" variant="contained" disabled={busy} onClick={() => void duplicateForJob()}>
Duplicate for this job
{t("assetsDuplicateForJob")}
</Button>
</Stack>
</Stack>
</Paper>
) : (
<Typography variant="body2" color="text.secondary">
No CV attached to this application yet.
{t("assetsNoCvAttached")}
</Typography>
)}
{data?.hasTailoredCvText && (
<Alert severity="info" sx={{ borderRadius: 2 }}>
This application also has legacy tailored CV text saved on it. A CV variant supersedes it.
{t("assetsLegacyCvText")}
</Alert>
)}
</Stack>
@@ -202,6 +202,7 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) {
// ---------- Tailoring ----------
export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
const { t } = useI18n();
const { data, error, loading } = useAsset<TailoringPlan>(
() => applicationAssetsApi.tailoring(jobId),
[jobId],
@@ -209,26 +210,26 @@ export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
return (
<Shell
title="Tailoring"
subtitle="What to emphasise for this advert. Suggestions only — nothing here edits your profile or your CV."
title={t("assetsTailoring")}
subtitle={t("assetsTailoringSubtitle")}
loading={loading}
error={error}
>
<Stack spacing={2}>
{data && !data.hasCareerProfile && (
<Alert severity="info" sx={{ borderRadius: 2 }}>
Build your career profile to get experience and project suggestions.
{t("assetsBuildProfile")}
</Alert>
)}
{data && !data.hasJobDescription && (
<Alert severity="info" sx={{ borderRadius: 2 }}>
Paste the advert text to get keyword and requirement suggestions.
{t("assetsAddAdvert")}
</Alert>
)}
{(data?.suggestions.length ?? 0) === 0 ? (
<Typography variant="body2" color="text.secondary">
Nothing to suggest yet.
{t("assetsNoSuggestions")}
</Typography>
) : (
(data?.suggestions ?? []).map((s) => (
@@ -266,6 +267,7 @@ Kind regards,
[Your name]`;
export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: number; onDirtyChange?: (dirty: boolean) => void }) {
const { t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<CoverLetter>(
() => applicationAssetsApi.coverLetter(jobId),
[jobId],
@@ -315,36 +317,36 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
return (
<Stack spacing={2}>
<Shell
title="Cover letter"
subtitle="Every save keeps the previous text, so nothing you write is ever lost."
title={t("workspaceCoverLetter")}
subtitle={t("assetsCoverSubtitle")}
loading={loading}
error={error}
>
<Stack spacing={2}>
<RichTextField
minRows={12}
label="Cover letter"
label={t("jobDetailsCoverLetter")}
value={text}
disabled={busy}
onChange={setDraft}
placeholder="Write it yourself, start from the template, or generate a tailored draft."
placeholder={t("assetsCoverPlaceholder")}
/>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Button variant="contained" disabled={busy || !dirty} onClick={() => save(text, draftAiAction ? "ai" : "manual", draftAiAction ?? undefined)}>
Save
{t("save")}
</Button>
<Button disabled={busy || !dirty} onClick={() => { setDraft(null); setDraftAiAction(null); }}>
Discard changes
{t("assetsDiscardChanges")}
</Button>
<Button
disabled={busy || text.trim().length > 0}
onClick={() => setDraft(TEMPLATE)}
>
Start from template
{t("assetsStartTemplate")}
</Button>
{dirty && (
<Chip size="small" color="warning" variant="outlined" label="Unsaved changes" />
<Chip size="small" color="warning" variant="outlined" label={t("assetsUnsavedChanges")} />
)}
</Stack>
</Stack>
@@ -356,10 +358,10 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
onApply={(value, aiAction) => { setDraft(value); setDraftAiAction(aiAction); }}
/>
<Shell title="Version history" loading={loading} error={null}>
<Shell title={t("assetsVersionHistory")} loading={loading} error={null}>
{(data?.versions.length ?? 0) === 0 ? (
<Typography variant="body2" color="text.secondary">
No versions yet. The first save starts the history.
{t("assetsNoVersions")}
</Typography>
) : (
<Stack spacing={0.5}>
@@ -375,19 +377,19 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
<Typography variant="body2" sx={{ fontWeight: 600 }}>v{v.version}</Typography>
<Chip size="small" variant="outlined" label={v.aiAction ? `${v.source} · ${v.aiAction}` : v.source} />
{v.isCurrent && <Chip size="small" color="primary" variant="outlined" label="Current" />}
{v.isCurrent && <Chip size="small" color="primary" variant="outlined" label={t("assetsCurrent")} />}
</Stack>
<Typography variant="caption" color="text.secondary">
{new Date(v.createdAtUtc).toLocaleString()} · {v.length} characters
{new Date(v.createdAtUtc).toLocaleString()} · {t("assetsCharacterCount", { count: v.length })}
</Typography>
</Box>
{!v.isCurrent && (
<Tooltip title="Restore this version">
<Tooltip title={t("assetsRestoreVersion")}>
<span>
<IconButton
size="small"
disabled={busy}
aria-label={`Restore version ${v.version}`}
aria-label={t("assetsRestoreVersionNumber", { version: v.version })}
onClick={() => restore(v.version)}
>
<RestoreIcon fontSize="small" />
@@ -405,15 +407,15 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
}
const COVER_LETTER_ACTIONS = [
{ key: "generate", label: "Generate" },
{ key: "regenerate", label: "Fresh alternative" },
{ key: "improve", label: "Improve" },
{ key: "shorten", label: "Shorten" },
{ key: "expand", label: "Add detail" },
{ key: "professional", label: "More professional" },
{ key: "natural", label: "More natural" },
{ key: "grammar", label: "Fix grammar" },
{ key: "tailor", label: "Tailor more closely" },
{ key: "generate", labelKey: "coverActionGenerate" },
{ key: "regenerate", labelKey: "coverActionRegenerate" },
{ key: "improve", labelKey: "coverActionImprove" },
{ key: "shorten", labelKey: "coverActionShorten" },
{ key: "expand", labelKey: "coverActionExpand" },
{ key: "professional", labelKey: "coverActionProfessional" },
{ key: "natural", labelKey: "coverActionNatural" },
{ key: "grammar", labelKey: "coverActionGrammar" },
{ key: "tailor", labelKey: "coverActionTailor" },
] as const;
function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number; currentText: string; onApply: (text: string, action: string) => void }) {
@@ -466,17 +468,18 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
)}
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Action</InputLabel>
<Select label="Action" value={action} onChange={(event) => setAction(event.target.value)}>
{COVER_LETTER_ACTIONS.map((item) => <MenuItem key={item.key} value={item.key}>{item.label}</MenuItem>)}
<InputLabel>{t("coverAiAction")}</InputLabel>
<Select label={t("coverAiAction")} value={action} onChange={(event) => setAction(event.target.value)}>
{COVER_LETTER_ACTIONS.map((item) => <MenuItem key={item.key} value={item.key}>{t(item.labelKey)}</MenuItem>)}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Tone</InputLabel>
<Select label="Tone" value={mode} onChange={(event) => setMode(event.target.value)}>
<InputLabel>{t("coverAiTone")}</InputLabel>
<Select label={t("coverAiTone")} value={mode} onChange={(event) => setMode(event.target.value)}>
{[
"professional", "friendly", "short", "detailed", "modern", "traditional",
].map((item) => <MenuItem key={item} value={item}>{item[0].toUpperCase() + item.slice(1)}</MenuItem>)}
["professional", "coverToneProfessional"], ["friendly", "coverToneFriendly"], ["short", "coverToneShort"],
["detailed", "coverToneDetailed"], ["modern", "coverToneModern"], ["traditional", "coverToneTraditional"],
].map(([value, key]) => <MenuItem key={value} value={value}>{t(key as any)}</MenuItem>)}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 190 }}>
@@ -489,14 +492,14 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
</Stack>
<TextField
label={t("coverAiAdditionalInstructions")}
placeholder="For example: Focus on my .NET experience and keep it concise."
placeholder={t("coverAiInstructionsPlaceholder")}
value={instructions}
onChange={(event) => setInstructions(event.target.value)}
multiline
minRows={2}
fullWidth
/>
{error && <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => void generate()}>Retry</Button>}>{error}</Alert>}
{error && <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => void generate()}>{t("retry")}</Button>}>{error}</Alert>}
<Button
variant="contained"
startIcon={<AutoFixHighIcon />}
@@ -504,7 +507,7 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
onClick={() => void generate()}
sx={{ alignSelf: "flex-start" }}
>
{busy ? "Generating" : canUseAi ? COVER_LETTER_ACTIONS.find((item) => item.key === action)?.label : "Pro required"}
{busy ? t("interviewAiGenerating") : canUseAi ? t(COVER_LETTER_ACTIONS.find((item) => item.key === action)?.labelKey ?? "coverActionGenerate") : t("interviewAiProRequired")}
</Button>
{suggestion && (
@@ -512,7 +515,7 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
<Paper variant="outlined" sx={{ p: 2, minWidth: 0 }}>
<Typography variant="overline" color="text.secondary">{t("coverAiCurrent")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
{currentText || "No current draft"}
{currentText || t("coverAiNoDraft")}
</Typography>
</Paper>
<Paper variant="outlined" sx={{ p: 2, minWidth: 0, borderColor: "primary.main" }}>
@@ -547,6 +550,7 @@ export function ApplicationPackageDraftsSection({
onSaved?: () => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const { t } = useI18n();
const initial = { applicationAnswer: initialApplicationAnswer, recruiterMessage: initialRecruiterMessage };
const [saved, setSaved] = useState<PackageDrafts>(initial);
const [draft, setDraft] = useState<PackageDrafts | null>(null);
@@ -588,36 +592,36 @@ export function ApplicationPackageDraftsSection({
return (
<Shell
title="Application answers and recruiter message"
subtitle="Keep reusable application-form answers and a recruiter note with this application. Ordinary job notes stay separate."
title={t("assetsApplicationDrafts")}
subtitle={t("assetsApplicationDraftsSubtitle")}
loading={false}
error={error}
>
<Stack spacing={2}>
<RichTextField
minRows={6}
label="Application answer"
label={t("assetsApplicationAnswer")}
value={value.applicationAnswer}
disabled={busy}
onChange={(applicationAnswer) => update({ applicationAnswer })}
placeholder="Draft an answer for motivation, suitability, or another application-form question."
placeholder={t("assetsApplicationAnswerPlaceholder")}
/>
<RichTextField
minRows={4}
label="Recruiter message"
label={t("assetsRecruiterMessage")}
value={value.recruiterMessage}
disabled={busy}
onChange={(recruiterMessage) => update({ recruiterMessage })}
placeholder="Draft a concise message to the recruiter or hiring manager."
placeholder={t("assetsRecruiterMessagePlaceholder")}
/>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap alignItems="center">
<Button variant="contained" disabled={busy || !dirty} onClick={save}>
Save application drafts
{t("assetsSaveDrafts")}
</Button>
<Button disabled={busy || !dirty} onClick={() => setDraft(null)}>
Discard changes
{t("assetsDiscardChanges")}
</Button>
{dirty && <Chip size="small" color="warning" variant="outlined" label="Unsaved changes" />}
{dirty && <Chip size="small" color="warning" variant="outlined" label={t("assetsUnsavedChanges")} />}
</Stack>
</Stack>
</Shell>
@@ -7,7 +7,7 @@ import {
import { getApiErrorMessage } from "../api";
import {
CareerMatch, JobAnalysis, TIMELINE_CATEGORY_LABELS, Timeline, applicationIntelligenceApi,
CareerMatch, JobAnalysis, Timeline, applicationIntelligenceApi,
} from "../applicationWorkspace";
import { useI18n } from "../i18n/I18nProvider";
@@ -110,6 +110,7 @@ function Bullets({ label, values }: { label: string; values: string[] }) {
// ---------- Timeline ----------
export function ApplicationTimeline({ jobId }: { jobId: number }) {
const { t } = useI18n();
const [category, setCategory] = useState<string>("");
const { data, error, loading } = useIntelligence<Timeline>(
() => applicationIntelligenceApi.timeline(jobId, category || undefined),
@@ -122,7 +123,7 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) {
<Stack spacing={2}>
{hasEvents && (data?.milestones.length ?? 0) > 0 && (
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Milestones</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("intelligenceMilestones")}</Typography>
<Stack spacing={0.75}>
{(data?.milestones ?? []).map((m) => (
<Stack key={m.id} direction="row" spacing={1} justifyContent="space-between" alignItems="baseline">
@@ -137,12 +138,12 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) {
)}
<SectionShell
title="Timeline"
subtitle="Everything recorded for this application, newest first."
title={t("intelligenceTimeline")}
subtitle={t("intelligenceTimelineSubtitle")}
loading={loading}
error={error}
empty={!hasEvents}
emptyText="Nothing has happened yet. Activity appears here as you move the application along."
emptyText={t("intelligenceTimelineEmpty")}
>
{/* SectionShell takes children as a prop, so this JSX is built before it decides whether to
render it — every access has to tolerate a null `data`. */}
@@ -153,20 +154,20 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) {
exclusive
value={category}
onChange={(_e, next) => setCategory(next ?? "")}
aria-label="Filter timeline by category"
aria-label={t("intelligenceTimelineFilter")}
sx={{ flexWrap: "wrap" }}
>
<ToggleButton value="" aria-label="All events">All</ToggleButton>
<ToggleButton value="" aria-label={t("intelligenceAllEvents")}>{t("intelligenceAll")}</ToggleButton>
{(data?.categories ?? []).map((c) => (
<ToggleButton key={c} value={c} aria-label={TIMELINE_CATEGORY_LABELS[c] ?? c}>
{TIMELINE_CATEGORY_LABELS[c] ?? c}
<ToggleButton key={c} value={c} aria-label={timelineCategoryLabel(t, c)}>
{timelineCategoryLabel(t, c)}
</ToggleButton>
))}
</ToggleButtonGroup>
)}
{(data?.days.length ?? 0) === 0 ? (
<Typography variant="body2" color="text.secondary">No events in this category.</Typography>
<Typography variant="body2" color="text.secondary">{t("intelligenceNoCategoryEvents")}</Typography>
) : (
(data?.days ?? []).map((day) => (
<Box key={day.date}>
@@ -180,7 +181,7 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) {
<Typography variant="body2" sx={{ fontWeight: e.isMilestone ? 700 : 500 }}>
{e.summary}
</Typography>
{e.isMilestone && <Chip size="small" label="Milestone" color="primary" variant="outlined" />}
{e.isMilestone && <Chip size="small" label={t("intelligenceMilestone")} color="primary" variant="outlined" />}
</Stack>
{e.detail && (
<Typography variant="caption" color="text.secondary">{e.detail}</Typography>
@@ -200,6 +201,7 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) {
// ---------- Analysis ----------
export function ApplicationAnalysis({ jobId }: { jobId: number }) {
const { t } = useI18n();
const { data, error, loading } = useIntelligence<JobAnalysis>(
() => applicationIntelligenceApi.analysis(jobId),
[jobId],
@@ -207,27 +209,26 @@ export function ApplicationAnalysis({ jobId }: { jobId: number }) {
const facts: [string, string | null][] = data
? [
["Role", data.role],
["Company", data.company],
["Location", data.location],
["Employment type", data.employmentType],
["Seniority", data.seniority],
["Salary", data.salary],
[t("intelligenceRole"), data.role],
[t("company"), data.company],
[t("location"), data.location],
[t("intelligenceEmploymentType"), data.employmentType],
[t("intelligenceSeniority"), data.seniority],
[t("jobDetailsSalary"), data.salary],
]
: [];
return (
<SectionShell
title="Analysis"
subtitle="Read straight from the advert — no AI, same answer every time."
title={t("workspaceAnalysis")}
subtitle={t("intelligenceAnalysisSubtitle")}
loading={loading}
error={error}
>
<Stack spacing={2}>
{data && !data.hasJobDescription && (
<Alert severity="info" sx={{ borderRadius: 2 }}>
No advert text saved yet. Paste it into the application to get requirements, technologies
and interview topics.
{t("intelligenceNoAdvert")}
</Alert>
)}
@@ -242,12 +243,12 @@ export function ApplicationAnalysis({ jobId }: { jobId: number }) {
))}
</Box>
<Chips label="Technologies" values={data?.technologies ?? []} />
<Chips label="Skills" values={data?.skills ?? []} />
<Bullets label="Important requirements" values={data?.importantRequirements ?? []} />
<Bullets label="Responsibilities" values={data?.responsibilities ?? []} />
<Chips label="Likely interview topics" values={data?.interviewTopics ?? []} />
<Bullets label="Not stated in the advert" values={data?.missingInformation ?? []} />
<Chips label={t("intelligenceTechnologies")} values={data?.technologies ?? []} />
<Chips label={t("intelligenceSkills")} values={data?.skills ?? []} />
<Bullets label={t("intelligenceRequirements")} values={data?.importantRequirements ?? []} />
<Bullets label={t("intelligenceResponsibilities")} values={data?.responsibilities ?? []} />
<Chips label={t("intelligenceInterviewTopics")} values={data?.interviewTopics ?? []} />
<Bullets label={t("intelligenceNotStated")} values={data?.missingInformation ?? []} />
</Stack>
</SectionShell>
);
@@ -278,7 +279,7 @@ export function ApplicationMatch({ jobId }: { jobId: number }) {
</Alert>
) : data && !data.hasCareerProfile ? (
<Alert severity="info" sx={{ borderRadius: 2 }}>
The linked CV cannot be matched until its career profile has content.
{t("intelligenceCvProfileEmpty")}
</Alert>
) : (
<>
@@ -290,24 +291,24 @@ export function ApplicationMatch({ jobId }: { jobId: number }) {
<LinearProgress
variant="determinate"
value={data?.score ?? 0}
aria-label="Career match score"
aria-label={t("intelligenceMatchScore")}
sx={{ mt: 1, height: 8, borderRadius: 4 }}
/>
</Box>
{data && !data.hasEnoughSignal && (
<Alert severity="warning" sx={{ borderRadius: 2 }}>
The advert is too short to score reliably. Paste the full text for a real match.
{t("intelligenceAdvertTooShort")}
</Alert>
)}
<Chips label="Matched" values={data?.matchedSkills ?? []} color="success" />
<Chips label="Missing" values={data?.missingSkills ?? []} color="warning" />
<Chips label={t("intelligenceMatched")} values={data?.matchedSkills ?? []} color="success" />
<Chips label={t("intelligenceMissing")} values={data?.missingSkills ?? []} color="warning" />
{(data?.relevantExperience.length ?? 0) > 0 && (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
Relevant experience
{t("intelligenceRelevantExperience")}
</Typography>
<Stack spacing={1} sx={{ mt: 0.5 }}>
{(data?.relevantExperience ?? []).map((e, i) => (
@@ -326,7 +327,7 @@ export function ApplicationMatch({ jobId }: { jobId: number }) {
{(data?.relevantProjects.length ?? 0) > 0 && (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
Relevant projects
{t("intelligenceRelevantProjects")}
</Typography>
<Stack spacing={1} sx={{ mt: 0.5 }}>
{(data?.relevantProjects ?? []).map((p, i) => (
@@ -344,8 +345,19 @@ export function ApplicationMatch({ jobId }: { jobId: number }) {
</>
)}
<Bullets label="Suggestions" values={data?.suggestions ?? []} />
<Bullets label={t("intelligenceSuggestions")} values={data?.suggestions ?? []} />
</Stack>
</SectionShell>
);
}
function timelineCategoryLabel(t: (key: any) => string, category: string): string {
const keys: Record<string, any> = {
lifecycle: "intelligenceCategoryLifecycle",
stage: "intelligenceCategoryStage",
"follow-up": "intelligenceCategoryFollowUp",
communication: "intelligenceCategoryCommunication",
ai: "intelligenceCategoryAi",
};
return keys[category] ? t(keys[category]) : category;
}