Files
perinco-CRM/src/types.ts
T
Sascha WolfandClaude Opus 5 05cc261900 feat(akquise): Auswahllisten statt Freitext, Felder abgesetzt
"Von", "Anbieter", "Kanal" und "Berater" sind Dropdowns. Ihre Inhalte stehen in
crm_acquisition_options und werden im neuen Reiter Einstellungen -> Akquise
gepflegt. Freitext hat aus "Hays", "hays" und "Hays AG" drei Anbieter gemacht;
der Unique-Schluessel greift ueber die Kollation auch bei abweichender
Gross-/Kleinschreibung.

Eine Tabelle mit Feldkennzeichen statt vier Tabellen: die Felder verhalten sich
gleich, also ist jede weitere Liste ein Wert im ENUM statt einer neuen Route und
einer neuen Oberflaeche.

Zwei Entscheidungen dabei:

- Pflegen darf jeder Angemeldete, nicht nur ein Administrator. Eine Liste, die
  nur ein Admin ergaenzen kann, blockiert die Eingabe genau dann, wenn ein neuer
  Anbieter auftaucht.
- Die Eintraege halten den Text, keinen Fremdschluessel. Umbenennen zieht sie
  darum ausdruecklich mit (in einer Transaktion), Loeschen nicht: eine Liste
  aufzuraeumen darf keine Daten aendern. Ein Wert, der nicht mehr in der Liste
  steht, wird im Dropdown weiter angeboten und als solcher gekennzeichnet -
  sonst wuerde das Bearbeiten eines alten Eintrags ihn stillschweigend
  ueberschreiben.

Das Backend prueft beim Speichern eines Eintrags nur noch die Laenge und nicht
mehr die Zugehoerigkeit zur Liste - aus demselben Grund.

Dazu: die Felder der Akquise-Eingabe sind abgesetzt (getoente Flaeche,
deutlicherer Rahmen, beim Hineinklicken weiss). Auf der weissen Karte gingen sie
unter, und im Formular stehen bis zu fuenfzehn davon nebeneinander.

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

419 lines
13 KiB
TypeScript

