Complete S03 runtime closure and S04 control loop
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
Box,
|
||||
@@ -22,6 +23,7 @@ import AutoGraphIcon from "@mui/icons-material/AutoGraph";
|
||||
import { api } from "../api";
|
||||
import { getUserKeyFromToken } from "../themePrefs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { buildJobWorkspacePath, JOB_DETAILS_TABS } from "../jobWorkspaceRoute";
|
||||
|
||||
interface JobStats {
|
||||
total: number;
|
||||
@@ -34,8 +36,12 @@ interface JobStats {
|
||||
|
||||
type ReminderJob = {
|
||||
id: number;
|
||||
jobTitle: string;
|
||||
status: string;
|
||||
followUpAt?: string | null;
|
||||
tailoredCvText?: string | null;
|
||||
followUpReason?: string | null;
|
||||
company?: { name?: string | null };
|
||||
};
|
||||
|
||||
type AnalyticsPoint = { month: string; applied: number; responses: number };
|
||||
@@ -127,6 +133,7 @@ function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: an
|
||||
|
||||
export default function DashboardView() {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
const [stats, setStats] = useState<JobStats | null>(null);
|
||||
const [overview, setOverview] = useState<OverviewAnalytics | null>(null);
|
||||
@@ -206,6 +213,15 @@ export default function DashboardView() {
|
||||
const totalApplied = appliedValues.reduce((sum, value) => sum + value, 0);
|
||||
const totalResponses = responseValues.reduce((sum, value) => sum + value, 0);
|
||||
const responseRate = totalApplied > 0 ? Math.round((totalResponses / totalApplied) * 100) : 0;
|
||||
const priorityJobs = reminderJobs.slice(0, 5);
|
||||
const openReminderJob = (job: ReminderJob) => {
|
||||
const reason = (job.followUpReason ?? '').toLowerCase();
|
||||
if (reason.includes('tailored cv')) {
|
||||
navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.tailoredCv }));
|
||||
return;
|
||||
}
|
||||
navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.followUp, followMode: 'waiting-update' }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -370,6 +386,31 @@ export default function DashboardView() {
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1.15fr 0.85fr" }, gap: 2, mt: 2 }}>
|
||||
<SectionCard>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("remindersTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("remindersSubtitle")}</Typography>
|
||||
{priorityJobs.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>{t("remindersNothing")}</Typography>
|
||||
) : (
|
||||
<Stack spacing={1.1}>
|
||||
{priorityJobs.map((job) => (
|
||||
<Box key={job.id} sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: alpha(theme.palette.primary.main, 0.03), display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900 }}>{job.company?.name ?? t("jobTableCompany")} • {job.jobTitle}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
|
||||
</Box>
|
||||
<Button variant="outlined" onClick={() => openReminderJob(job)}>
|
||||
{job.followUpReason?.toLowerCase().includes('tailored cv') ? t("jobDetailsTabTailoredCv") : t("jobTableFollowUp")}
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Button variant="text" onClick={() => navigate('/reminders')}>{t("reminders")}</Button>
|
||||
</Box>
|
||||
</SectionCard>
|
||||
|
||||
{prefs.companies ? (
|
||||
<SectionCard>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopCompaniesByActivity")}</Typography>
|
||||
|
||||
@@ -47,6 +47,7 @@ import { useToast } from "../toast";
|
||||
import SavedViewsMenu, { SavedViewParams } from "./SavedViewsMenu";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { buildJobWorkspacePath, JOB_DETAILS_TABS } from "../jobWorkspaceRoute";
|
||||
|
||||
interface JobApplication {
|
||||
id: number;
|
||||
@@ -376,9 +377,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<span>{job.jobTitle}</span>
|
||||
{job.needsFollowUp ? <Chip size="small" label={t("jobTableFollowUp")} title={job.followUpReason ?? undefined} sx={{ fontWeight: 800 }} /> : null}
|
||||
{!job.tailoredCvText && !job.isDeleted ? <Chip size="small" label={t("jobTableCvMissing")} color="warning" variant="outlined" /> : null}
|
||||
{job.tailoredCvText ? <Chip size="small" label={t("jobTableCvReady")} color="success" variant="outlined" /> : null}
|
||||
{job.needsFollowUp ? <Chip size="small" label={t("jobTableFollowUp")} title={job.followUpReason ?? undefined} sx={{ fontWeight: 800, cursor: "pointer" }} onClick={() => navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.followUp, followMode: "waiting-update" }))} /> : null}
|
||||
{!job.tailoredCvText && !job.isDeleted ? <Chip size="small" label={t("jobTableCvMissing")} color="warning" variant="outlined" sx={{ cursor: "pointer" }} onClick={() => navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.tailoredCv }))} /> : null}
|
||||
{job.tailoredCvText ? <Chip size="small" label={t("jobTableCvReady")} color="success" variant="outlined" sx={{ cursor: "pointer" }} onClick={() => navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.tailoredCv }))} /> : null}
|
||||
</Box>
|
||||
</TableCell>
|
||||
{columns.status ? <TableCell><Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} /></TableCell> : null}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Box, Button, Chip, Divider, Paper, Typography } from "@mui/material";
|
||||
|
||||
@@ -6,8 +7,7 @@ import { api } from "../api";
|
||||
import { JobApplication } from "../types";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
import JobDetailsDialog from "./JobDetailsDialog";
|
||||
import { buildJobWorkspacePath, JOB_DETAILS_TABS } from "../jobWorkspaceRoute";
|
||||
|
||||
type ReminderGroups = {
|
||||
missingCv: JobApplication[];
|
||||
@@ -28,7 +28,7 @@ function groupItems(items: JobApplication[]): ReminderGroups {
|
||||
return groups;
|
||||
}
|
||||
|
||||
function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: string; items: JobApplication[]; onOpen: (id: number) => void; onSetFollowUp: (id: number, days: number | null) => void }) {
|
||||
function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: string; items: JobApplication[]; onOpen: (job: JobApplication) => void; onSetFollowUp: (id: number, days: number | null) => void }) {
|
||||
const { t } = useI18n();
|
||||
if (items.length === 0) return null;
|
||||
|
||||
@@ -49,7 +49,7 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||
<Button size="small" variant="outlined" onClick={() => onOpen(j.id)}>{t("remindersOpen")}</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onOpen(j)}>{t("remindersOpen")}</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 3)}>+3d</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 7)}>+7d</Button>
|
||||
<Button size="small" onClick={() => onSetFollowUp(j.id, null)}>{t("remindersClear")}</Button>
|
||||
@@ -61,10 +61,10 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin
|
||||
}
|
||||
|
||||
export default function RemindersView() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const [items, setItems] = useState<JobApplication[]>([]);
|
||||
const [openJobId, setOpenJobId] = useState<number | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
const res = await api.get<JobApplication[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } });
|
||||
@@ -77,6 +77,15 @@ export default function RemindersView() {
|
||||
|
||||
const grouped = useMemo(() => groupItems(items), [items]);
|
||||
|
||||
const openJob = (job: JobApplication) => {
|
||||
const reason = (job.followUpReason ?? '').toLowerCase();
|
||||
if (reason.includes('tailored cv')) {
|
||||
navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.tailoredCv }));
|
||||
return;
|
||||
}
|
||||
navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.followUp, followMode: 'waiting-update' }));
|
||||
};
|
||||
|
||||
const setFollowUp = async (id: number, daysFromNow: number | null) => {
|
||||
try {
|
||||
const d = daysFromNow === null ? null : new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
@@ -96,10 +105,10 @@ export default function RemindersView() {
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<ReminderSection title={t("remindersMissingTailoredCv")} items={grouped.missingCv} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersMissingInterviewPrep")} items={grouped.missingInterviewNotes} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersFollowUpDue")} items={grouped.overdueFollowUp} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersOther")} items={grouped.other} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersMissingTailoredCv")} items={grouped.missingCv} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersMissingInterviewPrep")} items={grouped.missingInterviewNotes} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersFollowUpDue")} items={grouped.overdueFollowUp} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersOther")} items={grouped.other} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
|
||||
{items.length === 0 ? <Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>{t("remindersNothing")}</Typography> : null}
|
||||
</Box>
|
||||
@@ -108,8 +117,6 @@ export default function RemindersView() {
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{t("remindersTip")}
|
||||
</Typography>
|
||||
|
||||
<JobDetailsDialog open={openJobId !== null} jobId={openJobId} onClose={() => setOpenJobId(null)} />
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { ConfirmProvider } from './confirm';
|
||||
import { PromptProvider } from './prompt';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import DashboardView from './components/DashboardView';
|
||||
import RemindersView from './components/RemindersView';
|
||||
import JobTable from './components/JobTable';
|
||||
import { api } from './api';
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
const pagedJobs = {
|
||||
items: [
|
||||
{
|
||||
id: 42,
|
||||
jobTitle: 'Backend Developer',
|
||||
status: 'Waiting',
|
||||
dateApplied: new Date().toISOString(),
|
||||
daysSince: 10,
|
||||
company: { name: 'Acme' },
|
||||
companyId: 1,
|
||||
needsFollowUp: true,
|
||||
followUpReason: 'Follow-up due soon',
|
||||
shortSummary: 'Strong backend match',
|
||||
tailoredCvText: null,
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: 43,
|
||||
jobTitle: 'Platform Engineer',
|
||||
status: 'Applied',
|
||||
dateApplied: new Date().toISOString(),
|
||||
daysSince: 4,
|
||||
company: { name: 'Beta' },
|
||||
companyId: 2,
|
||||
needsFollowUp: false,
|
||||
followUpReason: 'Tailored CV missing',
|
||||
shortSummary: 'Platform work',
|
||||
tailoredCvText: null,
|
||||
notes: null,
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
pageSize: 15,
|
||||
};
|
||||
|
||||
const reminderItems = [
|
||||
{
|
||||
id: 42,
|
||||
jobTitle: 'Backend Developer',
|
||||
status: 'Waiting',
|
||||
dateApplied: new Date().toISOString(),
|
||||
daysSince: 10,
|
||||
company: { name: 'Acme' },
|
||||
needsFollowUp: true,
|
||||
followUpReason: 'Follow-up due soon',
|
||||
tailoredCvText: 'Saved CV',
|
||||
followUpAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 43,
|
||||
jobTitle: 'Platform Engineer',
|
||||
status: 'Applied',
|
||||
dateApplied: new Date().toISOString(),
|
||||
daysSince: 4,
|
||||
company: { name: 'Beta' },
|
||||
needsFollowUp: true,
|
||||
followUpReason: 'Tailored CV missing',
|
||||
tailoredCvText: null,
|
||||
followUpAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
function setupApiMocks() {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/companies') return Promise.resolve({ data: [{ id: 1, name: 'Acme' }, { id: 2, name: 'Beta' }] } as any);
|
||||
if (url === '/jobapplications/reminders') return Promise.resolve({ data: reminderItems } as any);
|
||||
if (url === '/jobapplications') return Promise.resolve({ data: pagedJobs } as any);
|
||||
if (url === '/jobapplications/stats') return Promise.resolve({ data: { total: 2, active: 2, deleted: 0, byStatus: {}, appliedLast30Days: 2, averageDaysSinceApplied: 7 } } as any);
|
||||
if (url === '/jobapplications/analytics-overview') return Promise.resolve({ data: { funnel: [], responseRateBySource: [], topCompanies: [], totalResponses: 1, totalActive: 2 } } as any);
|
||||
if (url === '/jobapplications/analytics' || url === '/jobapplications/tags') return Promise.resolve({ data: [] } as any);
|
||||
if (url === '/jobapplications/tag-trends') return Promise.resolve({ data: { months: [], series: [] } } as any);
|
||||
if (url === '/jobapplications/42') {
|
||||
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Waiting', dateApplied: new Date().toISOString(), daysSince: 10, company: { name: 'Acme', recruiterEmail: 'maria@acme.test' }, tailoredCvText: 'Saved CV', coverLetterText: 'Saved cover letter', recruiterMessageDraft: 'Saved recruiter message', notes: 'Notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nSaved answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>', shortSummary: 'Strong backend match' } } as any);
|
||||
}
|
||||
if (url === '/jobapplications/43') {
|
||||
return Promise.resolve({ data: { id: 43, jobTitle: 'Platform Engineer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 4, company: { name: 'Beta', recruiterEmail: 'recruiter@beta.test' }, tailoredCvText: '', coverLetterText: '', recruiterMessageDraft: '', notes: '', shortSummary: 'Platform work' } } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/history' || url === '/jobapplications/43/history') return Promise.resolve({ data: [] } as any);
|
||||
if (url === '/attachments/42' || url === '/attachments/43') return Promise.resolve({ data: [] } as any);
|
||||
if (url === '/jobapplications/42/followup-draft') {
|
||||
return Promise.resolve({ data: { subject: 'Re: Backend Developer application update', body: 'Hi Maria,\n\nFollow-up draft.\n\nThanks,\nDemo', reason: 'Follow-up due soon', suggestedSendOn: new Date().toISOString(), contextSummary: 'Saved application package material is available for reuse.', contextSignals: ['Saved cover letter available'], threadSubject: 'Backend Developer application update', lastCorrespondenceFrom: 'Maria Recruiter <maria@acme.test>', lastCorrespondenceAt: new Date().toISOString() } } as any);
|
||||
}
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
});
|
||||
}
|
||||
|
||||
function LocationIndicator() {
|
||||
const location = useLocation();
|
||||
return <div data-testid="location-indicator">{location.pathname}{location.search}</div>;
|
||||
}
|
||||
|
||||
function renderLoop(initialPath: string) {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<ConfirmProvider>
|
||||
<PromptProvider>
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<LocationIndicator />
|
||||
<Routes>
|
||||
<Route path="/dashboard" element={<DashboardView />} />
|
||||
<Route path="/reminders" element={<RemindersView />} />
|
||||
<Route path="/jobs" element={<JobTable refreshToken={0} pageSize={15} onPageSizeChange={() => {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} mode="jobs" />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</PromptProvider>
|
||||
</ConfirmProvider>
|
||||
</I18nProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
setupApiMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('dashboard attention card opens follow-up workspace', async () => {
|
||||
renderLoop('/dashboard');
|
||||
|
||||
expect(await screen.findByText(/needs follow-up/i)).toBeInTheDocument();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /follow up/i }));
|
||||
|
||||
expect(await screen.findByText(/follow-up context/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/saved cover letter available/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('reminders open action routes tailored-cv gaps into the tailored cv workspace', async () => {
|
||||
renderLoop('/reminders');
|
||||
|
||||
expect(await screen.findByText(/missing tailored cv/i)).toBeInTheDocument();
|
||||
const platformJob = await screen.findByText(/platform engineer/i);
|
||||
const platformCard = platformJob.closest('.MuiPaper-root') ?? platformJob.parentElement?.parentElement;
|
||||
expect(platformCard).toBeTruthy();
|
||||
fireEvent.click(within(platformCard as HTMLElement).getByRole('button', { name: /open/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs');
|
||||
});
|
||||
expect(await screen.findByText(/build the package here, then save the working copy back onto this job/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('job table urgency chips route into the correct workspace tab', async () => {
|
||||
renderLoop('/jobs');
|
||||
|
||||
const followUpChip = await screen.findByText(/follow up/i);
|
||||
fireEvent.click(followUpChip);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/follow-up context/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
@@ -13,9 +14,11 @@ const root = ReactDOM.createRoot(
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
<I18nProvider>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</I18nProvider>
|
||||
</LocalizationProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export const JOB_DETAILS_TABS = {
|
||||
overview: 0,
|
||||
correspondence: 1,
|
||||
attachments: 2,
|
||||
tailoredCv: 3,
|
||||
followUp: 4,
|
||||
candidateFit: 5,
|
||||
focusPlan: 6,
|
||||
interviewPrep: 7,
|
||||
readiness: 8,
|
||||
history: 9,
|
||||
} as const;
|
||||
|
||||
export type JobWorkspaceOpenOptions = {
|
||||
tab?: number;
|
||||
followMode?: string;
|
||||
};
|
||||
|
||||
export function buildJobWorkspacePath(jobId: number, options: JobWorkspaceOpenOptions = {}) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('open', String(jobId));
|
||||
if (typeof options.tab === 'number') params.set('tab', String(options.tab));
|
||||
if (options.followMode) params.set('followMode', options.followMode);
|
||||
return `/jobs?${params.toString()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user