Files
Jobber/orchestrator/src/client/pages/settings/components/ProfileManager.tsx
T
ilia fea00ae656 feat: search profiles, cover letters, discovery fixes
- Add search profiles (DB, API, settings UI) and wire into scorer/pipeline search terms.
- Add cover letter generation (service, job action, JobDetail UI).
- Align JobSpy Indeed country with country-level search geography when settings conflict; warn in logs.
- Infer country from search cities via inferCountryKeyFromSearchGeography (shared).
- Ignore extractor venv/storage and local data in Biome; ignore orchestrator/storage and JobSpy .venv in git.
- Vite: do not watch orchestrator/storage (prevents reloads during startup.jobs pipeline).
- JobSpy: document Python 3.10+ and venv setup in README/requirements.
- Onboarding and settings: local resume path handling, orchestrator .env.example for Vite.

Made-with: Cursor
2026-04-05 19:35:14 -04:00

253 lines
8.2 KiB
TypeScript

import * as api from "@client/api";
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import type { JobSearchProfile, SearchProfile } from "@shared/types.js";
import { Loader2, Plus, Sparkles, Trash2 } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
const EMPTY_PROFILE: JobSearchProfile = {
targetRoles: [],
experienceLevel: "",
mustHaveSkills: [],
niceToHaveSkills: [],
dealBreakers: [],
preferredWorkArrangement: [],
preferredLocations: [],
minimumSalary: "",
industriesToTarget: [],
industriesToAvoid: [],
aboutMe: "",
};
interface ProfileManagerProps {
activeProfileId: string | null;
disabled: boolean;
}
export const ProfileManager: React.FC<ProfileManagerProps> = ({
activeProfileId,
disabled,
}) => {
const { setValue } = useFormContext<UpdateSettingsInput>();
const [profiles, setProfiles] = useState<SearchProfile[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(activeProfileId);
const [loading, setLoading] = useState(false);
const [generating, setGenerating] = useState(false);
const [newProfileName, setNewProfileName] = useState("");
const [showCreate, setShowCreate] = useState(false);
const loadProfiles = useCallback(async () => {
try {
const list = await api.listProfiles();
setProfiles(list);
} catch {
toast.error("Failed to load profiles");
}
}, []);
useEffect(() => {
void loadProfiles();
}, [loadProfiles]);
useEffect(() => {
setSelectedId(activeProfileId);
}, [activeProfileId]);
const handleSelect = useCallback(
async (profileId: string) => {
setLoading(true);
try {
await api.activateProfile(profileId);
setSelectedId(profileId);
const profile = profiles.find((p) => p.id === profileId);
if (profile) {
setValue("jobSearchProfile", profile.data, { shouldDirty: false });
setValue("activeProfileId", profileId, { shouldDirty: false });
}
toast.success("Profile activated");
} catch {
toast.error("Failed to activate profile");
} finally {
setLoading(false);
}
},
[profiles, setValue],
);
const handleCreateBlank = useCallback(async () => {
if (!newProfileName.trim()) {
toast.error("Enter a profile name");
return;
}
setLoading(true);
try {
const profile = await api.createProfile({
name: newProfileName.trim(),
data: EMPTY_PROFILE,
});
await api.activateProfile(profile.id);
setProfiles((prev) => [...prev, profile]);
setSelectedId(profile.id);
setValue("jobSearchProfile", profile.data, { shouldDirty: false });
setValue("activeProfileId", profile.id, { shouldDirty: false });
setNewProfileName("");
setShowCreate(false);
toast.success(`Profile "${profile.name}" created`);
} catch {
toast.error("Failed to create profile");
} finally {
setLoading(false);
}
}, [newProfileName, setValue]);
const handleGenerateFromResume = useCallback(async () => {
if (!newProfileName.trim()) {
toast.error("Enter a profile name first");
return;
}
setGenerating(true);
try {
const generated = await api.generateProfileFromResume();
const profile = await api.createProfile({
name: newProfileName.trim(),
data: generated,
});
await api.activateProfile(profile.id);
setProfiles((prev) => [...prev, profile]);
setSelectedId(profile.id);
setValue("jobSearchProfile", profile.data, { shouldDirty: false });
setValue("activeProfileId", profile.id, { shouldDirty: false });
setNewProfileName("");
setShowCreate(false);
toast.success(`Profile "${profile.name}" created from your resume`);
} catch (err) {
const msg =
err instanceof Error ? err.message : "Failed to generate profile";
toast.error(msg);
} finally {
setGenerating(false);
}
}, [newProfileName, setValue]);
const handleDelete = useCallback(
async (profileId: string) => {
try {
await api.deleteProfile(profileId);
setProfiles((prev) => prev.filter((p) => p.id !== profileId));
if (selectedId === profileId) {
setSelectedId(null);
setValue("jobSearchProfile", EMPTY_PROFILE, { shouldDirty: false });
setValue("activeProfileId", null, { shouldDirty: false });
}
toast.success("Profile deleted");
} catch {
toast.error("Failed to delete profile");
}
},
[selectedId, setValue],
);
const activeProfile = profiles.find((p) => p.id === selectedId);
return (
<div className="space-y-3 mb-4">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<select
className="flex h-9 flex-1 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={selectedId ?? ""}
onChange={(e) => {
if (e.target.value) void handleSelect(e.target.value);
}}
disabled={disabled || loading || generating}
>
<option value="">
{profiles.length === 0
? "No profiles — create one"
: "Select a profile..."}
</option>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setShowCreate(!showCreate)}
disabled={disabled || generating}
>
<Plus className="h-3.5 w-3.5 mr-1" />
New
</Button>
{activeProfile && (
<Button
type="button"
size="icon"
variant="ghost"
className="h-9 w-9 text-muted-foreground hover:text-destructive"
onClick={() => void handleDelete(activeProfile.id)}
disabled={disabled || generating}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
{showCreate && (
<div className="flex items-center gap-2 p-3 rounded-lg border border-dashed bg-muted/30">
<Input
value={newProfileName}
onChange={(e) => setNewProfileName(e.target.value)}
placeholder="Profile name (e.g. QA Engineer, DevOps)"
className="flex-1 h-8 text-sm"
disabled={generating}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleCreateBlank();
}
}}
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={handleCreateBlank}
disabled={!newProfileName.trim() || generating}
>
Create blank
</Button>
<Button
type="button"
size="sm"
onClick={handleGenerateFromResume}
disabled={!newProfileName.trim() || generating}
>
{generating ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5 mr-1" />
)}
{generating ? "Analysing resume..." : "Generate from resume"}
</Button>
</div>
)}
</div>
{!selectedId && profiles.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-2">
Create a profile to get started. "Generate from resume" will analyse
your Reactive Resume and fill in the fields automatically.
</p>
)}
</div>
);
};