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

290 lines
9.6 KiB
TypeScript

import {
AlertTriangle,
Check,
FileText,
Loader2,
Sparkles,
} from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
import * as api from "../api";
interface TailoringEditorProps {
job: Job;
onUpdate: () => void | Promise<void>;
onDirtyChange?: (isDirty: boolean) => void;
onRegisterSave?: (save: () => Promise<void>) => void;
onBeforeGenerate?: () => boolean | Promise<boolean>;
}
export const TailoringEditor: React.FC<TailoringEditorProps> = ({
job,
onUpdate,
onDirtyChange,
onRegisterSave,
onBeforeGenerate,
}) => {
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
const [summary, setSummary] = useState(job.tailoredSummary || "");
const [jobDescription, setJobDescription] = useState(
job.jobDescription || "",
);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [isSummarizing, setIsSummarizing] = useState(false);
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const savedSelectedIds = useMemo(() => {
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
return new Set(saved);
}, [job.selectedProjectIds]);
const hasSelectionDiff = useMemo(() => {
if (selectedIds.size !== savedSelectedIds.size) return true;
for (const id of selectedIds) {
if (!savedSelectedIds.has(id)) return true;
}
return false;
}, [selectedIds, savedSelectedIds]);
const isDirty =
summary !== (job.tailoredSummary || "") ||
jobDescription !== (job.jobDescription || "") ||
hasSelectionDiff;
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
useEffect(() => {
// Load project catalog
api.getResumeProjectsCatalog().then(setCatalog).catch(console.error);
// Set initial selection
if (job.selectedProjectIds) {
setSelectedIds(
new Set(job.selectedProjectIds.split(",").filter(Boolean)),
);
}
setJobDescription(job.jobDescription || "");
}, [job.selectedProjectIds, job.jobDescription]);
useEffect(() => {
setSummary(job.tailoredSummary || "");
}, [job.tailoredSummary]);
const saveChanges = useCallback(
async ({ showToast = true }: { showToast?: boolean } = {}) => {
try {
setIsSaving(true);
await api.updateJob(job.id, {
tailoredSummary: summary,
jobDescription: jobDescription,
selectedProjectIds: Array.from(selectedIds).join(","),
});
if (showToast) toast.success("Changes saved");
await onUpdate();
} catch (error) {
if (showToast) toast.error("Failed to save changes");
throw error;
} finally {
setIsSaving(false);
}
},
[job.id, onUpdate, selectedIds, summary, jobDescription],
);
useEffect(() => {
onRegisterSave?.(() => saveChanges({ showToast: false }));
}, [onRegisterSave, saveChanges]);
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 {
await saveChanges();
} catch {
// Toast handled in saveChanges
}
};
const handleSummarize = async () => {
try {
setIsSummarizing(true);
// Save changes first so AI uses latest description
if (isDirty) {
await saveChanges({ showToast: false });
}
const updatedJob = await api.summarizeJob(job.id, { force: true });
setSummary(updatedJob.tailoredSummary || "");
setJobDescription(updatedJob.jobDescription || "");
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 {
const shouldProceed = onBeforeGenerate ? await onBeforeGenerate() : true;
if (shouldProceed === false) return;
setIsGeneratingPdf(true);
// Save current state first to ensure PDF uses latest
await saveChanges({ showToast: false });
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 (
<div className="space-y-4">
<div className="flex flex-col gap-2 pb-2 sm:flex-row sm:items-center sm:justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">Editor</h3>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<Button
size="sm"
variant="outline"
onClick={handleSummarize}
disabled={isSummarizing || isGeneratingPdf || isSaving}
className="w-full sm:w-auto"
>
{isSummarizing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
AI Summarize
</Button>
<Button
size="sm"
onClick={handleGeneratePdf}
disabled={isSummarizing || isGeneratingPdf || isSaving || !summary}
className="w-full sm:w-auto"
>
{isGeneratingPdf ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FileText className="mr-2 h-4 w-4" />
)}
Generate PDF
</Button>
</div>
</div>
<div className="space-y-4 rounded-lg border bg-card p-4 shadow-sm">
<div className="space-y-2">
<label htmlFor="tailor-jd" className="text-sm font-medium">
Job Description (Edit to help AI tailoring)
</label>
<textarea
id="tailor-jd"
className="w-full min-h-[120px] max-h-[250px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={jobDescription}
onChange={(e) => setJobDescription(e.target.value)}
placeholder="The raw job description..."
/>
</div>
<Separator />
<div className="space-y-2">
<label htmlFor="tailor-summary" className="text-sm font-medium">
Tailored Summary
</label>
<textarea
id="tailor-summary"
className="w-full min-h-[120px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={summary}
onChange={(e) => setSummary(e.target.value)}
placeholder="AI will generate this, or you can write your own..."
/>
</div>
<Separator />
<div className="space-y-3">
<div className="flex flex-wrap items-start gap-2 sm:items-center sm:justify-between">
<span className="text-sm font-medium">Selected Projects</span>
{tooManyProjects && (
<span className="flex items-center gap-1 text-xs text-amber-600 font-medium">
<AlertTriangle className="h-3 w-3" />
Warning: More than {maxProjects} projects might make the resume
too long.
</span>
)}
</div>
<div className="grid gap-2 max-h-[300px] overflow-auto pr-2">
{catalog.map((project) => (
<div
key={project.id}
className="flex items-start gap-3 rounded-lg border p-3 text-sm transition-colors hover:bg-muted/50"
>
<Checkbox
id={`project-${project.id}`}
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleProject(project.id)}
className="mt-1"
/>
<label
htmlFor={`project-${project.id}`}
className="flex flex-1 flex-col gap-1 cursor-pointer"
>
<span className="font-semibold">{project.name}</span>
<span className="text-xs text-muted-foreground line-clamp-2">
{project.description}
</span>
</label>
</div>
))}
</div>
</div>
<div className="flex justify-end border-t pt-4">
<Button
variant="ghost"
size="sm"
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Check className="mr-2 h-4 w-4" />
)}
Save Selection
</Button>
</div>
</div>
</div>
);
};