Files
ICON-CRM/src/types.ts
T
schakal00783andClaude Opus 5.5 6a452f1510 Suspects: Vorstufe der Leads als Tabelle
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>
2026-09-29 12:11:36 +02:00

426 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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' });
}