import type { Acquisition, AcquisitionList, AcquisitionOption, AcquisitionOptionField, AcquisitionOptions, AppUser, CalendarEvent, Lead, LeadConnection, Opportunity, Role, } from '../types'; const TOKEN_KEY = 'perinco_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 App zurueck auf den Login springt. */ type UnauthorizedHandler = () => void; let onUnauthorized: UnauthorizedHandler | null = null; export function setUnauthorizedHandler(handler: UnauthorizedHandler | null) { onUnauthorized = handler; } export async function apiRequest(path: string, options: RequestInit = {}): Promise { const headers = new Headers(options.headers); // JSON ist der Normalfall, aber nicht der einzige: der CV-Upload des Scanners // schickt FormData, dort muss der Browser die multipart-Grenze selbst setzen. 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; } /** Kurzform fuer die Aufrufe in dieser Datei. */ const request = apiRequest; // ---------- Auth ---------- export const authApi = { login: (identifier: string, password: string) => request<{ token: string; user: AppUser }>('/auth/login', { method: 'POST', body: JSON.stringify({ identifier, password }), }), me: () => request<{ user: AppUser }>('/auth/me'), /** * Eigene Terminfarbe und eigenes Foto setzen - ohne Adminrechte. * null setzt zurueck: Farbe auf die Palettenfarbe, Foto auf die Initiale. */ updateMe: (input: { color?: string | null; avatar?: string | null }) => request<{ user: AppUser }>('/auth/me', { method: 'PATCH', body: JSON.stringify(input) }), changePassword: (current_password: string, new_password: string) => request<{ ok: true }>('/auth/change-password', { method: 'POST', body: JSON.stringify({ current_password, new_password }), }), }; // ---------- Benutzerverwaltung (nur admin) ---------- export interface UserInput { username: string; name: string; email?: string | null; password?: string; role?: Role; color?: string | null; avatar?: string | null; is_active?: boolean; } export const usersApi = { list: () => request('/users'), /** * Aktive Benutzer - fuer jeden Angemeldeten abrufbar. Die Terminansicht braucht * die Liste fuer @-Mentions, Teilnehmer und Farben, auch fuer Rolle "user". */ directory: () => request('/users/directory'), create: (input: UserInput) => request('/users', { method: 'POST', body: JSON.stringify(input) }), update: (id: number, input: Partial) => request(`/users/${id}`, { method: 'PATCH', body: JSON.stringify(input) }), remove: (id: number) => request<{ ok: true }>(`/users/${id}`, { method: 'DELETE' }), }; // ---------- Leads ---------- export type LeadInput = Partial>; /** * Herkunft eines Eintrags im Vorbereich "Aus Mails": aus wie vielen Mails er * stammt, welche die letzte war und wie sicher der Fund ist. */ export interface MailPoolSource { message_count: number; last_received_at: string | null; last_subject: string | null; /** 0-100; null, wenn der Vorschlag inzwischen entfernt wurde. */ confidence: number | null; origin: 'absender' | 'empfaenger' | 'signatur' | 'text' | null; } export interface PromoteResult { /** true = vorhandener Kontakt ergaenzt, false = neuer Kontakt angelegt. */ merged: boolean; /** Anzahl der uebertragenen Felder (nur beim Ergaenzen). */ fields: number; removed_pool: boolean; lead: Lead; } export const leadsApi = { list: () => request('/leads'), create: (input: LeadInput) => request('/leads', { method: 'POST', body: JSON.stringify(input) }), update: (id: number, input: LeadInput) => request(`/leads/${id}`, { method: 'PATCH', body: JSON.stringify(input) }), remove: (id: number) => request<{ ok: true }>(`/leads/${id}`, { method: 'DELETE' }), /** Herkunftsangaben aller Eintraege aus "Aus Mails", nach Lead-ID. */ mailPoolSources: () => request>('/leads/mail-pool/sources'), /** * Eintrag aus "Aus Mails" in die Kontakte holen. Ohne `target_lead_id` * entsteht ein neuer Kontakt; mit ihr werden die genannten Felder in den * vorhandenen Kontakt geschrieben. `keep_pool` behaelt den Eintrag im * Vorbereich. */ promote: ( id: number, input: { target_lead_id?: number | null; fields?: string[]; keep_pool?: boolean; owner_id?: number } = {} ) => request(`/leads/${id}/promote`, { method: 'POST', body: JSON.stringify(input) }), promoteMany: (ids: number[], options: { keep_pool?: boolean } = {}) => request<{ ok: true; created: number[]; skipped: number[] }>('/leads/mail-pool/promote', { method: 'POST', body: JSON.stringify({ ids, ...options }), }), }; // ---------- Opportunities ---------- export type OpportunityInput = Partial< Omit > & { /** Ansprechpartner werden immer komplett ersetzt. */ contacts?: { lead_id: number; role?: string | null }[]; }; export const opportunitiesApi = { /** `trash` zeigt genau den Papierkorb, sonst ist er ausgeblendet. */ list: (options: { trash?: boolean } = {}) => request(`/opportunities${options.trash ? '?trash=true' : ''}`), create: (input: OpportunityInput) => request('/opportunities', { method: 'POST', body: JSON.stringify(input) }), update: (id: number, input: OpportunityInput) => request(`/opportunities/${id}`, { method: 'PATCH', body: JSON.stringify(input) }), /** Legt die Opportunity in den Papierkorb - endgueltig entfernt sie erst das Leeren. */ remove: (id: number) => request<{ ok: true }>(`/opportunities/${id}`, { method: 'DELETE' }), restore: (id: number) => request(`/opportunities/${id}/restore`, { method: 'POST' }), emptyTrash: () => request<{ ok: true; purged: number }>('/opportunities/trash/empty', { method: 'POST' }), }; // ---------- Dashboard ---------- /** Zeiteinteilung der Auswertung. */ export type DashboardBucket = 'tag' | 'monat' | 'quartal' | 'jahr'; /** * Zahlen des Dashboards. Die Reihen kommen als Zuordnung Zeitabschnitt -> Anzahl; * die Achse baut die Ansicht selbst, damit auch leere Abschnitte erscheinen. */ export interface DashboardStats { from: string; to: string; bucket: DashboardBucket; series: { applications: Record; converted: Record; won: Record; }; totals: { applications: number; converted: number; won: number }; by_consultant: { consultant: string | null; applications: number }[]; } export const dashboardApi = { stats: (params: { from: string; to: string; bucket: DashboardBucket }) => request( `/dashboard?from=${params.from}&to=${params.to}&bucket=${params.bucket}` ), }; // ---------- Zaehler der Seitenleiste ---------- /** * Zahlen hinter den Menuepunkten. `scanner_rejected` ist null, wenn die * Scanner-Datenbank nicht erreichbar ist - dann zeigt die Leiste dort keine Zahl. */ export interface SidebarBadges { acquisition_archive: number; acquisition_trash: number; opportunity_trash: number; mail_trash: number; scanner_rejected: number | null; } export const badgesApi = { get: () => request('/badges'), }; // ---------- Akquise ---------- /** * Zahlen gehen als Text raus, damit ein leeres Feld auch leer bleibt und nicht * als 0 in der Datenbank landet - dasselbe Muster wie bei den Opportunities. */ export type AcquisitionInput = Partial< Omit< Acquisition, | 'id' | 'created_at' | 'updated_at' | 'converted_at' | 'opportunity_id' | 'opportunity_title' | 'opportunity_stage' | 'created_by' | 'created_by_name' | 'daily_rate' | 'duration_days' | 'fixed_price' > > & { daily_rate?: string | number | null; duration_days?: string | number | null; fixed_price?: string | number | null; }; export type AcquisitionView = 'eingang' | 'archiv' | 'papierkorb' | 'alle'; export const acquisitionsApi = { list: (view: AcquisitionView = 'eingang') => request(`/acquisitions?view=${view}`), create: (input: AcquisitionInput) => request('/acquisitions', { method: 'POST', body: JSON.stringify(input) }), update: (id: number, input: AcquisitionInput) => request(`/acquisitions/${id}`, { method: 'PATCH', body: JSON.stringify(input) }), /** Legt den Eintrag in den Papierkorb - endgueltig loeschen tut `purge`. */ remove: (id: number) => request<{ ok: true }>(`/acquisitions/${id}`, { method: 'DELETE' }), /** * Aus dem Archiv zurueck in den Eingang: Funddatum auf heute, und bei einem * Rueckfaeller wird die Uebernahme geloest (der Verweis auf die geloeschte * Opportunity faellt weg). Nicht der Papierkorb - der ist `restore`. */ reopen: (id: number) => request(`/acquisitions/${id}/reopen`, { method: 'POST' }), /** * Aus dem Papierkorb zurueckholen. Das Funddatum bleibt - der Eintrag landet * wieder dort, wo er hingehoert: im Eingang, wenn er jung ist, sonst im Archiv. */ restore: (id: number) => request(`/acquisitions/${id}/restore`, { method: 'POST' }), /** Endgueltig loeschen; geht nur aus dem Papierkorb heraus. */ purge: (id: number) => request<{ ok: true }>(`/acquisitions/${id}/permanent`, { method: 'DELETE' }), /** Papierkorb in einem Zug leeren. */ emptyTrash: () => request<{ ok: true; purged: number }>('/acquisitions/trash/empty', { method: 'POST' }), /** * Legt die Opportunity an und vermerkt den Eintrag als uebernommen. Stehen * mehrere Berater im Eintrag, entsteht je Berater eine - `opportunity_ids` * zaehlt alle auf, `opportunity_id` ist die erste (die der Eintrag sich * merkt). */ toOpportunity: (id: number) => request<{ ok: true; opportunity_id: number; opportunity_ids: number[]; consultants: string[]; }>(`/acquisitions/${id}/opportunity`, { method: 'POST', }), }; /** * Inhalte der Dropdowns "Von", "Anbieter", "Kanal" und "Berater". Pflegen darf * jeder Angemeldete: eine Liste, die nur ein Administrator ergaenzen kann, wuerde * die Eingabe genau dann blockieren, wenn ein neuer Anbieter auftaucht. */ export const acquisitionOptionsApi = { list: () => request('/acquisitions/options'), create: (field: AcquisitionOptionField, value: string) => request('/acquisitions/options', { method: 'POST', body: JSON.stringify({ field, value }), }), /** Umbenennen zieht die vorhandenen Akquise-Eintraege mit; `updated` sagt wie viele. */ update: (id: number, value: string) => request( `/acquisitions/options/${id}`, { method: 'PATCH', body: JSON.stringify({ value }) } ), /** Entfernt nur die Auswahl; `still_in_use` zaehlt die Eintraege, die den Wert behalten. */ remove: (id: number) => request<{ ok: true; still_in_use: number }>(`/acquisitions/options/${id}`, { method: 'DELETE' }), }; // ---------- Interviews (Termine) ---------- export interface EventInput { title: string; description?: string | null; location?: string | null; /** `YYYY-MM-DDTHH:MM` oder `YYYY-MM-DD HH:MM:SS` */ starts_at: string; ends_at: string; all_day?: boolean; participant_ids?: number[]; } export const eventsApi = { list: () => request('/events'), create: (input: EventInput) => request('/events', { method: 'POST', body: JSON.stringify(input) }), update: (id: number, input: Partial) => request(`/events/${id}`, { method: 'PATCH', body: JSON.stringify(input) }), remove: (id: number) => request<{ ok: true }>(`/events/${id}`, { method: 'DELETE' }), }; // ---------- Verbindungen ---------- export interface ConnectionInput { from_lead_id: number; to_lead_id: number; type?: LeadConnection['type']; label?: string | null; strength?: number; } export const connectionsApi = { list: () => request('/connections'), create: (input: ConnectionInput) => request('/connections', { method: 'POST', body: JSON.stringify(input) }), update: (id: number, input: Partial>) => request(`/connections/${id}`, { method: 'PATCH', body: JSON.stringify(input) }), remove: (id: number) => request<{ ok: true }>(`/connections/${id}`, { method: 'DELETE' }), };