diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index 263fc8a..b797fb9 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -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 Loading...; + const { t } = useI18n(); + return {t("loading")}; } function LegacyApplicationRedirect() { @@ -269,7 +270,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo return () => window.removeEventListener("keydown", onKeyDown); }, []); - if (requireAuth === null || !authResolved) return Loading...; + if (requireAuth === null || !authResolved) return {t("loading")}; if (requireAuth && !me) return ; 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: , section: t("manage") }, { to: "/jobs", label: t("jobApplications"), icon: , section: t("manage") }, - { to: "/discover", label: "Discover jobs", icon: , section: t("manage") }, + { to: "/discover", label: t("discoverJobs"), icon: , section: t("manage") }, { to: "/reminders", label: t("reminders"), icon: , badgeCount: reminderCount, section: t("manage") }, { to: "/kanban", label: t("kanbanBoard"), icon: , section: t("manage") }, { to: "/companies", label: t("companies"), icon: , section: t("manage") }, { to: "/correspondence", label: t("correspondenceInbox"), icon: , section: t("manage") }, - { to: "/career", label: "Career Workspace", icon: , section: t("manage") }, - { to: "/career/builder", label: "CV Builder", icon: , section: t("manage") }, + { to: "/career", label: t("careerWorkspace"), icon: , section: t("manage") }, + { to: "/career/builder", label: t("cvBuilder"), icon: , section: t("manage") }, { to: "/trash", label: t("trash"), icon: , section: t("manage") }, ]; @@ -457,17 +458,17 @@ export default function App() { return ( - - - - + + + + - - - - + + + + ); } diff --git a/job-tracker-ui/src/ai-workspace-panel.test.tsx b/job-tracker-ui/src/ai-workspace-panel.test.tsx deleted file mode 100644 index 063dd1a..0000000 --- a/job-tracker-ui/src/ai-workspace-panel.test.tsx +++ /dev/null @@ -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; - -function renderPanel() { - return render( - - - , - ); -} - -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( - - - - - , - ); - - 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(); - expect(screen.getByText("Title")).toBeInTheDocument(); - expect(screen.getByText("Strengths")).toBeInTheDocument(); - expect(screen.getByText("one")).toBeInTheDocument(); - expect(screen.getByText("two")).toBeInTheDocument(); -}); diff --git a/job-tracker-ui/src/application-workspace-overlay.test.tsx b/job-tracker-ui/src/application-workspace-overlay.test.tsx index e074e6e..08bf2b6 100644 --- a/job-tracker-ui/src/application-workspace-overlay.test.tsx +++ b/job-tracker-ui/src/application-workspace-overlay.test.tsx @@ -13,7 +13,6 @@ import ApplicationWorkspacePage from "./views/ApplicationWorkspacePage"; jest.mock("./components/Attachments", () => () =>
Documents section
); jest.mock("./components/Correspondence", () => () =>
Communication section
); -jest.mock("./components/AiWorkspacePanel", () => () =>
AI panel
); jest.mock("./components/ApplicationChecklist", () => () =>
Checklist section
); jest.mock("./components/ApplicationIntelligence", () => ({ ApplicationAnalysis: () =>
Analysis section
, diff --git a/job-tracker-ui/src/components/AiWorkspacePanel.tsx b/job-tracker-ui/src/components/AiWorkspacePanel.tsx deleted file mode 100644 index 906de7a..0000000 --- a/job-tracker-ui/src/components/AiWorkspacePanel.tsx +++ /dev/null @@ -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(null); - const [busy, setBusy] = useState(false); - const [current, setCurrent] = useState(null); - const [history, setHistory] = useState([]); - const [loadingHistory, setLoadingHistory] = useState(true); - const [compareWith, setCompareWith] = useState(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 ( - - - {!canUseAi && ( - - Generate job analysis, cover letters and strategy suggestions while keeping your existing AI history available. - - )} - - AI suggestions never change your profile, CVs, or this application. Review, then copy what you want to keep. - {provider && <> Provider: {provider}.} - {usage && <> This month: {usage.currentMonth.calls} runs · approximately {usage.currentMonth.estimatedTokens.toLocaleString()} tokens.} - - - - {AI_MODULES.map((m) => ( - setModule(m.key)} /> - ))} - - - - {activeModule.label} - {activeModule.blurb} - - {module === "cover-letter" && ( - - Tone - - - )} - - setExtra(e.target.value)} /> - - - - - {busy && } - - {current && !busy && ( - - - {current.title} - - - - - )} - - {compareWith && ( - - - Comparing: {compareWith.title} · {relTime(compareWith.createdAtUtc)} - - - - - )} - - - - - - History - - {loadingHistory && {[0, 1, 2].map((i) => )}} - {!loadingHistory && moduleHistory.length === 0 && ( - No AI runs yet. Generate one to build history. - )} - - {moduleHistory.map((h) => ( - - {h.title} - {relTime(h.createdAtUtc)} · {h.provider} - - setCurrent(h)}> - setCompareWith(h)}> - copy(h.result.text ?? "")}> - - remove(h.id)}> - - - ))} - - - - ); -} - -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(); -} diff --git a/job-tracker-ui/src/components/ApplicationIntelligence.tsx b/job-tracker-ui/src/components/ApplicationIntelligence.tsx index 5dba521..d5e75e5 100644 --- a/job-tracker-ui/src/components/ApplicationIntelligence.tsx +++ b/job-tracker-ui/src/components/ApplicationIntelligence.tsx @@ -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. diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index 3af1c4e..244b940 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -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, {isAdmin ? : null} - {attachmentPicker} @@ -1283,8 +1281,6 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, {history.length === 0 ? {t("jobDetailsNoHistory")} : history.map((entry) => )} )} - {/* AI Workspace is the last tab: index depends on whether the admin History tab is present. */} - {tab === (isAdmin ? 10 : 9) && jobId && } ); diff --git a/job-tracker-ui/src/confirm.test.tsx b/job-tracker-ui/src/confirm.test.tsx index d40aaca..9bf8681 100644 --- a/job-tracker-ui/src/confirm.test.tsx +++ b/job-tracker-ui/src/confirm.test.tsx @@ -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 ; +} + test('renders app-owned confirmation dialog', async () => { render( @@ -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( + + + + + , + ); + + 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'); +}); diff --git a/job-tracker-ui/src/confirm.tsx b/job-tracker-ui/src/confirm.tsx index afceb8d..2836f01 100644 --- a/job-tracker-ui/src/confirm.tsx +++ b/job-tracker-ui/src/confirm.tsx @@ -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(null); export function ConfirmProvider({ children }: { children: React.ReactNode }) { + const i18n = useOptionalI18n(); const resolverRef = useRef<((value: boolean) => void) | null>(null); const [state, setState] = useState({ 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 ( {children} - closeWith(false)} fullWidth maxWidth="xs"> - {state.title} + closeWith(false)} fullWidth maxWidth="xs" + slotProps={{ paper: { sx: { borderRadius: 3, border: 1, borderColor: "divider", backgroundImage: "none" } } }}> + {state.title} - {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."} {state.message} - + diff --git a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx index b2383be..0d56408 100644 --- a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx +++ b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx @@ -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 ;