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
+32 -31
View File
@@ -86,21 +86,21 @@ type MeResponse = {
function breadcrumbsFor(path: string, t: (k: any) => string): string[] { function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
if (path.startsWith("/dashboard")) return [t("home"), t("analytics"), t("overview")]; if (path.startsWith("/dashboard")) return [t("home"), t("analytics"), t("overview")];
if (path.startsWith("/discover")) return [t("home"), "Discover jobs"]; if (path.startsWith("/discover")) return [t("home"), t("discoverJobs")];
if (/^\/jobs\/\d+/.test(path)) return [t("home"), t("jobApplications"), "Job details"]; if (/^\/jobs\/\d+/.test(path)) return [t("home"), t("jobApplications"), t("jobDetails")];
if (path.startsWith("/jobs")) return [t("home"), t("jobApplications")]; if (path.startsWith("/jobs")) return [t("home"), t("jobApplications")];
if (path.startsWith("/reminders")) return [t("home"), t("reminders")]; if (path.startsWith("/reminders")) return [t("home"), t("reminders")];
if (path.startsWith("/operations")) return [t("home"), "Operations"]; if (path.startsWith("/operations")) return [t("home"), t("operations")];
if (path.startsWith("/kanban")) return [t("home"), t("kanbanBoard")]; if (path.startsWith("/kanban")) return [t("home"), t("kanbanBoard")];
if (path.startsWith("/companies")) return [t("home"), t("companies")]; if (path.startsWith("/companies")) return [t("home"), t("companies")];
if (path.startsWith("/correspondence/review")) return [t("home"), "Gmail review queue"]; if (path.startsWith("/correspondence/review")) return [t("home"), t("gmailReviewQueue")];
if (path.startsWith("/correspondence")) return [t("home"), "Correspondence inbox"]; if (path.startsWith("/correspondence")) return [t("home"), t("correspondenceInboxTitle")];
if (path.startsWith("/trash")) return [t("home"), t("trash")]; if (path.startsWith("/trash")) return [t("home"), t("trash")];
if (path.startsWith("/settings/connected-accounts")) return [t("home"), t("settings"), "Connected accounts"]; if (path.startsWith("/settings/connected-accounts")) return [t("home"), t("settings"), t("connectedAccounts")];
if (path.startsWith("/settings")) return [t("home"), t("settings")]; if (path.startsWith("/settings")) return [t("home"), t("settings")];
if (path.startsWith("/profile")) return [t("home"), t("account"), t("profile")]; if (path.startsWith("/profile")) return [t("home"), t("account"), t("profile")];
if (path.startsWith("/career/builder")) return [t("home"), "Career Workspace", "CV Builder"]; if (path.startsWith("/career/builder")) return [t("home"), t("careerWorkspace"), t("cvBuilder")];
if (path.startsWith("/career")) return [t("home"), "Career Workspace"]; if (path.startsWith("/career")) return [t("home"), t("careerWorkspace")];
if (path.startsWith("/admin/audit")) return [t("home"), t("admin"), t("auditLog")]; if (path.startsWith("/admin/audit")) return [t("home"), t("admin"), t("auditLog")];
if (path.startsWith("/admin/users")) return [t("home"), t("admin"), t("users")]; if (path.startsWith("/admin/users")) return [t("home"), t("admin"), t("users")];
if (path.startsWith("/admin/system")) return [t("home"), t("admin"), t("system")]; if (path.startsWith("/admin/system")) return [t("home"), t("admin"), t("system")];
@@ -110,20 +110,20 @@ function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
function titleFor(path: string, t: (k: any) => string): string { function titleFor(path: string, t: (k: any) => string): string {
if (path === "/dashboard") return t("dashboard"); if (path === "/dashboard") return t("dashboard");
if (path.startsWith("/reminders")) return t("reminders"); if (path.startsWith("/reminders")) return t("reminders");
if (path.startsWith("/operations")) return "Operations"; if (path.startsWith("/operations")) return t("operations");
if (path.startsWith("/discover")) return "Discover jobs"; if (path.startsWith("/discover")) return t("discoverJobs");
if (/^\/jobs\/\d+/.test(path)) return "Job details"; if (/^\/jobs\/\d+/.test(path)) return t("jobDetails");
if (path.startsWith("/jobs")) return t("jobApplications"); if (path.startsWith("/jobs")) return t("jobApplications");
if (path.startsWith("/kanban")) return t("kanbanBoard"); if (path.startsWith("/kanban")) return t("kanbanBoard");
if (path.startsWith("/companies")) return t("companies"); if (path.startsWith("/companies")) return t("companies");
if (path.startsWith("/correspondence/review")) return "Gmail review queue"; if (path.startsWith("/correspondence/review")) return t("gmailReviewQueue");
if (path.startsWith("/correspondence")) return "Correspondence inbox"; if (path.startsWith("/correspondence")) return t("correspondenceInboxTitle");
if (path.startsWith("/trash")) return t("trash"); if (path.startsWith("/trash")) return t("trash");
if (path.startsWith("/settings/connected-accounts")) return "Connected accounts"; if (path.startsWith("/settings/connected-accounts")) return t("connectedAccounts");
if (path.startsWith("/settings")) return t("settings"); if (path.startsWith("/settings")) return t("settings");
if (path.startsWith("/profile")) return t("profile"); if (path.startsWith("/profile")) return t("profile");
if (path.startsWith("/career/builder")) return "CV Builder"; if (path.startsWith("/career/builder")) return t("cvBuilder");
if (path.startsWith("/career")) return "Career Workspace"; if (path.startsWith("/career")) return t("careerWorkspace");
if (path.startsWith("/admin/audit")) return t("auditLog"); if (path.startsWith("/admin/audit")) return t("auditLog");
if (path.startsWith("/admin/users")) return t("users"); if (path.startsWith("/admin/users")) return t("users");
if (path.startsWith("/admin/system")) return t("systemStatus"); if (path.startsWith("/admin/system")) return t("systemStatus");
@@ -132,8 +132,8 @@ function titleFor(path: string, t: (k: any) => string): string {
function subtitleFor(path: string, t: (k: any) => string): string | undefined { function subtitleFor(path: string, t: (k: any) => string): string | undefined {
if (path === "/dashboard") return t("dashboardPageSubtitle"); if (path === "/dashboard") return t("dashboardPageSubtitle");
if (path.startsWith("/discover")) return "Search official job-board feeds and save opportunities to your tracker."; if (path.startsWith("/discover")) return t("discoverJobsSubtitle");
if (/^\/jobs\/\d+/.test(path)) return "Manage this application, its documents, timeline, and correspondence."; if (/^\/jobs\/\d+/.test(path)) return t("jobDetailsWorkspaceSubtitle");
if (path.startsWith("/jobs")) return t("jobsPageSubtitle"); if (path.startsWith("/jobs")) return t("jobsPageSubtitle");
if (path.startsWith("/kanban")) return t("kanbanPageSubtitle"); if (path.startsWith("/kanban")) return t("kanbanPageSubtitle");
if (path.startsWith("/reminders")) return t("remindersPageSubtitle"); if (path.startsWith("/reminders")) return t("remindersPageSubtitle");
@@ -143,7 +143,8 @@ function subtitleFor(path: string, t: (k: any) => string): string | undefined {
} }
function PageLoader() { function PageLoader() {
return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>; const { t } = useI18n();
return <Box sx={{ p: 4 }}><Typography variant="h6">{t("loading")}</Typography></Box>;
} }
function LegacyApplicationRedirect() { function LegacyApplicationRedirect() {
@@ -269,7 +270,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
return () => window.removeEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown);
}, []); }, []);
if (requireAuth === null || !authResolved) return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>; if (requireAuth === null || !authResolved) return <Box sx={{ p: 4 }}><Typography variant="h6">{t("loading")}</Typography></Box>;
if (requireAuth && !me) return <Navigate to="/" replace state={{ from: path }} />; if (requireAuth && !me) return <Navigate to="/" replace state={{ from: path }} />;
const pageTitle = titleFor(path, t); const pageTitle = titleFor(path, t);
@@ -282,13 +283,13 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
const nav: NavItem[] = [ const nav: NavItem[] = [
{ to: "/dashboard", label: t("dashboard"), icon: <DashboardIcon fontSize="small" />, section: t("manage") }, { to: "/dashboard", label: t("dashboard"), icon: <DashboardIcon fontSize="small" />, section: t("manage") },
{ to: "/jobs", label: t("jobApplications"), icon: <WorkOutlineIcon fontSize="small" />, section: t("manage") }, { to: "/jobs", label: t("jobApplications"), icon: <WorkOutlineIcon fontSize="small" />, section: t("manage") },
{ to: "/discover", label: "Discover jobs", icon: <SearchIcon fontSize="small" />, section: t("manage") }, { to: "/discover", label: t("discoverJobs"), icon: <SearchIcon fontSize="small" />, section: t("manage") },
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: reminderCount, section: t("manage") }, { to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: reminderCount, section: t("manage") },
{ to: "/kanban", label: t("kanbanBoard"), icon: <ViewKanbanIcon fontSize="small" />, section: t("manage") }, { to: "/kanban", label: t("kanbanBoard"), icon: <ViewKanbanIcon fontSize="small" />, section: t("manage") },
{ to: "/companies", label: t("companies"), icon: <BusinessIcon fontSize="small" />, section: t("manage") }, { to: "/companies", label: t("companies"), icon: <BusinessIcon fontSize="small" />, section: t("manage") },
{ to: "/correspondence", label: t("correspondenceInbox"), icon: <MailOutlineIcon fontSize="small" />, section: t("manage") }, { to: "/correspondence", label: t("correspondenceInbox"), icon: <MailOutlineIcon fontSize="small" />, section: t("manage") },
{ to: "/career", label: "Career Workspace", icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("manage") }, { to: "/career", label: t("careerWorkspace"), icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("manage") },
{ to: "/career/builder", label: "CV Builder", icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("manage") }, { to: "/career/builder", label: t("cvBuilder"), icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("manage") },
{ to: "/trash", label: t("trash"), icon: <DeleteOutlineIcon fontSize="small" />, section: t("manage") }, { to: "/trash", label: t("trash"), icon: <DeleteOutlineIcon fontSize="small" />, section: t("manage") },
]; ];
@@ -457,17 +458,17 @@ export default function App() {
return ( return (
<ToastProvider> <ToastProvider>
<ConfirmProvider> <JobTrackerThemeProvider preference={themeMode} systemPrefersDark={systemPrefersDark}>
<PromptProvider> <I18nProvider>
<JobTrackerThemeProvider preference={themeMode} systemPrefersDark={systemPrefersDark}> <ConfirmProvider>
<I18nProvider> <PromptProvider>
<ThemeControlsContext.Provider value={themeControls}> <ThemeControlsContext.Provider value={themeControls}>
<RouterProvider router={router} /> <RouterProvider router={router} />
</ThemeControlsContext.Provider> </ThemeControlsContext.Provider>
</I18nProvider> </PromptProvider>
</JobTrackerThemeProvider> </ConfirmProvider>
</PromptProvider> </I18nProvider>
</ConfirmProvider> </JobTrackerThemeProvider>
</ToastProvider> </ToastProvider>
); );
} }
@@ -1,92 +0,0 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import AiWorkspacePanel from "./components/AiWorkspacePanel";
import Markdown from "./components/Markdown";
import { ToastProvider } from "./toast";
import { api } from "./api";
import { AccountPlanProvider } from "./accountPlan";
jest.mock("./api", () => ({
api: {
get: jest.fn(),
post: jest.fn(),
delete: jest.fn(),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
getApiErrorMessage: (_e: any, fallback?: string) => fallback || "Request failed.",
}));
const mockedApi = api as jest.Mocked<typeof api>;
function renderPanel() {
return render(
<ToastProvider>
<AiWorkspacePanel jobId={7} />
</ToastProvider>,
);
}
beforeEach(() => {
jest.clearAllMocks();
mockedApi.get.mockImplementation((url: string) => {
if (url.includes("/modules")) return Promise.resolve({ data: { modules: [], provider: "gemini" } } as any);
if (url === "/ai/usage") return Promise.resolve({ data: { currentMonth: { calls: 3, inputCharacters: 100, outputCharacters: 50, estimatedTokens: 38 }, allTime: { calls: 8, inputCharacters: 300, outputCharacters: 120, estimatedTokens: 105 } } } as any);
return Promise.resolve({ data: [] } as any); // history
});
});
test("shows transparent monthly usage", async () => {
renderPanel();
expect(await screen.findByText(/approximately/)).toHaveTextContent("3 runs");
expect(screen.getByText(/approximately/)).toHaveTextContent("38 tokens");
});
test("renders modules and generates a suggestion into history", async () => {
mockedApi.post.mockResolvedValueOnce({
data: { id: 1, module: "job-analysis", mode: null, title: "Job analysis", provider: "gemini", result: { text: "**Company**\nAcme" }, createdAtUtc: new Date().toISOString() },
} as any);
renderPanel();
fireEvent.click(await screen.findByRole("button", { name: /Generate/i }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/ai/generate", expect.objectContaining({ module: "job-analysis" })));
expect(await screen.findByText("Acme")).toBeInTheDocument();
});
test("cover letter sends the selected mode", async () => {
mockedApi.post.mockResolvedValueOnce({
data: { id: 2, module: "cover-letter", mode: "professional", title: "Cover letter · Professional", provider: "p", result: { text: "Dear team" }, createdAtUtc: new Date().toISOString() },
} as any);
renderPanel();
fireEvent.click(await screen.findByText("Cover Letter"));
fireEvent.click(screen.getByRole("button", { name: /Generate/i }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/ai/generate", expect.objectContaining({ module: "cover-letter", mode: "professional" })));
});
test("free users see a locked state and cannot start generation", async () => {
render(
<ToastProvider>
<AccountPlanProvider value={{ plan: "free", canUseAi: false, canUseProThemes: false }}>
<AiWorkspacePanel jobId={7} />
</AccountPlanProvider>
</ToastProvider>,
);
expect(await screen.findByText(/Build application drafts with Pro/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: "View Pro" })).toHaveAttribute("href", "/settings");
expect(screen.getByRole("button", { name: "Pro required" })).toBeDisabled();
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("Markdown renders headings, bold, and bullet lists", () => {
render(<Markdown text={"# Title\n**Strengths**\n- one\n- two\nplain *word*"} />);
expect(screen.getByText("Title")).toBeInTheDocument();
expect(screen.getByText("Strengths")).toBeInTheDocument();
expect(screen.getByText("one")).toBeInTheDocument();
expect(screen.getByText("two")).toBeInTheDocument();
});
@@ -13,7 +13,6 @@ import ApplicationWorkspacePage from "./views/ApplicationWorkspacePage";
jest.mock("./components/Attachments", () => () => <div>Documents section</div>); jest.mock("./components/Attachments", () => () => <div>Documents section</div>);
jest.mock("./components/Correspondence", () => () => <div>Communication section</div>); jest.mock("./components/Correspondence", () => () => <div>Communication section</div>);
jest.mock("./components/AiWorkspacePanel", () => () => <div>AI panel</div>);
jest.mock("./components/ApplicationChecklist", () => () => <div>Checklist section</div>); jest.mock("./components/ApplicationChecklist", () => () => <div>Checklist section</div>);
jest.mock("./components/ApplicationIntelligence", () => ({ jest.mock("./components/ApplicationIntelligence", () => ({
ApplicationAnalysis: () => <div>Analysis section</div>, ApplicationAnalysis: () => <div>Analysis section</div>,
@@ -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. // Phase 5.3 — Application Intelligence sections for the workspace.
// //
// Everything here renders a deterministic, read-only backend answer. No component triggers an AI // 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. // silently spend a token or change the user's data.
// docs/architecture/application-workspace.md. // docs/architecture/application-workspace.md.
@@ -29,7 +29,6 @@ import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines }
import Correspondence from "./Correspondence"; import Correspondence from "./Correspondence";
import Attachments from "./Attachments"; import Attachments from "./Attachments";
import AiWorkspacePanel from "./AiWorkspacePanel";
import JobInsightTabs from "./JobInsightTabs"; import JobInsightTabs from "./JobInsightTabs";
import { DraftCard, ListCard, MatchScoreCard, PaperRow, SectionChips, TwoColumnSection, WorkspaceDraftCard } from "./JobDetailsPanels"; import { DraftCard, ListCard, MatchScoreCard, PaperRow, SectionChips, TwoColumnSection, WorkspaceDraftCard } from "./JobDetailsPanels";
import JobFlowBar from "./JobFlowBar"; import JobFlowBar from "./JobFlowBar";
@@ -777,7 +776,6 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<Tab label={t("jobDetailsTabInterviewPrep")} /> <Tab label={t("jobDetailsTabInterviewPrep")} />
<Tab label={t("jobTableReadiness")} /> <Tab label={t("jobTableReadiness")} />
{isAdmin ? <Tab label={t("jobDetailsTabHistory")} /> : null} {isAdmin ? <Tab label={t("jobDetailsTabHistory")} /> : null}
<Tab label="AI Workspace" />
</Tabs> </Tabs>
{attachmentPicker} {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} />)} {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> </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> </DialogContent>
</Dialog> </Dialog>
); );
+22
View File
@@ -2,6 +2,7 @@ import React from 'react';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen, fireEvent } from '@testing-library/react';
import { ConfirmProvider, useConfirm } from './confirm'; import { ConfirmProvider, useConfirm } from './confirm';
import { I18nProvider } from './i18n/I18nProvider';
function Demo() { function Demo() {
const { confirm } = useConfirm(); const { confirm } = useConfirm();
@@ -12,6 +13,11 @@ function Demo() {
); );
} }
function DefaultDemo() {
const { confirm } = useConfirm();
return <button onClick={() => void confirm({ message: 'Fortsett?' })}>Open localized confirm</button>;
}
test('renders app-owned confirmation dialog', async () => { test('renders app-owned confirmation dialog', async () => {
render( render(
<ConfirmProvider> <ConfirmProvider>
@@ -24,3 +30,19 @@ test('renders app-owned confirmation dialog', async () => {
expect(await screen.findByText(/are you sure/i)).toBeInTheDocument(); expect(await screen.findByText(/are you sure/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
}); });
test('uses the selected language for shared dialog defaults', async () => {
window.localStorage.setItem('uiLanguage', 'nb');
render(
<I18nProvider>
<ConfirmProvider>
<DefaultDemo />
</ConfirmProvider>
</I18nProvider>,
);
fireEvent.click(screen.getByRole('button', { name: /open localized confirm/i }));
expect(await screen.findByRole('heading', { name: 'Bekreft handling' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Bekreft' })).toBeInTheDocument();
window.localStorage.removeItem('uiLanguage');
});
+13 -8
View File
@@ -1,5 +1,6 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react"; import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material"; import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material";
import { useOptionalI18n } from "./i18n/I18nProvider";
type ConfirmOptions = { type ConfirmOptions = {
title?: string; title?: string;
@@ -20,6 +21,7 @@ type ConfirmState = ConfirmOptions & {
const ConfirmContext = createContext<ConfirmContextValue | null>(null); const ConfirmContext = createContext<ConfirmContextValue | null>(null);
export function ConfirmProvider({ children }: { children: React.ReactNode }) { export function ConfirmProvider({ children }: { children: React.ReactNode }) {
const i18n = useOptionalI18n();
const resolverRef = useRef<((value: boolean) => void) | null>(null); const resolverRef = useRef<((value: boolean) => void) | null>(null);
const [state, setState] = useState<ConfirmState>({ const [state, setState] = useState<ConfirmState>({
open: false, open: false,
@@ -42,29 +44,32 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
resolverRef.current = resolve; resolverRef.current = resolve;
setState({ setState({
open: true, open: true,
title: options.title ?? "Confirm action", title: options.title ?? i18n?.t("confirmAction") ?? "Confirm action",
message: options.message, message: options.message,
confirmLabel: options.confirmLabel ?? "Confirm", confirmLabel: options.confirmLabel ?? i18n?.t("confirm") ?? "Confirm",
cancelLabel: options.cancelLabel ?? "Cancel", cancelLabel: options.cancelLabel ?? i18n?.t("cancel") ?? "Cancel",
destructive: options.destructive ?? false, destructive: options.destructive ?? false,
}); });
}); });
}, []); }, [i18n]);
const value = useMemo(() => ({ confirm }), [confirm]); const value = useMemo(() => ({ confirm }), [confirm]);
return ( return (
<ConfirmContext.Provider value={value}> <ConfirmContext.Provider value={value}>
{children} {children}
<Dialog open={state.open} onClose={() => closeWith(false)} fullWidth maxWidth="xs"> <Dialog open={state.open} onClose={() => closeWith(false)} fullWidth maxWidth="xs"
<DialogTitle>{state.title}</DialogTitle> slotProps={{ paper: { sx: { borderRadius: 3, border: 1, borderColor: "divider", backgroundImage: "none" } } }}>
<DialogTitle sx={{ pb: 1, fontWeight: 800 }}>{state.title}</DialogTitle>
<DialogContent> <DialogContent>
<Alert severity={state.destructive ? "warning" : "info"} variant="outlined" sx={{ mb: 2 }}> <Alert severity={state.destructive ? "warning" : "info"} variant="outlined" sx={{ mb: 2 }}>
{state.destructive ? "This action may be hard to undo." : "Please confirm this action."} {state.destructive
? i18n?.t("confirmDestructiveHint") ?? "This action may be hard to undo."
: i18n?.t("confirmStandardHint") ?? "Please confirm this action."}
</Alert> </Alert>
<Typography sx={{ color: "text.secondary" }}>{state.message}</Typography> <Typography sx={{ color: "text.secondary" }}>{state.message}</Typography>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions sx={{ px: 3, pb: 2.5, gap: 1 }}>
<Button onClick={() => closeWith(false)}>{state.cancelLabel}</Button> <Button onClick={() => closeWith(false)}>{state.cancelLabel}</Button>
<Button color={state.destructive ? "error" : "primary"} variant="contained" onClick={() => closeWith(true)}> <Button color={state.destructive ? "error" : "primary"} variant="contained" onClick={() => closeWith(true)}>
{state.confirmLabel} {state.confirmLabel}
+4
View File
@@ -60,3 +60,7 @@ export function useI18n() {
if (!ctx) throw new Error("useI18n must be used within I18nProvider"); if (!ctx) throw new Error("useI18n must be used within I18nProvider");
return ctx; return ctx;
} }
export function useOptionalI18n() {
return useContext(I18nContext);
}
+30
View File
@@ -76,6 +76,21 @@ export const translations = {
noWord: "No", noWord: "No",
createAndAddAnother: "Create & add another", createAndAddAnother: "Create & add another",
loading: "Loading...", loading: "Loading...",
discoverJobs: "Discover jobs",
discoverJobsSubtitle: "Search official job-board feeds and save opportunities to your tracker.",
jobDetails: "Job details",
jobDetailsWorkspaceSubtitle: "Manage this application, its documents, timeline, and correspondence.",
operations: "Operations",
gmailReviewQueue: "Gmail review queue",
connectedAccounts: "Connected accounts",
correspondenceInboxTitle: "Correspondence inbox",
careerWorkspace: "Career Workspace",
cvBuilder: "CV Builder",
confirmAction: "Confirm action",
confirm: "Confirm",
confirmDestructiveHint: "This action may be hard to undo.",
confirmStandardHint: "Please confirm this action.",
enterValue: "Enter value",
notFoundTitle: "Page not found", notFoundTitle: "Page not found",
notFoundBody: "The page you were looking for does not exist or may have moved.", notFoundBody: "The page you were looking for does not exist or may have moved.",
appErrorTitle: "Something went wrong", appErrorTitle: "Something went wrong",
@@ -1288,6 +1303,21 @@ export const translations = {
noWord: "Nei", noWord: "Nei",
createAndAddAnother: "Opprett og legg til en til", createAndAddAnother: "Opprett og legg til en til",
loading: "Laster...", loading: "Laster...",
discoverJobs: "Finn jobber",
discoverJobsSubtitle: "Søk i offisielle jobbportaler og lagre muligheter i oversikten din.",
jobDetails: "Jobbdetaljer",
jobDetailsWorkspaceSubtitle: "Administrer søknaden, dokumentene, tidslinjen og korrespondansen.",
operations: "Operasjoner",
gmailReviewQueue: "Gmail-gjennomgang",
connectedAccounts: "Tilkoblede kontoer",
correspondenceInboxTitle: "Korrespondanseinnboks",
careerWorkspace: "Karriereområde",
cvBuilder: "CV-bygger",
confirmAction: "Bekreft handling",
confirm: "Bekreft",
confirmDestructiveHint: "Denne handlingen kan være vanskelig å angre.",
confirmStandardHint: "Bekreft denne handlingen.",
enterValue: "Skriv inn verdi",
notFoundTitle: "Siden ble ikke funnet", notFoundTitle: "Siden ble ikke funnet",
notFoundBody: "Siden du lette etter finnes ikke eller kan ha blitt flyttet.", notFoundBody: "Siden du lette etter finnes ikke eller kan ha blitt flyttet.",
appErrorTitle: "Noe gikk galt", appErrorTitle: "Noe gikk galt",
+10 -7
View File
@@ -1,5 +1,6 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from "react"; import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography } from "@mui/material"; import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography } from "@mui/material";
import { useOptionalI18n } from "./i18n/I18nProvider";
type PromptOptions = { type PromptOptions = {
title?: string; title?: string;
@@ -21,6 +22,7 @@ type PromptState = PromptOptions & {
const PromptContext = createContext<PromptContextValue | null>(null); const PromptContext = createContext<PromptContextValue | null>(null);
export function PromptProvider({ children }: { children: React.ReactNode }) { export function PromptProvider({ children }: { children: React.ReactNode }) {
const i18n = useOptionalI18n();
const [state, setState] = useState<PromptState>({ const [state, setState] = useState<PromptState>({
open: false, open: false,
title: "Enter value", title: "Enter value",
@@ -43,28 +45,29 @@ export function PromptProvider({ children }: { children: React.ReactNode }) {
setValue(options.defaultValue ?? ""); setValue(options.defaultValue ?? "");
setState({ setState({
open: true, open: true,
title: options.title ?? "Enter value", title: options.title ?? i18n?.t("enterValue") ?? "Enter value",
message: options.message, message: options.message,
defaultValue: options.defaultValue ?? "", defaultValue: options.defaultValue ?? "",
confirmLabel: options.confirmLabel ?? "Save", confirmLabel: options.confirmLabel ?? i18n?.t("save") ?? "Save",
cancelLabel: options.cancelLabel ?? "Cancel", cancelLabel: options.cancelLabel ?? i18n?.t("cancel") ?? "Cancel",
resolver: resolve, resolver: resolve,
}); });
}); });
}, []); }, [i18n]);
const contextValue = useMemo(() => ({ prompt }), [prompt]); const contextValue = useMemo(() => ({ prompt }), [prompt]);
return ( return (
<PromptContext.Provider value={contextValue}> <PromptContext.Provider value={contextValue}>
{children} {children}
<Dialog open={state.open} onClose={() => closeWith(null)} fullWidth maxWidth="xs"> <Dialog open={state.open} onClose={() => closeWith(null)} fullWidth maxWidth="xs"
<DialogTitle>{state.title}</DialogTitle> slotProps={{ paper: { sx: { borderRadius: 3, border: 1, borderColor: "divider", backgroundImage: "none" } } }}>
<DialogTitle sx={{ pb: 1, fontWeight: 800 }}>{state.title}</DialogTitle>
<DialogContent> <DialogContent>
<Typography sx={{ color: "text.secondary", mb: 1.5 }}>{state.message}</Typography> <Typography sx={{ color: "text.secondary", mb: 1.5 }}>{state.message}</Typography>
<TextField autoFocus fullWidth value={value} onChange={(e) => setValue(e.target.value)} inputProps={{ maxLength: 180 }} helperText={`${value.length}/180`} /> <TextField autoFocus fullWidth value={value} onChange={(e) => setValue(e.target.value)} inputProps={{ maxLength: 180 }} helperText={`${value.length}/180`} />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions sx={{ px: 3, pb: 2.5, gap: 1 }}>
<Button onClick={() => closeWith(null)}>{state.cancelLabel}</Button> <Button onClick={() => closeWith(null)}>{state.cancelLabel}</Button>
<Button variant="contained" onClick={() => closeWith(value)}>{state.confirmLabel}</Button> <Button variant="contained" onClick={() => closeWith(value)}>{state.confirmLabel}</Button>
</DialogActions> </DialogActions>
@@ -41,7 +41,7 @@ import {
// Phase 5 Milestone 1 — the dedicated Application Workspace. // Phase 5 Milestone 1 — the dedicated Application Workspace.
// //
// This is a surface, not a new data store: the overview is one aggregate read, and each section // This is a surface, not a new data store: the overview is one aggregate read, and each section
// reuses the component that already owns that domain (Attachments, Correspondence, AiWorkspacePanel). // reuses the component that already owns that domain (for example Attachments and Correspondence).
// docs/architecture/application-workspace.md. // docs/architecture/application-workspace.md.
export default function ApplicationWorkspacePage() { export default function ApplicationWorkspacePage() {
return <ApplicationWorkspace />; return <ApplicationWorkspace />;