Quellcode des ICON CRM (6)

This commit is contained in:
2026-09-14 16:55:40 +02:00
parent 4fcb34f324
commit bd810fa608
24 changed files with 3855 additions and 0 deletions
+415
View File
@@ -0,0 +1,415 @@
/** 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', '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;
first_name: string | null;
last_name: string;
/** 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;
started_on: string | null;
ended_on: string | null;
status: 'Aktiv' | 'Nicht aktiv' | 'Abgeschlossen';
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'
| 'Leads'
| 'Leads · Archiviert'
| 'Kontakte'
| 'Unternehmen'
| 'Ausschreibungen'
| 'Live!'
| 'Ausschreibungen · Archiv'
| 'Laufende Projekte'
| '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' });
}