Files
perinco-CRM/src/lib/api.ts
T
Sascha WolfandClaude Opus 5 42639c65fa feat(opportunities): Phasen des Vermittlungsprozesses, Kanban zuerst, Zahlen im Menue
Drei Dinge:

- Die Phasen sind jetzt die des Vermittlungsprozesses: Neu, Beworben,
  Erstgespraech Berater, Kundenvorstellung, Interview Kunde, Zusage, Beauftragt,
  Absage. Offen ist alles bis einschliesslich Zusage - dort ist der Einsatz
  bestaetigt, die Beauftragung steht aber noch aus. migrateOpportunityStages()
  stellt bestehende Datenbanken um und setzt alle vorhandenen Opportunities auf
  "Neu": in welcher der neuen Phasen ein Projekt steckt, sagt der alte Trichter
  nicht (so mit dem Nutzer entschieden).

  Der Datenbankwert heisst 'Erstgespraech Berater' ohne Umlaut - Projektkonvention
  fuer Datenbankwerte. Angezeigt wird er ueber STAGE_LABELS; dazu gibt es
  STAGE_HINTS mit der Erlaeuterung je Phase als Titelhinweis an Auswahl, Badge
  und Spaltenkopf.

- Kanban ist die erste Ansicht der Opportunities. Die Pipeline ist die
  Arbeitsansicht, Kachel und Liste sind zum Nachschauen.

- Hinter "Archiv", beiden "Papierkorb"-Punkten und "Unpassend" steht die Anzahl,
  wie schon bei "Aus Mails". Die Zahlen kommen aus einem eigenen Endpunkt
  (/api/badges), weil sie alle ins Menue gehoeren und zusammen kommen sollen;
  Inhalte laedt weiter jede Ansicht selbst. Der Scanner liegt in einer eigenen
  Datenbank und kann fehlen - sein Zaehler ist dann null und die Leiste zeigt
  dort einfach keine Zahl. Der Aufruf haengt bewusst hinter den Daten und nicht
  im Promise.all: faellt er aus, bleibt das Menue ohne Zahlen statt die Ansicht
  mit einem Fehler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 14:34:41 +02:00

341 lines
12 KiB
TypeScript

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<T>(path: string, options: RequestInit = {}): Promise<T> {
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<AppUser[]>('/users'),
/**
* Aktive Benutzer - fuer jeden Angemeldeten abrufbar. Die Terminansicht braucht
* die Liste fuer @-Mentions, Teilnehmer und Farben, auch fuer Rolle "user".
*/
directory: () => request<AppUser[]>('/users/directory'),
create: (input: UserInput) =>
request<AppUser>('/users', { method: 'POST', body: JSON.stringify(input) }),
update: (id: number, input: Partial<UserInput>) =>
request<AppUser>(`/users/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
remove: (id: number) => request<{ ok: true }>(`/users/${id}`, { method: 'DELETE' }),
};
// ---------- Leads ----------
export type LeadInput = Partial<Omit<Lead, 'id' | 'created_at' | 'updated_at'>>;
/**
* 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<Lead[]>('/leads'),
create: (input: LeadInput) =>
request<Lead>('/leads', { method: 'POST', body: JSON.stringify(input) }),
update: (id: number, input: LeadInput) =>
request<Lead>(`/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<Record<string, MailPoolSource>>('/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<PromoteResult>(`/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<Opportunity, 'id' | 'created_at' | 'updated_at' | 'contacts'>
> & {
/** 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<Opportunity[]>(`/opportunities${options.trash ? '?trash=true' : ''}`),
create: (input: OpportunityInput) =>
request<Opportunity>('/opportunities', { method: 'POST', body: JSON.stringify(input) }),
update: (id: number, input: OpportunityInput) =>
request<Opportunity>(`/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<Opportunity>(`/opportunities/${id}/restore`, { method: 'POST' }),
emptyTrash: () =>
request<{ ok: true; purged: number }>('/opportunities/trash/empty', { method: 'POST' }),
};
// ---------- 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;
opportunity_trash: number;
mail_trash: number;
scanner_rejected: number | null;
}
export const badgesApi = {
get: () => request<SidebarBadges>('/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' | 'alle';
export const acquisitionsApi = {
list: (view: AcquisitionView = 'eingang') =>
request<AcquisitionList>(`/acquisitions?view=${view}`),
create: (input: AcquisitionInput) =>
request<Acquisition>('/acquisitions', { method: 'POST', body: JSON.stringify(input) }),
update: (id: number, input: AcquisitionInput) =>
request<Acquisition>(`/acquisitions/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
remove: (id: number) => request<{ ok: true }>(`/acquisitions/${id}`, { method: 'DELETE' }),
/** Legt die Opportunity an und vermerkt den Eintrag als uebernommen. */
toOpportunity: (id: number) =>
request<{ ok: true; opportunity_id: number }>(`/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<AcquisitionOptions>('/acquisitions/options'),
create: (field: AcquisitionOptionField, value: string) =>
request<AcquisitionOption & { field: AcquisitionOptionField }>('/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<AcquisitionOption & { field: AcquisitionOptionField; updated: number }>(
`/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<CalendarEvent[]>('/events'),
create: (input: EventInput) =>
request<CalendarEvent>('/events', { method: 'POST', body: JSON.stringify(input) }),
update: (id: number, input: Partial<EventInput>) =>
request<CalendarEvent>(`/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<LeadConnection[]>('/connections'),
create: (input: ConnectionInput) =>
request<LeadConnection>('/connections', { method: 'POST', body: JSON.stringify(input) }),
update: (id: number, input: Partial<Omit<ConnectionInput, 'from_lead_id' | 'to_lead_id'>>) =>
request<LeadConnection>(`/connections/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
remove: (id: number) => request<{ ok: true }>(`/connections/${id}`, { method: 'DELETE' }),
};