feat(jobs): embed route-backed workspace
CI and Deploy / test (pull_request) Successful in 4m34s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-10 11:28:01 +02:00
parent a54b70960c
commit b67a531af4
9 changed files with 303 additions and 19 deletions
+2
View File
@@ -189,3 +189,5 @@ Output was reduced to filenames and commit counts. The token artifact appears un
| V-155 | Focused `job-discovery.test.tsx`; full frontend Jest; `npm run build`; staged diff review | `job-tracker-ui` / repository root | Verify honest submitted-search, loading/error/retry/empty states, bounded sorting and missing-data disclosure | PASS — focused 4/4; frontend 50 suites and 201/201; production build/TypeScript and diff check pass; `3f74b23` pushed | JSDOM/mocked API only; no NAV/browser/production request. Jest retains its existing force-exit/open-handle warning | Repository UX increment verified; browser/live gates remain |
| V-156 | Focused discovery backend/UI; mocked Playwright JOBS journey; screenshot inspection; full backend/frontend/build; complete `npm run test:e2e`; staged diff review | Repository root / `job-tracker-ui` | Verify feed duplicate/withdrawal handling and discovery browser behavior across widths/themes/keyboard/long Norwegian content/reviewed import | PASS — focused backend 2/2 and UI 4/4; backend 631/631; frontend 201/201; build; Playwright 5/5 including 375/768/1440 with no overflow; dark banner contrast defect found visually, fixed and rerun; `82f4526` pushed | First two browser attempts exposed test-locator API mistakes and were corrected without weakening behavior. NAV/import responses were mocked; no external NAV/production request. Existing GSI repeated-initialize and Jest open-handle warnings remain | Synthetic/browser repository scope verified; live/production blocked |
| V-157 | Phase 10 source trace; failing computed-color browser reproduction; focused Kanban component tests; production build; mocked 1440/768/375 dark/light pointer/keyboard Playwright; screenshot inspection; full frontend and complete E2E; staged diff review | `job-tracker-ui` / repository root | Correct Kanban theme/state/accessibility/mobile behavior using shared tokens | PASS — pre-fix dark column was `rgb(245, 242, 250)`; after uses dark variable surface. Component 7/7, frontend 204/204, build and Playwright 6/6 pass; browser native hover/drag and keyboard drop each reach the mocked API; mobile owns horizontal board scroll; `fb6f17e`/`4e5ce0c` pushed | Iteration exposed CSS-variable `alpha()` incompatibility, test-theme fallback, stale locators and a one-pixel live region expressed as MUI width `1` (100%), all corrected. Mocked data/API only; GSI repeated-initialize and Jest open-handle warnings remain | Repository/browser scope verified; production/native-device gates remain |
| V-158 | Phase 11 requirement, route, `JobTable`, `JobDetailsDialog`, `ApplicationWorkspacePage`, workspace architecture and test inventory | Repository root | Reproduce JOBS-002 list-context, deep-link and presentation behavior before editing | PASS — filters/page were component-local; `?open=` was consumed and removed; row Open launched the legacy quick dialog; workspace navigation left `/jobs`, and workspace Back always returned to a fresh `/jobs`. The full-page workspace already composes the owned domain sections and remains the safe reuse boundary | Source execution-path inspection; no browser behavior relabelled as tested | Confirmed product/navigation gap |
| V-159 | Focused overlay Jest; full `npm test -- --runInBand --forceExit`; `npm run build`; `git diff --check` | `job-tracker-ui` / repository root | Verify first JOBS-002 route-backed embedded-workspace increment | PASS — focused 2/2, full frontend 51 suites and 206/206 tests, and optimized production build/TypeScript pass. Row Open creates `/jobs?workspace={id}`, direct URLs and section URLs render, close/Forward preserve history and in-memory search, the dialog has an accessible name/focus trap, mobile uses full screen, and the full-page route remains linked | JSDOM/mocked API only. Existing Jest force-exit notice remains. URL persistence of complete filter/page state, dirty-edit guards, table redesign and real browser/production checks remain | First cohesive increment verified |
@@ -0,0 +1,33 @@
# JOBS-002 application table and workspace verification
Updated: 2026-08-10
## Confirmed baseline
- `JobTable` owned search, filters, sort and page only in React state.
- `?open=` opened the legacy quick dialog and was immediately removed from the URL.
- “Open application workspace” navigated to `/applications/:id`; its Back control always navigated to a fresh `/jobs`.
- The existing full-page workspace already composes the owner-scoped checklist, intelligence, CV, cover-letter, attachment, correspondence and interview components. It is reused rather than duplicated.
## Increment 1 — route-backed overlay
- Table row Open now creates `/jobs?workspace={id}` and renders the existing workspace inside an accessible MUI dialog.
- Workspace sections update `section=` with replacement navigation; opening is a pushed entry, so Back closes and Forward reopens without losing the mounted list state.
- A direct `/jobs?workspace={id}&section={section}` opens the requested application and closes by safely removing only workspace query keys.
- MUI owns focus trap/restoration. The dialog is full-screen below 768px and links to `/applications/:id?section=...` as the full-page fallback.
- Legacy `?open=` quick-dialog links continue to work; their workspace action enters the same overlay route.
## Verification
- Focused Jest: `src/application-workspace-overlay.test.tsx` — 2/2 pass.
- Full frontend Jest: 51 suites and 206/206 tests pass; existing force-exit notice remains.
- Production build and TypeScript: pass.
- Evidence: V-158 and V-159 in `docs/audits/verification-log.md`.
## Remaining before completion
- Persist search, filters, sort and page in the URL and verify refresh/Back/Forward.
- Add a shared dirty-edit close/navigation guard for workspace sections that own unsaved content.
- Improve the table's scan-priority data without adding every available field.
- Add real-browser 375/768/1440, light/dark, keyboard/focus restoration, refresh/history, error and long-content evidence.
- Run tenant-authorization regressions and the production smoke gate.
+10
View File
@@ -639,3 +639,13 @@
- **Consequences:** pointer and keyboard users receive the same server-confirmed behavior and announcements; custom `Other` remains visibly invalid. The existing exact-stage menu stays available and now has an accessible name.
- **User approval required:** No; this is approved local UX/accessibility work with mocked browser APIs.
- **Reversible:** Revert `fb6f17e` and `4e5ce0c`; no schema/data change is involved.
## DEC-065 — Reuse the workspace as a route-backed list overlay
- **Date:** 2026-08-10
- **Decision:** Keep `/applications/:id` as the full-page fallback, but make a table row's primary Open action render that same workspace in a MUI dialog at `/jobs?workspace={id}&section={section}`. Use a pushed history entry for opening, replace only section changes, Back for UI-originated close and query removal for direct-link close.
- **Reason/evidence:** V-158 confirmed that the previous quick-dialog/full-page chain discarded list context and could not deep-link the embedded presentation. The workspace already composes the authoritative domain components; reusing it avoids a second workspace. MUI supplies the requested focus trap/restoration and full-screen mobile presentation.
- **Alternatives considered:** build a second drawer workspace; keep the quick dialog primary; clone workspace sections into `JobTable`; make the full-page route the only URL. These duplicate ownership or leave the Phase 11 navigation defect.
- **Consequences:** list state remains mounted while the overlay is open, direct workspace/section URLs and Back/Forward work, and a full-page link remains. Complete URL-backed filters and unsaved-edit guards are separate required increments before JOBS-002 can leave progress.
- **User approval required:** No; this is approved repository implementation with mocked local data and no production/provider action.
- **Reversible:** Revert the JOBS-002 overlay commit; no schema, dependency or stored-data change is involved.
+2 -2
View File
@@ -3,7 +3,7 @@
Updated: 2026-08-10
- **Overall programme status:** Active. Seven packages are locally verified; twenty-two packages through UX-003 are implemented with automated/runtime evidence but blocked from applicable live/provider/production gates; JOBS-002 is now in progress. Gitea run 609 passes the prior complete pull-request CI; DEP-001 awaits approved merge-to-main and production verification.
- **Current work package:** `JOBS-002` — applications table and embedded workspace (`IN PROGRESS`); Phase 11 route/list/workspace inventory and current-behavior reproduction are next.
- **Current work package:** `JOBS-002` — applications table and embedded workspace (`IN PROGRESS`); route-backed overlay increment passes focused tests/build. URL-backed filters/page and dirty-edit protection are next.
- **Completed work packages:** None are `DONE`; all repository security/AI packages still have applicable browser, provider and/or production gates.
- **Locally verified work:** SEC-001, SEC-002, SEC-003, SEC-005A, CORE-001, PROD-002 and DEP-001 (`VERIFIED LOCALLY`).
- **Implemented, verification incomplete:** SEC-004, SEC-005B, SEC-008, CORE-002, BG-001, OPS-001A/B/C, POL-001/002, AI-001/002/003/004, UX-001/002/003, QA-001, CAREER-001/002, MAIL-001 and JOBS-001 (`IMPLEMENTED — NOT VERIFIED`). UX-003 safe local/browser scope is implemented; production/native-device gates remain.
@@ -12,7 +12,7 @@ Updated: 2026-08-10
- **Deferred work:** None. Conditional multi-replica coordination, model deletion, realtime operation delivery and unrelated production changes remain outside current packages.
- **Next five work packages:** JOBS-002 applications/workspace; PRODUCT-001 homepage/Pro claims; VER-001 action matrix; production-blocked SEC-006/007 when package-index permission is available; REL-001 after prerequisites.
- **Status counts:** 7 `VERIFIED LOCALLY`; 22 `IMPLEMENTED — NOT VERIFIED`; 1 `IN PROGRESS`; 5 `NOT STARTED`; 5 `BLOCKED`; 0 `DONE`; 0 `DEFERRED`.
- **Test status:** backend 631/631; frontend 50/50 suites and 204/204 tests; Playwright 6/6; npm audit 0 vulnerabilities; production build passes. UX-003 component 7/7 and mocked browser 1/1 pass at 1440/768/375 with dark/light, pointer/keyboard and mobile containment. JOBS-001, DEP-001 and MAIL-001 suites remain as recorded. Historical JT-019 and Jest force-exit/open-handle behavior remain recorded.
- **Test status:** backend 631/631; frontend 51/51 suites and 206/206 tests; JOBS-002 overlay focused 2/2; Playwright 6/6; npm audit 0 vulnerabilities; production build passes. Historical JT-019 and Jest force-exit/open-handle behavior remain recorded.
- **Deployment status:** Gitea pull-request run 609 passed the complete CI job in 4m20s. Deploy was intentionally skipped because the workflow deploys only a `push` to `main`; live remains unchanged. No merge/deployment was performed directly, no production migrations were run and the AI operation worker remains disabled by default.
- **Production status:** Unchanged and unverified. No provider/model call, model pull, external request or paid API occurred.
- **Known regressions:** None found by automated/local browser checks. Jest still needs `--forceExit` and reports its existing open-handle notice. Email-provider/send tests are fake/local only; real delivery is not claimed. Current MAIL browser evidence is 1280×720 only because the browser surface could not resize or perform native Tab traversal. Interrupted attempts are aged after 15 minutes and notified without retry; the five-minute scan is unmeasured on a large ledger. Direct clean EF-only SQLite migration still hits the pre-existing historical blank-chain defect before later migrations; normal startup owns reconciliation. Cross-feature monthly AI usage accounting remains a rollout gap.
+2 -2
View File
@@ -687,9 +687,9 @@ Ordering differences from the suggested list:
- **Required production verification:** existing application/workspace smoke.
- **Status:** `IN PROGRESS`.
- **Blocker:** none after dependencies.
- **Evidence:** source requirement and existing workspace architecture; initial Phase 11 inventory begins at V-158.
- **Evidence:** V-158 execution-path inventory; V-159 first route-backed overlay tests/build; `docs/verification/jobs-002-application-workspace.md`.
- **Commit:** none.
- **Remaining work:** do not place every field in table or duplicate workspace data.
- **Remaining work:** first overlay increment implemented: row Open is route-backed, direct/section URLs and Back/Forward work, MUI owns focus trapping/restoration, mobile is full-screen, and full-page fallback remains. Still required: persist complete list/filter/page state in the URL; guard dirty section edits; improve scan-priority columns/states; browser widths/themes/keyboard/history/error/long-data checks; authorization regression and production smoke. Do not place every field in table or duplicate workspace data.
### UX-003 — Kanban theme-state correction
+7 -7
View File
@@ -2,17 +2,17 @@
Updated: 2026-08-10
- **Exact current task:** JOBS-002 — read Phase 11 and inventory the applications table, route-backed workspace, filters/history/focus/unsaved state and tests before editing.
- **Last completed step:** pushed `4e5ce0c`; UX-003 now has required 1440/768/375 dark/light, browser pointer hover/drag and keyboard evidence after the root implementation `fb6f17e`.
- **Files currently modified:** UX-003 tracking/decision/verification evidence only; application/tests/screenshots are committed.
- **Commands already run:** Phase 10 trace; failing pre-fix computed-color browser reproduction; iterative focused component/build/browser checks; screenshot inspection; full frontend/build and complete Playwright. See V-157.
- **Test results:** backend 631/631 (unchanged); frontend 50 suites and 204/204; Kanban component 7/7; Playwright 6/6 including Kanban 1440/768/375, dark/light, pointer/keyboard; production build passes. Jest retains its known force-exit/open-handle warning.
- **Exact current task:** JOBS-002 — finish and commit the first route-backed embedded-workspace increment, then make filters/page URL-owned without breaking direct workspace URLs.
- **Last completed step:** V-158 traced the confirmed list-context loss; V-159 focused 2/2 and production build pass for the route-backed overlay, direct/section URLs, close/Forward behavior, accessible dialog and full-page fallback.
- **Files currently modified:** `JobTable.tsx`, `ApplicationWorkspacePage.tsx`, new `application-workspace-overlay.test.tsx`, JOBS-002 verification/tracking/decision documents.
- **Commands already run:** bounded Phase 11 route/workspace/test trace; focused overlay Jest; production frontend build; diff check. See V-158/V-159.
- **Test results:** backend 631/631 unchanged; frontend 51 suites and 206/206; JOBS-002 overlay focused 2/2; production build passes. Jest retains its known force-exit/open-handle notice.
- **Services currently running:** none on task-owned ports 3000/5202. Playwright stopped its disposable API/Next servers. Pre-existing Docker services were not changed.
- **Temporary files or processes:** no task-owned process is running and the failed disposable migration database was removed. Existing synthetic browser evidence/account and startup-created local backup remain documented. No provider account, real email, private content, paid service or production service was accessed.
- **Production changes currently active:** none. No deployment, migration, provider connection/sync/send or production payload occurred.
- **Rollback status:** downgrade `20260810080858_AddEmailDraftClientRequestId`, then `20260810075206_AddEmailDrafts`, before reverting draft commits; then follow the existing MAIL rollback order (`ee5ef7e`, `449faeb`, `123fc55`/`e9937ac`, ledger downgrade before `653f011`). No production migration/deploy/provider grant occurred.
- **Uncommitted changes:** UX-003 closure/JOBS-002 transition tracking, decision, V-157 and verification document only; commit and push before JOBS-002 implementation.
- **Uncommitted changes:** first JOBS-002 route-backed overlay implementation, focused tests and tracking; no dependency/schema/config change.
- **Known failures:** live deployment is not verified because PR deploy is intentionally skipped and the active branch is not approved for merge. Draft export/API/UI, full thread/category actions and non-Gmail review remain; existing accounts need re-consent and IMAP stays read-only. A clean full-chain SQLite apply fails in the pre-existing JT-019 migration before the new draft migration. Browser/provider/MariaDB/production unavailable or unverified; recovery scan performance is unmeasured at large ledger scale; Jest open handles; SEC-006 parser dependency work is still separately gated; parser isolation remains SEC-007.
- **Exact next action:** read `docs/todo/work.md` Phase 11 and inspect `JobTable`, `/jobs` routing, `ApplicationWorkspacePage`, filter persistence/deep links and existing tests; reproduce list-context loss and direct-route behavior before choosing the first increment.
- **Exact next action:** review/stage/commit/push the overlay increment, then implement URL-backed search/status/company/location/follow-up/readiness/sort/page state with Back/Forward tests.
- **Work that can continue independently:** JOBS-002, PRODUCT-001 and VER-001. UX/JOBS production, MAIL provider mutations, SEC-006/007 and PROD packages retain their recorded external gates.
- **Decisions still required from the user:** none for synthetic/code-inspected repository work. Any provider connection or send test, internet/package upgrades, private data, external/paid providers and production actions retain explicit approval/safety gates; SEC-009 retention/legal policy remains unresolved.
@@ -0,0 +1,147 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { api } from "./api";
import JobTable from "./components/JobTable";
import { ConfirmProvider } from "./confirm";
import { I18nProvider } from "./i18n/I18nProvider";
import { PromptProvider } from "./prompt";
import { ToastProvider } from "./toast";
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>,
ApplicationMatch: () => <div>Match section</div>,
ApplicationTimeline: () => <div>Timeline section</div>,
}));
jest.mock("./components/ApplicationAssets", () => ({
ApplicationCoverLetterSection: () => <div>Cover letter section</div>,
ApplicationCvSection: () => <div>CV section</div>,
}));
jest.mock("./components/InterviewPrep", () => ({ ApplicationInterviewPrep: () => <div>Interview section</div> }));
const mockedApi = api as jest.Mocked<typeof api>;
const job = {
id: 42,
jobTitle: "Backend Developer",
company: { id: 1, name: "Acme" },
companyId: 1,
status: "Waiting",
dateApplied: "2026-08-01T00:00:00Z",
savedAt: "2026-08-01T00:00:00Z",
location: "Oslo",
description: "Build APIs",
daysSince: 9,
isDeleted: false,
needsFollowUp: false,
workflowSignal: null,
};
const overview = {
id: 42,
jobTitle: "Backend Developer",
company: "Acme",
location: "Oslo",
salary: null,
status: "Waiting",
stageGroup: "Applied",
stageOrder: 1,
dateApplied: "2026-08-01T00:00:00Z",
deadline: null,
followUpAt: null,
nextAction: null,
jobUrl: null,
hasJobDescription: true,
cv: { variantId: null, variantName: null, themeId: null, hasTailoredCvText: false, updatedAtUtc: null },
hasCoverLetter: false,
documentCount: 0,
hasPortfolio: false,
aiInteractionCount: 0,
lastAiAtUtc: null,
recentActivity: [],
nextStep: null,
checklistProgress: { total: 0, completed: 0, dismissed: 0, percent: 0 },
};
function LocationControls() {
const location = useLocation();
const navigate = useNavigate();
return (
<>
<output data-testid="location">{location.pathname}{location.search}</output>
<button onClick={() => navigate(1)}>Browser forward</button>
</>
);
}
function renderTable(path = "/jobs") {
return render(
<ToastProvider>
<I18nProvider>
<ConfirmProvider>
<PromptProvider>
<MemoryRouter initialEntries={[path]} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<LocationControls />
<Routes>
<Route path="/jobs" element={<JobTable refreshToken={0} pageSize={15} onPageSizeChange={() => {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} />} />
</Routes>
</MemoryRouter>
</PromptProvider>
</ConfirmProvider>
</I18nProvider>
</ToastProvider>,
);
}
beforeEach(() => {
mockedApi.get.mockImplementation((url: string) => {
if (url === "/companies") return Promise.resolve({ data: [{ id: 1, name: "Acme" }] } as any);
if (url === "/jobapplications") return Promise.resolve({ data: { items: [job], total: 1, page: 1, pageSize: 15 } } as any);
if (url === "/jobapplications/42/workspace") return Promise.resolve({ data: overview } as any);
return Promise.resolve({ data: [] } as any);
});
});
afterEach(() => jest.clearAllMocks());
test("opens the workspace in a route-backed overlay and preserves list state through Back/Forward", async () => {
renderTable();
const search = await screen.findByRole("textbox", { name: /search/i });
fireEvent.change(search, { target: { value: "backend" } });
fireEvent.click(await screen.findByRole("button", { name: /open: backend developer/i }));
await screen.findByRole("dialog", { name: /application workspace/i });
expect(screen.getByTestId("location")).toHaveTextContent("/jobs?workspace=42");
expect(screen.getByRole("link", { name: /open full-page workspace/i })).toHaveAttribute("href", "/applications/42?section=overview");
fireEvent.click(screen.getByRole("button", { name: "Match" }));
expect(await screen.findByText("Match section")).toBeInTheDocument();
expect(screen.getByTestId("location")).toHaveTextContent("/jobs?workspace=42&section=match");
fireEvent.click(screen.getByRole("button", { name: /back to applications/i }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: /application workspace/i })).not.toBeInTheDocument());
expect(screen.getByTestId("location")).toHaveTextContent("/jobs");
expect(screen.getByRole("textbox", { name: /search/i })).toHaveValue("backend");
fireEvent.click(screen.getByRole("button", { name: /browser forward/i }));
await screen.findByRole("dialog", { name: /application workspace/i });
expect(screen.getByTestId("location")).toHaveTextContent("/jobs?workspace=42&section=match");
});
test("opens a direct workspace URL and closes it without inventing browser history", async () => {
renderTable("/jobs?workspace=42&section=match");
await screen.findByRole("dialog", { name: /application workspace/i });
expect(await screen.findByText("Match section")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /back to applications/i }));
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs"));
await waitFor(() => expect(screen.queryByRole("dialog", { name: /application workspace/i })).not.toBeInTheDocument());
});
+58 -3
View File
@@ -7,6 +7,8 @@ import {
Checkbox,
Chip,
Collapse,
Dialog,
DialogContent,
FormControl,
FormControlLabel,
IconButton,
@@ -56,6 +58,8 @@ import { useI18n } from "../i18n/I18nProvider";
import { JobApplication } from "../types";
import { useViewResource } from "../hooks/useViewResource";
import { getWorkflowAction, needsInterviewPrep, needsWorkflowWork } from "../jobWorkflowSignals";
import { ApplicationWorkspace } from "../views/ApplicationWorkspacePage";
import { workspaceSection, WorkspaceSectionKey } from "../applicationWorkspace";
interface PagedResult<T> {
items: T[];
@@ -179,6 +183,37 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
const [statusJobId, setStatusJobId] = useState<number | null>(null);
const [sortBy, setSortBy] = useState<"dateApplied" | "company" | "jobTitle" | "status" | "daysSince" | "location">("dateApplied");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const searchParams = useMemo(() => new URLSearchParams(location.search), [location.search]);
const workspaceJobId = Number(searchParams.get("workspace")) || null;
const workspaceSectionKey = workspaceSection(searchParams.get("section"));
const updateWorkspaceRoute = (jobId: number, section: WorkspaceSectionKey = "overview") => {
const next = new URLSearchParams(location.search);
next.set("workspace", String(jobId));
if (section === "overview") next.delete("section");
else next.set("section", section);
navigate({ pathname: location.pathname, search: `?${next.toString()}` }, { state: { workspaceOverlay: true } });
};
const updateWorkspaceSection = (section: WorkspaceSectionKey) => {
if (!workspaceJobId) return;
const next = new URLSearchParams(location.search);
next.set("workspace", String(workspaceJobId));
if (section === "overview") next.delete("section");
else next.set("section", section);
navigate({ pathname: location.pathname, search: `?${next.toString()}` }, { replace: true, state: location.state });
};
const closeWorkspace = () => {
if (location.state?.workspaceOverlay) {
navigate(-1);
return;
}
const next = new URLSearchParams(location.search);
next.delete("workspace");
next.delete("section");
navigate({ pathname: location.pathname, search: next.toString() ? `?${next.toString()}` : "" }, { replace: true });
};
const params = useMemo(() => ({
page: page + 1,
@@ -655,7 +690,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<Button variant="outlined" startIcon={<MoreHorizIcon />} onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }} sx={{ minHeight: 42, fontWeight: 700 }}>
{t("jobTableQuickStatus")}
</Button>
<Button variant="outlined" startIcon={<LaunchIcon />} onClick={() => setDetailsJobId(job.id)} sx={{ minHeight: 42, fontWeight: 700 }}>
<Button variant="outlined" startIcon={<LaunchIcon />} onClick={() => updateWorkspaceRoute(job.id)} sx={{ minHeight: 42, fontWeight: 700 }}>
{t("jobTableOpen")}
</Button>
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? (
@@ -744,7 +779,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 0.5 }}>
<Tooltip title={t("jobTableEdit")}><IconButton size="small" onClick={() => setEditJobId(job.id)}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title={t("jobTableQuickStatus")}><IconButton size="small" onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title={t("jobTableOpen")}><IconButton size="small" onClick={() => setDetailsJobId(job.id)}><LaunchIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title={t("jobTableOpen")}><IconButton size="small" aria-label={`${t("jobTableOpen")}: ${job.jobTitle}`} onClick={() => updateWorkspaceRoute(job.id)}><LaunchIcon fontSize="small" /></IconButton></Tooltip>
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? <Tooltip title={t("jobTableRestore")}><IconButton size="small" onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon fontSize="small" /></IconButton></Tooltip> : <Tooltip title={t("jobTableSoftDelete")}><IconButton size="small" onClick={() => void softDelete(job)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>}
</Box>
</Box>
@@ -776,7 +811,27 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<TablePagination component="div" count={total} page={page} onPageChange={(_, next) => setPage(next)} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); }} rowsPerPageOptions={[15, 20, 25]} />
</Paper>
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} initialTab={detailsInitialTab} initialFollowUpMode={detailsFollowUpMode} onClose={() => { setDetailsJobId(null); setDetailsInitialTab(0); setDetailsFollowUpMode(undefined); }} onOpenWorkspace={(id) => { setDetailsJobId(null); navigate(`/applications/${id}`); }} />
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} initialTab={detailsInitialTab} initialFollowUpMode={detailsFollowUpMode} onClose={() => { setDetailsJobId(null); setDetailsInitialTab(0); setDetailsFollowUpMode(undefined); }} onOpenWorkspace={(id) => { setDetailsJobId(null); updateWorkspaceRoute(id); }} />
<Dialog
open={workspaceJobId !== null}
onClose={closeWorkspace}
fullScreen={isMobile}
fullWidth
maxWidth="xl"
slotProps={{ paper: { "aria-label": "Application workspace" } }}
>
<DialogContent sx={{ p: { xs: 1.5, sm: 2.5 } }}>
{workspaceJobId ? (
<ApplicationWorkspace
jobIdOverride={workspaceJobId}
sectionOverride={workspaceSectionKey}
onSectionChange={updateWorkspaceSection}
onClose={closeWorkspace}
fullPageHref={`/applications/${workspaceJobId}?section=${workspaceSectionKey}`}
/>
) : null}
</DialogContent>
</Dialog>
<EditJobDialog open={editJobId !== null} jobId={editJobId} onClose={() => setEditJobId(null)} onSaved={() => setReloadToken((token) => token + 1)} />
<Menu anchorEl={statusAnchor} open={Boolean(statusAnchor)} onClose={() => { setStatusAnchor(null); setStatusJobId(null); }}>
{statusOptions.map((status) => <MenuItem key={status} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, status); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status })}</MenuItem>)}
@@ -36,11 +36,29 @@ import {
// reuses the component that already owns that domain (Attachments, Correspondence, AiWorkspacePanel).
// docs/architecture/application-workspace.md.
export default function ApplicationWorkspacePage() {
return <ApplicationWorkspace />;
}
type ApplicationWorkspaceProps = {
jobIdOverride?: number;
sectionOverride?: WorkspaceSectionKey;
onSectionChange?: (section: WorkspaceSectionKey) => void;
onClose?: () => void;
fullPageHref?: string;
};
export function ApplicationWorkspace({
jobIdOverride,
sectionOverride,
onSectionChange,
onClose,
fullPageHref,
}: ApplicationWorkspaceProps) {
const { id } = useParams();
const jobId = Number(id);
const jobId = jobIdOverride ?? Number(id);
const navigate = useNavigate();
const [params, setParams] = useSearchParams();
const section = workspaceSection(params.get("section"));
const section = sectionOverride ?? workspaceSection(params.get("section"));
const [overview, setOverview] = useState<WorkspaceOverview | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -57,12 +75,16 @@ export default function ApplicationWorkspacePage() {
load();
}, [load]);
const go = (next: WorkspaceSectionKey) => setParams({ section: next }, { replace: true });
const go = (next: WorkspaceSectionKey) => {
if (onSectionChange) onSectionChange(next);
else setParams({ section: next }, { replace: true });
};
const close = onClose ?? (() => navigate("/jobs"));
if (error) {
return (
<Box sx={{ p: 3 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/jobs")}>Back to applications</Button>
<Button startIcon={<ArrowBackIcon />} onClick={close}>Back to applications</Button>
<Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>
</Box>
);
@@ -73,13 +95,28 @@ export default function ApplicationWorkspacePage() {
<Paper sx={{ p: 1, borderRadius: 3, position: { md: "sticky" }, top: 12 }}>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: 1, py: 0.5 }}>
<Tooltip title="Back to applications">
<IconButton size="small" aria-label="Back to applications" onClick={() => navigate("/jobs")}>
<IconButton size="small" aria-label="Back to applications" onClick={close}>
<ArrowBackIcon fontSize="small" />
</IconButton>
</Tooltip>
<Typography variant="caption" sx={{ fontWeight: 800, letterSpacing: ".08em", textTransform: "uppercase", color: "text.secondary" }}>
Workspace
</Typography>
{fullPageHref ? (
<Tooltip title="Open full-page workspace">
<IconButton
component="a"
href={fullPageHref}
target="_blank"
rel="noopener noreferrer"
size="small"
aria-label="Open full-page workspace"
sx={{ ml: "auto" }}
>
<OpenInNewIcon fontSize="small" />
</IconButton>
</Tooltip>
) : null}
</Stack>
<List dense component="nav" aria-label="Workspace sections">
{WORKSPACE_SECTIONS.map((s) => (