Ein gefundenes Projekt passt oft zu mehr als einem Berater, und fuer jeden geht eine eigene Bewerbung heraus. Das Feld "Berater" liess bisher nur einen Namen zu. Gespeichert wird eine kommagetrennte Liste in crm_acquisitions.consultant (auf VARCHAR(500) erweitert, Migration in db.js) und keine eigene Zuordnungstabelle: die Namen sind reiner Text, die Auswahlliste in crm_acquisition_options ist eine Pflegehilfe und kein Fremdschluessel. Suche, Sortierung und Anzeige der Liste arbeiten damit weiter auf einem Feld. Die Funktionen zum Zerlegen stehen in acquisitionRules.js, das Gegenstueck fuer das Frontend in src/types.ts, geprueft von server/test/acquisition.test.mjs. Zerlegt wird an vier Stellen: - Uebernahme: je Berater eine Opportunity, alle in einer Transaktion. Ihre Titel tragen den Namen in Klammern, weil die Opportunity kein eigenes Feld fuer den Berater hat und in Pipeline und Liste sonst identische Zeilen stehen; bei einem einzigen Berater bleibt der Titel unveraendert. In den Notizen steht neben dem eigenen Berater auch, mit wem er sich das Projekt teilt. Der Eintrag merkt sich die erste Opportunity - sie entscheidet nur darueber, ob er wieder im Eingang auftaucht. - Verlauf (crm_acquisition_history): ein Berater je Zeile. Ein Eintrag mit drei Beratern sind drei Bewerbungen, "Nico, Lisa" waere sonst ein eigener Schluessel neben "Nico" und "Lisa". - Dashboard: dieselbe Zaehlung. Die Summe der Balken liegt damit ueber der Gesamtzahl "Bewerbungen", die weiter die Eintraege zaehlt. - Auswahlliste pflegen: Umbenennen ersetzt den Namen innerhalb der Liste, Loeschen zaehlt auch, wer nur einer von mehreren ist. Ein Gleichheitsvergleich uebersah beides. In der Oberflaeche haengt das Auswahlfeld jeweils einen Namen an, die Gewaehlten stehen als Plaketten darunter und lassen sich einzeln entfernen; ein bereits gewaehlter Berater verschwindet aus der Liste. Bewusst kein <select multiple>: Strg-Klick ist mit der Maus unauffindbar und mit einer Beruehrung gar nicht bedienbar. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
405 lines
14 KiB
TypeScript
405 lines
14 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' }),
|
|
};
|
|
|
|
// ---------- 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' }),
|
|
};
|