Files
ICON-CRM/src/lib/api.ts
T
schakal00783andClaude Opus 5.5 1b82f580db Scanner: Stellenanzeigen aus Jobbörsen (DACH, max. 21 Tage)
Neuer Menüpunkt Scanner mit Dashboard und Suchagenten, Zeitplan und
API-Schlüssel unter Einstellungen -> Scanner.

- Quellen: Agentur für Arbeit (API), StepStone, karriere.at,
  freelancermap (HTML) sowie Adzuna und Jooble (kostenlose Schlüssel)
- Quote = gefundene Schlagwörter, Titel zählt doppelt; keine KI
- Abgleich mit icon_companies, bekannte Firmen oben mit Abzeichen
- Zeitplan Vorgabe stündlich Mo-Fr 06-20 Uhr, einstellbar
- Sperre gegen parallele Läufe wie im perinco-Scanner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 10:49:44 +02:00

537 lines
20 KiB
TypeScript

import type {
AppUser,
Company,
Connection,
Contact,
DirectoryUser,
Employment,
HistoryEntry,
Interview,
Lead,
LeadTodo,
PerincoContact,
Project,
Tender,
} from '../types';
const TOKEN_KEY = 'icon_crm_token';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY);
}
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
/** Wird bei 401 gefeuert, damit die Anwendung zurueck auf den Login springt. */
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(handler: (() => void) | null) {
onUnauthorized = handler;
}
export async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers);
if (options.body && !headers.has('Content-Type') && !(options.body instanceof FormData)) {
headers.set('Content-Type', 'application/json');
}
const token = getToken();
if (token) headers.set('Authorization', `Bearer ${token}`);
let response: Response;
try {
response = await fetch(`/api${path}`, { ...options, headers });
} catch {
throw new ApiError('Server nicht erreichbar. Bitte Verbindung prüfen.', 0);
}
if (response.status === 401) {
clearToken();
onUnauthorized?.();
}
const isJson = response.headers.get('content-type')?.includes('application/json');
const payload = isJson ? await response.json().catch(() => null) : null;
if (!response.ok) {
const message =
(payload && typeof payload === 'object' && 'error' in payload && String(payload.error)) ||
`Anfrage fehlgeschlagen (HTTP ${response.status}).`;
throw new ApiError(message, response.status);
}
return payload as T;
}
const get = <T>(path: string) => apiRequest<T>(path);
const post = <T>(path: string, body?: unknown) =>
apiRequest<T>(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) });
const patch = <T>(path: string, body: unknown) =>
apiRequest<T>(path, { method: 'PATCH', body: JSON.stringify(body) });
const del = <T>(path: string) => apiRequest<T>(path, { method: 'DELETE' });
/** Haengt nur gesetzte Werte als Abfrageparameter an. */
function query(params: Record<string, unknown>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === '') continue;
search.set(key, String(value));
}
const text = search.toString();
return text ? `?${text}` : '';
}
export const authApi = {
login: (identifier: string, password: string) =>
post<{ token: string; user: AppUser }>('/auth/login', { identifier, password }),
me: () => get<{ user: AppUser }>('/auth/me'),
updateMe: (patchBody: Partial<Pick<AppUser, 'color' | 'avatar' | 'theme' | 'theme_mode'>>) =>
patch<{ user: AppUser }>('/auth/me', patchBody),
changePassword: (current_password: string, new_password: string) =>
post<{ ok: true }>('/auth/change-password', { current_password, new_password }),
};
export const usersApi = {
directory: () => get<{ users: DirectoryUser[] }>('/users/directory'),
list: () => get<{ users: AppUser[] }>('/users'),
create: (body: Record<string, unknown>) => post<{ user: AppUser }>('/users', body),
update: (id: number, body: Record<string, unknown>) => patch<{ user: AppUser }>(`/users/${id}`, body),
remove: (id: number) => del<{ ok: true }>(`/users/${id}`),
};
export interface CompanyDetail {
company: Company;
contacts: Array<Pick<Contact, 'id' | 'first_name' | 'last_name' | 'position' | 'email' | 'phone' | 'avatar' | 'kind' | 'status'>>;
former: Array<{
employment_id: number;
id: number;
first_name: string | null;
last_name: string;
avatar: string | null;
position: string | null;
started_on: string | null;
ended_on: string | null;
now_company: string | null;
now_company_id: number | null;
}>;
interviews: Interview[];
history: HistoryEntry[];
mentioned: HistoryEntry[];
}
export const companiesApi = {
list: (params: Record<string, unknown> = {}) =>
get<{ companies: Company[] }>(`/companies${query(params)}`),
options: () => get<{ industries: string[]; groups: string[] }>('/companies/options'),
detail: (id: number) => get<CompanyDetail>(`/companies/${id}`),
create: (body: Record<string, unknown>) => post<{ company: Company }>('/companies', body),
update: (id: number, body: Record<string, unknown>) =>
patch<{ company: Company }>(`/companies/${id}`, body),
remove: (id: number) => del<{ ok: true }>(`/companies/${id}`),
addInterview: (id: number, body: Record<string, unknown>) =>
post<{ id: number }>(`/companies/${id}/interviews`, body),
};
export interface ContactDetail {
contact: Contact;
employments: Employment[];
interviews: Interview[];
history: HistoryEntry[];
mentioned: HistoryEntry[];
}
export const contactsApi = {
list: (params: Record<string, unknown> = {}) =>
get<{ contacts: Contact[] }>(`/contacts${query(params)}`),
detail: (id: number) => get<ContactDetail>(`/contacts/${id}`),
create: (body: Record<string, unknown>) => post<{ contact: Contact }>('/contacts', body),
update: (id: number, body: Record<string, unknown>) =>
patch<{ contact: Contact }>(`/contacts/${id}`, body),
remove: (id: number) => del<{ ok: true }>(`/contacts/${id}`),
addInterview: (id: number, body: Record<string, unknown>) =>
post<{ id: number }>(`/contacts/${id}/interviews`, body),
/*
* Werdegang von Hand pflegen. Die Stationen entstehen zwar beim Setzen eines
* Arbeitgebers und beim CV-Import von selbst - aber kein Lebenslauf wird
* vollständig erkannt, und ein falscher Firmenname ließ sich sonst nicht mehr
* geradeziehen.
*
* `company` (Name) oder `company_id` - ein unbekannter Name legt das
* Unternehmen an, wie im CV-Import auch.
*/
addEmployment: (id: number, body: Record<string, unknown>) =>
post<{ id: number }>(`/contacts/${id}/employments`, body),
updateEmployment: (employmentId: number, body: Record<string, unknown>) =>
patch<{ ok: true }>(`/contacts/employments/${employmentId}`, body),
removeEmployment: (employmentId: number) =>
del<{ ok: true }>(`/contacts/employments/${employmentId}`),
lastEmployers: () =>
get<{
entries: Array<{
contact_id: number;
first_name: string | null;
last_name: string;
avatar: string | null;
company_id: number;
company: string;
industry: string | null;
position: string | null;
ended_on: string | null;
}>;
}>('/contacts/last-employers'),
linkedInPhoto: (url: string) =>
post<{ avatar: string; bytes: number }>('/contacts/linkedin-photo', { url }),
};
export const tendersApi = {
list: (params: Record<string, unknown> = {}) => get<{ tenders: Tender[] }>(`/tenders${query(params)}`),
detail: (id: number) =>
get<{ tender: Tender; contacts: Contact[]; history: HistoryEntry[] }>(`/tenders/${id}`),
create: (body: Record<string, unknown>) => post<{ tender: Tender }>('/tenders', body),
update: (id: number, body: Record<string, unknown>) => patch<{ tender: Tender }>(`/tenders/${id}`, body),
remove: (id: number) => del<{ ok: true }>(`/tenders/${id}`),
};
export const projectsApi = {
list: (params: Record<string, unknown> = {}) =>
get<{ projects: Project[] }>(`/projects${query(params)}`),
create: (body: Record<string, unknown>) => post<{ project: Project }>('/projects', body),
update: (id: number, body: Record<string, unknown>) =>
patch<{ project: Project }>(`/projects/${id}`, body),
remove: (id: number) => del<{ ok: true }>(`/projects/${id}`),
};
export const leadsApi = {
list: (archived = false) => get<{ leads: Lead[] }>(`/leads${query({ archived: archived ? 1 : 0 })}`),
detail: (id: number) =>
get<{ lead: Lead; todos: LeadTodo[]; history: HistoryEntry[] }>(`/leads/${id}`),
create: (body: Record<string, unknown>) => post<{ lead: Lead }>('/leads', body),
update: (id: number, body: Record<string, unknown>) => patch<{ lead: Lead }>(`/leads/${id}`, body),
remove: (id: number) => del<{ ok: true }>(`/leads/${id}`),
reminders: () =>
get<{ due: Lead[]; stale: Lead[]; stale_days: number }>('/leads/reminders'),
acknowledge: (id: number, snooze_days?: number) =>
post<{ ok: true }>(`/leads/${id}/acknowledge`, { snooze_days }),
addTodo: (id: number, text: string, due_on?: string | null) =>
post<{ id: number }>(`/leads/${id}/todos`, { text, due_on }),
updateTodo: (todoId: number, body: Record<string, unknown>) =>
patch<{ ok: true }>(`/leads/todos/${todoId}`, body),
removeTodo: (todoId: number) => del<{ ok: true }>(`/leads/todos/${todoId}`),
};
/** Vorschlag der KI zu einem Lebenslauf. */
export interface CvSuggestion {
first_name: string | null;
last_name: string | null;
position: string | null;
email: string | null;
phone: string | null;
linkedin: string | null;
company: string | null;
tags: string[];
bio: string | null;
rate_onsite: number | null;
rate_remote: number | null;
employments: Array<{ company: string; position: string | null; from: string | null; to: string | null }>;
confidence: number | null;
}
export interface CvImport {
id: number;
filename: string;
original_name: string;
mime: string | null;
size_bytes: number;
status: 'eingang' | 'screening' | 'auswahl' | 'verworfen';
extracted: CvSuggestion | null;
/**
* Traegt der Lebenslauf ein Bewerbungsfoto? Das Bild selbst steht nicht in
* der Liste - es wird bei Bedarf einzeln geholt (`cvsApi.photo`) und erst im
* Browser verkleinert (siehe lib/imageCompress.ts).
*/
has_photo: boolean;
error: string | null;
contact_id: number | null;
contact_name: string | null;
uploaded_by_name: string | null;
created_at: string;
processed_at: string | null;
}
export const cvsApi = {
list: () =>
get<{ imports: CvImport[]; ai: { configured: boolean; model: string } }>('/cvs'),
/** Mehrere Dateien auf einmal - der Browser setzt die multipart-Grenze selbst. */
upload: (files: File[]) => {
const body = new FormData();
for (const file of files) body.append('files', file);
return apiRequest<{ ids: number[]; count: number }>('/cvs', { method: 'POST', body });
},
analyze: (id: number) =>
post<{ extracted: CvSuggestion; photo: string | null }>(`/cvs/${id}/analyze`),
/** Das gefundene Foto, unveraendert - nur fuer das Formular. */
photo: (id: number) => get<{ photo: string | null }>(`/cvs/${id}/photo`),
accept: (id: number, overrides: Record<string, unknown> = {}) =>
post<{ contact_id: number }>(`/cvs/${id}/accept`, overrides),
reject: (id: number) => post<{ ok: true }>(`/cvs/${id}/reject`),
remove: (id: number) => del<{ ok: true }>(`/cvs/${id}`),
};
export type AiTask = 'write' | 'improve' | 'shorten' | 'bullets' | 'profile';
/**
* Zustand der KI-Einrichtung. Der Schlüssel steht hier nur verkürzt
* ("sk-proj-…a1b2") - vollständig verlässt er den Server nie.
*/
export interface AiConfig {
configured: boolean;
model: string;
defaultModel: string;
/** 'db' = in den Einstellungen gepflegt, 'env' = aus server/.env, 'none' = keiner */
source: 'db' | 'env' | 'none';
masked: string | null;
envAvailable: boolean;
}
export const aiApi = {
status: () => get<{ configured: boolean; model: string; tasks: AiTask[] }>('/ai/status'),
text: (task: AiTask, current: string, prompt?: string) =>
post<{ text: string; model: string }>('/ai/text', { task, current, prompt }),
config: () => get<AiConfig>('/ai/config'),
models: () => get<{ models: { id: string; created: number }[] }>('/ai/models'),
saveConfig: (changes: { key?: string; model?: string }) => patch<AiConfig>('/ai/config', changes),
test: () => post<{ ok: true; model: string; ms: number; answer: string }>('/ai/test'),
};
export const perincoApi = {
status: () => get<{ configured: boolean; url: string | null }>('/perinco/status'),
contacts: () => get<{ contacts: PerincoContact[]; cached: boolean }>('/perinco/contacts'),
/**
* Abgleich eines doppelt gefuehrten Kontakts.
*
* `local` sind die Felder, die hier gelten sollen, `remote` die, die drueben
* gelten sollen - beide in der Sprache der Bruecke (`notes`, `source`).
*/
merge: (
remoteId: string,
body: {
contact_id: number;
local: Record<string, unknown>;
remote: Record<string, unknown>;
}
) =>
post<{ ok: true; local_changed: string[]; remote_changed: string[] }>(
`/perinco/contacts/${remoteId}/merge`,
body
),
};
export const historyApi = {
list: (params: Record<string, unknown> = {}) =>
get<{ entries: HistoryEntry[] }>(`/history${query(params)}`),
add: (entity_type: string, entity_id: number | null, text: string) =>
post<{ entry: HistoryEntry }>('/history', { entity_type, entity_id, text }),
remove: (id: number) => del<{ ok: true }>(`/history/${id}`),
};
export interface SearchGroup {
type: 'contact' | 'company' | 'tender' | 'project' | 'lead' | 'history';
label: string;
items: Array<Record<string, unknown> & { id: number; title?: string }>;
}
export interface MentionCandidate {
kind: 'user' | 'contact' | 'company';
id: number;
label: string;
hint: string | null;
avatar: string | null;
color?: string | null;
}
export interface AiAnswer {
answer: string;
refs: Array<{ type: 'contact' | 'company' | 'tender' | 'project' | 'lead'; id: number; label: string }>;
model: string;
truncated: boolean;
}
export const searchApi = {
search: (q: string) => get<{ query: string; groups: SearchGroup[]; total: number }>(`/search${query({ q })}`),
mentions: (q: string) => get<{ candidates: MentionCandidate[] }>(`/search/mentions${query({ q })}`),
ask: (question: string) => post<AiAnswer>('/search/ai', { question }),
};
export interface OptionValue {
id: number;
field: string;
value: string;
position: number;
/** Wie oft der Wert im Bestand verwendet wird. */
used: number;
}
export interface OptionField {
field: string;
label: string;
values: OptionValue[];
}
export const optionsApi = {
list: () => get<{ fields: OptionField[] }>('/options'),
create: (field: string, value: string) => post<{ id: number }>('/options', { field, value }),
rename: (id: number, value: string) => patch<{ ok: true; renamed: number }>(`/options/${id}`, { value }),
remove: (id: number) => del<{ ok: true }>(`/options/${id}`),
};
export interface NetworkData {
companies: Array<Company & { id: number }>;
contacts: Array<{
id: number;
name: string;
position: string | null;
avatar: string | null;
kind: string;
status: string;
company_id: number | null;
}>;
edges: Array<Record<string, unknown> & { type: 'employment' | 'former' | 'contact' | 'group' }>;
groups: Array<{ name: string; count: number }>;
}
export const networkApi = {
load: (params: Record<string, unknown> = {}) => get<NetworkData>(`/network${query(params)}`),
};
export const connectionsApi = {
list: () => get<{ connections: Connection[] }>('/connections'),
create: (contact_a: number, contact_b: number, kind?: string) =>
post<Connection>('/connections', { contact_a, contact_b, kind }),
update: (id: number, kind: string) => patch<{ ok: true }>(`/connections/${id}`, { kind }),
remove: (id: number) => del<{ ok: true }>(`/connections/${id}`),
};
export interface DashboardData {
counts: Record<string, number>;
contacts_by_status: Array<{ status: string; count: number }>;
companies_by_industry: Array<{ industry: string; count: number }>;
tenders_by_stage: Array<{ stage: string; count: number }>;
recent: HistoryEntry[];
}
export const dashboardApi = {
load: () => get<DashboardData>('/dashboard'),
};
/* ---------------------------------------------------------------- Scanner */
export type ScannerSourceId = 'ba' | 'stepstone' | 'karriere' | 'adzuna' | 'jooble' | 'freelancermap';
export type ScannerMatchStatus = 'neu' | 'gesehen' | 'in_bearbeitung' | 'verworfen';
export interface ScannerAgent {
id: number;
name: string;
keywords: string[];
sources: ScannerSourceId[];
excludes: string[];
is_active: boolean;
matches: number;
}
export interface ScannerSchedule {
active: boolean;
/** ISO-Wochentage, 1 = Montag */
days: number[];
from: string;
to: string;
/** Minuten */
interval: number;
/** höchstes Alter einer Anzeige in Tagen */
maxAge: number;
}
export interface ScannerSource {
id: ScannerSourceId;
label: string;
countries: string;
ready: boolean;
needsKey: 'adzuna' | 'jooble' | null;
last: { ok: boolean; count: number; errors: string[] } | null;
}
export interface ScannerOverview {
stats: { agents: number; jobs: number; matches: number; fresh: number; known: number };
sources: ScannerSource[];
schedule: ScannerSchedule;
lastScanAt: string | null;
lastRun: {
id: number;
trigger: 'auto' | 'manual';
started_at: string;
finished_at: string;
jobs_found: number;
matches_new: number;
} | null;
status: { running: boolean; since: string | null };
}
export interface ScannerMatch {
id: number;
score: number;
hits: string[];
status: ScannerMatchStatus;
created_at: string;
agent_id: number;
agent_name: string;
job_id: number;
source: ScannerSourceId;
title: string;
company: string | null;
location: string | null;
country: string | null;
employment_type: string | null;
url: string;
description: string | null;
published_at: string | null;
company_id: number | null;
company_name: string | null;
is_customer: boolean | null;
known: boolean;
}
export interface ScannerSecrets {
adzunaId: string | null;
adzunaKey: string | null;
joobleKey: string | null;
}
export type ScannerAgentInput = Pick<ScannerAgent, 'name' | 'keywords' | 'sources' | 'excludes' | 'is_active'>;
const put = <T>(path: string, body: unknown) =>
apiRequest<T>(path, { method: 'PUT', body: JSON.stringify(body) });
export const scannerApi = {
overview: () => get<ScannerOverview>('/scanner/overview'),
agents: () => get<{ agents: ScannerAgent[] }>('/scanner/agents'),
createAgent: (agent: ScannerAgentInput) => post<{ agent: ScannerAgent }>('/scanner/agents', agent),
updateAgent: (id: number, agent: ScannerAgentInput) => put<{ agent: ScannerAgent }>(`/scanner/agents/${id}`, agent),
removeAgent: (id: number) => del<{ ok: true }>(`/scanner/agents/${id}`),
matches: (params: Record<string, unknown> = {}) =>
get<{ total: number; matches: ScannerMatch[] }>(`/scanner/matches${query(params)}`),
setStatus: (id: number, status: ScannerMatchStatus) => patch<{ ok: true }>(`/scanner/matches/${id}`, { status }),
scan: () => post<{ started: true }>('/scanner/scan'),
settings: () => get<{ settings: ScannerSchedule; secrets: ScannerSecrets }>('/scanner/settings'),
saveSettings: (changes: Partial<ScannerSchedule> & Partial<Record<keyof ScannerSecrets, string>>) =>
patch<{ settings: ScannerSchedule; secrets: ScannerSecrets }>('/scanner/settings', changes),
};