diff --git a/orchestrator/src/client/App.tsx b/orchestrator/src/client/App.tsx index 54fc597..8fb2815 100644 --- a/orchestrator/src/client/App.tsx +++ b/orchestrator/src/client/App.tsx @@ -1,189 +1,21 @@ -/** +/** * Main App component. */ -import React, { useCallback, useEffect, useState } from "react"; -import { toast } from "sonner"; +import React from "react"; import { Route, Routes } from "react-router-dom"; import { Toaster } from "@/components/ui/sonner"; -import type { Job, JobSource, JobStatus } from "../shared/types"; -import { Header, JobList, PipelineProgress, Stats } from "./components"; -import * as api from "./api"; +import { OrchestratorPage } from "./pages/OrchestratorPage"; import { SettingsPage } from "./pages/SettingsPage"; -const DEFAULT_PIPELINE_SOURCES: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"]; -const PIPELINE_SOURCES_STORAGE_KEY = "jobops.pipeline.sources"; +export const App: React.FC = () => ( + <> + + } /> + } /> + -export const App: React.FC = () => { - const [jobs, setJobs] = useState([]); - const [stats, setStats] = useState>({ - discovered: 0, - processing: 0, - ready: 0, - applied: 0, - rejected: 0, - expired: 0, - }); - const [isLoading, setIsLoading] = useState(true); - const [isPipelineRunning, setIsPipelineRunning] = useState(false); - const [processingJobId, setProcessingJobId] = useState(null); - const [pipelineSources, setPipelineSources] = useState(() => { - try { - const raw = localStorage.getItem(PIPELINE_SOURCES_STORAGE_KEY); - if (!raw) return DEFAULT_PIPELINE_SOURCES; - const parsed = JSON.parse(raw) as unknown; - const allowed: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"]; - if (!Array.isArray(parsed)) return DEFAULT_PIPELINE_SOURCES; - const next = parsed.filter((value): value is JobSource => allowed.includes(value)); - return next.length > 0 ? next : DEFAULT_PIPELINE_SOURCES; - } catch { - return DEFAULT_PIPELINE_SOURCES; - } - }); - - useEffect(() => { - try { - localStorage.setItem(PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(pipelineSources)); - } catch { - // Ignore localStorage errors - } - }, [pipelineSources]); - - const loadJobs = useCallback(async () => { - try { - setIsLoading(true); - const data = await api.getJobs(); - setJobs(data.jobs); - setStats(data.byStatus); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to load jobs"; - toast.error(message); - } finally { - setIsLoading(false); - } - }, []); - - const checkPipelineStatus = useCallback(async () => { - try { - const status = await api.getPipelineStatus(); - setIsPipelineRunning(status.isRunning); - } catch { - // Ignore errors - } - }, []); - - useEffect(() => { - loadJobs(); - checkPipelineStatus(); - - const interval = setInterval(() => { - loadJobs(); - checkPipelineStatus(); - }, 10000); - - return () => clearInterval(interval); - }, [loadJobs, checkPipelineStatus]); - - 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 handleProcess = async (jobId: string) => { - try { - setProcessingJobId(jobId); - const job = jobs.find((item) => item.id === jobId); - const force = job?.status === "ready"; - await api.processJob(jobId, { force }); - toast.success(force ? "Resume regenerated successfully" : "Resume generated successfully"); - await loadJobs(); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to process job"; - toast.error(message); - } finally { - setProcessingJobId(null); - } - }; - - const handleApply = async (jobId: string) => { - try { - await api.markAsApplied(jobId); - toast.success("Marked as applied"); - await loadJobs(); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to mark as applied"; - toast.error(message); - } - }; - - const handleReject = async (jobId: string) => { - try { - await api.rejectJob(jobId); - toast.message("Job skipped"); - await loadJobs(); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to reject job"; - toast.error(message); - } - }; - - return ( - <> -
- - - - - - - - } - /> - } /> - - - - - ); -}; + + +); \ No newline at end of file diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx new file mode 100644 index 0000000..d7a2045 --- /dev/null +++ b/orchestrator/src/client/pages/OrchestratorPage.tsx @@ -0,0 +1,1049 @@ +/** + * Orchestrator layout with a split list/detail experience. + */ + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + ArrowUpDown, + Calendar, + CheckCircle2, + ChevronDown, + Copy, + DollarSign, + ExternalLink, + FileText, + Filter, + GraduationCap, + Loader2, + MapPin, + MoreHorizontal, + Play, + RefreshCcw, + Search, + Settings, + Sparkles, + XCircle, +} from "lucide-react"; +import { Link } from "react-router-dom"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy"; +import { PipelineProgress } from "../components"; +import * as api from "../api"; +import { TailoringEditor } from "../components/TailoringEditor"; +import type { Job, JobSource, JobStatus } from "../../shared/types"; + +const DEFAULT_PIPELINE_SOURCES: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"]; +const PIPELINE_SOURCES_STORAGE_KEY = "jobops.pipeline.sources"; + +const sourceLabel: Record = { + gradcracker: "Gradcracker", + indeed: "Indeed", + linkedin: "LinkedIn", + ukvisajobs: "UK Visa Jobs", +}; + +const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"]; + +const statusTokens: Record< + JobStatus, + { label: string; badge: string; dot: string } +> = { + discovered: { + label: "Discovered", + badge: "border-sky-500/30 bg-sky-500/10 text-sky-200", + dot: "bg-sky-400", + }, + processing: { + label: "Processing", + badge: "border-amber-500/30 bg-amber-500/10 text-amber-200", + dot: "bg-amber-400", + }, + ready: { + label: "Ready", + badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200", + dot: "bg-emerald-400", + }, + applied: { + label: "Applied", + badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200", + dot: "bg-emerald-400", + }, + rejected: { + label: "Rejected", + badge: "border-rose-500/30 bg-rose-500/10 text-rose-200", + dot: "bg-rose-400", + }, + expired: { + label: "Expired", + badge: "border-muted-foreground/20 bg-muted/30 text-muted-foreground", + dot: "bg-muted-foreground", + }, +}; + +type FilterTab = "ready" | "discovered" | "applied" | "all"; + +type SortKey = "discoveredAt" | "score" | "title" | "employer"; +type SortDirection = "asc" | "desc"; + +interface JobSort { + key: SortKey; + direction: SortDirection; +} + +const DEFAULT_SORT: JobSort = { key: "score", direction: "desc" }; + +const sortLabels: Record = { + discoveredAt: "Discovered", + score: "Score", + title: "Title", + employer: "Company", +}; + +const defaultSortDirection: Record = { + discoveredAt: "desc", + score: "desc", + title: "asc", + employer: "asc", +}; + +const tabs: Array<{ id: FilterTab; label: string; statuses: JobStatus[] }> = [ + { id: "ready", label: "Ready", statuses: ["ready"] }, + { id: "discovered", label: "Discovered", statuses: ["discovered", "processing"] }, + { id: "applied", label: "Applied", statuses: ["applied"] }, + { id: "all", label: "All Jobs", statuses: [] }, +]; + +const emptyStateCopy: Record = { + ready: "Run the pipeline to discover and process new jobs.", + discovered: "All discovered jobs have been processed.", + applied: "You have not applied to any jobs yet.", + all: "No jobs in the system yet. Run the pipeline to get started.", +}; + +const formatDate = (dateStr: string | null) => { + if (!dateStr) return null; + try { + return new Date(dateStr).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); + } catch { + return dateStr; + } +}; + +const dateValue = (value: string | null) => { + if (!value) return null; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +const compareString = (a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: "base" }); +const compareNumber = (a: number, b: number) => a - b; + +const compareJobs = (a: Job, b: Job, sort: JobSort) => { + let value = 0; + + switch (sort.key) { + case "title": + value = compareString(a.title, b.title); + break; + case "employer": + value = compareString(a.employer, b.employer); + break; + case "score": { + const aScore = a.suitabilityScore; + const bScore = b.suitabilityScore; + + if (aScore == null && bScore == null) { + value = 0; + break; + } + if (aScore == null) return 1; + if (bScore == null) return -1; + value = compareNumber(aScore, bScore); + break; + } + case "discoveredAt": + const aDate = dateValue(a.discoveredAt); + const bDate = dateValue(b.discoveredAt); + if (aDate == null && bDate == null) { + value = 0; + break; + } + if (aDate == null) return 1; + if (bDate == null) return -1; + value = compareNumber(aDate, bDate); + break; + default: + value = 0; + } + + if (value !== 0) return sort.direction === "asc" ? value : -value; + return a.id.localeCompare(b.id); +}; + +const jobMatchesQuery = (job: Job, query: string) => { + const normalized = query.trim().toLowerCase(); + if (!normalized) return true; + const haystack = [ + job.title, + job.employer, + job.location, + job.source, + job.status, + job.jobType, + job.jobFunction, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return haystack.includes(normalized); +}; + +const stripHtml = (value: string) => value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); + +const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => { + const tokens = statusTokens[status]; + return ( + + + {tokens.label} + + ); +}; + +const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => { + if (score == null) { + return Not scored; + } + + return ( +
+
+
+
+ {score} +
+ ); +}; + +export const OrchestratorPage: React.FC = () => { + const [jobs, setJobs] = useState([]); + const [stats, setStats] = useState>({ + discovered: 0, + processing: 0, + ready: 0, + applied: 0, + rejected: 0, + expired: 0, + }); + const [isLoading, setIsLoading] = useState(true); + const [isPipelineRunning, setIsPipelineRunning] = useState(false); + const [processingJobId, setProcessingJobId] = useState(null); + const [activeTab, setActiveTab] = useState("ready"); + const [searchQuery, setSearchQuery] = useState(""); + const [sourceFilter, setSourceFilter] = useState("all"); + const [sort, setSort] = useState(DEFAULT_SORT); + const [selectedJobId, setSelectedJobId] = useState(null); + const [detailTab, setDetailTab] = useState<"overview" | "tailoring" | "description">("overview"); + const [isEditingDescription, setIsEditingDescription] = useState(false); + const [editedDescription, setEditedDescription] = useState(""); + const [isSavingDescription, setIsSavingDescription] = useState(false); + const [pipelineSources, setPipelineSources] = useState(() => { + try { + const raw = localStorage.getItem(PIPELINE_SOURCES_STORAGE_KEY); + if (!raw) return DEFAULT_PIPELINE_SOURCES; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return DEFAULT_PIPELINE_SOURCES; + const next = parsed.filter((value): value is JobSource => orderedSources.includes(value as JobSource)); + return next.length > 0 ? next : DEFAULT_PIPELINE_SOURCES; + } catch { + return DEFAULT_PIPELINE_SOURCES; + } + }); + + useEffect(() => { + try { + localStorage.setItem(PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(pipelineSources)); + } catch { + // Ignore localStorage errors + } + }, [pipelineSources]); + + const loadJobs = useCallback(async () => { + try { + setIsLoading(true); + const data = await api.getJobs(); + setJobs(data.jobs); + setStats(data.byStatus); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to load jobs"; + toast.error(message); + } finally { + setIsLoading(false); + } + }, []); + + const checkPipelineStatus = useCallback(async () => { + try { + const status = await api.getPipelineStatus(); + setIsPipelineRunning(status.isRunning); + } catch { + // Ignore errors + } + }, []); + + useEffect(() => { + loadJobs(); + checkPipelineStatus(); + + const interval = setInterval(() => { + loadJobs(); + checkPipelineStatus(); + }, 10000); + + return () => clearInterval(interval); + }, [loadJobs, checkPipelineStatus]); + + 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 handleProcess = async (jobId: string) => { + try { + setProcessingJobId(jobId); + const job = jobs.find((item) => item.id === jobId); + const force = job?.status === "ready"; + await api.processJob(jobId, { force }); + toast.success(force ? "Resume regenerated successfully" : "Resume generated successfully"); + await loadJobs(); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to process job"; + toast.error(message); + } finally { + setProcessingJobId(null); + } + }; + + const handleApply = async (jobId: string) => { + try { + await api.markAsApplied(jobId); + toast.success("Marked as applied"); + await loadJobs(); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to mark as applied"; + toast.error(message); + } + }; + + const handleReject = async (jobId: string) => { + try { + await api.rejectJob(jobId); + toast.message("Job skipped"); + await loadJobs(); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to reject job"; + toast.error(message); + } + }; + + const handleCopyInfo = async (job: Job) => { + try { + await copyTextToClipboard(formatJobForWebhook(job)); + toast.success("Copied job info", { description: "Webhook payload copied to clipboard." }); + } catch { + toast.error("Could not copy job info"); + } + }; + + const activeJobs = useMemo(() => { + let filtered = jobs; + + if (activeTab === "ready") { + filtered = filtered.filter((job) => job.status === "ready"); + } else if (activeTab === "discovered") { + filtered = filtered.filter((job) => job.status === "discovered" || job.status === "processing"); + } else if (activeTab === "applied") { + filtered = filtered.filter((job) => job.status === "applied"); + } + + if (sourceFilter !== "all") { + filtered = filtered.filter((job) => job.source === sourceFilter); + } + + if (searchQuery.trim()) { + filtered = filtered.filter((job) => jobMatchesQuery(job, searchQuery)); + } + + return [...filtered].sort((a, b) => compareJobs(a, b, sort)); + }, [jobs, activeTab, sourceFilter, searchQuery, sort]); + + useEffect(() => { + if (activeJobs.length === 0) { + setSelectedJobId(null); + return; + } + if (!selectedJobId || !activeJobs.some((job) => job.id === selectedJobId)) { + setSelectedJobId(activeJobs[0].id); + } + }, [activeJobs, selectedJobId]); + + const selectedJob = useMemo( + () => (selectedJobId ? jobs.find((job) => job.id === selectedJobId) ?? null : null), + [jobs, selectedJobId], + ); + + const description = useMemo(() => { + if (!selectedJob?.jobDescription) return "No description available."; + const jd = selectedJob.jobDescription; + if (jd.includes("<") && jd.includes(">")) return stripHtml(jd); + return jd; + }, [selectedJob]); + + useEffect(() => { + if (!selectedJob) { + setIsEditingDescription(false); + setEditedDescription(""); + return; + } + setIsEditingDescription(false); + setEditedDescription(selectedJob.jobDescription || ""); + }, [selectedJob?.id]); + + useEffect(() => { + if (!selectedJob) return; + if (!isEditingDescription) { + setEditedDescription(selectedJob.jobDescription || ""); + } + }, [selectedJob?.jobDescription, isEditingDescription]); + + const handleSaveDescription = async () => { + if (!selectedJob) return; + try { + setIsSavingDescription(true); + await api.updateJob(selectedJob.id, { jobDescription: editedDescription }); + toast.success("Job description updated"); + setIsEditingDescription(false); + await loadJobs(); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to update description"; + toast.error(message); + } finally { + setIsSavingDescription(false); + } + }; + + const totalJobs = Object.values(stats).reduce((a, b) => a + b, 0); + const activeResultsCount = activeJobs.length; + const selectedHasPdf = !!selectedJob?.pdfPath; + const selectedJobLink = selectedJob ? selectedJob.applicationLink || selectedJob.jobUrl : "#"; + const selectedPdfHref = selectedJob ? `/pdfs/resume_${selectedJob.id}.pdf` : "#"; + const selectedDeadline = selectedJob ? formatDate(selectedJob.deadline) : null; + const canApply = selectedJob?.status === "ready"; + const canProcess = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false; + const canReject = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false; + const showReadyPdf = activeTab === "ready"; + const showGeneratePdf = activeTab === "discovered"; + const isProcessingSelected = + selectedJob ? processingJobId === selectedJob.id || selectedJob.status === "processing" : false; + + const toggleSource = (source: JobSource, checked: boolean) => { + const next = checked + ? Array.from(new Set([...pipelineSources, source])) + : pipelineSources.filter((s) => s !== source); + + if (next.length === 0) return; + setPipelineSources(next); + }; + + const counts = useMemo(() => { + const byTab: Record = { + ready: 0, + discovered: 0, + applied: 0, + all: jobs.length, + }; + + for (const job of jobs) { + if (job.status === "ready") byTab.ready += 1; + if (job.status === "applied") byTab.applied += 1; + if (job.status === "discovered" || job.status === "processing") byTab.discovered += 1; + } + + return byTab; + }, [jobs]); + + return ( + <> +
+
+
+
+ +
+
+
Job Ops
+
Orchestrator
+
+ {isPipelineRunning && ( + + + Pipeline running + + )} +
+ +
+ + + +
+ + + + + + + + Sources + + {orderedSources.map((source) => ( + toggleSource(source, Boolean(checked))} + > + {sourceLabel[source]} + + ))} + + setPipelineSources(orderedSources)}>All sources + setPipelineSources(["gradcracker"])}>Gradcracker only + setPipelineSources(["indeed", "linkedin"])}> + Indeed + LinkedIn only + + + +
+
+
+
+ +
+
+
+
+

