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>
432 lines
15 KiB
TypeScript
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' }),
|
|
};
|