/** Gemeinsame Typen und Konstanten des ICON CRM. */ export type Role = 'admin' | 'user'; /* * Farbwelten. Die Liste muss mit THEMES in server/src/userFields.js * übereinstimmen - das Backend weist einen unbekannten Wert ab, damit ein * Tippfehler hier nicht still zum Standarddesign führt. */ export const THEME_PALETTES = ['icon', 'halflife', 'pixel'] as const; export type ThemePalette = (typeof THEME_PALETTES)[number]; /** Beschriftung und Kurzbeschreibung für die Auswahl im Kopf der Anwendung. */ export const THEME_PALETTE_LABELS: Record = { icon: { name: 'ICON', hint: 'Das Design des Hauses – Gold auf neutralem Grau' }, halflife: { name: 'Half-Life', hint: 'Black Mesa: HEV-Orange auf Beton, harte Kanten, Oswald', }, pixel: { name: 'Pixel Art', hint: '8-bit: eckige Kanten, harte Schatten, Pixelschrift in den Überschriften', }, }; export interface AppUser { id: number; username: string; email: string | null; name: string; role: Role; color: string | null; avatar: string | null; theme: string | null; theme_mode: 'light' | 'dark' | null; is_active: boolean; created_at?: string; updated_at?: string; } /** Abgespeckter Benutzer aus /users/directory - fuer Auswahl und Erwaehnungen. */ export interface DirectoryUser { id: number; username: string; name: string; role: Role; color: string | null; avatar: string | null; is_active: boolean; } /* * Die Kontaktart wird in den Einstellungen gepflegt (Stammdaten) und ist * deshalb kein fester Typ mehr. Die Vorgabewerte dienen nur als Rückfall, * solange die Liste noch nicht geladen ist. */ export type ContactKind = string; export const DEFAULT_CONTACT_KINDS = ['Experte', 'Ansprechpartner (Kunde)', 'Lead']; /* * Der frueher vorhandene Verfuegbarkeitsstatus ist entfallen (Featureliste vom * 14.09.2026). Was bleibt, beschreibt den Stand der Zusammenarbeit. */ export type ContactStatus = 'Interessiert' | 'Interview' | 'Gebucht' | 'Inaktiv'; export const CONTACT_STATUS: ContactStatus[] = ['Interessiert', 'Interview', 'Gebucht', 'Inaktiv']; export type Tristate = 'unbekannt' | 'ja' | 'nein'; export type ContactState = 'unbekannt' | 'persoenlich' | 'kein Kontakt'; export const CONTACT_STATE_LABELS: Record = { unbekannt: 'Unbekannt', persoenlich: 'Persönlicher Kontakt', 'kein Kontakt': 'Noch kein Kontakt gehabt', }; export interface Contact { id: number; /** Akademischer Titel als Freitext ("Dr.", "Prof. Dr."). */ title: string | null; first_name: string | null; last_name: string; /** Titel, Vor- und Nachname, vom Backend zusammengesetzt. */ name: string; position: string | null; email: string | null; phone: string | null; linkedin: string | null; avatar: string | null; company_id: number | null; company_name?: string | null; company_industry?: string | null; kind: ContactKind; status: ContactStatus; lead_source: string | null; contact_state: ContactState; freelancers: Tristate; bio: string | null; tags: string[]; rate_onsite: number | null; rate_remote: number | null; cv_url: string | null; /** Dateiname des hochgeladenen Lebenslaufs (aus dem CV-Import). */ cv_file: string | null; owner_id: number | null; created_by: number | null; created_at: string; updated_at: string; interview_count?: number; } /* * Ein Kontakt aus dem perinco CRM. * * Er wird nur angezeigt, nie gespeichert. Die Kennung ist eine Zeichenkette * ("perinco-31"), weil sie in einer anderen Datenbank zaehlt und nie mit einer * ICON-Kennung verwechselt werden darf. */ export interface PerincoContact { id: string; first_name: string | null; last_name: string; name: string; position: string | null; company_name: string | null; email: string | null; phone: string | null; linkedin: string | null; avatar: string | null; /** Drüben: Recruiter, Partner oder Endkunde - unverändert übernommen. */ kind: string | null; lead_source: string | null; tags: string[]; bio: string | null; created_at: string | null; updated_at: string | null; source_system: 'perinco'; } export interface Company { id: number; name: string; group_name: string | null; industry: string | null; employees: number | null; website: string | null; address: string | null; logo: string | null; /** 0 = nicht bewertet, sonst 1 bis 5 Sterne. */ rating: number; is_customer: boolean; customer_state: 'aktiv' | 'alumni'; freelancers: Tristate; notes: string | null; owner_id: number | null; /** Hauptansprechpartner – Auswahl aus den Kontakten dieses Unternehmens. */ primary_contact_id: number | null; created_by: number | null; created_at: string; updated_at: string; contact_count?: number; former_count?: number; } export interface Employment { id: number; contact_id: number; company_id: number; company?: string; industry?: string | null; position: string | null; started_on: string | null; ended_on: string | null; is_current: 0 | 1; } export interface Interview { id: number; contact_id: number | null; company_id: number | null; user_id: number | null; user_name?: string | null; contact_name?: string | null; company?: string | null; held_on: string; notes: string | null; } export type HistoryEntityType = | 'contact' | 'company' | 'tender' | 'project' | 'lead' | 'user' | 'system'; export interface HistoryEntry { id: number; entity_type: HistoryEntityType; entity_id: number | null; entity_label: string | null; action: string; text: string | null; changes: Record | null; user_id: number | null; user_name: string | null; user_color?: string | null; user_avatar?: string | null; created_at: string; } export type TenderStatus = 'Aktiv' | 'Inaktiv' | 'Ausgelaufen' | 'Archiviert'; export type TenderStage = | 'Ausschreibung' | 'Kontakte sichten' | 'Interview' | 'Kunden Interview' | 'Vertragsgestaltung' | 'Abschluss' | 'Geschlossen'; export const TENDER_STAGES: TenderStage[] = [ 'Ausschreibung', 'Kontakte sichten', 'Interview', 'Kunden Interview', 'Vertragsgestaltung', 'Abschluss', 'Geschlossen', ]; export interface TenderContact { id: number; name: string; position: string | null; avatar: string | null; status: ContactStatus; } export interface Tender { id: number; title: string; source: string | null; link: string | null; company_id: number | null; company_name?: string | null; company_logo?: string | null; status: TenderStatus; stage: TenderStage; /** Steuert die Anzeige auf der ICON Website. */ online: boolean; /** * Der Ausschreibungstext als HTML. * * Er liegt in `notes` und nicht in `details` - so war es im ursprünglichen * ICON CRM, und die Website liest genau dieses Feld. */ notes: string | null; details: string | null; deadline: string | null; contacts?: TenderContact[]; contact_count?: number; created_at: string; updated_at: string; } export interface Project { id: number; title: string; company_id: number | null; company_name?: string | null; contact_id: number | null; contact_name?: string | null; contact_avatar?: string | null; /** Stundensaetze des Beraters - die Liste liefert sie mit, siehe `projectMargin`. */ contact_rate_onsite?: number | null; contact_rate_remote?: number | null; started_on: string | null; ended_on: string | null; status: 'Aktiv' | 'Nicht aktiv' | 'Abgeschlossen'; /** Tagessatz, den ICON dem Kunden berechnet (in Euro). */ rate: number | null; notes: string | null; } export interface Connection { id: number; contact_a: number; contact_b: number; a_name?: string; b_name?: string; kind: string; } /* * Die Netzwerkansicht ist bewusst kein eigener Menuepunkt: sie ist eine Ansicht * der Kontakte bzw. der Unternehmen - wie im perinco CRM. Ein eigener Eintrag * haette dieselben Daten ein zweites Mal aufgemacht, mit eigenen Filtern. */ /* * Lead-Phasen. In der Datenbank stehen sie ohne Umlaute (ENUM-Werte werden * verglichen, nicht angezeigt) - die Beschriftung kommt aus LEAD_STAGE_LABELS. */ export type LeadStage = | 'Fuer Spaeter' | 'Neuer Lead' | 'Erstkontakt' | '1. Gespraech' | 'Followup geplant' | 'Followup Gespraech' | 'Closed' | 'Absage'; export const LEAD_STAGES: LeadStage[] = [ 'Fuer Spaeter', 'Neuer Lead', 'Erstkontakt', '1. Gespraech', 'Followup geplant', 'Followup Gespraech', 'Closed', 'Absage', ]; export const LEAD_STAGE_LABELS: Record = { // In der Oberfläche heißt die erste Spalte Backlog; der Wert in der // Datenbank bleibt unverändert, sonst wäre es eine Migration. 'Fuer Spaeter': 'Backlog', 'Neuer Lead': 'Neuer Lead', Erstkontakt: 'Erstkontakt', '1. Gespraech': '1. Gespräch', 'Followup geplant': 'Followup geplant', 'Followup Gespraech': 'Followup Gespräch', Closed: 'Closed', Absage: 'Absage', }; export interface Lead { id: number; title: string; company_id: number | null; company_name: string | null; company_industry: string | null; company_logo: string | null; company_rating: number | null; contact_id: number | null; contact_name: string | null; contact_email: string | null; contact_phone: string | null; contact_avatar: string | null; stage: LeadStage; contact_state: ContactState; archived: boolean; position: number; notes: string | null; remind_at: string | null; reminder_days: number | null; last_contact_at: string | null; owner_id: number | null; owner_name: string | null; todo_count: number; todo_done: number; created_at: string; updated_at: string; } export interface LeadTodo { id: number; lead_id: number; text: string; done: boolean; due_on: string | null; position: number; } export type View = | 'Dashboard' | 'Suspects' | 'Suspects · Archiv' | 'Leads' | 'Leads · Archiviert' | 'Kontakte' | 'Unternehmen' | 'Ausschreibungen' | 'Live!' | 'Ausschreibungen · Archiv' | 'Laufende Projekte' | 'Scanner' | 'Scanner · Suchagenten' | 'Historie' | 'Einstellungen'; /** Farbe eines Benutzers; ohne eigene Wahl aus der Kennung abgeleitet. */ const USER_COLORS = ['#c1862f', '#8a6d3b', '#4f7d8c', '#7d5ba6', '#3f8f6b', '#b4623c']; export function userColor(user: { id: number; color?: string | null } | null | undefined): string { if (!user) return '#93939b'; return user.color || USER_COLORS[user.id % USER_COLORS.length]; } export function initials(name: string): string { const parts = name.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return '?'; if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } /** 14.09.'26 09:35 - das Format aus der Featureliste. */ export function formatStamp(value: string | null | undefined): string { if (!value) return ''; const date = new Date(value.replace(' ', 'T')); if (Number.isNaN(date.getTime())) return String(value); const pad = (n: number) => String(n).padStart(2, '0'); return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.'${String(date.getFullYear()).slice( 2 )} ${pad(date.getHours())}:${pad(date.getMinutes())}`; } export function formatDate(value: string | null | undefined): string { if (!value) return '–'; const date = new Date(value.length <= 10 ? `${value}T00:00:00` : value.replace(' ', 'T')); if (Number.isNaN(date.getTime())) return String(value); return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); }