ilia 60b61ffe03 feat: job notes, deal-breaker score cap, richer cover letters, bulk action limit bump
- Add per-listing notes (JobNotes component, notes column, auto-save)
- Enforce hard score cap (≤15) when deal-breaker hits are present;
  add clearance/citizenship deal-breaker rules to scoring prompt
- Cover letter prompt now uses full search profile (experience level,
  skills, work arrangement, location, salary, industries, deal-breakers)
  and produces longer, name-signed output
- Include candidate name + location in sanitized scorer profile
- Raise MAX_JOB_ACTION_BATCH_SIZE from 100 → 2500 (shared constant)
- Update README with new features

Made-with: Cursor
2026-04-06 15:55:26 -04:00

115 lines
3.2 KiB
TypeScript

import * as api from "@client/api";
import type { Job } from "@shared/types.js";
import { StickyNote } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
interface JobNotesProps {
job: Job;
onJobUpdated: () => void | Promise<void>;
className?: string;
}
const DEBOUNCE_MS = 800;
export const JobNotes: React.FC<JobNotesProps> = ({
job,
onJobUpdated,
className,
}) => {
const [value, setValue] = useState(job.notes ?? "");
const [isSaving, setIsSaving] = useState(false);
const pendingRef = useRef<string | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const jobIdRef = useRef(job.id);
useEffect(() => {
if (jobIdRef.current !== job.id) {
jobIdRef.current = job.id;
if (timerRef.current) clearTimeout(timerRef.current);
pendingRef.current = null;
}
setValue(job.notes ?? "");
}, [job.id, job.notes]);
const persist = useCallback(
async (text: string) => {
try {
setIsSaving(true);
await api.updateJob(job.id, { notes: text || null });
await onJobUpdated();
} catch (error) {
const msg =
error instanceof Error ? error.message : "Failed to save notes";
toast.error(msg);
} finally {
setIsSaving(false);
if (pendingRef.current !== null) {
const next = pendingRef.current;
pendingRef.current = null;
void persist(next);
}
}
},
[job.id, onJobUpdated],
);
const scheduleFlush = useCallback(
(text: string) => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
if (isSaving) {
pendingRef.current = text;
} else {
void persist(text);
}
}, DEBOUNCE_MS);
},
[isSaving, persist],
);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const next = e.target.value;
setValue(next);
scheduleFlush(next);
};
const handleBlur = () => {
if (timerRef.current) clearTimeout(timerRef.current);
const trimmed = value;
if (trimmed !== (job.notes ?? "")) {
if (isSaving) {
pendingRef.current = trimmed;
} else {
void persist(trimmed);
}
}
};
return (
<div className={cn("space-y-1.5", className)}>
<div className="flex items-center gap-1.5">
<StickyNote className="h-3.5 w-3.5 text-muted-foreground/70" />
<span className="text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wide">
Notes
</span>
{isSaving && (
<span className="text-[10px] text-muted-foreground/50 ml-auto">
Saving
</span>
)}
</div>
<Textarea
value={value}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Add notes about this listing…"
className="min-h-[72px] resize-y text-xs leading-relaxed bg-muted/5 border-border/40 placeholder:text-muted-foreground/40 focus-visible:ring-1"
/>
</div>
);
};