Neuer Menüpunkt über Leads mit Unterpunkt Archiv. - Tabelle Name, Funktion, Unternehmen, LinkedIn, Website, Notiz, Letzter Kontakt, Nächster Impuls; leere Zeile unten, speichert beim Verlassen. Links als Symbol mit URL im Tooltip. - Nächster Impuls: Tage eingeben, Erinnerung über den ReminderWatcher - Archivieren, Zurückholen, endgültig löschen (nur aus dem Archiv) - Lead erstellen öffnet die Lead-Karte vorbelegt (LeadModal.initial), danach wandert der Suspect mit lead_id ins Archiv Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
426 lines
12 KiB
TypeScript
426 lines
12 KiB
TypeScript
/** 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<ThemePalette, { name: string; hint: string }> = {
|
||
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<ContactState, string> = {
|
||
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<string, [unknown, unknown]> | 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<LeadStage, string> = {
|
||
// 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' });
|
||
}
|