a704c75440
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.
224 lines
9.0 KiB
TypeScript
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`),
|
|
};
|
|
}
|