diff --git a/orchestrator/src/client/App.tsx b/orchestrator/src/client/App.tsx index 0b8365d..54fc597 100644 --- a/orchestrator/src/client/App.tsx +++ b/orchestrator/src/client/App.tsx @@ -174,6 +174,7 @@ export const App: React.FC = () => { onApply={handleApply} onReject={handleReject} onProcess={handleProcess} + onUpdate={loadJobs} processingJobId={processingJobId} /> diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 53d3656..344cdbd 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -63,6 +63,19 @@ export async function processJob(id: string, options?: { force?: boolean }): Pro }); } +export async function summarizeJob(id: string, options?: { force?: boolean }): Promise { + const query = options?.force ? '?force=1' : ''; + return fetchApi(`/jobs/${id}/summarize${query}`, { + method: 'POST', + }); +} + +export async function generateJobPdf(id: string): Promise { + return fetchApi(`/jobs/${id}/generate-pdf`, { + method: 'POST', + }); +} + export async function markAsApplied(id: string): Promise { return fetchApi(`/jobs/${id}/apply`, { method: 'POST', @@ -95,11 +108,16 @@ export async function runPipeline(config?: { }); } -// Settings API +// Settings & Profile API export async function getSettings(): Promise { return fetchApi('/settings'); } +export async function getProfileProjects(): Promise { + return fetchApi('/profile/projects'); +} + + export async function updateSettings(update: { model?: string | null pipelineWebhookUrl?: string | null diff --git a/orchestrator/src/client/components/JobList.tsx b/orchestrator/src/client/components/JobList.tsx index 7c9a9de..611c8b8 100644 --- a/orchestrator/src/client/components/JobList.tsx +++ b/orchestrator/src/client/components/JobList.tsx @@ -26,12 +26,14 @@ import { cn } from "@/lib/utils"; import type { Job, JobStatus, JobSource } from "../../shared/types"; import { JobCard } from "./JobCard"; import { JobTable, type JobSort } from "./JobTable"; +import { TailoringEditor } from "./TailoringEditor"; interface JobListProps { jobs: Job[]; onApply: (id: string) => void | Promise; onReject: (id: string) => void | Promise; onProcess: (id: string) => void | Promise; + onUpdate: () => void | Promise; processingJobId: string | null; } @@ -186,6 +188,7 @@ export const JobList: React.FC = ({ onApply, onReject, onProcess, + onUpdate, processingJobId, }) => { const [activeTab, setActiveTab] = useState("ready"); @@ -408,6 +411,8 @@ export const JobList: React.FC = ({ onHighlightChange={setHighlightedJobId} /> + + Job description diff --git a/orchestrator/src/client/components/TailoringEditor.tsx b/orchestrator/src/client/components/TailoringEditor.tsx new file mode 100644 index 0000000..167a541 --- /dev/null +++ b/orchestrator/src/client/components/TailoringEditor.tsx @@ -0,0 +1,181 @@ +import React, { useEffect, useState } from "react"; +import { Check, Loader2, Sparkles, FileText, AlertTriangle } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import * as api from "../api"; +import type { Job, ResumeProjectCatalogItem } from "../../shared/types"; + +interface TailoringEditorProps { + job: Job; + onUpdate: () => void | Promise; +} + +export const TailoringEditor: React.FC = ({ job, onUpdate }) => { + const [catalog, setCatalog] = useState([]); + const [summary, setSummary] = useState(job.tailoredSummary || ""); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [isSummarizing, setIsSummarizing] = useState(false); + const [isGeneratingPdf, setIsGeneratingPdf] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + // Load project catalog + api.getProfileProjects().then(setCatalog).catch(console.error); + + // Set initial selection + if (job.selectedProjectIds) { + setSelectedIds(new Set(job.selectedProjectIds.split(',').filter(Boolean))); + } + }, [job.selectedProjectIds]); + + useEffect(() => { + setSummary(job.tailoredSummary || ""); + }, [job.tailoredSummary]); + + const handleToggleProject = (id: string) => { + const next = new Set(selectedIds); + if (next.has(id)) next.delete(id); + else next.add(id); + setSelectedIds(next); + }; + + const handleSave = async () => { + try { + setIsSaving(true); + await api.updateJob(job.id, { + tailoredSummary: summary, + selectedProjectIds: Array.from(selectedIds).join(','), + }); + toast.success("Changes saved"); + await onUpdate(); + } catch (error) { + toast.error("Failed to save changes"); + } finally { + setIsSaving(false); + } + }; + + const handleSummarize = async () => { + try { + setIsSummarizing(true); + const updatedJob = await api.summarizeJob(job.id, { force: true }); + setSummary(updatedJob.tailoredSummary || ""); + if (updatedJob.selectedProjectIds) { + setSelectedIds(new Set(updatedJob.selectedProjectIds.split(',').filter(Boolean))); + } + toast.success("AI Summary & Projects generated"); + await onUpdate(); + } catch (error) { + toast.error("AI summarization failed"); + } finally { + setIsSummarizing(false); + } + }; + + const handleGeneratePdf = async () => { + try { + setIsGeneratingPdf(true); + // Save current state first to ensure PDF uses latest + await api.updateJob(job.id, { + tailoredSummary: summary, + selectedProjectIds: Array.from(selectedIds).join(','), + }); + + await api.generateJobPdf(job.id); + toast.success("Resume PDF generated"); + await onUpdate(); + } catch (error) { + toast.error("PDF generation failed"); + } finally { + setIsGeneratingPdf(false); + } + }; + + const maxProjects = 3; // Example limit, could come from settings + const tooManyProjects = selectedIds.size > maxProjects; + + return ( + + + Tailoring Editor +
+ + +
+
+ +
+ +