Files
jobtrackingapp/job-tracker-ui/src/views/career/CareerEvidenceBank.tsx
T

105 lines
7.3 KiB
TypeScript

import React, { useEffect, useRef, useState } from "react";
import { Alert, Box, Button, Chip, MenuItem, Paper, Stack, TextField, Typography } from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { api, getApiErrorMessage } from "../../api";
import { useConfirm } from "../../confirm";
import { useI18n } from "../../i18n/I18nProvider";
import { PageSection } from "../../layout/PageLayout";
type Evidence = { id: number; category: string; title: string; statement: string; tags: string[]; sourceReference: string | null };
export default function CareerEvidenceBank() {
const { t } = useI18n();
const { confirm } = useConfirm();
const [items, setItems] = useState<Evidence[]>([]);
const [title, setTitle] = useState("");
const [statement, setStatement] = useState("");
const [category, setCategory] = useState("achievement");
const [tags, setTags] = useState("");
const [source, setSource] = useState("");
const [loading, setLoading] = useState(true);
const [readAttempt, setReadAttempt] = useState(0);
const [loadError, setLoadError] = useState<{ cause: unknown } | null>(null);
const [saveError, setSaveError] = useState<{ cause: unknown } | null>(null);
const [deleteErrors, setDeleteErrors] = useState<Record<number, { cause: unknown }>>({});
const [busy, setBusy] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
const writePending = useRef(false);
const titleInputRef = useRef<HTMLInputElement>(null);
const focusAfterDelete = useRef(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
api.get<Evidence[]>("/career/evidence")
.then((response) => { if (!cancelled) { setItems(response.data); setLoadError(null); } })
.catch((cause) => { if (!cancelled) setLoadError({ cause }); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [readAttempt]);
useEffect(() => {
if (focusAfterDelete.current) {
focusAfterDelete.current = false;
titleInputRef.current?.focus();
}
}, [items]);
const add = async () => {
if (writePending.current || loading || loadError || !title.trim() || !statement.trim()) return;
writePending.current = true;
setBusy(true);
try {
const response = await api.post<Evidence>("/career/evidence", { category, title, statement, tags: tags.split(","), sourceType: "manual", sourceReference: source, isVerified: true });
setItems((previous) => [response.data, ...previous]);
setTitle(""); setStatement(""); setTags(""); setSource("");
setSaveError(null);
} catch (cause) {
setSaveError({ cause });
} finally {
writePending.current = false;
setBusy(false);
}
};
const remove = async (item: Evidence) => {
if (writePending.current || loading || loadError) return;
writePending.current = true;
try {
if (!(await confirm({ title: t("careerEvidenceDeleteTitle"), message: t("careerEvidenceDeleteMessage", { title: item.title }), confirmLabel: t("deleteAction"), destructive: true }))) return;
setDeletingId(item.id);
await api.delete(`/career/evidence/${item.id}`);
focusAfterDelete.current = true;
setItems((previous) => previous.filter((entry) => entry.id !== item.id));
setDeleteErrors((previous) => { const next = { ...previous }; delete next[item.id]; return next; });
} catch (cause) {
setDeleteErrors((previous) => ({ ...previous, [item.id]: { cause } }));
} finally {
writePending.current = false;
setDeletingId(null);
}
};
const categories = ["achievement", "metric", "project", "technology", "responsibility", "example"];
const categoryLabel = (value: string) => categories.includes(value) ? t(`careerEvidenceCategory_${value}` as "careerEvidenceCategory_achievement") : value;
return <PageSection title={t("careerEvidenceTitle")} description={t("careerEvidenceSubtitle")}>
<Stack spacing={2}>
{loading ? <Typography role="status" color="text.secondary">{t("loading")}</Typography> : loadError ? <Alert severity="error" action={<Button color="inherit" disabled={busy || deletingId !== null} onClick={() => { titleInputRef.current?.focus(); setReadAttempt((attempt) => attempt + 1); }}>{t("retry")}</Button>}>{getApiErrorMessage(loadError.cause, t("careerEvidenceLoadFailed"))}</Alert> : null}
<PageSection surface title={t("careerEvidenceAdd")} level="h3"><Stack spacing={1.5}>
{saveError ? <Alert severity="error">{getApiErrorMessage(saveError.cause, t("careerEvidenceSaveFailed"))}</Alert> : null}
<TextField select size="small" disabled={busy} label={t("careerEvidenceCategory")} value={category} onChange={e => setCategory(e.target.value)}>{categories.map(v => <MenuItem key={v} value={v}>{categoryLabel(v)}</MenuItem>)}</TextField>
<TextField required disabled={busy} inputRef={titleInputRef} label={t("careerEvidenceItemTitle")} value={title} onChange={e => setTitle(e.target.value)} />
<TextField required disabled={busy} label={t("careerEvidenceStatement")} value={statement} onChange={e => setStatement(e.target.value)} multiline minRows={3} helperText={t("careerEvidenceTruthHelp")} />
<TextField disabled={busy} label={t("careerEvidenceTags")} value={tags} onChange={e => setTags(e.target.value)} helperText={t("careerEvidenceTagsHelp")} />
<TextField disabled={busy} label={t("careerEvidenceSource")} value={source} onChange={e => setSource(e.target.value)} helperText={t("careerEvidenceSourceHelp")} />
<Button variant="contained" disabled={busy || deletingId !== null || loading || Boolean(loadError) || !title.trim() || !statement.trim()} onClick={() => void add()} sx={{ alignSelf: "flex-start" }}>{t("careerEvidenceSave")}</Button>
</Stack></PageSection>
{!loading && !loadError && items.length === 0 ? <Alert severity="info">{t("careerEvidenceEmpty")}</Alert> : null}
{items.map(item => <Paper key={item.id} component="article" aria-labelledby={`career-evidence-${item.id}-title`} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, overflowWrap: "anywhere" }}><Stack spacing={1}>
<Stack direction="row" justifyContent="space-between" flexWrap="wrap" gap={1}><Box sx={{ minWidth: 0 }}><Typography id={`career-evidence-${item.id}-title`} component="h3" variant="sectionTitle">{item.title}</Typography><Typography variant="caption" color="text.secondary">{categoryLabel(item.category)} · {item.sourceReference || t("careerEvidenceManualSource")}</Typography></Box><Button color="error" size="small" disabled={busy || deletingId !== null || loading || Boolean(loadError)} aria-label={t("careerEvidenceDeleteMessage", { title: item.title })} startIcon={<DeleteOutlineIcon />} onClick={() => void remove(item)}>{t("deleteAction")}</Button></Stack>
{deleteErrors[item.id] ? <Alert severity="error" action={<Button color="inherit" disabled={busy || deletingId !== null || loading || Boolean(loadError)} onClick={() => void remove(item)}>{t("retry")}</Button>}>{getApiErrorMessage(deleteErrors[item.id].cause, t("careerEvidenceDeleteFailed"))}</Alert> : null}
<Typography variant="body2">{item.statement}</Typography><Stack direction="row" flexWrap="wrap" gap={0.5}>{item.tags.map(tag => <Chip key={tag} size="small" label={tag} />)}</Stack>
</Stack></Paper>)}
</Stack>
</PageSection>;
}