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> = { '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 = { 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 = { sender: 'Von', channel: 'Kanal', consultant: 'Berater', }; /** Was in der jeweiligen Liste steht - als Hilfe in den Einstellungen. */ export const ACQUISITION_FIELD_HINTS: Record = { 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; } /* * "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(); 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; 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): 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): string { return `${lead.first_name} ${lead.last_name}`.trim(); } export function leadInitials(lead: Pick): 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'; }