Files
jobtrackingapp/job-tracker-ui/src/components/ApplicationChecklist.tsx
T
cesnimda a704c75440 fix(i18n): localize application workflows
Use stable checklist and interview keys for system copy while preserving user-authored content. Localize creation-tab errors, language selectors, match bands, dates, and selected-CV context.
2026-08-29 16:51:59 +02:00

224 lines
9.0 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert, Box, Button, Checkbox, Chip, IconButton, LinearProgress, Paper, Skeleton, Stack,
TextField, Tooltip, Typography,
} from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import { getApiErrorMessage } from "../api";
import { useI18n } from "../i18n/I18nProvider";
import {
CHECKLIST_CATEGORIES, Checklist, ChecklistItem, applicationChecklistApi, checklistSystemTranslationToken,
} from "../applicationWorkspace";
// Phase 5 Milestone 2 — the application checklist.
//
// One workflow surface, not a new tracker: the system items arrive already ticked wherever the
// existing readiness signals say the work is done (CV attached, cover letter written, follow-up
// scheduled...). Everything here is the user's to tick, add to, reorder or dismiss.
// docs/architecture/application-workspace.md.
export default function ApplicationChecklist({ jobId, onChanged }: { jobId: number; onChanged?: () => void }) {
const { t } = useI18n();
const [checklist, setChecklist] = useState<Checklist | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [draft, setDraft] = useState("");
const load = useCallback(async () => {
try {
setChecklist(await applicationChecklistApi.get(jobId));
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("checklistLoadFailed")));
}
}, [jobId, t]);
useEffect(() => {
load();
}, [load]);
// Every mutation re-reads: the backend re-syncs the auto-completed items on each read, so the
// response is the only thing that knows the real state.
const mutate = useCallback(async (run: () => Promise<unknown>) => {
setBusy(true);
try {
await run();
await load();
onChanged?.();
} catch (err) {
setError(getApiErrorMessage(err, t("checklistUpdateFailed")));
} finally {
setBusy(false);
}
}, [load, onChanged, t]);
const toggle = (item: ChecklistItem) =>
mutate(() => applicationChecklistApi.update(jobId, item.id, {
status: item.status === "done" ? "pending" : "done",
}));
const remove = (item: ChecklistItem) => mutate(() => applicationChecklistApi.remove(jobId, item.id));
const move = (item: ChecklistItem, delta: -1 | 1) => {
if (!checklist) return;
const ids = checklist.items.map((i) => i.id);
const from = ids.indexOf(item.id);
const to = from + delta;
if (to < 0 || to >= ids.length) return;
[ids[from], ids[to]] = [ids[to], ids[from]];
return mutate(() => applicationChecklistApi.reorder(jobId, ids));
};
const add = (e: React.FormEvent) => {
e.preventDefault();
const title = draft.trim();
if (!title) return;
setDraft("");
return mutate(() => applicationChecklistApi.add(jobId, title));
};
const grouped = useMemo(() => {
const live = checklist?.items.filter((i) => i.status !== "dismissed") ?? [];
return CHECKLIST_CATEGORIES
.map((c) => ({ ...c, items: live.filter((i) => i.category === c.key) }))
.filter((c) => c.items.length > 0);
}, [checklist]);
if (!checklist && !error) {
return <Stack spacing={1}>{[0, 1, 2].map((i) => <Skeleton key={i} variant="rounded" height={52} />)}</Stack>;
}
const progress = checklist?.progress;
return (
<Stack spacing={2}>
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
{progress && (
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Stack direction="row" alignItems="baseline" justifyContent="space-between" sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("checklistTitle")}</Typography>
<Typography variant="body2" color="text.secondary">
{t("checklistProgress", { completed: progress.completed, total: progress.total })}
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={progress.percent}
aria-label={t("checklistCompletion")}
sx={{ height: 8, borderRadius: 4 }}
/>
</Paper>
)}
{grouped.map((group) => (
<Paper key={group.key} sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="overline" color="text.secondary">{checklistCategoryLabel(t, group.key)}</Typography>
<Stack sx={{ mt: 0.5 }}>
{group.items.map((item) => {
const display = checklistItemDisplay(t, item);
return (
<Stack
key={item.id}
direction="row"
alignItems="flex-start"
spacing={1}
sx={{ py: 0.75, borderRadius: 2, "&:hover .checklist-actions": { opacity: 1 } }}
>
<Checkbox
size="small"
checked={item.status === "done"}
disabled={busy}
onChange={() => toggle(item)}
inputProps={{ "aria-label": display.title }}
sx={{ mt: -0.25 }}
/>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
<Typography
variant="body2"
sx={{
fontWeight: 600,
textDecoration: item.status === "done" ? "line-through" : "none",
color: item.status === "done" ? "text.disabled" : "text.primary",
}}
>
{display.title}
</Typography>
{!item.isSystemGenerated && <Chip size="small" label={t("checklistYours")} variant="outlined" />}
{item.isAutoCompleted && <Chip size="small" label={t("checklistDetected")} color="success" variant="outlined" />}
</Stack>
{display.description && (
<Typography variant="caption" color="text.secondary">{display.description}</Typography>
)}
</Box>
<Stack direction="row" className="checklist-actions" sx={{ opacity: { xs: 1, md: 0 }, transition: "opacity .15s" }}>
<Tooltip title={t("checklistMoveUp")}>
<span>
<IconButton size="small" disabled={busy} aria-label={t("checklistMoveUpItem", { title: display.title })} onClick={() => move(item, -1)}>
<ArrowUpwardIcon fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
<Tooltip title={t("checklistMoveDown")}>
<span>
<IconButton size="small" disabled={busy} aria-label={t("checklistMoveDownItem", { title: display.title })} onClick={() => move(item, 1)}>
<ArrowDownwardIcon fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
<Tooltip title={item.isSystemGenerated ? t("checklistNotRelevant") : t("deleteAction")}>
<span>
<IconButton size="small" disabled={busy} aria-label={t("checklistRemoveItem", { title: display.title })} onClick={() => remove(item)}>
<DeleteOutlineIcon fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
</Stack>
</Stack>
);
})}
</Stack>
</Paper>
))}
<Paper component="form" onSubmit={add} sx={{ p: 2, borderRadius: 3 }}>
<Stack direction="row" spacing={1}>
<TextField
fullWidth
size="small"
label={t("checklistAddTask")}
value={draft}
disabled={busy}
onChange={(e) => setDraft(e.target.value)}
/>
<Button type="submit" variant="contained" disabled={busy || !draft.trim()}>{t("checklistAdd")}</Button>
</Stack>
</Paper>
</Stack>
);
}
function checklistCategoryLabel(t: (key: any, vars?: Record<string, string | number>) => string, category: string) {
const keys: Record<string, string> = {
preparation: "checklistCategoryPreparation",
submission: "checklistCategorySubmission",
"follow-up": "checklistCategoryFollowUp",
interview: "checklistCategoryInterview",
custom: "checklistCategoryCustom",
};
return keys[category] ? t(keys[category]) : category;
}
function checklistItemDisplay(t: (key: any, vars?: Record<string, string | number>) => string, item: ChecklistItem) {
const token = checklistSystemTranslationToken(item.systemKey);
if (!token) return { title: item.title, description: item.description };
return {
title: t(`checklistItem_${token}`),
description: t(`checklistItem_${token}Description`),
};
}