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(path: string, options: RequestInit = {}): Promise { 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 = (path: string) => apiRequest(path); const post = (path: string, body?: unknown) => apiRequest(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) }); const patch = (path: string, body: unknown) => apiRequest(path, { method: 'PATCH', body: JSON.stringify(body) }); const del = (path: string) => apiRequest(path, { method: 'DELETE' }); /** Haengt nur gesetzte Werte als Abfrageparameter an. */ function query(params: Record): 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>) => 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) => post<{ user: AppUser }>('/users', body), update: (id: number, body: Record) => patch<{ user: AppUser }>(`/users/${id}`, body), remove: (id: number) => del<{ ok: true }>(`/users/${id}`), }; export interface CompanyDetail { company: Company; contacts: Array>; 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 = {}) => get<{ companies: Company[] }>(`/companies${query(params)}`), options: () => get<{ industries: string[]; groups: string[] }>('/companies/options'), detail: (id: number) => get(`/companies/${id}`), create: (body: Record) => post<{ company: Company }>('/companies', body), update: (id: number, body: Record) => patch<{ company: Company }>(`/companies/${id}`, body), remove: (id: number) => del<{ ok: true }>(`/companies/${id}`), addInterview: (id: number, body: Record) => 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 = {}) => get<{ contacts: Contact[] }>(`/contacts${query(params)}`), detail: (id: number) => get(`/contacts/${id}`), create: (body: Record) => post<{ contact: Contact }>('/contacts', body), update: (id: number, body: Record) => patch<{ contact: Contact }>(`/contacts/${id}`, body), remove: (id: number) => del<{ ok: true }>(`/contacts/${id}`), addInterview: (id: number, body: Record) => 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) => post<{ id: number }>(`/contacts/${id}/employments`, body), updateEmployment: (employmentId: number, body: Record) => 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 = {}) => get<{ tenders: Tender[] }>(`/tenders${query(params)}`), detail: (id: number) => get<{ tender: Tender; contacts: Contact[]; history: HistoryEntry[] }>(`/tenders/${id}`), create: (body: Record) => post<{ tender: Tender }>('/tenders', body), update: (id: number, body: Record) => patch<{ tender: Tender }>(`/tenders/${id}`, body), remove: (id: number) => del<{ ok: true }>(`/tenders/${id}`), }; export const projectsApi = { list: (params: Record = {}) => get<{ projects: Project[] }>(`/projects${query(params)}`), create: (body: Record) => post<{ project: Project }>('/projects', body), update: (id: number, body: Record) => 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) => post<{ lead: Lead }>('/leads', body), update: (id: number, body: Record) => 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) => 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 = {}) => 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('/ai/config'), saveConfig: (changes: { key?: string; model?: string }) => patch('/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'), }; export const historyApi = { list: (params: Record = {}) => 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 & { 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('/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; contacts: Array<{ id: number; name: string; position: string | null; avatar: string | null; kind: string; status: string; company_id: number | null; }>; edges: Array & { type: 'employment' | 'former' | 'contact' | 'group' }>; groups: Array<{ name: string; count: number }>; } export const networkApi = { load: (params: Record = {}) => get(`/network${query(params)}`), }; export const connectionsApi = { list: () => get<{ connections: Connection[] }>('/connections'), create: (contact_a: number, contact_b: number, kind?: string) => post('/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; 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('/dashboard'), };