Jobber/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx
Shaheer Sarfaraz b88d00b15d
Make projects optional when moving jobs to Ready (#189)
* Make resume projects optional and reuse selection rules

* Apply Biome import/format fixes

* Handle explicit empty project selection in PDF generation

* Hide selected projects section when catalog is empty

* Avoid projects section flash while catalog is loading
2026-02-18 22:31:59 +00:00

259 lines
11 KiB
TypeScript

import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import type { ResumeProjectCatalogItem } from "@shared/types.js";
import { AlertCircle, CheckCircle2 } from "lucide-react";
import type React from "react";
import { Controller, useFormContext } from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { clampInt } from "@/lib/utils";
import {
toggleAiSelectable,
toggleMustInclude,
} from "../resume-projects-state";
import { BaseResumeSelection } from "./BaseResumeSelection";
type ReactiveResumeSectionProps = {
rxResumeBaseResumeIdDraft: string | null;
setRxResumeBaseResumeIdDraft: (value: string | null) => void;
// True when v4 credentials or v5 API key are configured.
hasRxResumeAccess: boolean;
profileProjects: ResumeProjectCatalogItem[];
lockedCount: number;
maxProjectsTotal: number;
isProjectsLoading: boolean;
isLoading: boolean;
isSaving: boolean;
};
export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
rxResumeBaseResumeIdDraft,
setRxResumeBaseResumeIdDraft,
hasRxResumeAccess,
profileProjects,
lockedCount,
maxProjectsTotal,
isProjectsLoading,
isLoading,
isSaving,
}) => {
const {
control,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
return (
<AccordionItem value="reactive-resume" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Reactive Resume</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
{!hasRxResumeAccess ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>RxResume Access Missing</AlertTitle>
<AlertDescription>
Configure RxResume credentials in settings (email + password) or
set <code>RXRESUME_API_KEY</code> to enable access.
</AlertDescription>
</Alert>
) : (
<>
<Alert className="bg-green-50 border-green-200 dark:bg-green-900/10 dark:border-green-900/20">
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
<AlertTitle className="text-green-800 dark:text-green-300">
RxResume Access Ready
</AlertTitle>
<AlertDescription className="text-green-700 dark:text-green-400">
Reactive Resume access is active.
</AlertDescription>
</Alert>
<BaseResumeSelection
value={rxResumeBaseResumeIdDraft}
onValueChange={setRxResumeBaseResumeIdDraft}
hasRxResumeAccess={hasRxResumeAccess}
disabled={isLoading || isSaving}
/>
<Separator />
<div className="space-y-4">
{!rxResumeBaseResumeIdDraft ? (
<div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
Choose a PDF to configure resume projects.
</div>
) : (
<>
<div className="space-y-2">
<div className="text-sm font-medium">
Max projects to choose
</div>
<Controller
name="resumeProjects"
control={control}
render={({ field }) => (
<Input
type="number"
inputMode="numeric"
min={lockedCount}
max={maxProjectsTotal}
value={field.value?.maxProjects ?? 0}
onChange={(event) => {
if (!field.value) return;
const next = Number(event.target.value);
const clamped = clampInt(
next,
lockedCount,
maxProjectsTotal,
);
field.onChange({
...field.value,
maxProjects: clamped,
});
}}
disabled={
isLoading ||
isSaving ||
isProjectsLoading ||
!field.value
}
/>
)}
/>
{errors.resumeProjects?.maxProjects && (
<p className="text-xs text-destructive">
{errors.resumeProjects.maxProjects.message}
</p>
)}
</div>
<Controller
name="resumeProjects"
control={control}
render={({ field }) => (
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
Project
</TableHead>
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
Visible in template
</TableHead>
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
Must Include
</TableHead>
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
AI selectable
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{profileProjects.map((project) => {
const locked = Boolean(
field.value?.lockedProjectIds.includes(
project.id,
),
);
const aiSelectable = Boolean(
field.value?.aiSelectableProjectIds.includes(
project.id,
),
);
return (
<TableRow key={project.id}>
<TableCell>
<div className="space-y-0.5">
<div className="font-medium">
{project.name || project.id}
</div>
<div className="text-xs text-muted-foreground">
{[project.description, project.date]
.filter(Boolean)
.join(" - ")}
</div>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{project.isVisibleInBase ? "Yes" : "No"}
</TableCell>
<TableCell>
<Checkbox
checked={locked}
disabled={
isLoading ||
isSaving ||
isProjectsLoading ||
!field.value
}
onCheckedChange={(checked) => {
if (!field.value) return;
field.onChange(
toggleMustInclude({
settings: field.value,
projectId: project.id,
checked: checked === true,
maxProjectsTotal,
}),
);
}}
/>
</TableCell>
<TableCell>
<Checkbox
checked={locked ? true : aiSelectable}
disabled={
locked ||
isLoading ||
isSaving ||
isProjectsLoading ||
!field.value
}
onCheckedChange={(checked) => {
if (!field.value) return;
field.onChange(
toggleAiSelectable({
settings: field.value,
projectId: project.id,
checked: checked === true,
}),
);
}}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
/>
</>
)}
</div>
</>
)}
</div>
</AccordionContent>
</AccordionItem>
);
};