refactor(ui): centralise themed dialogs

This commit is contained in:
cesnimda
2026-08-28 12:48:38 +02:00
parent 5805c1a621
commit 3b4adb8281
12 changed files with 113 additions and 335 deletions
@@ -1,190 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert, Box, Button, Chip, Divider, FormControl, IconButton, InputLabel, MenuItem, Paper, Select,
Skeleton, Stack, TextField, Tooltip, Typography,
} from "@mui/material";
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import HistoryIcon from "@mui/icons-material/History";
import CompareArrowsIcon from "@mui/icons-material/CompareArrows";
import ReplayIcon from "@mui/icons-material/Replay";
import { getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import Markdown from "./Markdown";
import { AI_MODULES, AiInteraction, AiUsage, COVER_LETTER_MODES, aiWorkspaceApi } from "../aiWorkspace";
import { useAccountPlan } from "../accountPlan";
import ProFeatureNotice from "./ProFeatureNotice";
// Phase 5 — the central AI Workspace for one job application. Every result is a suggestion the user
// reviews and copies; nothing is applied automatically.
export default function AiWorkspacePanel({ jobId }: { jobId: number }) {
const { toast } = useToast();
const { canUseAi } = useAccountPlan();
const [module, setModule] = useState("job-analysis");
const [mode, setMode] = useState("professional");
const [extra, setExtra] = useState("");
const [provider, setProvider] = useState("");
const [usage, setUsage] = useState<AiUsage | null>(null);
const [busy, setBusy] = useState(false);
const [current, setCurrent] = useState<AiInteraction | null>(null);
const [history, setHistory] = useState<AiInteraction[]>([]);
const [loadingHistory, setLoadingHistory] = useState(true);
const [compareWith, setCompareWith] = useState<AiInteraction | null>(null);
const activeModule = AI_MODULES.find((m) => m.key === module)!;
const loadHistory = useCallback(async () => {
try {
setHistory(await aiWorkspaceApi.history(jobId));
} catch {
/* non-fatal */
} finally {
setLoadingHistory(false);
}
}, [jobId]);
useEffect(() => {
aiWorkspaceApi.modules(jobId).then((r) => setProvider(r.provider)).catch(() => undefined);
aiWorkspaceApi.usage().then(setUsage).catch(() => undefined);
loadHistory();
}, [jobId, loadHistory]);
const generate = async () => {
if (!canUseAi) return;
setBusy(true);
setCompareWith(null);
try {
const res = await aiWorkspaceApi.generate(jobId, { module, mode: module === "cover-letter" ? mode : undefined, extraContext: extra || undefined });
setCurrent(res);
setHistory((h) => [res, ...h]);
aiWorkspaceApi.usage().then(setUsage).catch(() => undefined);
} catch (err) {
toast(getApiErrorMessage(err, "AI generation failed."), "error");
} finally {
setBusy(false);
}
};
const remove = async (id: number) => {
try {
await aiWorkspaceApi.remove(jobId, id);
setHistory((h) => h.filter((x) => x.id !== id));
if (current?.id === id) setCurrent(null);
if (compareWith?.id === id) setCompareWith(null);
} catch (err) {
toast(getApiErrorMessage(err, "Delete failed."), "error");
}
};
const copy = (text: string) => {
navigator.clipboard?.writeText(text);
toast("Copied to clipboard.", "success");
};
const moduleHistory = useMemo(() => history, [history]);
return (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 300px" }, gap: 2 }}>
<Stack spacing={2}>
{!canUseAi && (
<ProFeatureNotice featureKey="application-ai" title="Build application drafts with Pro.">
Generate job analysis, cover letters and strategy suggestions while keeping your existing AI history available.
</ProFeatureNotice>
)}
<Alert severity="info" sx={{ py: 0.5 }}>
AI suggestions never change your profile, CVs, or this application. Review, then copy what you want to keep.
{provider && <> Provider: <strong>{provider}</strong>.</>}
{usage && <> This month: <strong>{usage.currentMonth.calls}</strong> runs · approximately <strong>{usage.currentMonth.estimatedTokens.toLocaleString()}</strong> tokens.</>}
</Alert>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
{AI_MODULES.map((m) => (
<Chip key={m.key} label={m.label} color={m.key === module ? "primary" : "default"}
variant={m.key === module ? "filled" : "outlined"} onClick={() => setModule(m.key)} />
))}
</Box>
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{activeModule.label}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>{activeModule.blurb}</Typography>
{module === "cover-letter" && (
<FormControl size="small" sx={{ minWidth: 180, mb: 1.5 }}>
<InputLabel>Tone</InputLabel>
<Select label="Tone" value={mode} onChange={(e) => setMode(e.target.value)}>
{COVER_LETTER_MODES.map((m) => <MenuItem key={m} value={m}>{m[0].toUpperCase() + m.slice(1)}</MenuItem>)}
</Select>
</FormControl>
)}
<TextField label="Extra context (optional)" size="small" fullWidth multiline minRows={2} sx={{ mb: 1.5 }}
placeholder="Anything specific to emphasise…" value={extra} onChange={(e) => setExtra(e.target.value)} />
<Button variant="contained" startIcon={<AutoFixHighIcon />} disabled={busy || !canUseAi} onClick={generate}>
{busy ? "Generating…" : canUseAi ? "Generate" : "Pro required"}
</Button>
</Paper>
{busy && <Skeleton variant="rounded" height={220} />}
{current && !busy && (
<Paper variant="outlined" sx={{ p: 2 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{current.title}</Typography>
<Button size="small" startIcon={<ContentCopyIcon />} onClick={() => copy(current.result.text ?? "")}>Copy</Button>
</Stack>
<Markdown text={current.result.text ?? ""} />
</Paper>
)}
{compareWith && (
<Paper variant="outlined" sx={{ p: 2, borderStyle: "dashed" }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Comparing: {compareWith.title} · {relTime(compareWith.createdAtUtc)}</Typography>
<Button size="small" onClick={() => setCompareWith(null)}>Close</Button>
</Stack>
<Markdown text={compareWith.result.text ?? ""} />
</Paper>
)}
</Stack>
<Paper variant="outlined" sx={{ p: 1.5, alignSelf: "start" }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<HistoryIcon fontSize="small" />
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>History</Typography>
</Stack>
{loadingHistory && <Stack spacing={1}>{[0, 1, 2].map((i) => <Skeleton key={i} variant="rounded" height={52} />)}</Stack>}
{!loadingHistory && moduleHistory.length === 0 && (
<Typography variant="body2" color="text.secondary">No AI runs yet. Generate one to build history.</Typography>
)}
<Stack spacing={1}>
{moduleHistory.map((h) => (
<Paper key={h.id} variant="outlined" sx={{ p: 1, bgcolor: current?.id === h.id ? "action.selected" : undefined }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{h.title}</Typography>
<Typography variant="caption" color="text.secondary">{relTime(h.createdAtUtc)} · {h.provider}</Typography>
<Stack direction="row" spacing={0.25} sx={{ mt: 0.5 }}>
<Tooltip title="Reuse (show)"><IconButton size="small" aria-label="Reuse" onClick={() => setCurrent(h)}><ReplayIcon sx={{ fontSize: 16 }} /></IconButton></Tooltip>
<Tooltip title="Compare"><IconButton size="small" aria-label="Compare" onClick={() => setCompareWith(h)}><CompareArrowsIcon sx={{ fontSize: 16 }} /></IconButton></Tooltip>
<Tooltip title="Copy"><IconButton size="small" aria-label="Copy" onClick={() => copy(h.result.text ?? "")}><ContentCopyIcon sx={{ fontSize: 16 }} /></IconButton></Tooltip>
<Box sx={{ flex: 1 }} />
<Tooltip title="Delete"><IconButton size="small" aria-label="Delete" onClick={() => remove(h.id)}><DeleteOutlineIcon sx={{ fontSize: 16 }} /></IconButton></Tooltip>
</Stack>
</Paper>
))}
</Stack>
</Paper>
</Box>
);
}
function relTime(iso: string): string {
const mins = Math.round((Date.now() - new Date(iso).getTime()) / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return new Date(iso).toLocaleDateString();
}
@@ -14,7 +14,7 @@ import { useI18n } from "../i18n/I18nProvider";
// Phase 5.3 — Application Intelligence sections for the workspace.
//
// Everything here renders a deterministic, read-only backend answer. No component triggers an AI
// generation: that stays an explicit user action in AiWorkspacePanel, so nothing on this page can
// generation stays in the dedicated document/preparation tools, so nothing on this page can
// silently spend a token or change the user's data.
// docs/architecture/application-workspace.md.
@@ -29,7 +29,6 @@ import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines }
import Correspondence from "./Correspondence";
import Attachments from "./Attachments";
import AiWorkspacePanel from "./AiWorkspacePanel";
import JobInsightTabs from "./JobInsightTabs";
import { DraftCard, ListCard, MatchScoreCard, PaperRow, SectionChips, TwoColumnSection, WorkspaceDraftCard } from "./JobDetailsPanels";
import JobFlowBar from "./JobFlowBar";
@@ -777,7 +776,6 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<Tab label={t("jobDetailsTabInterviewPrep")} />
<Tab label={t("jobTableReadiness")} />
{isAdmin ? <Tab label={t("jobDetailsTabHistory")} /> : null}
<Tab label="AI Workspace" />
</Tabs>
{attachmentPicker}
@@ -1283,8 +1281,6 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
{history.length === 0 ? <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoHistory")}</Typography> : history.map((entry) => <PaperRow key={entry.id} type={entry.type} oldValue={entry.oldValue} newValue={entry.newValue} at={entry.at} note={entry.note} />)}
</Box>
)}
{/* AI Workspace is the last tab: index depends on whether the admin History tab is present. */}
{tab === (isAdmin ? 10 : 9) && jobId && <AiWorkspacePanel jobId={jobId} />}
</DialogContent>
</Dialog>
);