Wird in der Akquise ein Projekt mit Berater angelegt (oder ein Berater beim Bearbeiten hinzugefuegt), bekommt er eine Mail ueber Graph sendMail aus crm@perinco.de; Reply-To ist der eintragende Benutzer. Je Berater abschaltbar unter Einstellungen -> Akquise (Spalte notify, Vorgabe an). Versand laeuft nach der Antwort und kippt das Speichern nie. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
626 lines
20 KiB
TypeScript
626 lines
20 KiB
TypeScript
export type ViewState =
|
|
| 'Dashboard'
|
|
| 'Akquise'
|
|
| 'Archiv'
|
|
| 'Akquise-Papierkorb'
|
|
| 'Opportunities'
|
|
| 'Opportunity-Papierkorb'
|
|
| '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'
|
|
| 'sso'
|
|
| 'scanner'
|
|
| 'ki'
|
|
| 'whisper';
|
|
|
|
export const ADMIN_SETTINGS_TABS: SettingsTab[] = ['users', 'mail', 'sso', 'scanner', 'ki'];
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* Der Papierkorb kommt aus einer anderen Ecke: dort liegt, was jemand von Hand
|
|
* geloescht hat. Der Wert heisst "Akquise-Papierkorb", weil Mails und
|
|
* Opportunities schon einen Papierkorb haben und der Ansichtsname eindeutig sein
|
|
* muss; angezeigt wird er als "Papierkorb" (siehe VIEW_TITLES).
|
|
*/
|
|
export const ACQUISITION_VIEWS: ViewState[] = ['Akquise', 'Archiv', 'Akquise-Papierkorb'];
|
|
|
|
/**
|
|
* Opportunities mit ihrem Papierkorb. Der Wert heisst "Opportunity-Papierkorb",
|
|
* weil die Mails schon einen "Papierkorb" haben und der Ansichtsname eindeutig
|
|
* sein muss; angezeigt wird beides als "Papierkorb" (siehe VIEW_TITLES).
|
|
*/
|
|
export const OPPORTUNITY_VIEWS: ViewState[] = ['Opportunities', 'Opportunity-Papierkorb'];
|
|
|
|
/**
|
|
* Abweichende Beschriftung einer Ansicht. Ohne Eintrag ist der Name der Ansicht
|
|
* gleichzeitig ihr Titel - das gilt fuer alle bis auf die, deren Name nur
|
|
* intern eindeutig sein muss.
|
|
*/
|
|
export const VIEW_TITLES: Partial<Record<ViewState, string>> = {
|
|
'Opportunity-Papierkorb': 'Papierkorb',
|
|
'Akquise-Papierkorb': 'Papierkorb',
|
|
};
|
|
|
|
export const viewTitle = (view: ViewState): string => VIEW_TITLES[view] ?? view;
|
|
|
|
export type Role = 'admin' | 'user';
|
|
|
|
/*
|
|
* Farbwelten. Die Liste muss mit THEMES in server/src/userFields.js
|
|
* uebereinstimmen - das Backend weist einen unbekannten Wert ab, damit ein
|
|
* Tippfehler hier nicht still zum Standarddesign fuehrt.
|
|
* server/test/userFields.test.mjs prueft, dass beide Listen gleich bleiben.
|
|
*/
|
|
export const THEME_PALETTES = ['perinco', 'pixel', 'outdoor', 'cyberpunk', 'rainy', 'snow', 'aura'] as const;
|
|
|
|
export type ThemePalette = (typeof THEME_PALETTES)[number];
|
|
|
|
/** Beschriftung und Kurzbeschreibung fuer die Auswahl im Kopf der App. */
|
|
export const THEME_PALETTE_LABELS: Record<ThemePalette, { name: string; hint: string }> = {
|
|
perinco: { name: 'perinco', hint: 'Das Design des Hauses - Blattgrün auf neutralem Grau' },
|
|
pixel: { name: 'Pixel Art', hint: '8-bit: eckige Kanten, harte Schatten, Pixelschrift' },
|
|
outdoor: { name: 'Outdoor', hint: 'Wanderkarte: Sandpapier, Bergsee-Türkis, weiche Ecken' },
|
|
cyberpunk: {
|
|
name: 'Cyberpunk',
|
|
hint: 'Neon in der Nacht: scharfe Kanten, violettes Leuchten, Chakra Petch',
|
|
},
|
|
rainy: { name: 'Rainy', hint: 'Regenwetter: Nieselregen, Wolken mit Wetterleuchten, Schieferblau' },
|
|
snow: { name: 'Snow', hint: 'Winter: Schneefall, der sich unten sammelt, Eisblau' },
|
|
aura: {
|
|
name: 'Aura',
|
|
hint: 'Hell Sunshine mit Sonnenstrahlen, dunkel Mond mit Sternen - ziehende Wolken',
|
|
},
|
|
};
|
|
|
|
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;
|
|
/**
|
|
* Persoenliche Darstellung, am Benutzer und nicht am Browser: null = das
|
|
* Standarddesign perinco bzw. der Systemeinstellung folgen.
|
|
*/
|
|
theme?: ThemePalette | null;
|
|
theme_mode?: 'light' | 'dark' | 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;
|
|
/**
|
|
* Frueher das Haekchen "an das ICON CRM weitergeben". Ohne Funktion: drueben
|
|
* werden alle Kontakte angezeigt, sobald dort der Schalter gesetzt ist. Das
|
|
* Feld kommt noch aus der Datenbank mit und wird nirgends mehr ausgewertet.
|
|
*/
|
|
icon: 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 des Vermittlungsprozesses, in dieser Reihenfolge - vom identifizierten
|
|
* Projekt bis zur Beauftragung, plus die Absage am Ende.
|
|
*
|
|
* "Erstgespraech Berater" steht bewusst ohne Umlaut: der Wert liegt so in der
|
|
* Datenbank (Projektkonvention fuer Datenbankwerte). Gelesen wird er ueber
|
|
* STAGE_LABELS in opportunityUi.tsx.
|
|
*/
|
|
export const OPPORTUNITY_STAGES = [
|
|
'Neu',
|
|
'Beworben',
|
|
'Erstgespraech Berater',
|
|
'Kundenvorstellung',
|
|
'Interview Kunde',
|
|
'Zusage',
|
|
'Beauftragt',
|
|
'Absage',
|
|
] as const;
|
|
|
|
export type OpportunityStage = (typeof OPPORTUNITY_STAGES)[number];
|
|
|
|
/**
|
|
* Phasen, in denen die Chance noch offen ist - alles bis auf das Ende. "Zusage"
|
|
* gehoert dazu: der Einsatz ist bestaetigt, die Beauftragung steht aber noch aus.
|
|
*/
|
|
export const OPEN_STAGES: OpportunityStage[] = [
|
|
'Neu',
|
|
'Beworben',
|
|
'Erstgespraech Berater',
|
|
'Kundenvorstellung',
|
|
'Interview Kunde',
|
|
'Zusage',
|
|
];
|
|
|
|
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;
|
|
/**
|
|
* Fuer welchen Berater das Projekt laeuft - neben dem Titel die wichtigste
|
|
* Angabe. Genau ein Name: passt ein Fund der Akquise zu drei Beratern,
|
|
* entstehen bei der Uebernahme drei Opportunities.
|
|
*/
|
|
consultant: string | null;
|
|
/**
|
|
* Eintrag der Akquise, aus dem die Opportunity uebernommen wurde - null bei
|
|
* von Hand angelegten. Bei drei Beratern zeigen alle drei auf denselben.
|
|
*/
|
|
acquisition_id: number | null;
|
|
/** Der Eintrag existiert noch und liegt nicht im Papierkorb: der Rueckweg ist offen. */
|
|
acquisition_available?: boolean;
|
|
/** Link zur Ausschreibung, bei der Uebernahme aus der Akquise mitgenommen. */
|
|
link: 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;
|
|
/** Zeitpunkt des Wechsels nach "Beauftragt" - Grundlage der Gewinn-Auswertung. */
|
|
won_at: string | null;
|
|
/** Letzter Phasenwechsel (UTC) und wer ihn gemacht hat; leer, solange nie verschoben. */
|
|
stage_changed_at?: string | null;
|
|
stage_changed_by?: number | null;
|
|
/** Gesetzt = liegt im Papierkorb; nach 90 Tagen wird sie dort geloescht. */
|
|
deleted_at: string | null;
|
|
deleted_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;
|
|
deleted_by_name?: string | null;
|
|
stage_changed_by_name?: 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.
|
|
*
|
|
* "Anbieter" ist ausdruecklich nicht dabei: dort wird frei getippt, mit
|
|
* Vorschlaegen aus den bisherigen Eintraegen (`AcquisitionList.provider_suggestions`).
|
|
*/
|
|
export const ACQUISITION_OPTION_FIELDS = ['sender', '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',
|
|
channel: 'Kanal',
|
|
consultant: 'Berater',
|
|
};
|
|
|
|
/** Was in der jeweiligen Liste steht - als Hilfe in den Einstellungen. */
|
|
export const ACQUISITION_FIELD_HINTS: Record<AcquisitionOptionField, string> = {
|
|
sender: 'Wer den Fund hereingebracht hat; vorbelegt ist der angemeldete Benutzer',
|
|
channel: 'Auf welchem Weg das Projekt kam',
|
|
consultant: 'Eigene Berater, für die Projekte gesucht werden',
|
|
};
|
|
|
|
export interface AcquisitionOption {
|
|
id: number;
|
|
value: string;
|
|
/** Nur bei "Berater" gepflegt, sonst immer null. */
|
|
email: string | null;
|
|
/** Nur bei "Berater": Mail, sobald er in der Akquise einem Projekt zugeordnet wird. */
|
|
notify: boolean;
|
|
}
|
|
|
|
/*
|
|
* "Berater" nimmt mehrere Namen auf - ein Fund passt oft zu mehr als einem, und
|
|
* fuer jeden geht eine Bewerbung heraus. Gespeichert wird eine kommagetrennte
|
|
* Liste in einem Feld; das Gegenstueck im Backend steht in
|
|
* server/src/acquisitionRules.js. Wer hier etwas aendert, muss dort mitziehen.
|
|
*/
|
|
|
|
/** Trennzeichen der gespeicherten Liste - mit Leerzeichen, das ist auch die Anzeige. */
|
|
export const CONSULTANT_SEPARATOR = ', ';
|
|
|
|
/**
|
|
* Zerlegt das Feld in einzelne Namen. Leere Stuecke und Dubletten fallen weg -
|
|
* verglichen wird ohne Ruecksicht auf Gross- und Kleinschreibung, erhalten
|
|
* bleibt die erste Schreibweise.
|
|
*/
|
|
export function splitConsultants(value: string | null | undefined): string[] {
|
|
if (!value) return [];
|
|
const seen = new Set<string>();
|
|
const names: string[] = [];
|
|
for (const part of value.split(',')) {
|
|
const name = part.trim();
|
|
if (name === '') continue;
|
|
const key = name.toLowerCase();
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
names.push(name);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
/** Setzt die Namen wieder zur gespeicherten Form zusammen. */
|
|
export function joinConsultants(names: string[]): string {
|
|
return names.filter((name) => name.trim() !== '').join(CONSULTANT_SEPARATOR);
|
|
}
|
|
|
|
export type AcquisitionOptions = Record<AcquisitionOptionField, AcquisitionOption[]>;
|
|
|
|
export const EMPTY_ACQUISITION_OPTIONS: AcquisitionOptions = {
|
|
sender: [],
|
|
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 - bei mehreren eine kommagetrennte Liste
|
|
* (`splitConsultants`).
|
|
*/
|
|
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 -
|
|
* es sei denn, die Opportunity ist inzwischen weg (`opportunity_gone`): dann
|
|
* faellt die Bewerbung ins Archiv zurueck.
|
|
*/
|
|
converted_at: string | null;
|
|
opportunity_id: number | null;
|
|
opportunity_title?: string | null;
|
|
opportunity_stage?: OpportunityStage | null;
|
|
/** Gesetzt = die Opportunity liegt im Papierkorb (und ist zurueckholbar). */
|
|
opportunity_deleted_at?: string | null;
|
|
/** Die Opportunity ist im Papierkorb oder endgueltig geloescht. */
|
|
opportunity_gone?: boolean;
|
|
created_by: number | null;
|
|
created_by_name?: string | null;
|
|
/**
|
|
* Gesetzt = im Papierkorb (UTC). Ein solcher Eintrag steht in keiner der
|
|
* beiden Listen und in keiner Auswertung - nur im Papierkorb.
|
|
*/
|
|
deleted_at?: string | null;
|
|
deleted_by?: number | null;
|
|
deleted_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;
|
|
/** So lange liegt ein von Hand geloeschter Eintrag im Papierkorb. */
|
|
trash_delete_after_days: number;
|
|
counts: { eingang: number; archiv: number; papierkorb: number };
|
|
/** Inhalte der Dropdowns; kommen mit, damit die Eingabe sie sofort hat. */
|
|
options: AcquisitionOptions;
|
|
/** Schon benutzte Anbieter, der zuletzt verwendete zuerst - "Anbieter" ist Freitext. */
|
|
provider_suggestions: string[];
|
|
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;
|
|
}
|
|
|
|
/*
|
|
* Ein mit der Abschlusswahrscheinlichkeit gewichtetes Volumen gibt es hier
|
|
* bewusst nicht (am 18.08.2026 entfernt): so wird bei perinco nicht gerechnet.
|
|
* Die Wahrscheinlichkeit selbst bleibt an der Opportunity - sie sagt etwas, ohne
|
|
* dass daraus eine Summe gebildet werden muss.
|
|
*/
|
|
|
|
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() || '?';
|
|
}
|
|
|
|
/*
|
|
* Ein Kontakt aus dem ICON CRM.
|
|
*
|
|
* Er wird nur angezeigt, nie gespeichert. Die Kennung ist eine Zeichenkette
|
|
* ("icon-31"), weil sie in einer anderen Datenbank zaehlt und nie mit einer
|
|
* hiesigen verwechselt werden darf.
|
|
*/
|
|
export interface IconContact {
|
|
id: string;
|
|
first_name: string;
|
|
last_name: string;
|
|
company: string | null;
|
|
position: string | null;
|
|
email: string | null;
|
|
phone: string | null;
|
|
linkedin: string | null;
|
|
/** Drueben: Experte, Ansprechpartner oder Lead - unveraendert uebernommen. */
|
|
kind: string | null;
|
|
source: string | null;
|
|
tags: string[];
|
|
notes: string | null;
|
|
avatar: string | null;
|
|
created_at: string | null;
|
|
updated_at: string | null;
|
|
source_system: 'icon';
|
|
}
|