export type ViewState =
| 'Akquise'
| 'Archiv'
| 'Opportunities'
| 'Interviews'
| 'Kontakte'
| 'Aus Mails'
| 'Mails'
| 'Papierkorb'
| 'Scanner'
| 'Unpassend'
| 'Suchagenten'
| 'Börsen-Konten'
| 'Einstellungen';
/**
* Reiter der Einstellungen. Whisper sitzt hier und nicht mehr im Menue - es sind
* Geraeteeinstellungen, die man einmal einrichtet.
*
* Rollen: `users`, `scanner` und `mail` sind Administratoren vorbehalten,
* `account` und `whisper` stehen jedem offen - jeder pflegt sein eigenes Konto
* und richtet sein eigenes Diktat ein.
*/
export type SettingsTab = 'users' | 'account' | 'akquise' | 'mail' | 'scanner' | 'whisper';
export const ADMIN_SETTINGS_TABS: SettingsTab[] = ['users', 'mail', 'scanner'];
/**
* Reiter des Projekt Scanners. "Scanner" selbst ist das Dashboard und damit die
* Ansicht, die beim Aufklappen des Menuepunkts erscheint. Die Einstellungen des
* Scanners liegen absichtlich nicht hier, sondern als Reiter in den
* CRM-Einstellungen.
*/
export const SCANNER_VIEWS: ViewState[] = ['Scanner', 'Unpassend', 'Suchagenten', 'Börsen-Konten'];
/**
* Kontaktbereich. "Kontakte" ist der gepflegte Bestand, "Aus Mails" der
* Vorbereich mit den Personen, die der Postfachabgleich gefunden hat. Der
* Menuepunkt klappt beide untereinander auf - genau wie beim Scanner.
*/
export const CONTACT_VIEWS: ViewState[] = ['Kontakte', 'Aus Mails'];
/**
* Mailbereich. "Mails" ist das gespiegelte Postfach, "Papierkorb" enthaelt die
* im CRM geloeschten Mails. Geloescht heisst nur: das CRM listet sie nicht mehr -
* im Postfach crm@perinco.de bleiben sie unberuehrt.
*/
export const MAIL_VIEWS: ViewState[] = ['Mails', 'Papierkorb'];
/**
* Akquise. "Akquise" ist der Eingang der letzten 14 Tage, "Archiv" alles
* Aeltere. Beides ist dieselbe Liste, nur nach dem Funddatum geteilt.
*/
export const ACQUISITION_VIEWS: ViewState[] = ['Akquise', 'Archiv'];
export type Role = 'admin' | 'user';
export interface AppUser {
id: number;
username: string;
email: string | null;
name: string;
role: Role;
/** Persoenliche Terminfarbe als Hex; null = automatisch aus der Palette. */
color?: string | null;
/** Benutzerfoto als Data-URL oder externe URL; null = Initiale anzeigen. */
avatar?: string | null;
is_active: boolean;
created_at?: string;
updated_at?: string;
}
/**
* Farbpalette fuer Benutzer in den Terminen. Bewusst kontraststark untereinander
* und dunkel genug fuer weisse Schrift; das CI-Gruen steht an erster Stelle.
*/
export const USER_COLORS = [
'#6ba04a',
'#0284c7',
'#d97706',
'#7c3aed',
'#db2777',
'#0d9488',
'#dc2626',
'#4f46e5',
'#65a30d',
'#c2410c',
] as const;
/** Gesetzte Farbe, sonst stabil aus der ID abgeleitet. */
export function userColor(user?: { id: number; color?: string | null } | null): string {
if (!user) return '#9a9a9a';
if (user.color && /^#[0-9a-fA-F]{6}$/.test(user.color)) return user.color;
return USER_COLORS[Math.abs(user.id) % USER_COLORS.length];
}
/**
* Art eines Kontakts - in der Oberflaeche "Art". Frueher stand hier ein
* Vertriebsstatus (Neu .. Verloren); der sagte nichts darueber, mit wem man es
* zu tun hat. Bestehende Kontakte wurden bei der Umstellung auf 'Recruiter'
* gesetzt (siehe migrateLeadKind() im Backend).
*/
export const LEAD_KINDS = ['Recruiter', 'Partner', 'Endkunde'] as const;
export type LeadKind = (typeof LEAD_KINDS)[number];
/**
* Herkunft von Kontakten, die aus dem Postfach entstanden sind. Steht in
* `Lead.source` und traegt damit die Unterkategorie in der Kontaktliste - es
* braucht dafuer kein eigenes Feld.
*/
export const MAIL_LEAD_SOURCE = 'Mail';
/** Filter der Kontaktliste nach Herkunft. */
export type LeadOriginFilter = 'Alle' | 'Mail' | 'Manuell';
export interface Lead {
id: number;
salutation: string | null;
first_name: string;
last_name: string;
company: string | null;
position: string | null;
email: string | null;
phone: string | null;
mobile: string | null;
linkedin: string | null;
website: string | null;
street: string | null;
zip: string | null;
city: string | null;
country: string | null;
kind: LeadKind;
source: string | null;
/**
* true = Eintrag im Vorbereich "Aus Mails". Solche Kontakte stammen aus dem
* Postfachabgleich und erscheinen nicht in der Kontaktliste, in Opportunities
* oder in Terminen - erst die Uebernahme macht daraus einen Kontakt.
*/
mail_pool: boolean;
/**
* Einverständnis in Werbemails. Nur damit kommt die Adresse in eine
* Mailingliste. Gilt ausschliesslich für richtige Kontakte - im Vorbereich
* "Aus Mails" gibt es das Kennzeichen nicht.
*/
mail_consent: boolean;
/** Wann der Haken gesetzt wurde - der Nachweis, seit wann es gilt. */
mail_consent_at?: string | null;
tags: string[];
notes: string | null;
avatar: string | null;
owner_id: number | null;
created_by: number | null;
created_at?: string;
updated_at?: string;
/** Angereichert vom Backend */
created_by_name?: string | null;
owner_name?: string | null;
}
export const CONNECTION_TYPES = [
'Kollege',
'Vorgesetzter',
'Kunde',
'Partner',
'Empfehlung',
'Netzwerk',
'Sonstige',
] as const;
export type ConnectionType = (typeof CONNECTION_TYPES)[number];
export interface LeadConnection {
id: number;
from_lead_id: number;
to_lead_id: number;
type: ConnectionType;
label: string | null;
strength: number;
created_by: number | null;
created_at?: string;
}
export type LeadViewMode = 'grid' | 'list' | 'network';
// ---------- Opportunities ----------
/** Phasen der Vertriebspipeline, in dieser Reihenfolge. */
export const OPPORTUNITY_STAGES = [
'Neu',
'Qualifizierung',
'Angebot',
'Verhandlung',
'Gewonnen',
'Verloren',
] as const;
export type OpportunityStage = (typeof OPPORTUNITY_STAGES)[number];
/** Phasen, in denen die Chance noch offen ist. */
export const OPEN_STAGES: OpportunityStage[] = ['Neu', 'Qualifizierung', 'Angebot', 'Verhandlung'];
export interface OpportunityContact {
lead_id: number;
/** Freitext, z. B. "Entscheider" */
role: string | null;
first_name: string;
last_name: string;
company: string | null;
position: string | null;
email: string | null;
phone: string | null;
avatar: string | null;
}
export interface Opportunity {
id: number;
title: string;
company: string | null;
description: string | null;
stage: OpportunityStage;
/** 0-100 */
probability: number;
/** Tagessatz in Euro */
daily_rate: number | null;
/** Laufzeit in Personentagen */
duration_days: number | null;
/** Festpreis in Euro; ersetzt die Rechnung aus Tagessatz und Laufzeit */
fixed_price: number | null;
start_date: string | null;
end_date: string | null;
last_interaction_at: string | null;
last_interaction_note: string | null;
next_step: string | null;
tags: string[];
notes: string | null;
owner_id: number | null;
created_by: number | null;
created_at?: string;
updated_at?: string;
/** Angereichert vom Backend */
created_by_name?: string | null;
owner_name?: string | null;
owner_color?: string | null;
owner_avatar?: string | null;
contacts: OpportunityContact[];
}
export type OpportunityViewMode = 'grid' | 'list' | 'pipeline';
// ---------- Akquise ----------
/**
* Felder der Akquise, die aus einer Auswahlliste kommen. Die Werte stehen in der
* Datenbank (crm_acquisition_options) und werden unter Einstellungen -> Akquise
* gepflegt - als Freitext waeren "Hays", "hays" und "Hays AG" drei Anbieter.
*/
export const ACQUISITION_OPTION_FIELDS = ['sender', 'provider', 'channel', 'consultant'] as const;
export type AcquisitionOptionField = (typeof ACQUISITION_OPTION_FIELDS)[number];
/** Beschriftung der Felder - dieselbe in der Liste, im Formular und in den Einstellungen. */
export const ACQUISITION_FIELD_LABELS: Record<AcquisitionOptionField, string> = {
sender: 'Von',
provider: 'Anbieter',
channel: 'Kanal',
consultant: 'Berater',
};
/** Was in der jeweiligen Liste steht - als Hilfe in den Einstellungen. */
export const ACQUISITION_FIELD_HINTS: Record<AcquisitionOptionField, string> = {
sender: 'Personen, von denen Projekte kommen',
provider: 'Vermittler und Portale, z. B. Hays',
channel: 'Auf welchem Weg das Projekt kam',
consultant: 'Eigene Berater, für die Projekte gesucht werden',
};
export interface AcquisitionOption {
id: number;
value: string;
}
export type AcquisitionOptions = Record<AcquisitionOptionField, AcquisitionOption[]>;
export const EMPTY_ACQUISITION_OPTIONS: AcquisitionOptions = {
sender: [],
provider: [],
channel: [],
consultant: [],
};
/**
* Ein gefundenes Projekt, bevor daraus eine Opportunity wird.
*
* Die ersten Felder stehen in der Liste, die uebrigen nur im Formular - sie
* wandern bei der Uebernahme in die Opportunity.
*/
export interface Acquisition {
id: number;
/** Wann gefunden - entscheidet ueber Eingang, Archiv und Loeschung. */
found_at: string;
/** "Von": wer es geschickt hat. */
sender: string | null;
/** "Anbieter": Vermittler oder Portal. */
provider: string | null;
channel: string | null;
title: string;
/** "Berater", fuer den es passt. */
consultant: string | null;
/** Ausschreibung; ist er gesetzt, wird der Titel in der Liste klickbar. */
link: string | null;
company: string | null;
description: string | null;
daily_rate: number | null;
duration_days: number | null;
fixed_price: number | null;
start_date: string | null;
end_date: string | null;
next_step: string | null;
notes: string | null;
tags: string[];
/** Gesetzt = uebernommen; der Eintrag verschwindet dann aus beiden Listen. */
converted_at: string | null;
opportunity_id: number | null;
opportunity_title?: string | null;
opportunity_stage?: OpportunityStage | null;
created_by: number | null;
created_by_name?: string | null;
created_at?: string;
updated_at?: string;
}
/** Zaehler und Fristen liefert das Backend mit - sie stehen dort, nicht hier. */
export interface AcquisitionList {
archive_after_days: number;
delete_after_days: number;
counts: { eingang: number; archiv: number };
/** Inhalte der Dropdowns; kommen mit, damit die Eingabe sie sofort hat. */
options: AcquisitionOptions;
entries: Acquisition[];
}
/**
* Projektvolumen. Ein gesetzter Projektpreis gilt als Festpreis und schlaegt die
* Rechnung aus Tagessatz x Personentagen; sonst braucht es beide Werte.
*/
export function opportunityVolume(opportunity: Opportunity): number | null {
if (opportunity.fixed_price !== null) return opportunity.fixed_price;
if (opportunity.daily_rate === null || opportunity.duration_days === null) return null;
return opportunity.daily_rate * opportunity.duration_days;
}
/** Mit der Abschlusswahrscheinlichkeit gewichtetes Volumen. */
export function weightedVolume(opportunity: Opportunity): number | null {
const volume = opportunityVolume(opportunity);
return volume === null ? null : (volume * opportunity.probability) / 100;
}
export function formatEuro(value: number | null): string {
if (value === null) return '—';
return value.toLocaleString('de-DE', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
});
}
export function contactFullName(contact: Pick<OpportunityContact, 'first_name' | 'last_name'>): string {
return `${contact.first_name} ${contact.last_name}`.trim();
}
// ---------- Interviews (Termine) ----------
export interface EventParticipant {
id: number;
username: string;
name: string;
color: string | null;
avatar: string | null;
}
export interface CalendarEvent {
id: number;
title: string;
/** Kann Mention-Tokens der Form `@[Name](lead:12)` bzw. `@[Name](user:3)` enthalten. */
description: string | null;
location: string | null;
/** MariaDB-DATETIME als String: `YYYY-MM-DD HH:MM:SS` (lokale Wandzeit). */
starts_at: string;
ends_at: string;
all_day: boolean;
created_by: number | null;
created_at?: string;
updated_at?: string;
/** Angereichert vom Backend */
created_by_name?: string | null;
created_by_color?: string | null;
created_by_avatar?: string | null;
participants: EventParticipant[];
}
export type CalendarViewMode = 'month' | 'week' | 'day';
/** Farbe eines Termins = Farbe seines Erstellers. */
export function eventColor(event: CalendarEvent): string {
if (event.created_by === null) return '#9a9a9a';
return userColor({ id: event.created_by, color: event.created_by_color ?? null });
}
export function leadFullName(lead: Pick<Lead, 'first_name' | 'last_name'>): string {
return `${lead.first_name} ${lead.last_name}`.trim();
}
export function leadInitials(lead: Pick<Lead, 'first_name' | 'last_name'>): string {
const a = lead.first_name?.charAt(0) ?? '';
const b = lead.last_name?.charAt(0) ?? '';
return (a + b).toUpperCase() || '?';
}