refactor(ui): centralise themed dialogs
This commit is contained in:
+32
-31
@@ -86,21 +86,21 @@ type MeResponse = {
|
||||
|
||||
function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
|
||||
if (path.startsWith("/dashboard")) return [t("home"), t("analytics"), t("overview")];
|
||||
if (path.startsWith("/discover")) return [t("home"), "Discover jobs"];
|
||||
if (/^\/jobs\/\d+/.test(path)) return [t("home"), t("jobApplications"), "Job details"];
|
||||
if (path.startsWith("/discover")) return [t("home"), t("discoverJobs")];
|
||||
if (/^\/jobs\/\d+/.test(path)) return [t("home"), t("jobApplications"), t("jobDetails")];
|
||||
if (path.startsWith("/jobs")) return [t("home"), t("jobApplications")];
|
||||
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("/companies")) return [t("home"), t("companies")];
|
||||
if (path.startsWith("/correspondence/review")) return [t("home"), "Gmail review queue"];
|
||||
if (path.startsWith("/correspondence")) return [t("home"), "Correspondence inbox"];
|
||||
if (path.startsWith("/correspondence/review")) return [t("home"), t("gmailReviewQueue")];
|
||||
if (path.startsWith("/correspondence")) return [t("home"), t("correspondenceInboxTitle")];
|
||||
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("/profile")) return [t("home"), t("account"), t("profile")];
|
||||
if (path.startsWith("/career/builder")) return [t("home"), "Career Workspace", "CV Builder"];
|
||||
if (path.startsWith("/career")) return [t("home"), "Career Workspace"];
|
||||
if (path.startsWith("/career/builder")) return [t("home"), t("careerWorkspace"), t("cvBuilder")];
|
||||
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/users")) return [t("home"), t("admin"), t("users")];
|
||||
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 {
|
||||
if (path === "/dashboard") return t("dashboard");
|
||||
if (path.startsWith("/reminders")) return t("reminders");
|
||||
if (path.startsWith("/operations")) return "Operations";
|
||||
if (path.startsWith("/discover")) return "Discover jobs";
|
||||
if (/^\/jobs\/\d+/.test(path)) return "Job details";
|
||||
if (path.startsWith("/operations")) return t("operations");
|
||||
if (path.startsWith("/discover")) return t("discoverJobs");
|
||||
if (/^\/jobs\/\d+/.test(path)) return t("jobDetails");
|
||||
if (path.startsWith("/jobs")) return t("jobApplications");
|
||||
if (path.startsWith("/kanban")) return t("kanbanBoard");
|
||||
if (path.startsWith("/companies")) return t("companies");
|
||||
if (path.startsWith("/correspondence/review")) return "Gmail review queue";
|
||||
if (path.startsWith("/correspondence")) return "Correspondence inbox";
|
||||
if (path.startsWith("/correspondence/review")) return t("gmailReviewQueue");
|
||||
if (path.startsWith("/correspondence")) return t("correspondenceInboxTitle");
|
||||
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("/profile")) return t("profile");
|
||||
if (path.startsWith("/career/builder")) return "CV Builder";
|
||||
if (path.startsWith("/career")) return "Career Workspace";
|
||||
if (path.startsWith("/career/builder")) return t("cvBuilder");
|
||||
if (path.startsWith("/career")) return t("careerWorkspace");
|
||||
if (path.startsWith("/admin/audit")) return t("auditLog");
|
||||
if (path.startsWith("/admin/users")) return t("users");
|
||||
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 {
|
||||
if (path === "/dashboard") return t("dashboardPageSubtitle");
|
||||
if (path.startsWith("/discover")) return "Search official job-board feeds and save opportunities to your tracker.";
|
||||
if (/^\/jobs\/\d+/.test(path)) return "Manage this application, its documents, timeline, and correspondence.";
|
||||
if (path.startsWith("/discover")) return t("discoverJobsSubtitle");
|
||||
if (/^\/jobs\/\d+/.test(path)) return t("jobDetailsWorkspaceSubtitle");
|
||||
if (path.startsWith("/jobs")) return t("jobsPageSubtitle");
|
||||
if (path.startsWith("/kanban")) return t("kanbanPageSubtitle");
|
||||
if (path.startsWith("/reminders")) return t("remindersPageSubtitle");
|
||||
@@ -143,7 +143,8 @@ function subtitleFor(path: string, t: (k: any) => string): string | undefined {
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -269,7 +270,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
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 }} />;
|
||||
|
||||
const pageTitle = titleFor(path, t);
|
||||
@@ -282,13 +283,13 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
const nav: NavItem[] = [
|
||||
{ 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: "/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: "/kanban", label: t("kanbanBoard"), icon: <ViewKanbanIcon 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: "/career", label: "Career Workspace", icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("manage") },
|
||||
{ to: "/career/builder", label: "CV Builder", icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("manage") },
|
||||
{ to: "/career", label: t("careerWorkspace"), 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") },
|
||||
];
|
||||
|
||||
@@ -457,17 +458,17 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<ConfirmProvider>
|
||||
<PromptProvider>
|
||||
<JobTrackerThemeProvider preference={themeMode} systemPrefersDark={systemPrefersDark}>
|
||||
<I18nProvider>
|
||||
<JobTrackerThemeProvider preference={themeMode} systemPrefersDark={systemPrefersDark}>
|
||||
<I18nProvider>
|
||||
<ConfirmProvider>
|
||||
<PromptProvider>
|
||||
<ThemeControlsContext.Provider value={themeControls}>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeControlsContext.Provider>
|
||||
</I18nProvider>
|
||||
</JobTrackerThemeProvider>
|
||||
</PromptProvider>
|
||||
</ConfirmProvider>
|
||||
</PromptProvider>
|
||||
</ConfirmProvider>
|
||||
</I18nProvider>
|
||||
</JobTrackerThemeProvider>
|
||||
</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/Correspondence", () => () => <div>Communication section</div>);
|
||||
jest.mock("./components/AiWorkspacePanel", () => () => <div>AI panel</div>);
|
||||
jest.mock("./components/ApplicationChecklist", () => () => <div>Checklist section</div>);
|
||||
jest.mock("./components/ApplicationIntelligence", () => ({
|
||||
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.
|
||||
//
|
||||
// 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>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ConfirmProvider, useConfirm } from './confirm';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
|
||||
function Demo() {
|
||||
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 () => {
|
||||
render(
|
||||
<ConfirmProvider>
|
||||
@@ -24,3 +30,19 @@ test('renders app-owned confirmation dialog', async () => {
|
||||
expect(await screen.findByText(/are you sure/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');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material";
|
||||
import { useOptionalI18n } from "./i18n/I18nProvider";
|
||||
|
||||
type ConfirmOptions = {
|
||||
title?: string;
|
||||
@@ -20,6 +21,7 @@ type ConfirmState = ConfirmOptions & {
|
||||
const ConfirmContext = createContext<ConfirmContextValue | null>(null);
|
||||
|
||||
export function ConfirmProvider({ children }: { children: React.ReactNode }) {
|
||||
const i18n = useOptionalI18n();
|
||||
const resolverRef = useRef<((value: boolean) => void) | null>(null);
|
||||
const [state, setState] = useState<ConfirmState>({
|
||||
open: false,
|
||||
@@ -42,29 +44,32 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
|
||||
resolverRef.current = resolve;
|
||||
setState({
|
||||
open: true,
|
||||
title: options.title ?? "Confirm action",
|
||||
title: options.title ?? i18n?.t("confirmAction") ?? "Confirm action",
|
||||
message: options.message,
|
||||
confirmLabel: options.confirmLabel ?? "Confirm",
|
||||
cancelLabel: options.cancelLabel ?? "Cancel",
|
||||
confirmLabel: options.confirmLabel ?? i18n?.t("confirm") ?? "Confirm",
|
||||
cancelLabel: options.cancelLabel ?? i18n?.t("cancel") ?? "Cancel",
|
||||
destructive: options.destructive ?? false,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
}, [i18n]);
|
||||
|
||||
const value = useMemo(() => ({ confirm }), [confirm]);
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={value}>
|
||||
{children}
|
||||
<Dialog open={state.open} onClose={() => closeWith(false)} fullWidth maxWidth="xs">
|
||||
<DialogTitle>{state.title}</DialogTitle>
|
||||
<Dialog open={state.open} onClose={() => closeWith(false)} fullWidth maxWidth="xs"
|
||||
slotProps={{ paper: { sx: { borderRadius: 3, border: 1, borderColor: "divider", backgroundImage: "none" } } }}>
|
||||
<DialogTitle sx={{ pb: 1, fontWeight: 800 }}>{state.title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<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>
|
||||
<Typography sx={{ color: "text.secondary" }}>{state.message}</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<DialogActions sx={{ px: 3, pb: 2.5, gap: 1 }}>
|
||||
<Button onClick={() => closeWith(false)}>{state.cancelLabel}</Button>
|
||||
<Button color={state.destructive ? "error" : "primary"} variant="contained" onClick={() => closeWith(true)}>
|
||||
{state.confirmLabel}
|
||||
|
||||
@@ -60,3 +60,7 @@ export function useI18n() {
|
||||
if (!ctx) throw new Error("useI18n must be used within I18nProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useOptionalI18n() {
|
||||
return useContext(I18nContext);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,21 @@ export const translations = {
|
||||
noWord: "No",
|
||||
createAndAddAnother: "Create & add another",
|
||||
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",
|
||||
notFoundBody: "The page you were looking for does not exist or may have moved.",
|
||||
appErrorTitle: "Something went wrong",
|
||||
@@ -1288,6 +1303,21 @@ export const translations = {
|
||||
noWord: "Nei",
|
||||
createAndAddAnother: "Opprett og legg til en til",
|
||||
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",
|
||||
notFoundBody: "Siden du lette etter finnes ikke eller kan ha blitt flyttet.",
|
||||
appErrorTitle: "Noe gikk galt",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography } from "@mui/material";
|
||||
import { useOptionalI18n } from "./i18n/I18nProvider";
|
||||
|
||||
type PromptOptions = {
|
||||
title?: string;
|
||||
@@ -21,6 +22,7 @@ type PromptState = PromptOptions & {
|
||||
const PromptContext = createContext<PromptContextValue | null>(null);
|
||||
|
||||
export function PromptProvider({ children }: { children: React.ReactNode }) {
|
||||
const i18n = useOptionalI18n();
|
||||
const [state, setState] = useState<PromptState>({
|
||||
open: false,
|
||||
title: "Enter value",
|
||||
@@ -43,28 +45,29 @@ export function PromptProvider({ children }: { children: React.ReactNode }) {
|
||||
setValue(options.defaultValue ?? "");
|
||||
setState({
|
||||
open: true,
|
||||
title: options.title ?? "Enter value",
|
||||
title: options.title ?? i18n?.t("enterValue") ?? "Enter value",
|
||||
message: options.message,
|
||||
defaultValue: options.defaultValue ?? "",
|
||||
confirmLabel: options.confirmLabel ?? "Save",
|
||||
cancelLabel: options.cancelLabel ?? "Cancel",
|
||||
confirmLabel: options.confirmLabel ?? i18n?.t("save") ?? "Save",
|
||||
cancelLabel: options.cancelLabel ?? i18n?.t("cancel") ?? "Cancel",
|
||||
resolver: resolve,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
}, [i18n]);
|
||||
|
||||
const contextValue = useMemo(() => ({ prompt }), [prompt]);
|
||||
|
||||
return (
|
||||
<PromptContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<Dialog open={state.open} onClose={() => closeWith(null)} fullWidth maxWidth="xs">
|
||||
<DialogTitle>{state.title}</DialogTitle>
|
||||
<Dialog open={state.open} onClose={() => closeWith(null)} fullWidth maxWidth="xs"
|
||||
slotProps={{ paper: { sx: { borderRadius: 3, border: 1, borderColor: "divider", backgroundImage: "none" } } }}>
|
||||
<DialogTitle sx={{ pb: 1, fontWeight: 800 }}>{state.title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<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`} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<DialogActions sx={{ px: 3, pb: 2.5, gap: 1 }}>
|
||||
<Button onClick={() => closeWith(null)}>{state.cancelLabel}</Button>
|
||||
<Button variant="contained" onClick={() => closeWith(value)}>{state.confirmLabel}</Button>
|
||||
</DialogActions>
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
// 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
|
||||
// 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.
|
||||
export default function ApplicationWorkspacePage() {
|
||||
return <ApplicationWorkspace />;
|
||||
|
||||
Reference in New Issue
Block a user