Jobber/orchestrator/src/client/pages/OrchestratorPage.tsx
2026-01-25 13:14:59 +00:00

394 lines
12 KiB
TypeScript

/**
* Orchestrator layout with a split list/detail experience.
*/
import { useSettings } from "@client/hooks/useSettings";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
import type { JobSource } from "../../shared/types";
import * as api from "../api";
import { ManualImportSheet } from "../components";
import type { FilterTab, JobSort } from "./orchestrator/constants";
import { DEFAULT_SORT } from "./orchestrator/constants";
import { JobDetailPanel } from "./orchestrator/JobDetailPanel";
import { JobListPanel } from "./orchestrator/JobListPanel";
import { OrchestratorFilters } from "./orchestrator/OrchestratorFilters";
import { OrchestratorHeader } from "./orchestrator/OrchestratorHeader";
import { OrchestratorSummary } from "./orchestrator/OrchestratorSummary";
import { useFilteredJobs } from "./orchestrator/useFilteredJobs";
import { useOrchestratorData } from "./orchestrator/useOrchestratorData";
import { usePipelineSources } from "./orchestrator/usePipelineSources";
import {
getEnabledSources,
getJobCounts,
getSourcesWithJobs,
} from "./orchestrator/utils";
export const OrchestratorPage: React.FC = () => {
const { tab, jobId } = useParams<{ tab: string; jobId?: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = useMemo(() => {
const validTabs: FilterTab[] = ["ready", "discovered", "applied", "all"];
if (tab && validTabs.includes(tab as FilterTab)) {
return tab as FilterTab;
}
return "ready";
}, [tab]);
// Helper to change URL while preserving search params
const navigateWithContext = useCallback(
(newTab: string, newJobId?: string | null, isReplace = false) => {
const search = searchParams.toString();
const suffix = search ? `?${search}` : "";
const path = newJobId
? `/${newTab}/${newJobId}${suffix}`
: `/${newTab}${suffix}`;
navigate(path, { replace: isReplace });
},
[navigate, searchParams],
);
const selectedJobId = jobId || null;
// Sync searchQuery with URL
const searchQuery = searchParams.get("q") || "";
const setSearchQuery = useCallback(
(q: string) => {
setSearchParams(
(prev) => {
if (q) prev.set("q", q);
else prev.delete("q");
return prev;
},
{ replace: true },
);
},
[setSearchParams],
);
// Sync sourceFilter with URL
const sourceFilter =
(searchParams.get("source") as JobSource | "all") || "all";
const setSourceFilter = useCallback(
(source: JobSource | "all") => {
setSearchParams(
(prev) => {
if (source !== "all") prev.set("source", source);
else prev.delete("source");
return prev;
},
{ replace: true },
);
},
[setSearchParams],
);
// Sync sort with URL
const sort = useMemo((): JobSort => {
const s = searchParams.get("sort");
if (!s) return DEFAULT_SORT;
const [key, direction] = s.split("-");
return {
key: key as JobSort["key"],
direction: direction as JobSort["direction"],
};
}, [searchParams]);
const setSort = useCallback(
(newSort: JobSort) => {
setSearchParams(
(prev) => {
if (
newSort.key === DEFAULT_SORT.key &&
newSort.direction === DEFAULT_SORT.direction
) {
prev.delete("sort");
} else {
prev.set("sort", `${newSort.key}-${newSort.direction}`);
}
return prev;
},
{ replace: true },
);
},
[setSearchParams],
);
// Effect to sync URL if it was invalid
useEffect(() => {
const validTabs: FilterTab[] = ["ready", "discovered", "applied", "all"];
if (tab && !validTabs.includes(tab as FilterTab)) {
navigateWithContext("ready", null, true);
}
}, [tab, navigateWithContext]);
const [navOpen, setNavOpen] = useState(false);
const [isManualImportOpen, setIsManualImportOpen] = useState(false);
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
const [isDesktop, setIsDesktop] = useState(() =>
typeof window !== "undefined"
? window.matchMedia("(min-width: 1024px)").matches
: false,
);
const setActiveTab = useCallback(
(newTab: FilterTab) => {
navigateWithContext(newTab, selectedJobId);
},
[navigateWithContext, selectedJobId],
);
const handleSelectJobId = useCallback(
(id: string | null) => {
navigateWithContext(activeTab, id);
},
[navigateWithContext, activeTab],
);
const { settings } = useSettings();
const {
jobs,
stats,
isLoading,
isPipelineRunning,
setIsPipelineRunning,
loadJobs,
} = useOrchestratorData();
const enabledSources = useMemo(
() => getEnabledSources(settings ?? null),
[settings],
);
const { pipelineSources, setPipelineSources, toggleSource } =
usePipelineSources(enabledSources);
const activeJobs = useFilteredJobs(
jobs,
activeTab,
sourceFilter,
searchQuery,
sort,
);
const counts = useMemo(() => getJobCounts(jobs), [jobs]);
const sourcesWithJobs = useMemo(() => getSourcesWithJobs(jobs), [jobs]);
const selectedJob = useMemo(
() =>
selectedJobId
? (jobs.find((job) => job.id === selectedJobId) ?? null)
: null,
[jobs, selectedJobId],
);
useEffect(() => {
if (isLoading || sourceFilter === "all") return;
if (!sourcesWithJobs.includes(sourceFilter)) {
setSourceFilter("all");
}
}, [isLoading, sourceFilter, setSourceFilter, sourcesWithJobs]);
const handleManualImported = useCallback(
async (importedJobId: string) => {
// Refresh jobs and navigate to the new job in discovered tab
await loadJobs();
navigateWithContext("discovered", importedJobId);
},
[loadJobs, navigateWithContext],
);
const handleRunPipeline = async () => {
try {
setIsPipelineRunning(true);
await api.runPipeline({ sources: pipelineSources });
toast.message("Pipeline started", {
description: `Sources: ${pipelineSources.join(", ")}. This may take a few minutes.`,
});
const pollInterval = setInterval(async () => {
try {
const status = await api.getPipelineStatus();
if (!status.isRunning) {
clearInterval(pollInterval);
setIsPipelineRunning(false);
await loadJobs();
toast.success("Pipeline completed");
}
} catch {
// Ignore errors
}
}, 5000);
} catch (error) {
setIsPipelineRunning(false);
const message =
error instanceof Error ? error.message : "Failed to start pipeline";
toast.error(message);
}
};
const handleSelectJob = (id: string) => {
handleSelectJobId(id);
if (!isDesktop) {
setIsDetailDrawerOpen(true);
}
};
useEffect(() => {
if (activeJobs.length === 0) {
if (selectedJobId) handleSelectJobId(null);
return;
}
if (!selectedJobId || !activeJobs.some((job) => job.id === selectedJobId)) {
// Auto-select first job ONLY on desktop
if (isDesktop) {
navigateWithContext(activeTab, activeJobs[0].id, true);
}
}
}, [
activeJobs,
selectedJobId,
isDesktop,
activeTab,
navigateWithContext,
handleSelectJobId,
]);
useEffect(() => {
if (!selectedJobId) {
setIsDetailDrawerOpen(false);
} else if (!isDesktop) {
setIsDetailDrawerOpen(true);
}
}, [selectedJobId, isDesktop]);
useEffect(() => {
if (typeof window === "undefined") return;
const media = window.matchMedia("(min-width: 1024px)");
const handleChange = () => setIsDesktop(media.matches);
handleChange();
if (media.addEventListener) {
media.addEventListener("change", handleChange);
return () => media.removeEventListener("change", handleChange);
}
media.addListener(handleChange);
return () => media.removeListener(handleChange);
}, []);
useEffect(() => {
if (isDesktop && isDetailDrawerOpen) {
setIsDetailDrawerOpen(false);
}
}, [isDesktop, isDetailDrawerOpen]);
const onDrawerOpenChange = (open: boolean) => {
setIsDetailDrawerOpen(open);
if (!open && !isDesktop) {
// Clear job ID from URL when closing drawer on mobile
handleSelectJobId(null);
}
};
return (
<>
<OrchestratorHeader
navOpen={navOpen}
onNavOpenChange={setNavOpen}
isPipelineRunning={isPipelineRunning}
pipelineSources={pipelineSources}
enabledSources={enabledSources}
onToggleSource={toggleSource}
onSetPipelineSources={setPipelineSources}
onRunPipeline={handleRunPipeline}
onOpenManualImport={() => setIsManualImportOpen(true)}
/>
<main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
<OrchestratorSummary
stats={stats}
isPipelineRunning={isPipelineRunning}
/>
{/* Main content: tabs/filters -> list/detail */}
<section className="space-y-4">
<OrchestratorFilters
activeTab={activeTab}
onTabChange={setActiveTab}
counts={counts}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
sourceFilter={sourceFilter}
onSourceFilterChange={setSourceFilter}
sourcesWithJobs={sourcesWithJobs}
sort={sort}
onSortChange={setSort}
/>
{/* List/Detail grid - directly under tabs, no extra section */}
<div className="grid gap-4 lg:grid-cols-[minmax(0,400px)_minmax(0,1fr)]">
{/* Primary region: Job list with highest visual weight */}
<JobListPanel
isLoading={isLoading}
jobs={jobs}
activeJobs={activeJobs}
selectedJobId={selectedJobId}
activeTab={activeTab}
searchQuery={searchQuery}
onSelectJob={handleSelectJob}
/>
{/* Inspector panel: visually subordinate to list */}
{isDesktop && (
<div className="min-w-0 rounded-lg border border-border/40 bg-muted/5 p-4 lg:sticky lg:top-24 lg:self-start lg:max-h-[calc(100vh-8rem)] lg:overflow-y-auto">
<JobDetailPanel
activeTab={activeTab}
activeJobs={activeJobs}
selectedJob={selectedJob}
onSelectJobId={handleSelectJobId}
onJobUpdated={loadJobs}
onSetActiveTab={setActiveTab}
/>
</div>
)}
</div>
</section>
</main>
<ManualImportSheet
open={isManualImportOpen}
onOpenChange={setIsManualImportOpen}
onImported={handleManualImported}
/>
{!isDesktop && (
<Drawer open={isDetailDrawerOpen} onOpenChange={onDrawerOpenChange}>
<DrawerContent className="max-h-[90vh]">
<div className="flex items-center justify-between px-4 pt-2">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Job details
</div>
<DrawerClose asChild>
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
Close
</Button>
</DrawerClose>
</div>
<div className="max-h-[calc(90vh-3.5rem)] overflow-y-auto px-4 pb-6 pt-3">
<JobDetailPanel
activeTab={activeTab}
activeJobs={activeJobs}
selectedJob={selectedJob}
onSelectJobId={handleSelectJobId}
onJobUpdated={loadJobs}
onSetActiveTab={setActiveTab}
/>
</div>
</DrawerContent>
</Drawer>
)}
</>
);
};