Quellcode des ICON CRM (6)
This commit is contained in:
+415
@@ -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' });
|
||||
}
|
||||
Reference in New Issue
Block a user