- 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
115 lines
3.2 KiB
TypeScript
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>
|
|
);
|
|
};
|