Fix Tailor CV adding new skills and restore original skills on revert (#190)
* Initial commit * refactor slightly * refactor and fix bugs
This commit is contained in:
parent
a148030d46
commit
16dd17ebea
@ -3,6 +3,7 @@ import type { Job } from "@shared/types.js";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../api";
|
||||
import { useProfile } from "../hooks/useProfile";
|
||||
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
|
||||
import { TailoringEditor } from "./TailoringEditor";
|
||||
|
||||
@ -14,6 +15,10 @@ vi.mock("../api", () => ({
|
||||
getTracerReadiness: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useProfile", () => ({
|
||||
useProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
@ -54,6 +59,32 @@ describe("TailoringEditor", () => {
|
||||
lastSuccessAt: Date.now(),
|
||||
reason: null,
|
||||
});
|
||||
vi.mocked(useProfile).mockReturnValue({
|
||||
profile: {
|
||||
basics: {
|
||||
summary: "Original base summary",
|
||||
label: "Original base headline",
|
||||
},
|
||||
sections: {
|
||||
skills: {
|
||||
items: [
|
||||
{
|
||||
id: "s1",
|
||||
name: "Backend",
|
||||
description: "",
|
||||
level: 0,
|
||||
keywords: ["Node.js", "TypeScript"],
|
||||
visible: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
isLoading: false,
|
||||
personName: "Resume",
|
||||
refreshProfile: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not rehydrate local edits from same-job prop updates", async () => {
|
||||
@ -277,4 +308,93 @@ describe("TailoringEditor", () => {
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports undo to template and redo to AI draft", async () => {
|
||||
render(<TailoringEditor job={createJob()} onUpdate={vi.fn()} />);
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
ensureAccordionOpen("Summary");
|
||||
fireEvent.click(screen.getAllByLabelText("Undo to template")[0]);
|
||||
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
|
||||
"Original base summary",
|
||||
);
|
||||
fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[0]);
|
||||
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
|
||||
"Saved summary",
|
||||
);
|
||||
|
||||
ensureAccordionOpen("Headline");
|
||||
fireEvent.click(screen.getAllByLabelText("Undo to template")[1]);
|
||||
expect(screen.getByLabelText("Tailored Headline")).toHaveValue(
|
||||
"Original base headline",
|
||||
);
|
||||
fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[1]);
|
||||
expect(screen.getByLabelText("Tailored Headline")).toHaveValue(
|
||||
"Saved headline",
|
||||
);
|
||||
|
||||
ensureAccordionOpen("Tailored Skills");
|
||||
fireEvent.click(screen.getAllByLabelText("Undo to template")[2]);
|
||||
ensureAccordionOpen("Backend");
|
||||
expect(screen.getByDisplayValue("Node.js, TypeScript")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[2]);
|
||||
ensureAccordionOpen("Core");
|
||||
expect(screen.getByDisplayValue("React, TypeScript")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resets redo baseline when switching jobs", async () => {
|
||||
const { rerender } = render(
|
||||
<TailoringEditor job={createJob()} onUpdate={vi.fn()} />,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
ensureAccordionOpen("Summary");
|
||||
fireEvent.click(screen.getAllByLabelText("Undo to template")[0]);
|
||||
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
|
||||
"Original base summary",
|
||||
);
|
||||
|
||||
rerender(
|
||||
<TailoringEditor
|
||||
job={createJob({
|
||||
id: "job-2",
|
||||
tailoredSummary: "Second job summary",
|
||||
})}
|
||||
onUpdate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
ensureAccordionOpen("Summary");
|
||||
fireEvent.click(screen.getAllByLabelText("Undo to template")[0]);
|
||||
fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[0]);
|
||||
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
|
||||
"Second job summary",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps undo disabled until profile template is loaded", async () => {
|
||||
vi.mocked(useProfile).mockReturnValue({
|
||||
profile: null,
|
||||
error: null,
|
||||
isLoading: true,
|
||||
personName: "Resume",
|
||||
refreshProfile: vi.fn(),
|
||||
});
|
||||
|
||||
render(<TailoringEditor job={createJob()} onUpdate={vi.fn()} />);
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
ensureAccordionOpen("Summary");
|
||||
ensureAccordionOpen("Headline");
|
||||
ensureAccordionOpen("Tailored Skills");
|
||||
|
||||
for (const button of screen.getAllByLabelText("Undo to template")) {
|
||||
expect(button).toBeDisabled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,6 +3,7 @@ import type { Job } from "@shared/types.js";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../../api";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import { _resetTracerReadinessCache } from "../../hooks/useTracerReadiness";
|
||||
import { TailorMode } from "./TailorMode";
|
||||
|
||||
@ -13,6 +14,10 @@ vi.mock("../../api", () => ({
|
||||
getTracerReadiness: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useProfile", () => ({
|
||||
useProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
@ -53,6 +58,32 @@ describe("TailorMode", () => {
|
||||
lastSuccessAt: Date.now(),
|
||||
reason: null,
|
||||
});
|
||||
vi.mocked(useProfile).mockReturnValue({
|
||||
profile: {
|
||||
basics: {
|
||||
summary: "Original base summary",
|
||||
label: "Original base headline",
|
||||
},
|
||||
sections: {
|
||||
skills: {
|
||||
items: [
|
||||
{
|
||||
id: "s1",
|
||||
name: "Backend",
|
||||
description: "",
|
||||
level: 0,
|
||||
keywords: ["Node.js", "TypeScript"],
|
||||
visible: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
isLoading: false,
|
||||
personName: "Resume",
|
||||
refreshProfile: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not rehydrate local edits from same-job prop updates", async () => {
|
||||
@ -270,4 +301,46 @@ describe("TailorMode", () => {
|
||||
expect(screen.getByDisplayValue("Backend")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("Node.js, Kafka")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports undo to template and redo to AI draft", async () => {
|
||||
render(
|
||||
<TailorMode
|
||||
job={createJob()}
|
||||
onBack={vi.fn()}
|
||||
onFinalize={vi.fn()}
|
||||
isFinalizing={false}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
ensureAccordionOpen("Summary");
|
||||
fireEvent.click(screen.getAllByLabelText("Undo to template")[0]);
|
||||
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
|
||||
"Original base summary",
|
||||
);
|
||||
fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[0]);
|
||||
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
|
||||
"Saved summary",
|
||||
);
|
||||
|
||||
ensureAccordionOpen("Headline");
|
||||
fireEvent.click(screen.getAllByLabelText("Undo to template")[1]);
|
||||
expect(screen.getByLabelText("Tailored Headline")).toHaveValue(
|
||||
"Original base headline",
|
||||
);
|
||||
fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[1]);
|
||||
expect(screen.getByLabelText("Tailored Headline")).toHaveValue(
|
||||
"Saved headline",
|
||||
);
|
||||
|
||||
ensureAccordionOpen("Tailored Skills");
|
||||
fireEvent.click(screen.getAllByLabelText("Undo to template")[2]);
|
||||
ensureAccordionOpen("Backend");
|
||||
expect(screen.getByDisplayValue("Node.js, TypeScript")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[2]);
|
||||
ensureAccordionOpen("Core");
|
||||
expect(screen.getByDisplayValue("React, TypeScript")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseTailoredSkills } from "./tailoring-utils";
|
||||
import {
|
||||
getOriginalHeadline,
|
||||
getOriginalSkills,
|
||||
getOriginalSummary,
|
||||
parseTailoredSkills,
|
||||
} from "./tailoring-utils";
|
||||
|
||||
describe("parseTailoredSkills", () => {
|
||||
it("parses object-based tailored skills payload", () => {
|
||||
@ -44,4 +49,45 @@ describe("parseTailoredSkills", () => {
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts original summary and headline from profile basics", () => {
|
||||
const profile = {
|
||||
basics: {
|
||||
summary: " Base summary ",
|
||||
label: " Base headline ",
|
||||
},
|
||||
};
|
||||
|
||||
expect(getOriginalSummary(profile)).toBe("Base summary");
|
||||
expect(getOriginalHeadline(profile)).toBe("Base headline");
|
||||
});
|
||||
|
||||
it("extracts original skills from profile skills items", () => {
|
||||
const profile = {
|
||||
sections: {
|
||||
skills: {
|
||||
items: [
|
||||
{
|
||||
id: "1",
|
||||
name: "Backend",
|
||||
description: "",
|
||||
level: 0,
|
||||
keywords: [" Node.js ", "TypeScript"],
|
||||
visible: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(getOriginalSkills(profile)).toEqual([
|
||||
{ name: "Backend", keywords: ["Node.js", "TypeScript"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns defaults when profile sections are missing", () => {
|
||||
expect(getOriginalSummary(null)).toBe("");
|
||||
expect(getOriginalHeadline(null)).toBe("");
|
||||
expect(getOriginalSkills(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import type { ResumeProfile } from "@shared/types";
|
||||
|
||||
export interface TailoredSkillGroup {
|
||||
name: string;
|
||||
keywords: string[];
|
||||
@ -93,3 +95,42 @@ export function fromEditableSkillGroups(
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getOriginalSummary(profile: ResumeProfile | null): string {
|
||||
if (!profile) return "";
|
||||
return profile.basics?.summary?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function getOriginalHeadline(profile: ResumeProfile | null): string {
|
||||
if (!profile) return "";
|
||||
return profile.basics?.label?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function getOriginalSkills(
|
||||
profile: ResumeProfile | null,
|
||||
): TailoredSkillGroup[] {
|
||||
if (!profile) return [];
|
||||
|
||||
const items = profile.sections?.skills?.items;
|
||||
if (!Array.isArray(items)) return [];
|
||||
|
||||
const groups: TailoredSkillGroup[] = [];
|
||||
for (const item of items) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const name =
|
||||
typeof item.name === "string"
|
||||
? item.name.trim()
|
||||
: typeof item.description === "string"
|
||||
? item.description.trim()
|
||||
: "";
|
||||
const keywordsRaw = Array.isArray(item.keywords) ? item.keywords : [];
|
||||
const keywords = keywordsRaw
|
||||
.filter((value: unknown): value is string => typeof value === "string")
|
||||
.map((value: string) => value.trim())
|
||||
.filter(Boolean);
|
||||
if (!name && keywords.length === 0) continue;
|
||||
groups.push({ name, keywords });
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ResumeProjectCatalogItem } from "@shared/types.js";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Plus, Redo2, Trash2, Undo2 } from "lucide-react";
|
||||
import type React from "react";
|
||||
import {
|
||||
Accordion,
|
||||
@ -9,6 +9,12 @@ import {
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { ProjectSelector } from "../discovered-panel/ProjectSelector";
|
||||
import type { EditableSkillGroup } from "../tailoring-utils";
|
||||
|
||||
@ -28,6 +34,19 @@ interface TailoringSectionsProps {
|
||||
disableInputs: boolean;
|
||||
onSummaryChange: (value: string) => void;
|
||||
onHeadlineChange: (value: string) => void;
|
||||
onUndoSummary: () => void;
|
||||
onUndoHeadline: () => void;
|
||||
onUndoSkills: () => void;
|
||||
onRedoSummary: () => void;
|
||||
onRedoHeadline: () => void;
|
||||
onRedoSkills: () => void;
|
||||
canUndoSummary: boolean;
|
||||
canUndoHeadline: boolean;
|
||||
canUndoSkills: boolean;
|
||||
canRedoSummary: boolean;
|
||||
canRedoHeadline: boolean;
|
||||
canRedoSkills: boolean;
|
||||
undoDisabledReason?: string | null;
|
||||
onDescriptionChange: (value: string) => void;
|
||||
onSkillGroupOpenChange: (value: string) => void;
|
||||
onAddSkillGroup: () => void;
|
||||
@ -63,6 +82,19 @@ export const TailoringSections: React.FC<TailoringSectionsProps> = ({
|
||||
disableInputs,
|
||||
onSummaryChange,
|
||||
onHeadlineChange,
|
||||
onUndoSummary,
|
||||
onUndoHeadline,
|
||||
onUndoSkills,
|
||||
onRedoSummary,
|
||||
onRedoHeadline,
|
||||
onRedoSkills,
|
||||
canUndoSummary,
|
||||
canUndoHeadline,
|
||||
canUndoSkills,
|
||||
canRedoSummary,
|
||||
canRedoHeadline,
|
||||
canRedoSkills,
|
||||
undoDisabledReason = null,
|
||||
onDescriptionChange,
|
||||
onSkillGroupOpenChange,
|
||||
onAddSkillGroup,
|
||||
@ -73,226 +105,345 @@ export const TailoringSections: React.FC<TailoringSectionsProps> = ({
|
||||
}) => {
|
||||
const tracerToggleDisabled =
|
||||
disableInputs || (!tracerLinksEnabled && tracerEnableBlocked);
|
||||
const undoTooltip = "Undo to template";
|
||||
const redoTooltip = "Redo to AI draft";
|
||||
|
||||
return (
|
||||
<Accordion type="multiple" className="space-y-3">
|
||||
<AccordionItem value="job-description" className={sectionClass}>
|
||||
<AccordionTrigger className={triggerClass}>
|
||||
Job Description
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 pt-1">
|
||||
<label htmlFor="tailor-jd-edit" className="sr-only">
|
||||
Job Description
|
||||
</label>
|
||||
<textarea
|
||||
id="tailor-jd-edit"
|
||||
className={`${inputClass} min-h-[120px] max-h-[250px]`}
|
||||
value={jobDescription}
|
||||
onChange={(event) => onDescriptionChange(event.target.value)}
|
||||
placeholder="The raw job description..."
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="summary" className={sectionClass}>
|
||||
<AccordionTrigger className={triggerClass}>Summary</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 pt-1">
|
||||
<label htmlFor="tailor-summary-edit" className="sr-only">
|
||||
Tailored Summary
|
||||
</label>
|
||||
<textarea
|
||||
id="tailor-summary-edit"
|
||||
className={`${inputClass} min-h-[120px]`}
|
||||
value={summary}
|
||||
onChange={(event) => onSummaryChange(event.target.value)}
|
||||
placeholder="Write a tailored summary for this role, or generate with AI..."
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="headline" className={sectionClass}>
|
||||
<AccordionTrigger className={triggerClass}>Headline</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 pt-1">
|
||||
<label htmlFor="tailor-headline-edit" className="sr-only">
|
||||
Tailored Headline
|
||||
</label>
|
||||
<input
|
||||
id="tailor-headline-edit"
|
||||
type="text"
|
||||
className={inputClass}
|
||||
value={headline}
|
||||
onChange={(event) => onHeadlineChange(event.target.value)}
|
||||
placeholder="Write a concise headline tailored to this role..."
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="skills" className={sectionClass}>
|
||||
<AccordionTrigger className={triggerClass}>
|
||||
Tailored Skills
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 pt-1">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 pb-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-[11px]"
|
||||
onClick={onAddSkillGroup}
|
||||
disabled={disableInputs}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
Add Skill Group
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{skillsDraft.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 px-3 py-4 text-center text-[11px] text-muted-foreground">
|
||||
No skill groups yet. Add one to tailor keywords for this role.
|
||||
</div>
|
||||
) : (
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
value={openSkillGroupId}
|
||||
onValueChange={onSkillGroupOpenChange}
|
||||
className="space-y-2"
|
||||
>
|
||||
{skillsDraft.map((group, index) => (
|
||||
<AccordionItem
|
||||
key={group.id}
|
||||
value={group.id}
|
||||
className="rounded-lg border border-border/60 bg-background/40 px-0"
|
||||
>
|
||||
<AccordionTrigger className="px-3 py-2 text-[11px] font-medium hover:no-underline">
|
||||
{group.name.trim() || `Skill Group ${index + 1}`}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 pt-1">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor={`tailor-skill-group-name-${group.id}`}
|
||||
className="text-[11px] font-medium text-muted-foreground"
|
||||
>
|
||||
Category
|
||||
</label>
|
||||
<input
|
||||
id={`tailor-skill-group-name-${group.id}`}
|
||||
type="text"
|
||||
className={inputClass}
|
||||
value={group.name}
|
||||
onChange={(event) =>
|
||||
onUpdateSkillGroup(
|
||||
group.id,
|
||||
"name",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Backend, Frontend, Infrastructure..."
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor={`tailor-skill-group-keywords-${group.id}`}
|
||||
className="text-[11px] font-medium text-muted-foreground"
|
||||
>
|
||||
Keywords (comma-separated)
|
||||
</label>
|
||||
<textarea
|
||||
id={`tailor-skill-group-keywords-${group.id}`}
|
||||
className={`${inputClass} min-h-[88px]`}
|
||||
value={group.keywordsText}
|
||||
onChange={(event) =>
|
||||
onUpdateSkillGroup(
|
||||
group.id,
|
||||
"keywordsText",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="TypeScript, Node.js, REST APIs..."
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[11px]"
|
||||
onClick={() => onRemoveSkillGroup(group.id)}
|
||||
disabled={disableInputs}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{!isCatalogLoading && catalog.length > 0 && (
|
||||
<AccordionItem value="projects" className={sectionClass}>
|
||||
<TooltipProvider>
|
||||
<Accordion type="multiple" className="space-y-3">
|
||||
<AccordionItem value="job-description" className={sectionClass}>
|
||||
<AccordionTrigger className={triggerClass}>
|
||||
Selected Projects
|
||||
Job Description
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 pt-1">
|
||||
<ProjectSelector
|
||||
catalog={catalog}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={onToggleProject}
|
||||
maxProjects={3}
|
||||
<label htmlFor="tailor-jd-edit" className="sr-only">
|
||||
Job Description
|
||||
</label>
|
||||
<textarea
|
||||
id="tailor-jd-edit"
|
||||
className={`${inputClass} min-h-[120px] max-h-[250px]`}
|
||||
value={jobDescription}
|
||||
onChange={(event) => onDescriptionChange(event.target.value)}
|
||||
placeholder="The raw job description..."
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
<AccordionItem value="tracer-links" className={sectionClass}>
|
||||
<AccordionTrigger className={triggerClass}>
|
||||
Tracer Links
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 pt-1">
|
||||
<div className="rounded-md border border-border/60 bg-background/60 p-3">
|
||||
<label
|
||||
htmlFor="tailor-tracer-links-enabled"
|
||||
className="flex cursor-pointer items-center gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
id="tailor-tracer-links-enabled"
|
||||
checked={tracerLinksEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onTracerLinksEnabledChange(Boolean(checked))
|
||||
}
|
||||
disabled={tracerToggleDisabled}
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Enable tracer links for this job
|
||||
</span>
|
||||
<AccordionItem value="summary" className={sectionClass}>
|
||||
<AccordionTrigger className={triggerClass}>Summary</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 pt-1">
|
||||
<div className="mb-2 flex justify-end gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={onUndoSummary}
|
||||
disabled={disableInputs || !canUndoSummary}
|
||||
aria-label={undoTooltip}
|
||||
title={
|
||||
!canUndoSummary
|
||||
? (undoDisabledReason ?? undefined)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{undoTooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={onRedoSummary}
|
||||
disabled={disableInputs || !canRedoSummary}
|
||||
aria-label={redoTooltip}
|
||||
>
|
||||
<Redo2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{redoTooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<label htmlFor="tailor-summary-edit" className="sr-only">
|
||||
Tailored Summary
|
||||
</label>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{tracerReadinessChecking
|
||||
? "Checking tracer-link readiness..."
|
||||
: "When enabled, outgoing resume links are rewritten to JobOps tracer links on the next PDF generation. Existing PDFs are unchanged."}
|
||||
</p>
|
||||
{tracerEnableBlockedReason && !tracerLinksEnabled ? (
|
||||
<p className="mt-2 text-xs text-destructive">
|
||||
Tracer links are unavailable: {tracerEnableBlockedReason}
|
||||
<textarea
|
||||
id="tailor-summary-edit"
|
||||
className={`${inputClass} min-h-[120px]`}
|
||||
value={summary}
|
||||
onChange={(event) => onSummaryChange(event.target.value)}
|
||||
placeholder="Write a tailored summary for this role, or generate with AI..."
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="headline" className={sectionClass}>
|
||||
<AccordionTrigger className={triggerClass}>Headline</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 pt-1">
|
||||
<div className="mb-2 flex justify-end gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={onUndoHeadline}
|
||||
disabled={disableInputs || !canUndoHeadline}
|
||||
aria-label={undoTooltip}
|
||||
title={
|
||||
!canUndoHeadline
|
||||
? (undoDisabledReason ?? undefined)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{undoTooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={onRedoHeadline}
|
||||
disabled={disableInputs || !canRedoHeadline}
|
||||
aria-label={redoTooltip}
|
||||
>
|
||||
<Redo2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{redoTooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<label htmlFor="tailor-headline-edit" className="sr-only">
|
||||
Tailored Headline
|
||||
</label>
|
||||
<input
|
||||
id="tailor-headline-edit"
|
||||
type="text"
|
||||
className={inputClass}
|
||||
value={headline}
|
||||
onChange={(event) => onHeadlineChange(event.target.value)}
|
||||
placeholder="Write a concise headline tailored to this role..."
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="skills" className={sectionClass}>
|
||||
<AccordionTrigger className={triggerClass}>
|
||||
Tailored Skills
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 pt-1">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 pb-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={onUndoSkills}
|
||||
disabled={disableInputs || !canUndoSkills}
|
||||
aria-label={undoTooltip}
|
||||
title={
|
||||
!canUndoSkills
|
||||
? (undoDisabledReason ?? undefined)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{undoTooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={onRedoSkills}
|
||||
disabled={disableInputs || !canRedoSkills}
|
||||
aria-label={redoTooltip}
|
||||
>
|
||||
<Redo2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{redoTooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-[11px]"
|
||||
onClick={onAddSkillGroup}
|
||||
disabled={disableInputs}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
Add Skill Group
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{skillsDraft.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 px-3 py-4 text-center text-[11px] text-muted-foreground">
|
||||
No skill groups yet. Add one to tailor keywords for this role.
|
||||
</div>
|
||||
) : (
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
value={openSkillGroupId}
|
||||
onValueChange={onSkillGroupOpenChange}
|
||||
className="space-y-2"
|
||||
>
|
||||
{skillsDraft.map((group, index) => (
|
||||
<AccordionItem
|
||||
key={group.id}
|
||||
value={group.id}
|
||||
className="rounded-lg border border-border/60 bg-background/40 px-0"
|
||||
>
|
||||
<AccordionTrigger className="px-3 py-2 text-[11px] font-medium hover:no-underline">
|
||||
{group.name.trim() || `Skill Group ${index + 1}`}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 pt-1">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor={`tailor-skill-group-name-${group.id}`}
|
||||
className="text-[11px] font-medium text-muted-foreground"
|
||||
>
|
||||
Category
|
||||
</label>
|
||||
<input
|
||||
id={`tailor-skill-group-name-${group.id}`}
|
||||
type="text"
|
||||
className={inputClass}
|
||||
value={group.name}
|
||||
onChange={(event) =>
|
||||
onUpdateSkillGroup(
|
||||
group.id,
|
||||
"name",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Backend, Frontend, Infrastructure..."
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor={`tailor-skill-group-keywords-${group.id}`}
|
||||
className="text-[11px] font-medium text-muted-foreground"
|
||||
>
|
||||
Keywords (comma-separated)
|
||||
</label>
|
||||
<textarea
|
||||
id={`tailor-skill-group-keywords-${group.id}`}
|
||||
className={`${inputClass} min-h-[88px]`}
|
||||
value={group.keywordsText}
|
||||
onChange={(event) =>
|
||||
onUpdateSkillGroup(
|
||||
group.id,
|
||||
"keywordsText",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="TypeScript, Node.js, REST APIs..."
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[11px]"
|
||||
onClick={() => onRemoveSkillGroup(group.id)}
|
||||
disabled={disableInputs}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{!isCatalogLoading && catalog.length > 0 && (
|
||||
<AccordionItem value="projects" className={sectionClass}>
|
||||
<AccordionTrigger className={triggerClass}>
|
||||
Selected Projects
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 pt-1">
|
||||
<ProjectSelector
|
||||
catalog={catalog}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={onToggleProject}
|
||||
maxProjects={3}
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
<AccordionItem value="tracer-links" className={sectionClass}>
|
||||
<AccordionTrigger className={triggerClass}>
|
||||
Tracer Links
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 pt-1">
|
||||
<div className="rounded-md border border-border/60 bg-background/60 p-3">
|
||||
<label
|
||||
htmlFor="tailor-tracer-links-enabled"
|
||||
className="flex cursor-pointer items-center gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
id="tailor-tracer-links-enabled"
|
||||
checked={tracerLinksEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onTracerLinksEnabledChange(Boolean(checked))
|
||||
}
|
||||
disabled={tracerToggleDisabled}
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Enable tracer links for this job
|
||||
</span>
|
||||
</label>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{tracerReadinessChecking
|
||||
? "Checking tracer-link readiness..."
|
||||
: "When enabled, outgoing resume links are rewritten to JobOps tracer links on the next PDF generation. Existing PDFs are unchanged."}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
{tracerEnableBlockedReason && !tracerLinksEnabled ? (
|
||||
<p className="mt-2 text-xs text-destructive">
|
||||
Tracer links are unavailable: {tracerEnableBlockedReason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,12 +1,23 @@
|
||||
import type { Job } from "@shared/types.js";
|
||||
import { ArrowLeft, Check, FileText, Loader2, Sparkles } from "lucide-react";
|
||||
import type React from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import * as api from "../../api";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import { useTracerReadiness } from "../../hooks/useTracerReadiness";
|
||||
import {
|
||||
fromEditableSkillGroups,
|
||||
getOriginalHeadline,
|
||||
getOriginalSkills,
|
||||
getOriginalSummary,
|
||||
parseTailoredSkills,
|
||||
serializeTailoredSkills,
|
||||
toEditableSkillGroups,
|
||||
} from "../tailoring-utils";
|
||||
import { canFinalizeTailoring } from "./rules";
|
||||
import { TailoringSections } from "./TailoringSections";
|
||||
import { useTailoringDraft } from "./useTailoringDraft";
|
||||
@ -34,6 +45,22 @@ interface TailoringWorkspaceTailorProps extends TailoringWorkspaceBaseProps {
|
||||
type TailoringWorkspaceProps =
|
||||
| TailoringWorkspaceEditorProps
|
||||
| TailoringWorkspaceTailorProps;
|
||||
type TailoringSectionsProps = ComponentProps<typeof TailoringSections>;
|
||||
|
||||
interface TailoringBaseline {
|
||||
summary: string;
|
||||
headline: string;
|
||||
skillsJson: string;
|
||||
}
|
||||
|
||||
const normalizeSkillsJson = (value: string | null | undefined) =>
|
||||
serializeTailoredSkills(parseTailoredSkills(value));
|
||||
|
||||
const toBaselineFromJob = (job: Job): TailoringBaseline => ({
|
||||
summary: job.tailoredSummary ?? "",
|
||||
headline: job.tailoredHeadline ?? "",
|
||||
skillsJson: normalizeSkillsJson(job.tailoredSkills),
|
||||
});
|
||||
|
||||
export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
||||
props,
|
||||
@ -55,6 +82,7 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
||||
tracerLinksEnabled,
|
||||
setTracerLinksEnabled,
|
||||
skillsDraft,
|
||||
setSkillsDraft,
|
||||
openSkillGroupId,
|
||||
setOpenSkillGroupId,
|
||||
skillsJson,
|
||||
@ -73,9 +101,36 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
||||
const [isSummarizing, setIsSummarizing] = useState(false);
|
||||
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const { profile, error: profileError } = useProfile();
|
||||
const { readiness: tracerReadiness, isChecking: isTracerReadinessChecking } =
|
||||
useTracerReadiness();
|
||||
|
||||
const originalValues = useMemo(() => {
|
||||
const skillsDraft = toEditableSkillGroups(getOriginalSkills(profile));
|
||||
return {
|
||||
summary: getOriginalSummary(profile),
|
||||
headline: getOriginalHeadline(profile),
|
||||
skillsDraft,
|
||||
skillsJson: serializeTailoredSkills(fromEditableSkillGroups(skillsDraft)),
|
||||
};
|
||||
}, [profile]);
|
||||
const canUseOriginalValues = Boolean(profile) && !profileError;
|
||||
const [aiBaseline, setAiBaseline] = useState<TailoringBaseline>(() =>
|
||||
toBaselineFromJob(props.job),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setAiBaseline({
|
||||
summary: props.job.tailoredSummary ?? "",
|
||||
headline: props.job.tailoredHeadline ?? "",
|
||||
skillsJson: normalizeSkillsJson(props.job.tailoredSkills),
|
||||
});
|
||||
}, [
|
||||
props.job.tailoredSummary,
|
||||
props.job.tailoredHeadline,
|
||||
props.job.tailoredSkills,
|
||||
]);
|
||||
|
||||
const tracerEnableBlocked =
|
||||
!tracerLinksEnabled && !tracerReadiness?.canEnable;
|
||||
const tracerEnableBlockedReason =
|
||||
@ -149,6 +204,7 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
||||
|
||||
const updatedJob = await api.summarizeJob(props.job.id, { force: true });
|
||||
applyIncomingDraft(updatedJob);
|
||||
setAiBaseline(toBaselineFromJob(updatedJob));
|
||||
toast.success("AI Summary & Projects generated");
|
||||
await editorProps.onUpdate();
|
||||
} catch (error) {
|
||||
@ -172,6 +228,7 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
||||
|
||||
const updatedJob = await api.summarizeJob(props.job.id, { force: true });
|
||||
applyIncomingDraft(updatedJob);
|
||||
setAiBaseline(toBaselineFromJob(updatedJob));
|
||||
|
||||
toast.success("Draft generated with AI", {
|
||||
description: "Review and edit before finalizing.",
|
||||
@ -233,11 +290,115 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
||||
tailorProps.onFinalize();
|
||||
}, [tailorProps, isDirty, persistCurrent]);
|
||||
|
||||
const handleUndoSummary = useCallback(() => {
|
||||
setSummary(originalValues.summary);
|
||||
}, [originalValues.summary, setSummary]);
|
||||
|
||||
const handleUndoHeadline = useCallback(() => {
|
||||
setHeadline(originalValues.headline);
|
||||
}, [originalValues.headline, setHeadline]);
|
||||
|
||||
const handleUndoSkills = useCallback(() => {
|
||||
setSkillsDraft(originalValues.skillsDraft);
|
||||
}, [originalValues.skillsDraft, setSkillsDraft]);
|
||||
|
||||
const handleRedoSummary = useCallback(() => {
|
||||
setSummary(aiBaseline.summary);
|
||||
}, [aiBaseline.summary, setSummary]);
|
||||
|
||||
const handleRedoHeadline = useCallback(() => {
|
||||
setHeadline(aiBaseline.headline);
|
||||
}, [aiBaseline.headline, setHeadline]);
|
||||
|
||||
const handleRedoSkills = useCallback(() => {
|
||||
setSkillsDraft(
|
||||
toEditableSkillGroups(parseTailoredSkills(aiBaseline.skillsJson)),
|
||||
);
|
||||
}, [aiBaseline.skillsJson, setSkillsDraft]);
|
||||
|
||||
const disableInputs = editorProps
|
||||
? isSummarizing || isGeneratingPdf || isSaving
|
||||
: isGenerating || Boolean(tailorProps?.isFinalizing) || isSaving;
|
||||
|
||||
const canFinalize = canFinalizeTailoring(summary);
|
||||
const tailoringSectionsProps = useMemo<TailoringSectionsProps>(
|
||||
() => ({
|
||||
catalog,
|
||||
isCatalogLoading,
|
||||
summary,
|
||||
headline,
|
||||
jobDescription,
|
||||
skillsDraft,
|
||||
selectedIds,
|
||||
tracerLinksEnabled,
|
||||
tracerEnableBlocked,
|
||||
tracerEnableBlockedReason,
|
||||
tracerReadinessChecking: isTracerReadinessChecking,
|
||||
openSkillGroupId,
|
||||
disableInputs,
|
||||
onSummaryChange: setSummary,
|
||||
onHeadlineChange: setHeadline,
|
||||
onUndoSummary: handleUndoSummary,
|
||||
onUndoHeadline: handleUndoHeadline,
|
||||
onUndoSkills: handleUndoSkills,
|
||||
onRedoSummary: handleRedoSummary,
|
||||
onRedoHeadline: handleRedoHeadline,
|
||||
onRedoSkills: handleRedoSkills,
|
||||
canUndoSummary:
|
||||
canUseOriginalValues && summary !== originalValues.summary,
|
||||
canUndoHeadline:
|
||||
canUseOriginalValues && headline !== originalValues.headline,
|
||||
canUndoSkills:
|
||||
canUseOriginalValues && skillsJson !== originalValues.skillsJson,
|
||||
canRedoSummary: summary !== aiBaseline.summary,
|
||||
canRedoHeadline: headline !== aiBaseline.headline,
|
||||
canRedoSkills: skillsJson !== aiBaseline.skillsJson,
|
||||
undoDisabledReason: canUseOriginalValues
|
||||
? null
|
||||
: "Original base CV unavailable.",
|
||||
onDescriptionChange: setJobDescription,
|
||||
onSkillGroupOpenChange: setOpenSkillGroupId,
|
||||
onAddSkillGroup: handleAddSkillGroup,
|
||||
onUpdateSkillGroup: handleUpdateSkillGroup,
|
||||
onRemoveSkillGroup: handleRemoveSkillGroup,
|
||||
onToggleProject: handleToggleProject,
|
||||
onTracerLinksEnabledChange: setTracerLinksEnabled,
|
||||
}),
|
||||
[
|
||||
catalog,
|
||||
isCatalogLoading,
|
||||
summary,
|
||||
headline,
|
||||
jobDescription,
|
||||
skillsDraft,
|
||||
selectedIds,
|
||||
tracerLinksEnabled,
|
||||
tracerEnableBlocked,
|
||||
tracerEnableBlockedReason,
|
||||
isTracerReadinessChecking,
|
||||
openSkillGroupId,
|
||||
disableInputs,
|
||||
setSummary,
|
||||
setHeadline,
|
||||
handleUndoSummary,
|
||||
handleUndoHeadline,
|
||||
handleUndoSkills,
|
||||
handleRedoSummary,
|
||||
handleRedoHeadline,
|
||||
handleRedoSkills,
|
||||
canUseOriginalValues,
|
||||
originalValues,
|
||||
skillsJson,
|
||||
aiBaseline,
|
||||
setJobDescription,
|
||||
setOpenSkillGroupId,
|
||||
handleAddSkillGroup,
|
||||
handleUpdateSkillGroup,
|
||||
handleRemoveSkillGroup,
|
||||
handleToggleProject,
|
||||
setTracerLinksEnabled,
|
||||
],
|
||||
);
|
||||
|
||||
if (editorProps) {
|
||||
return (
|
||||
@ -280,30 +441,7 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border bg-card p-4 shadow-sm">
|
||||
<TailoringSections
|
||||
catalog={catalog}
|
||||
isCatalogLoading={isCatalogLoading}
|
||||
summary={summary}
|
||||
headline={headline}
|
||||
jobDescription={jobDescription}
|
||||
skillsDraft={skillsDraft}
|
||||
selectedIds={selectedIds}
|
||||
tracerLinksEnabled={tracerLinksEnabled}
|
||||
tracerEnableBlocked={tracerEnableBlocked}
|
||||
tracerEnableBlockedReason={tracerEnableBlockedReason}
|
||||
tracerReadinessChecking={isTracerReadinessChecking}
|
||||
openSkillGroupId={openSkillGroupId}
|
||||
disableInputs={disableInputs}
|
||||
onSummaryChange={setSummary}
|
||||
onHeadlineChange={setHeadline}
|
||||
onDescriptionChange={setJobDescription}
|
||||
onSkillGroupOpenChange={setOpenSkillGroupId}
|
||||
onAddSkillGroup={handleAddSkillGroup}
|
||||
onUpdateSkillGroup={handleUpdateSkillGroup}
|
||||
onRemoveSkillGroup={handleRemoveSkillGroup}
|
||||
onToggleProject={handleToggleProject}
|
||||
onTracerLinksEnabledChange={setTracerLinksEnabled}
|
||||
/>
|
||||
<TailoringSections {...tailoringSectionsProps} />
|
||||
|
||||
<div className="flex justify-end border-t pt-4">
|
||||
<Button
|
||||
@ -372,30 +510,7 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TailoringSections
|
||||
catalog={catalog}
|
||||
isCatalogLoading={isCatalogLoading}
|
||||
summary={summary}
|
||||
headline={headline}
|
||||
jobDescription={jobDescription}
|
||||
skillsDraft={skillsDraft}
|
||||
selectedIds={selectedIds}
|
||||
tracerLinksEnabled={tracerLinksEnabled}
|
||||
tracerEnableBlocked={tracerEnableBlocked}
|
||||
tracerEnableBlockedReason={tracerEnableBlockedReason}
|
||||
tracerReadinessChecking={isTracerReadinessChecking}
|
||||
openSkillGroupId={openSkillGroupId}
|
||||
disableInputs={disableInputs}
|
||||
onSummaryChange={setSummary}
|
||||
onHeadlineChange={setHeadline}
|
||||
onDescriptionChange={setJobDescription}
|
||||
onSkillGroupOpenChange={setOpenSkillGroupId}
|
||||
onAddSkillGroup={handleAddSkillGroup}
|
||||
onUpdateSkillGroup={handleUpdateSkillGroup}
|
||||
onRemoveSkillGroup={handleRemoveSkillGroup}
|
||||
onToggleProject={handleToggleProject}
|
||||
onTracerLinksEnabledChange={setTracerLinksEnabled}
|
||||
/>
|
||||
<TailoringSections {...tailoringSectionsProps} />
|
||||
</div>
|
||||
|
||||
<Separator className="my-4 opacity-50" />
|
||||
|
||||
@ -224,6 +224,7 @@ export function useTailoringDraft({
|
||||
selectedIds,
|
||||
selectedIdsCsv,
|
||||
skillsDraft,
|
||||
setSkillsDraft,
|
||||
openSkillGroupId,
|
||||
setOpenSkillGroupId,
|
||||
skillsJson,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user