feat(i18n): localise application workspace
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user