Files
jobtrackingapp/job-tracker-ui/src/components/ApplicationChecklist.tsx
T
cesnimda 3a906b881e
CI and Deploy / test (push) Failing after 1m8s
CI and Deploy / deploy (push) Has been skipped
feat(workspace): unified application checklist (Phase 5 milestone 2)
Evolve the existing readiness workflow into one persisted, user-controlled
checklist rather than adding a second tracker.

ApplicationChecklistItem records only completion state and user intent. Each
default system item carries a stable SystemKey and an AutoSignal — the same
signal /readiness already computed — and re-syncs on every read: a satisfied
signal auto-completes the item, a reverted signal reopens it, and a manual tick
always wins. Users can add, reorder, dismiss and delete.

Readiness is refactored into a projection of the checklist (score = completion
percentage, completed/missing = live items by status). Its DTO shape and the
workflowSignal/reminders health view are unchanged, so no API contract breaks.

The workspace's next recommended action now comes from the first pending
checklist item in category priority order (preparation, submission, follow-up,
interview, custom), replacing the parallel ruleset — so the overview can never
recommend something already ticked off, and a user's own task can be next.

The table follows the established MariaDB-safe path: the scaffolded migration is
a no-op and the idempotent reconciler owns the DDL for both providers. Verified
on MariaDB 11 — auto_increment PK, varchar/datetime(6)/tinyint(1) columns, both
indexes inside the key limit, cascade delete, unique system key per application,
and NULL system keys not colliding for custom items.

329 backend tests, 94 frontend tests, type check, production build and both
Docker builds pass locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 11:15:46 +02:00

199 lines
7.8 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 {
CHECKLIST_CATEGORIES, Checklist, ChecklistItem, applicationChecklistApi,
} 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 [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, "Could not load the checklist."));
}
}, [jobId]);
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, "Could not update the checklist."));
} finally {
setBusy(false);
}
}, [load, onChanged]);
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 }}>Application checklist</Typography>
<Typography variant="body2" color="text.secondary">
{progress.completed} of {progress.total} done
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={progress.percent}
aria-label="Checklist completion"
sx={{ height: 8, borderRadius: 4 }}
/>
</Paper>
)}
{grouped.map((group) => (
<Paper key={group.key} sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="overline" color="text.secondary">{group.label}</Typography>
<Stack sx={{ mt: 0.5 }}>
{group.items.map((item) => (
<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": item.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",
}}
>
{item.title}
</Typography>
{!item.isSystemGenerated && <Chip size="small" label="Yours" variant="outlined" />}
{item.isAutoCompleted && <Chip size="small" label="Detected" color="success" variant="outlined" />}
</Stack>
{item.description && (
<Typography variant="caption" color="text.secondary">{item.description}</Typography>
)}
</Box>
<Stack direction="row" className="checklist-actions" sx={{ opacity: { xs: 1, md: 0 }, transition: "opacity .15s" }}>
<Tooltip title="Move up">
<span>
<IconButton size="small" disabled={busy} aria-label={`Move up: ${item.title}`} onClick={() => move(item, -1)}>
<ArrowUpwardIcon fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Move down">
<span>
<IconButton size="small" disabled={busy} aria-label={`Move down: ${item.title}`} onClick={() => move(item, 1)}>
<ArrowDownwardIcon fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
<Tooltip title={item.isSystemGenerated ? "Not relevant for this role" : "Delete"}>
<span>
<IconButton size="small" disabled={busy} aria-label={`Remove: ${item.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="Add your own task"
value={draft}
disabled={busy}
onChange={(e) => setDraft(e.target.value)}
/>
<Button type="submit" variant="contained" disabled={busy || !draft.trim()}>Add</Button>
</Stack>
</Paper>
</Stack>
);
}