Pipeline console

+

+ Focused workspace with a split list/detail layout and icon-led actions. +

+
+
{totalJobs} total jobs
+
+ + {isPipelineRunning && ( +
+ +
+ )} + +
+ {[ + { label: "Discovered", value: stats.discovered }, + { label: "Processing", value: stats.processing }, + { label: "Ready", value: stats.ready }, + { label: "Applied", value: stats.applied }, + { label: "Rejected", value: stats.rejected }, + { label: "Expired", value: stats.expired }, + ].map((item, index) => ( +
0 && "border-t border-border/60 sm:border-t-0 sm:border-l", + index > 0 && index % 3 === 0 && "sm:border-l-0 sm:border-t", + index > 2 && "lg:border-t-0 lg:border-l", + )} + > + {item.label} + {item.value} +
+ ))} +
+
+
+ setActiveTab(value as FilterTab)}> +
+ + {tabs.map((tab) => ( + + {tab.label} + ({counts[tab.id]}) + + ))} + + +
+
+ + setSearchQuery(event.target.value)} + placeholder="Filter jobs..." + className="pl-9" + /> +
+ + + + + + + Filter by source + + setSourceFilter(value as JobSource | "all")} + > + All Sources + {(Object.keys(sourceLabel) as JobSource[]).map((key) => ( + + {sourceLabel[key]} + + ))} + + + + + + + + + + Sort by + + + setSort({ + key: value as JobSort["key"], + direction: defaultSortDirection[value as JobSort["key"]], + }) + } + > + {(Object.keys(sortLabels) as Array).map((key) => ( + + {sortLabels[key]} + + ))} + + + + setSort((current) => ({ + ...current, + direction: current.direction === "asc" ? "desc" : "asc", + })) + } + > + Direction: {sort.direction === "asc" ? "Ascending" : "Descending"} + + + + + + {activeResultsCount} jobs + +
+
+
+
+
+
+ {activeJobs.length === 0 ? ( +
+
No jobs found
+

+ {searchQuery.trim() ? `No jobs match "${searchQuery.trim()}".` : emptyStateCopy[activeTab]} +

+
+ ) : ( +
+ {activeJobs.map((job) => { + const isSelected = job.id === selectedJobId; + return ( + + ); + })} +
+ )} +
+ +
+ {!selectedJob ? ( +
+
Select a job
+

Pick a job from the list to see details here.

+
+ ) : ( +
+
+
+
{selectedJob.title}
+
{selectedJob.employer}
+
+
+ + {sourceLabel[selectedJob.source]} + + +
+
+ +
+ {selectedJob.location && ( + + + {selectedJob.location} + + )} + {selectedDeadline && ( + + + {selectedDeadline} + + )} + {selectedJob.salary && ( + + + {selectedJob.salary} + + )} + {selectedJob.degreeRequired && ( + + + {selectedJob.degreeRequired} + + )} +
+ +
+ Suitability + +
+ + + +
+ + + {showReadyPdf && + (selectedHasPdf ? ( + + ) : ( + + ))} + + {showGeneratePdf && ( + + )} + + {canApply && ( + + )} + + + + + + + {canProcess && !showGeneratePdf && ( + handleProcess(selectedJob.id)} + disabled={isProcessingSelected} + > + + {isProcessingSelected + ? "Processing..." + : selectedJob.status === "ready" + ? "Regenerate PDF" + : "Generate PDF"} + + )} + void handleCopyInfo(selectedJob)}> + + Copy info + + {selectedHasPdf && ( + <> + {!showReadyPdf && ( + + + + View PDF + + + )} + + + + Download PDF + + + + )} + {canReject && ( + <> + + handleReject(selectedJob.id)} + className="text-destructive focus:text-destructive" + > + + Skip job + + + )} + + +
+ setDetailTab(value as typeof detailTab)}> + + Overview + Tailoring + Description + + + +
+ {selectedJob.suitabilityReason + ? `"${selectedJob.suitabilityReason}"` + : "No suitability summary yet."} +
+ +
+
+
Discipline
+
{selectedJob.disciplines || "Not set"}
+
+
+
Job function
+
{selectedJob.jobFunction || "Not set"}
+
+
+
Job level
+
{selectedJob.jobLevel || "Not set"}
+
+
+
Job type
+
{selectedJob.jobType || "Not set"}
+
+
+
+ + + + + + +
+
+ Job description +
+ + + + + + {!isEditingDescription ? ( + setIsEditingDescription(true)}> + Edit description + + ) : ( + <> + + {isSavingDescription ? "Saving..." : "Save changes"} + + { + setIsEditingDescription(false); + setEditedDescription(selectedJob.jobDescription || ""); + }} + disabled={isSavingDescription} + > + Cancel edit + + + )} + + +
+ +
+ {isEditingDescription ? ( +