From 53af2fd1a25a48073a6018422fbec549f3f63891 Mon Sep 17 00:00:00 2001 From: Roman Mazuryk Date: Sun, 3 May 2026 08:58:07 +0200 Subject: [PATCH 1/8] refactor(job-os): compact source hub with tabbed workflow Replace vertical scroll layout with four-tab execution dashboard: Due Today (default), Sources, Saved Searches, Qualification Queue. Each tab has inline filters; KPI cards remain above for context. Removes duplicate Add Source button from Source Library card header. Closes #166 --- src/app/pages/job-os/JobOsSourcesPage.tsx | 1013 +++++++++++++-------- 1 file changed, 614 insertions(+), 399 deletions(-) diff --git a/src/app/pages/job-os/JobOsSourcesPage.tsx b/src/app/pages/job-os/JobOsSourcesPage.tsx index 651dc9e..fc3b558 100644 --- a/src/app/pages/job-os/JobOsSourcesPage.tsx +++ b/src/app/pages/job-os/JobOsSourcesPage.tsx @@ -7,12 +7,13 @@ import { JobOsLayout } from "../../components/job-os/JobOsLayout"; import { JobOsTransferControls } from "../../components/job-os/JobOsTransferControls"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"; +import { Card, CardContent, CardHeader } from "../../components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../../components/ui/dialog"; import { Input } from "../../components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "../../components/ui/sheet"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs"; import { Textarea } from "../../components/ui/textarea"; import { useJobOsContext } from "../../context/JobOsContext"; import { appPath } from "../../routing"; @@ -256,6 +257,19 @@ export default function JobOsSourcesPage() { const [sourceEditDraft, setSourceEditDraft] = useState(null); const [editingSavedSearch, setEditingSavedSearch] = useState(null); const [savedSearchEditDraft, setSavedSearchEditDraft] = useState(null); + const [activeTab, setActiveTab] = useState("due-today"); + const [showAllDue, setShowAllDue] = useState(false); + const [sourcesSearch, setSourcesSearch] = useState(""); + const [sourcesPriorityFilter, setSourcesPriorityFilter] = useState("all"); + const [sourcesCategoryFilter, setSourcesCategoryFilter] = useState("all"); + const [sourcesActiveFilter, setSourcesActiveFilter] = useState<"all" | "active" | "inactive">("all"); + const [searchesSearch, setSearchesSearch] = useState(""); + const [searchesPriorityFilter, setSearchesPriorityFilter] = useState("all"); + const [searchesSourceFilter, setSearchesSourceFilter] = useState("all"); + const [searchesActiveFilter, setSearchesActiveFilter] = useState<"all" | "active" | "inactive">("all"); + const [queueSearch, setQueueSearch] = useState(""); + const [queueFitFilter, setQueueFitFilter] = useState("all"); + const [queueStatusFilter, setQueueStatusFilter] = useState("all"); const companiesById = useMemo( () => new Map(companies.map((company) => [company.id, company])), @@ -314,9 +328,9 @@ export default function JobOsSourcesPage() { [roles] ); - const searchesDueToday = useMemo( - () => activeSavedSearches.filter((savedSearch) => isDue(savedSearch.lastCheckedAt, savedSearch.cadence)).length, - [activeSavedSearches] + const dueScanItems = useMemo( + () => scanItems.filter((entry) => isDue(entry.item.lastCheckedAt, entry.item.cadence)), + [scanItems] ); const filteredSavedSearchOptions = useMemo(() => { @@ -324,6 +338,49 @@ export default function JobOsSourcesPage() { return activeSavedSearches.filter((savedSearch) => savedSearch.sourceId === captureDraft.sourceId); }, [activeSavedSearches, captureDraft.sourceId]); + const filteredSources = useMemo(() => { + return sources.filter((source) => { + if ( + sourcesSearch && + !source.name.toLowerCase().includes(sourcesSearch.toLowerCase()) && + !source.bestFor.toLowerCase().includes(sourcesSearch.toLowerCase()) + ) return false; + if (sourcesPriorityFilter !== "all" && source.priority !== sourcesPriorityFilter) return false; + if (sourcesCategoryFilter !== "all" && source.category !== sourcesCategoryFilter) return false; + if (sourcesActiveFilter === "active" && !source.active) return false; + if (sourcesActiveFilter === "inactive" && source.active) return false; + return true; + }); + }, [sources, sourcesSearch, sourcesPriorityFilter, sourcesCategoryFilter, sourcesActiveFilter]); + + const filteredSavedSearches = useMemo(() => { + return savedSearches.filter((savedSearch) => { + if ( + searchesSearch && + !savedSearch.name.toLowerCase().includes(searchesSearch.toLowerCase()) && + !savedSearch.query.toLowerCase().includes(searchesSearch.toLowerCase()) + ) return false; + if (searchesPriorityFilter !== "all" && savedSearch.priority !== searchesPriorityFilter) return false; + if (searchesSourceFilter !== "all" && savedSearch.sourceId !== searchesSourceFilter) return false; + if (searchesActiveFilter === "active" && !savedSearch.active) return false; + if (searchesActiveFilter === "inactive" && savedSearch.active) return false; + return true; + }); + }, [savedSearches, searchesSearch, searchesPriorityFilter, searchesSourceFilter, searchesActiveFilter]); + + const filteredQueue = useMemo(() => { + return qualificationQueue.filter((role) => { + if (queueSearch) { + const company = companiesById.get(role.companyId); + const searchText = `${role.title} ${company?.name ?? ""}`.toLowerCase(); + if (!searchText.includes(queueSearch.toLowerCase())) return false; + } + if (queueFitFilter !== "all" && String(role.fitScore) !== queueFitFilter) return false; + if (queueStatusFilter !== "all" && role.discoveryStatus !== queueStatusFilter) return false; + return true; + }); + }, [qualificationQueue, queueSearch, queueFitFilter, queueStatusFilter, companiesById]); + function openUrl(url?: string): void { if (!url) return; window.open(url, "_blank", "noopener,noreferrer"); @@ -568,9 +625,9 @@ export default function JobOsSourcesPage() { > -
+
@@ -587,9 +644,9 @@ export default function JobOsSourcesPage() {
- Searches Due Today + Due Today
-
{searchesDueToday}
+
{dueScanItems.length}
@@ -618,415 +675,573 @@ export default function JobOsSourcesPage() {
-
- - - Today's Scan - - - {scanItems.length > 0 ? ( - scanItems.map((entry) => { - const isSource = entry.kind === "source"; - const sourceName = isSource - ? entry.item.name - : sourcesById.get(entry.item.sourceId)?.name ?? "Unknown source"; - - return ( -
-
-
-
-
{entry.item.name}
- - {entry.item.priority} - - - {isSource ? "Source" : "Saved Search"} - - {isDue(entry.item.lastCheckedAt, entry.item.cadence) ? ( - - Due - - ) : null} -
-
- {isSource ? (entry.item as JobSource).bestFor : sourceName} -
-
- Cadence: {CADENCE_LABELS[entry.item.cadence]} - Last checked: {formatDateLabel(entry.item.lastCheckedAt)} + + + + Due Today{dueScanItems.length > 0 ? ` (${dueScanItems.length})` : ""} + + + Sources ({activeSources.length}/{sources.length}) + + + Saved Searches ({activeSavedSearches.length}/{savedSearches.length}) + + + Queue{qualificationQueue.length > 0 ? ` (${qualificationQueue.length})` : ""} + + + + + + + {scanItems.length === 0 ? ( +
+ Source Hub is ready. Your default source stack should appear here as soon as sync finishes. +
+ ) : dueScanItems.length === 0 && !showAllDue ? ( +
+
+ Nothing is due right now. Check back when cadences reset. +
+
+ +
+
+ ) : ( + <> + {(showAllDue ? scanItems : dueScanItems.slice(0, 10)).map((entry) => { + const isSource = entry.kind === "source"; + const sourceName = isSource + ? entry.item.name + : sourcesById.get(entry.item.sourceId)?.name ?? "Unknown source"; + + return ( +
+
+
+
+
{entry.item.name}
+ + {entry.item.priority} + + + {isSource ? "Source" : "Saved Search"} + + {isDue(entry.item.lastCheckedAt, entry.item.cadence) ? ( + + Due + + ) : null} +
+
+ {isSource ? (entry.item as JobSource).bestFor : sourceName} +
+
+ Cadence: {CADENCE_LABELS[entry.item.cadence]} + Last checked: {formatDateLabel(entry.item.lastCheckedAt)} +
+
+
+ + + +
-
- - - + ); + })} +
+ + {showAllDue + ? `Showing all ${scanItems.length} scan items` + : `${dueScanItems.length} item${dueScanItems.length !== 1 ? "s" : ""} due`} + + +
+ + )} + + + + + + + +
+
+ + setSourcesSearch(e.target.value)} + /> +
+ + + +
+
+ +
+ + + + Source + Category + Best for + Priority + Cadence + Last checked + Actions + + + + {filteredSources.map((source) => ( + + {source.name} + {SOURCE_CATEGORY_LABELS[source.category]} + {source.bestFor} + + + {source.priority} + + + {CADENCE_LABELS[source.cadence]} + {formatDateLabel(source.lastCheckedAt)} + +
+ + + + +
+
+
+ ))} + {filteredSources.length === 0 && ( + + + No sources match the current filters. + + + )} +
+
+
+
+ {filteredSources.map((source) => ( +
+
+
+
{source.name}
+
{source.bestFor}
+ {source.priority} +
+
+ {SOURCE_CATEGORY_LABELS[source.category]} + {CADENCE_LABELS[source.cadence]} + {formatDateLabel(source.lastCheckedAt)} +
+
+ + + +
- ); - }) - ) : ( -
- Source Hub is ready. Your default source stack should appear here as soon as sync finishes. + ))} + {filteredSources.length === 0 && ( +
+ No sources match the current filters. +
+ )}
- )} - - - - - - Qualification Queue - - - {qualificationQueue.length > 0 ? ( - qualificationQueue.map((role) => { - const company = companiesById.get(role.companyId); - const sourceName = - (role.savedSearchId - ? sourcesById.get(savedSearchesById.get(role.savedSearchId)?.sourceId ?? "") - : undefined)?.name ?? - (role.sourceId ? sourcesById.get(role.sourceId)?.name : undefined) ?? - "Manual capture"; - - return ( -
= 4 - ? "border-foreground/20 bg-neutral-100/70 dark:bg-neutral-900/60" - : "border-border bg-background/80" - }`} - > -
-
-
-
{role.title}
-
- {company?.name ?? "Unknown company"} · {sourceName} -
-
-
- Fit {role.fitScore}/5 - - {DISCOVERY_STATUS_LABELS[role.discoveryStatus ?? "discovered"]} + + + + + + + +
+
+ + setSearchesSearch(e.target.value)} + /> +
+ + + +
+
+ +
+ + + + Search name + Source + Target track + Priority + Cadence + Last checked + Actions + + + + {filteredSavedSearches.map((savedSearch) => ( + + {savedSearch.name} + {sourcesById.get(savedSearch.sourceId)?.name ?? "Unknown source"} + {savedSearch.targetTrack} + + + {savedSearch.priority} - {role.track} - - -
-
- Notes:{" "} - {role.qualificationNotes || "No qualification notes yet."} -
-
- Next step:{" "} - {role.nextStep || "Clarify next move."} + + {CADENCE_LABELS[savedSearch.cadence]} + {formatDateLabel(savedSearch.lastCheckedAt)} + +
+ + + + + +
+
+ + ))} + {filteredSavedSearches.length === 0 && ( + + + No saved searches match the current filters. + + + )} + +
+
+
+ {filteredSavedSearches.map((savedSearch) => ( +
+
+
+
{savedSearch.name}
+
+ {sourcesById.get(savedSearch.sourceId)?.name ?? "Unknown source"} · {savedSearch.targetTrack}
-
- - - - - -
+ {savedSearch.priority} +
+
+ {CADENCE_LABELS[savedSearch.cadence]} + {formatDateLabel(savedSearch.lastCheckedAt)} +
+
+ + + + +
- ); - }) - ) : ( -
- Captured roles that are still worth qualifying will show up here. -
- )} - - -
- - - - Source Library - - - -
- - - - Source - Category - Best for - Priority - Cadence - Last checked - Actions - - - - {sources.map((source) => ( - - {source.name} - {SOURCE_CATEGORY_LABELS[source.category]} - {source.bestFor} - - - {source.priority} - - - {CADENCE_LABELS[source.cadence]} - {formatDateLabel(source.lastCheckedAt)} - -
- - - - -
-
-
))} -
-
-
-
- {sources.map((source) => ( -
-
-
-
{source.name}
-
{source.bestFor}
+ {filteredSavedSearches.length === 0 && ( +
+ No saved searches match the current filters.
- - {source.priority} - -
-
- {SOURCE_CATEGORY_LABELS[source.category]} - {CADENCE_LABELS[source.cadence]} - {formatDateLabel(source.lastCheckedAt)} -
-
- - - - + )} +
+ + + + + + + +
+
+ + setQueueSearch(e.target.value)} + />
+ +
- ))} -
- - - - - - Saved Searches - - -
- - - - Search name - Source - Target track - Priority - Cadence - Last checked - Actions - - - - {savedSearches.map((savedSearch) => ( - - {savedSearch.name} - - {sourcesById.get(savedSearch.sourceId)?.name ?? "Unknown source"} - - {savedSearch.targetTrack} - - - {savedSearch.priority} - - - {CADENCE_LABELS[savedSearch.cadence]} - {formatDateLabel(savedSearch.lastCheckedAt)} - -
- - - - - + + + {filteredQueue.length > 0 ? ( + filteredQueue.map((role) => { + const company = companiesById.get(role.companyId); + const sourceName = + (role.savedSearchId + ? sourcesById.get(savedSearchesById.get(role.savedSearchId)?.sourceId ?? "") + : undefined)?.name ?? + (role.sourceId ? sourcesById.get(role.sourceId)?.name : undefined) ?? + "Manual capture"; + + return ( +
= 4 + ? "border-foreground/20 bg-neutral-100/70 dark:bg-neutral-900/60" + : "border-border bg-background/80" + }`} + > +
+
+
+
{role.title}
+
+ {company?.name ?? "Unknown company"} · {sourceName} +
+
+
+ Fit {role.fitScore}/5 + + {DISCOVERY_STATUS_LABELS[role.discoveryStatus ?? "discovered"]} + + {role.track} +
+
+
+
+ Notes:{" "} + {role.qualificationNotes || "No qualification notes yet."} +
+
+ Next step:{" "} + {role.nextStep || "Clarify next move."} +
+
+
+ + + + + +
- - - ))} - -
-
-
- {savedSearches.map((savedSearch) => ( -
-
-
-
{savedSearch.name}
-
- {sourcesById.get(savedSearch.sourceId)?.name ?? "Unknown source"} · {savedSearch.targetTrack}
-
- - {savedSearch.priority} - + ); + }) + ) : ( +
+ {qualificationQueue.length === 0 + ? "Captured roles that are still worth qualifying will show up here." + : "No roles match the current filters."}
-
- {CADENCE_LABELS[savedSearch.cadence]} - {formatDateLabel(savedSearch.lastCheckedAt)} -
-
- - - - - -
-
- ))} -
- - + )} + + + +
From 8c04d3d6e5dd7b94572c29c0390f6c4fc08a729f Mon Sep 17 00:00:00 2001 From: Roman Mazuryk Date: Sun, 3 May 2026 11:06:18 +0200 Subject: [PATCH 2/8] refactor(job-os): restore Command Center nav, add tab hint and Due Today filters - JobOsLayout: add Command Center to action section secondary nav (synced with Sources, Pipeline, System sections) - Source Hub: wrap TabsList with hint tooltip explaining each tab's purpose - Source Hub: add text search, priority, and type filters to Due Today tab (matches filter pattern from Sources/Saved Searches/Queue tabs) Closes #166 --- src/app/components/job-os/JobOsLayout.tsx | 2 + src/app/pages/job-os/JobOsSourcesPage.tsx | 148 ++++++++++++++++------ 2 files changed, 114 insertions(+), 36 deletions(-) diff --git a/src/app/components/job-os/JobOsLayout.tsx b/src/app/components/job-os/JobOsLayout.tsx index f593d83..49ac505 100644 --- a/src/app/components/job-os/JobOsLayout.tsx +++ b/src/app/components/job-os/JobOsLayout.tsx @@ -6,6 +6,7 @@ import { FileSpreadsheet, FileText, FolderOpen, + LayoutDashboard, Megaphone, Settings, ShieldCheck, @@ -35,6 +36,7 @@ function getActiveSection(pathname: string): "action" | "pipeline" | "system" { const SECTION_NAV = { action: [ + { to: appPath(), label: "Command Center", icon: LayoutDashboard }, { to: appPath("/job-os/sources"), label: "Sources", icon: Compass }, ], pipeline: [ diff --git a/src/app/pages/job-os/JobOsSourcesPage.tsx b/src/app/pages/job-os/JobOsSourcesPage.tsx index fc3b558..8503317 100644 --- a/src/app/pages/job-os/JobOsSourcesPage.tsx +++ b/src/app/pages/job-os/JobOsSourcesPage.tsx @@ -1,6 +1,6 @@ import { Link } from "react-router"; import { useMemo, useState } from "react"; -import { Archive, CheckCheck, Compass, ExternalLink, Globe2, ListChecks, Plus, Search } from "lucide-react"; +import { Archive, CheckCheck, Compass, ExternalLink, Globe2, HelpCircle, ListChecks, Plus, Search } from "lucide-react"; import { toast } from "sonner"; import { AppPageShell } from "../../components/layout/AppPageShell"; import { JobOsLayout } from "../../components/job-os/JobOsLayout"; @@ -14,6 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ". import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "../../components/ui/sheet"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../../components/ui/tooltip"; import { Textarea } from "../../components/ui/textarea"; import { useJobOsContext } from "../../context/JobOsContext"; import { appPath } from "../../routing"; @@ -270,6 +271,9 @@ export default function JobOsSourcesPage() { const [queueSearch, setQueueSearch] = useState(""); const [queueFitFilter, setQueueFitFilter] = useState("all"); const [queueStatusFilter, setQueueStatusFilter] = useState("all"); + const [dueSearch, setDueSearch] = useState(""); + const [duePriorityFilter, setDuePriorityFilter] = useState("all"); + const [dueTypeFilter, setDueTypeFilter] = useState<"all" | "source" | "savedSearch">("all"); const companiesById = useMemo( () => new Map(companies.map((company) => [company.id, company])), @@ -333,6 +337,16 @@ export default function JobOsSourcesPage() { [scanItems] ); + const filteredDueItems = useMemo(() => { + const base = showAllDue ? scanItems : dueScanItems; + return base.filter((entry) => { + if (dueSearch && !entry.item.name.toLowerCase().includes(dueSearch.toLowerCase())) return false; + if (duePriorityFilter !== "all" && entry.item.priority !== duePriorityFilter) return false; + if (dueTypeFilter !== "all" && entry.kind !== dueTypeFilter) return false; + return true; + }); + }, [showAllDue, scanItems, dueScanItems, dueSearch, duePriorityFilter, dueTypeFilter]); + const filteredSavedSearchOptions = useMemo(() => { if (!captureDraft.sourceId) return activeSavedSearches; return activeSavedSearches.filter((savedSearch) => savedSearch.sourceId === captureDraft.sourceId); @@ -676,42 +690,106 @@ export default function JobOsSourcesPage() { - - - Due Today{dueScanItems.length > 0 ? ` (${dueScanItems.length})` : ""} - - - Sources ({activeSources.length}/{sources.length}) - - - Saved Searches ({activeSavedSearches.length}/{savedSearches.length}) - - - Queue{qualificationQueue.length > 0 ? ` (${qualificationQueue.length})` : ""} - - +
+ + + Due Today{dueScanItems.length > 0 ? ` (${dueScanItems.length})` : ""} + + + Sources ({activeSources.length}/{sources.length}) + + + Saved Searches ({activeSavedSearches.length}/{savedSearches.length}) + + + Queue{qualificationQueue.length > 0 ? ` (${qualificationQueue.length})` : ""} + + + + + + + +
+
Due Today — sources and saved searches overdue for their scan cadence, sorted by priority. Filter by type or priority.
+
Sources — your full discovery stack. Add, edit, enable or disable sources.
+
Saved Searches — keyword searches tied to specific sources. Filter by source or track.
+
Queue — roles captured from sources awaiting qualification before applying.
+
+
+
+
- + +
+
+ + setDueSearch(e.target.value)} + /> +
+ + + +
+
+ {scanItems.length === 0 ? (
Source Hub is ready. Your default source stack should appear here as soon as sync finishes.
- ) : dueScanItems.length === 0 && !showAllDue ? ( -
-
- Nothing is due right now. Check back when cadences reset. -
-
- -
+ ) : filteredDueItems.length === 0 ? ( +
+ {dueScanItems.length === 0 && !showAllDue + ? "Nothing is due right now." + : "No items match the current filters."}
) : ( <> - {(showAllDue ? scanItems : dueScanItems.slice(0, 10)).map((entry) => { + {filteredDueItems.map((entry) => { const isSource = entry.kind === "source"; const sourceName = isSource ? entry.item.name @@ -785,15 +863,13 @@ export default function JobOsSourcesPage() {
); })} -
- - {showAllDue - ? `Showing all ${scanItems.length} scan items` - : `${dueScanItems.length} item${dueScanItems.length !== 1 ? "s" : ""} due`} - - +
+ {showAllDue + ? `All ${scanItems.length} scan items` + : `${dueScanItems.length} item${dueScanItems.length !== 1 ? "s" : ""} due`} + {filteredDueItems.length !== (showAllDue ? scanItems : dueScanItems).length + ? ` · ${filteredDueItems.length} shown after filtering` + : ""}
)} From acd80f2cc38dc372d6a9bb4bd5a79b6170360a85 Mon Sep 17 00:00:00 2001 From: Roman Mazuryk Date: Sun, 3 May 2026 11:14:31 +0200 Subject: [PATCH 3/8] fix(dashboard): add Sources link to Command Center secondary nav DashboardPage uses AppNavbar directly (no JobOsLayout), so it needs its own secondary nav bar. Adds Command Center + Sources pill nav below the header, matching the action-section nav in JobOsLayout. Closes #166 --- src/app/pages/dashboard/DashboardPage.tsx | 30 +++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/app/pages/dashboard/DashboardPage.tsx b/src/app/pages/dashboard/DashboardPage.tsx index fef1450..91b73c7 100644 --- a/src/app/pages/dashboard/DashboardPage.tsx +++ b/src/app/pages/dashboard/DashboardPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; -import { useNavigate } from "react-router"; -import { ChevronDown, ChevronUp, Layers3 } from "lucide-react"; +import { NavLink, useNavigate } from "react-router"; +import { ChevronDown, ChevronUp, Compass, LayoutDashboard, Layers3 } from "lucide-react"; import { useApp } from "../../context"; import { useJobOs } from "../../hooks/useJobOs"; import { UnifiedAddModal } from "../../components/job-os/UnifiedAddModal"; @@ -116,6 +116,32 @@ export function DashboardPage() { showSync settingsContent={settingsContent} /> +
+
+ +
+
{holdDashboard ? ( From 5470b16cc32231e7e05764f4686c9715c8e3934e Mon Sep 17 00:00:00 2001 From: Roman Mazuryk Date: Sun, 3 May 2026 19:46:50 +0200 Subject: [PATCH 4/8] refactor(ux): simplify job sprint interface with minimalistic workflow-first design Source Hub: replace 4-card KPI grid with compact inline stat row; establish clear primary/secondary/tertiary action hierarchy across all tabs (Capture role = primary, Mark checked = outline, Open = ghost icon+tooltip); Queue reorders Move to apply queue to first slot. Roles: remove 7-field in-table filter row; replace with search + status bar above the table and a collapsible More filters panel (location, seniority, track, fit score). Search is unified across company and title. Closes #166 --- src/app/pages/job-os/JobOsRolesPage.tsx | 204 +++++++++++----------- src/app/pages/job-os/JobOsSourcesPage.tsx | 188 ++++++++++---------- 2 files changed, 194 insertions(+), 198 deletions(-) diff --git a/src/app/pages/job-os/JobOsRolesPage.tsx b/src/app/pages/job-os/JobOsRolesPage.tsx index cf43b54..2abd2da 100644 --- a/src/app/pages/job-os/JobOsRolesPage.tsx +++ b/src/app/pages/job-os/JobOsRolesPage.tsx @@ -24,6 +24,8 @@ import { ChevronRight, Pencil, Rocket, + Search, + SlidersHorizontal, Trash2, WandSparkles, X, @@ -114,6 +116,8 @@ export default function JobOsRolesPage() { fitMin: "all", status: "all", }); + const [unifiedSearch, setUnifiedSearch] = useState(""); + const [showMoreFilters, setShowMoreFilters] = useState(false); const [selectedJdRoleId, setSelectedJdRoleId] = useState(null); const [draft, setDraft] = useState>({ companyId: "", @@ -144,15 +148,16 @@ export default function JobOsRolesPage() { }, [companies, companySearch]); const filtered = useMemo(() => { - const companyQuery = tableFilters.company.trim().toLowerCase(); - const titleQuery = tableFilters.title.trim().toLowerCase(); + const searchQuery = unifiedSearch.trim().toLowerCase(); const locationQuery = tableFilters.location.trim().toLowerCase(); return roles.filter((role) => { const companyName = companiesById.get(role.companyId)?.name ?? ""; - if (companyQuery && !companyName.toLowerCase().includes(companyQuery)) return false; - if (titleQuery && !role.title.toLowerCase().includes(titleQuery)) return false; + if (searchQuery) { + const haystack = `${companyName} ${role.title}`.toLowerCase(); + if (!haystack.includes(searchQuery)) return false; + } if (locationQuery && !role.location.toLowerCase().includes(locationQuery)) return false; if ( tableFilters.seniority !== "all" && @@ -168,7 +173,7 @@ export default function JobOsRolesPage() { return true; }); - }, [companiesById, roles, tableFilters]); + }, [companiesById, roles, tableFilters, unifiedSearch]); const sortedRoles = useMemo(() => { const statusRank: Record = { @@ -235,7 +240,7 @@ export default function JobOsRolesPage() { useEffect(() => { resetRolesPage(); - }, [resetRolesPage, sortDir, sortKey, tableFilters]); + }, [resetRolesPage, sortDir, sortKey, tableFilters, unifiedSearch]); const applicationRoleIds = useMemo( () => new Set(applications.map((application) => application.roleId).filter(Boolean)), @@ -552,6 +557,94 @@ export default function JobOsRolesPage() { ) : null} +
+
+ + setUnifiedSearch(e.target.value)} + /> +
+ + + {(unifiedSearch || tableFilters.status !== "all" || tableFilters.location || tableFilters.seniority !== "all" || tableFilters.track !== "all" || tableFilters.fitMin !== "all") && ( + + )} +
+ {showMoreFilters && ( +
+ updateTableFilter("location", event.target.value)} + placeholder="Location" + className="h-9 w-[150px] text-sm" + /> + + + +
+ )} + Roles Pipeline @@ -583,105 +676,6 @@ export default function JobOsRolesPage() { JD Actions - - - updateTableFilter("company", event.target.value)} - placeholder="Filter company" - className="h-8 min-w-[140px]" - /> - - - updateTableFilter("title", event.target.value)} - placeholder="Filter title" - className="h-8 min-w-[160px]" - /> - - - updateTableFilter("location", event.target.value)} - placeholder="Filter location" - className="h-8 min-w-[130px]" - /> - - - - - - - - - - - - - - - - - {pagedRoles.map((role) => ( diff --git a/src/app/pages/job-os/JobOsSourcesPage.tsx b/src/app/pages/job-os/JobOsSourcesPage.tsx index 8503317..a0d9d12 100644 --- a/src/app/pages/job-os/JobOsSourcesPage.tsx +++ b/src/app/pages/job-os/JobOsSourcesPage.tsx @@ -1,6 +1,6 @@ import { Link } from "react-router"; import { useMemo, useState } from "react"; -import { Archive, CheckCheck, Compass, ExternalLink, Globe2, HelpCircle, ListChecks, Plus, Search } from "lucide-react"; +import { Archive, CheckCheck, ExternalLink, HelpCircle, Plus, Search, WandSparkles } from "lucide-react"; import { toast } from "sonner"; import { AppPageShell } from "../../components/layout/AppPageShell"; import { JobOsLayout } from "../../components/job-os/JobOsLayout"; @@ -642,52 +642,23 @@ export default function JobOsSourcesPage() { subtitle="Work through today's due scans, then capture only the roles worth qualifying." >
-
- - -
-
- Active Sources -
-
{activeSources.length}
-
- -
-
- - -
-
- Due Today -
-
{dueScanItems.length}
-
- -
-
- - -
-
- Captured Roles -
-
{capturedRolesCount}
-
- -
-
- - -
-
- Qualified Queue -
-
{qualificationQueue.length}
-
- -
-
-
+
+ + {activeSources.length} active sources + + · + + {dueScanItems.length} due today + + · + + {capturedRolesCount} captured roles + + · + + {qualificationQueue.length} in queue + +
@@ -824,23 +795,7 @@ export default function JobOsSourcesPage() { Last checked: {formatDateLabel(entry.item.lastCheckedAt)}
-
- - +
+ + + + + + Open in browser +
@@ -961,13 +936,20 @@ export default function JobOsSourcesPage() { {CADENCE_LABELS[source.cadence]} {formatDateLabel(source.lastCheckedAt)} -
- - +
+ + + + + + Open source +
@@ -998,12 +980,15 @@ export default function JobOsSourcesPage() { {formatDateLabel(source.lastCheckedAt)}
- - + +
))} @@ -1098,14 +1083,21 @@ export default function JobOsSourcesPage() { {CADENCE_LABELS[savedSearch.cadence]} {formatDateLabel(savedSearch.lastCheckedAt)} -
- - +
+ + + + + + Open search +
@@ -1137,13 +1129,16 @@ export default function JobOsSourcesPage() { {formatDateLabel(savedSearch.lastCheckedAt)}
- - + +
))} @@ -1250,31 +1245,31 @@ export default function JobOsSourcesPage() { {role.nextStep || "Clarify next move."}
-
+
- - + + + + + Open role URL + + + + + + Open CV Optimizer +
From bad557c09efc487d8c13038e1a87fbe61132f545 Mon Sep 17 00:00:00 2001 From: Roman Mazuryk Date: Sun, 3 May 2026 21:19:22 +0200 Subject: [PATCH 5/8] =?UTF-8?q?refactor(ux):=20remove=20Quick=20Actions=20?= =?UTF-8?q?panel=20and=20Add=20Role=20form=20=E2=80=94=20Sources=20is=20th?= =?UTF-8?q?e=20single=20entry=20point?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard: remove QuickActions widget and UnifiedAddModal. Roles are now captured exclusively via the Source Hub Capture Role flow, so the redundant shortcuts only created confusion about where data originates. Roles page: remove the collapsible Add Role card form and all associated state (draft, companySearch, addFormOpen, handleAddRole, etc.). The page now starts directly at the filter bar and table. Closes #166 --- src/app/pages/dashboard/DashboardPage.tsx | 14 +- src/app/pages/job-os/JobOsRolesPage.tsx | 225 ---------------------- 2 files changed, 2 insertions(+), 237 deletions(-) diff --git a/src/app/pages/dashboard/DashboardPage.tsx b/src/app/pages/dashboard/DashboardPage.tsx index 91b73c7..28cd595 100644 --- a/src/app/pages/dashboard/DashboardPage.tsx +++ b/src/app/pages/dashboard/DashboardPage.tsx @@ -3,7 +3,7 @@ import { NavLink, useNavigate } from "react-router"; import { ChevronDown, ChevronUp, Compass, LayoutDashboard, Layers3 } from "lucide-react"; import { useApp } from "../../context"; import { useJobOs } from "../../hooks/useJobOs"; -import { UnifiedAddModal } from "../../components/job-os/UnifiedAddModal"; + import { useJobOsSyncSnapshot } from "../../services/jobOsSync"; import { getNextActions, @@ -20,7 +20,7 @@ import { TodayPanel } from "../../components/dashboard/TodayPanel"; import { HotOpportunities } from "../../components/dashboard/HotOpportunities"; import { PipelineStats } from "../../components/dashboard/PipelineStats"; import { ProbabilityPanel } from "../../components/dashboard/ProbabilityPanel"; -import { QuickActions } from "../../components/dashboard/QuickActions"; + import { FirstRunScreen } from "../../components/dashboard/FirstRunScreen"; import { AppPageShell } from "../../components/layout/AppPageShell"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../components/ui/collapsible"; @@ -32,7 +32,6 @@ export function DashboardPage() { const navigate = useNavigate(); const jobOsSync = useJobOsSyncSnapshot(); const [supportOpen, setSupportOpen] = useState(false); - const [addModalOpen, setAddModalOpen] = useState(false); const { companies, roles, @@ -182,7 +181,6 @@ export function DashboardPage() {
- setAddModalOpen(true)} /> @@ -223,14 +221,6 @@ export function DashboardPage() { - setAddModalOpen(false)} - companies={companies} - addCompany={addCompany} - addRole={addRole} - addApplication={addApplication} - />
); } diff --git a/src/app/pages/job-os/JobOsRolesPage.tsx b/src/app/pages/job-os/JobOsRolesPage.tsx index 2abd2da..662bfff 100644 --- a/src/app/pages/job-os/JobOsRolesPage.tsx +++ b/src/app/pages/job-os/JobOsRolesPage.tsx @@ -1,6 +1,5 @@ import { Link } from "react-router"; import { useEffect, useMemo, useState } from "react"; -import { useUnsavedChanges } from "../../hooks/useUnsavedChanges"; import { toast } from "sonner"; import { AlertDialog, @@ -18,10 +17,7 @@ import { ArrowUp, ArrowUpDown, BriefcaseBusiness, - Check, CheckCircle2, - ChevronDown, - ChevronRight, Pencil, Rocket, Search, @@ -89,7 +85,6 @@ export default function JobOsRolesPage() { applications, assets, cvProfiles, - addRole, updateRole, addApplication, removeRole, @@ -98,12 +93,8 @@ export default function JobOsRolesPage() { replaceState, } = useJobOsContext(); - const [addFormOpen, setAddFormOpen] = useState(false); - const [companySearch, setCompanySearch] = useState(""); - const [companyOptionsOpen, setCompanyOptionsOpen] = useState(false); const [editingRoleId, setEditingRoleId] = useState(null); const [editDraft, setEditDraft] = useState | null>(null); - const [formNotice, setFormNotice] = useState(null); const [actionNotice, setActionNotice] = useState<{ tone: "success" | "error"; message: string } | null>(null); const [sortKey, setSortKey] = useState("createdAt"); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); @@ -119,33 +110,11 @@ export default function JobOsRolesPage() { const [unifiedSearch, setUnifiedSearch] = useState(""); const [showMoreFilters, setShowMoreFilters] = useState(false); const [selectedJdRoleId, setSelectedJdRoleId] = useState(null); - const [draft, setDraft] = useState>({ - companyId: "", - title: "", - url: "", - location: "", - seniority: "", - track: "TPM", - fitScore: 3, - status: "to_apply", - origin: "self_sourced", - jobDescription: "", - jobDescriptionUpdatedAt: undefined, - }); const companiesById = useMemo( () => new Map(companies.map((company) => [company.id, company])), [companies] ); - const selectedDraftCompany = draft.companyId ? companiesById.get(draft.companyId) : null; - const filteredCompanyOptions = useMemo(() => { - const query = companySearch.trim().toLowerCase(); - const matches = query - ? companies.filter((company) => company.name.toLowerCase().includes(query)) - : companies; - - return matches.slice(0, 50); - }, [companies, companySearch]); const filtered = useMemo(() => { const searchQuery = unifiedSearch.trim().toLowerCase(); @@ -316,51 +285,6 @@ export default function JobOsRolesPage() { toast.success("Role updated"); } - async function handleAddRole(): Promise { - const missingFields: string[] = []; - if (!draft.companyId) missingFields.push("company"); - if (!draft.title.trim()) missingFields.push("role title"); - - if (missingFields.length > 0) { - setFormNotice(`Add a ${missingFields.join(" and ")} before creating the role.`); - return; - } - - try { - setFormNotice("Saving role..."); - const createdId = await addRole({ - ...draft, - title: draft.title.trim(), - seniority: normalizeSeniority(draft.seniority), - jobDescriptionUpdatedAt: draft.jobDescription?.trim() ? new Date().toISOString() : undefined, - }); - - if (!createdId) { - setFormNotice("Role could not be created."); - return; - } - - setDraft({ - companyId: "", - title: "", - url: "", - location: "", - seniority: "", - track: "TPM", - fitScore: 3, - status: "to_apply", - origin: "self_sourced", - jobDescription: "", - jobDescriptionUpdatedAt: undefined, - }); - setCompanySearch(""); - setFormNotice(""); - toast.success("Role added"); - } catch (error) { - setFormNotice(error instanceof Error ? error.message : "Role could not be created."); - } - } - async function handleAddApplication(role: JobOsRole): Promise { if (applicationRoleIds.has(role.id)) { setActionNotice({ @@ -401,21 +325,6 @@ export default function JobOsRolesPage() { } } - const isAddFormDirty = - addFormOpen && - (draft.companyId !== "" || - draft.title.trim() !== "" || - (draft.url ?? "").trim() !== "" || - (draft.location ?? "").trim() !== "" || - (draft.jobDescription ?? "").trim() !== ""); - const { confirmDiscard: confirmAddFormDiscard } = useUnsavedChanges(isAddFormDirty); - - function handleToggleAddForm() { - if (addFormOpen && !confirmAddFormDiscard()) return; - setAddFormOpen((value) => !value); - setCompanyOptionsOpen(false); - } - return ( } > - - - - - {addFormOpen ? ( - -
- { - setCompanySearch(event.target.value); - setCompanyOptionsOpen(true); - setDraft((current) => ({ ...current, companyId: "" })); - }} - onFocus={() => { - setCompanySearch(selectedDraftCompany?.name ?? companySearch); - setCompanyOptionsOpen(true); - }} - onBlur={() => { - window.setTimeout(() => setCompanyOptionsOpen(false), 120); - }} - placeholder="Company" - autoComplete="off" - /> - {companyOptionsOpen ? ( -
- {filteredCompanyOptions.length > 0 ? ( - filteredCompanyOptions.map((company) => ( - - )) - ) : ( -
- No company found. -
- )} -
- ) : null} -
- setDraft((current) => ({ ...current, title: event.target.value }))} placeholder="Role title" /> - - setDraft((current) => ({ ...current, url: event.target.value }))} - placeholder={draft.origin === "recruiter" ? "Posting URL (optional)" : "Role URL"} - /> - setDraft((current) => ({ ...current, location: event.target.value }))} - placeholder="Location" - /> - - {LOCATION_SUGGESTIONS.map((option) => ( - - - - - -
- Company and role title are required before adding a role. -
- {formNotice ? ( -
- {formNotice} -
- ) : null} -
-