- 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
253 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
};
|