Files
perinco-CRM/src/lib/api.ts
T
Sascha WolfandClaude Opus 5 33dedaedfb feat(opportunities): Uebernahme zurueck in die Akquise schieben
Im Dialog einer Opportunity aus der Akquise nimmt "Zurueck in die Akquise"
eine versehentliche Uebernahme zurueck: alle Opportunities aus dem Eintrag
wandern in den Papierkorb, der Eintrag steht mit seinem Funddatum wieder in
Eingang bzw. Archiv. Neue Spalte crm_opportunities.acquisition_id, damit bei
mehreren Beratern alle gefunden werden. Wiederherstellen haengt den Eintrag
wieder an die Opportunity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 13:12:03 +02:00

432 lines
15 KiB
TypeScript

import type {
Acquisition,
AcquisitionList,
AcquisitionOption,
AcquisitionOptionField,
AcquisitionOptions,
AppUser,
CalendarEvent,
Lead,
LeadConnection,
Opportunity,
Role,
ThemePalette,
} 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, eigenes Foto und eigenes Design setzen - ohne
* Adminrechte. null setzt zurueck: Farbe auf die Palettenfarbe, Foto auf die
* Initiale, Design auf perinco, Modus auf "folgt dem System".
*/
updateMe: (input: {
color?: string | null;
avatar?: string | null;
theme?: ThemePalette | null;
theme_mode?: 'light' | 'dark' | 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' }),
/**
* Profilfoto zu einer LinkedIn-Adresse holen. Der Server liest das
* oeffentliche Vorschaubild des Profils und liefert es als Data-URL; das
* Speichern uebernimmt danach der normale Kontakt-Aufruf.
*/
linkedinPhoto: (linkedin: string) =>
request<{ avatar: string; bytes: number; source_url: string; profile_url: string }>(
'/leads/linkedin-photo',
{ method: 'POST', body: JSON.stringify({ linkedin }) }
),
/** 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' }),
/**
* Nimmt die Uebernahme aus der Akquise zurueck: alle Opportunities aus dem
* Eintrag wandern in den Papierkorb, der Eintrag steht wieder in der Akquise.
*/
backToAcquisition: (id: number) =>
request<{ ok: true; acquisition_id: number; trashed: number[] }>(
`/opportunities/${id}/acquisition`,
{ 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<string, number>;
converted: Record<string, number>;
won: Record<string, number>;
};
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<DashboardStats>(
`/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<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' | 'papierkorb' | '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) }),
/** 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<Acquisition>(`/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<Acquisition>(`/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<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' }),
};