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 @@
import type {
AppUser,
Company,
Connection,
Contact,
DirectoryUser,
Employment,
HistoryEntry,
Interview,
Lead,
LeadTodo,
PerincoContact,
Project,
Tender,
} from '../types';
const TOKEN_KEY = 'icon_crm_token';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY);
}
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
/** Wird bei 401 gefeuert, damit die Anwendung zurueck auf den Login springt. */
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(handler: (() => void) | null) {
onUnauthorized = handler;
}
export async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers);
if (options.body && !headers.has('Content-Type') && !(options.body instanceof FormData)) {
headers.set('Content-Type', 'application/json');
}
const token = getToken();
if (token) headers.set('Authorization', `Bearer ${token}`);
let response: Response;
try {
response = await fetch(`/api${path}`, { ...options, headers });
} catch {
throw new ApiError('Server nicht erreichbar. Bitte Verbindung prüfen.', 0);
}
if (response.status === 401) {
clearToken();
onUnauthorized?.();
}
const isJson = response.headers.get('content-type')?.includes('application/json');
const payload = isJson ? await response.json().catch(() => null) : null;
if (!response.ok) {
const message =
(payload && typeof payload === 'object' && 'error' in payload && String(payload.error)) ||
`Anfrage fehlgeschlagen (HTTP ${response.status}).`;
throw new ApiError(message, response.status);
}
return payload as T;
}
const get = <T>(path: string) => apiRequest<T>(path);
const post = <T>(path: string, body?: unknown) =>
apiRequest<T>(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) });
const patch = <T>(path: string, body: unknown) =>
apiRequest<T>(path, { method: 'PATCH', body: JSON.stringify(body) });
const del = <T>(path: string) => apiRequest<T>(path, { method: 'DELETE' });
/** Haengt nur gesetzte Werte als Abfrageparameter an. */
function query(params: Record<string, unknown>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === '') continue;
search.set(key, String(value));
}
const text = search.toString();
return text ? `?${text}` : '';
}
export const authApi = {
login: (identifier: string, password: string) =>
post<{ token: string; user: AppUser }>('/auth/login', { identifier, password }),
me: () => get<{ user: AppUser }>('/auth/me'),
updateMe: (patchBody: Partial<Pick<AppUser, 'color' | 'avatar' | 'theme' | 'theme_mode'>>) =>
patch<{ user: AppUser }>('/auth/me', patchBody),
changePassword: (current_password: string, new_password: string) =>
post<{ ok: true }>('/auth/change-password', { current_password, new_password }),
};
export const usersApi = {
directory: () => get<{ users: DirectoryUser[] }>('/users/directory'),
list: () => get<{ users: AppUser[] }>('/users'),
create: (body: Record<string, unknown>) => post<{ user: AppUser }>('/users', body),
update: (id: number, body: Record<string, unknown>) => patch<{ user: AppUser }>(`/users/${id}`, body),
remove: (id: number) => del<{ ok: true }>(`/users/${id}`),
};
export interface CompanyDetail {
company: Company;
contacts: Array<Pick<Contact, 'id' | 'first_name' | 'last_name' | 'position' | 'email' | 'phone' | 'avatar' | 'kind' | 'status'>>;
former: Array<{
employment_id: number;
id: number;
first_name: string | null;
last_name: string;
avatar: string | null;
position: string | null;
started_on: string | null;
ended_on: string | null;
now_company: string | null;
now_company_id: number | null;
}>;
interviews: Interview[];
history: HistoryEntry[];
mentioned: HistoryEntry[];
}
export const companiesApi = {
list: (params: Record<string, unknown> = {}) =>
get<{ companies: Company[] }>(`/companies${query(params)}`),
options: () => get<{ industries: string[]; groups: string[] }>('/companies/options'),
detail: (id: number) => get<CompanyDetail>(`/companies/${id}`),
create: (body: Record<string, unknown>) => post<{ company: Company }>('/companies', body),
update: (id: number, body: Record<string, unknown>) =>
patch<{ company: Company }>(`/companies/${id}`, body),
remove: (id: number) => del<{ ok: true }>(`/companies/${id}`),
addInterview: (id: number, body: Record<string, unknown>) =>
post<{ id: number }>(`/companies/${id}/interviews`, body),
};
export interface ContactDetail {
contact: Contact;
employments: Employment[];
interviews: Interview[];
history: HistoryEntry[];
mentioned: HistoryEntry[];
}
export const contactsApi = {
list: (params: Record<string, unknown> = {}) =>
get<{ contacts: Contact[] }>(`/contacts${query(params)}`),
detail: (id: number) => get<ContactDetail>(`/contacts/${id}`),
create: (body: Record<string, unknown>) => post<{ contact: Contact }>('/contacts', body),
update: (id: number, body: Record<string, unknown>) =>
patch<{ contact: Contact }>(`/contacts/${id}`, body),
remove: (id: number) => del<{ ok: true }>(`/contacts/${id}`),
addInterview: (id: number, body: Record<string, unknown>) =>
post<{ id: number }>(`/contacts/${id}/interviews`, body),
/*
* Werdegang von Hand pflegen. Die Stationen entstehen zwar beim Setzen eines
* Arbeitgebers und beim CV-Import von selbst - aber kein Lebenslauf wird
* vollständig erkannt, und ein falscher Firmenname ließ sich sonst nicht mehr
* geradeziehen.
*
* `company` (Name) oder `company_id` - ein unbekannter Name legt das
* Unternehmen an, wie im CV-Import auch.
*/
addEmployment: (id: number, body: Record<string, unknown>) =>
post<{ id: number }>(`/contacts/${id}/employments`, body),
updateEmployment: (employmentId: number, body: Record<string, unknown>) =>
patch<{ ok: true }>(`/contacts/employments/${employmentId}`, body),
removeEmployment: (employmentId: number) =>
del<{ ok: true }>(`/contacts/employments/${employmentId}`),
lastEmployers: () =>
get<{
entries: Array<{
contact_id: number;
first_name: string | null;
last_name: string;
avatar: string | null;
company_id: number;
company: string;
industry: string | null;
position: string | null;
ended_on: string | null;
}>;
}>('/contacts/last-employers'),
linkedInPhoto: (url: string) =>
post<{ avatar: string; bytes: number }>('/contacts/linkedin-photo', { url }),
};
export const tendersApi = {
list: (params: Record<string, unknown> = {}) => get<{ tenders: Tender[] }>(`/tenders${query(params)}`),
detail: (id: number) =>
get<{ tender: Tender; contacts: Contact[]; history: HistoryEntry[] }>(`/tenders/${id}`),
create: (body: Record<string, unknown>) => post<{ tender: Tender }>('/tenders', body),
update: (id: number, body: Record<string, unknown>) => patch<{ tender: Tender }>(`/tenders/${id}`, body),
remove: (id: number) => del<{ ok: true }>(`/tenders/${id}`),
};
export const projectsApi = {
list: (params: Record<string, unknown> = {}) =>
get<{ projects: Project[] }>(`/projects${query(params)}`),
create: (body: Record<string, unknown>) => post<{ project: Project }>('/projects', body),
update: (id: number, body: Record<string, unknown>) =>
patch<{ project: Project }>(`/projects/${id}`, body),
remove: (id: number) => del<{ ok: true }>(`/projects/${id}`),
};
export const leadsApi = {
list: (archived = false) => get<{ leads: Lead[] }>(`/leads${query({ archived: archived ? 1 : 0 })}`),
detail: (id: number) =>
get<{ lead: Lead; todos: LeadTodo[]; history: HistoryEntry[] }>(`/leads/${id}`),
create: (body: Record<string, unknown>) => post<{ lead: Lead }>('/leads', body),
update: (id: number, body: Record<string, unknown>) => patch<{ lead: Lead }>(`/leads/${id}`, body),
remove: (id: number) => del<{ ok: true }>(`/leads/${id}`),
reminders: () =>
get<{ due: Lead[]; stale: Lead[]; stale_days: number }>('/leads/reminders'),
acknowledge: (id: number, snooze_days?: number) =>
post<{ ok: true }>(`/leads/${id}/acknowledge`, { snooze_days }),
addTodo: (id: number, text: string, due_on?: string | null) =>
post<{ id: number }>(`/leads/${id}/todos`, { text, due_on }),
updateTodo: (todoId: number, body: Record<string, unknown>) =>
patch<{ ok: true }>(`/leads/todos/${todoId}`, body),
removeTodo: (todoId: number) => del<{ ok: true }>(`/leads/todos/${todoId}`),
};
/** Vorschlag der KI zu einem Lebenslauf. */
export interface CvSuggestion {
first_name: string | null;
last_name: string | null;
position: string | null;
email: string | null;
phone: string | null;
linkedin: string | null;
company: string | null;
tags: string[];
bio: string | null;
rate_onsite: number | null;
rate_remote: number | null;
employments: Array<{ company: string; position: string | null; from: string | null; to: string | null }>;
confidence: number | null;
}
export interface CvImport {
id: number;
filename: string;
original_name: string;
mime: string | null;
size_bytes: number;
status: 'eingang' | 'screening' | 'auswahl' | 'verworfen';
extracted: CvSuggestion | null;
/**
* Traegt der Lebenslauf ein Bewerbungsfoto? Das Bild selbst steht nicht in
* der Liste - es wird bei Bedarf einzeln geholt (`cvsApi.photo`) und erst im
* Browser verkleinert (siehe lib/imageCompress.ts).
*/
has_photo: boolean;
error: string | null;
contact_id: number | null;
contact_name: string | null;
uploaded_by_name: string | null;
created_at: string;
processed_at: string | null;
}
export const cvsApi = {
list: () =>
get<{ imports: CvImport[]; ai: { configured: boolean; model: string } }>('/cvs'),
/** Mehrere Dateien auf einmal - der Browser setzt die multipart-Grenze selbst. */
upload: (files: File[]) => {
const body = new FormData();
for (const file of files) body.append('files', file);
return apiRequest<{ ids: number[]; count: number }>('/cvs', { method: 'POST', body });
},
analyze: (id: number) =>
post<{ extracted: CvSuggestion; photo: string | null }>(`/cvs/${id}/analyze`),
/** Das gefundene Foto, unveraendert - nur fuer das Formular. */
photo: (id: number) => get<{ photo: string | null }>(`/cvs/${id}/photo`),
accept: (id: number, overrides: Record<string, unknown> = {}) =>
post<{ contact_id: number }>(`/cvs/${id}/accept`, overrides),
reject: (id: number) => post<{ ok: true }>(`/cvs/${id}/reject`),
remove: (id: number) => del<{ ok: true }>(`/cvs/${id}`),
};
export type AiTask = 'write' | 'improve' | 'shorten' | 'bullets' | 'profile';
/**
* Zustand der KI-Einrichtung. Der Schlüssel steht hier nur verkürzt
* ("sk-proj-…a1b2") - vollständig verlässt er den Server nie.
*/
export interface AiConfig {
configured: boolean;
model: string;
defaultModel: string;
/** 'db' = in den Einstellungen gepflegt, 'env' = aus server/.env, 'none' = keiner */
source: 'db' | 'env' | 'none';
masked: string | null;
envAvailable: boolean;
}
export const aiApi = {
status: () => get<{ configured: boolean; model: string; tasks: AiTask[] }>('/ai/status'),
text: (task: AiTask, current: string, prompt?: string) =>
post<{ text: string; model: string }>('/ai/text', { task, current, prompt }),
config: () => get<AiConfig>('/ai/config'),
saveConfig: (changes: { key?: string; model?: string }) => patch<AiConfig>('/ai/config', changes),
test: () => post<{ ok: true; model: string; ms: number; answer: string }>('/ai/test'),
};
export const perincoApi = {
status: () => get<{ configured: boolean; url: string | null }>('/perinco/status'),
contacts: () => get<{ contacts: PerincoContact[]; cached: boolean }>('/perinco/contacts'),
};
export const historyApi = {
list: (params: Record<string, unknown> = {}) =>
get<{ entries: HistoryEntry[] }>(`/history${query(params)}`),
add: (entity_type: string, entity_id: number | null, text: string) =>
post<{ entry: HistoryEntry }>('/history', { entity_type, entity_id, text }),
remove: (id: number) => del<{ ok: true }>(`/history/${id}`),
};
export interface SearchGroup {
type: 'contact' | 'company' | 'tender' | 'project' | 'lead' | 'history';
label: string;
items: Array<Record<string, unknown> & { id: number; title?: string }>;
}
export interface MentionCandidate {
kind: 'user' | 'contact' | 'company';
id: number;
label: string;
hint: string | null;
avatar: string | null;
color?: string | null;
}
export interface AiAnswer {
answer: string;
refs: Array<{ type: 'contact' | 'company' | 'tender' | 'project' | 'lead'; id: number; label: string }>;
model: string;
truncated: boolean;
}
export const searchApi = {
search: (q: string) => get<{ query: string; groups: SearchGroup[]; total: number }>(`/search${query({ q })}`),
mentions: (q: string) => get<{ candidates: MentionCandidate[] }>(`/search/mentions${query({ q })}`),
ask: (question: string) => post<AiAnswer>('/search/ai', { question }),
};
export interface OptionValue {
id: number;
field: string;
value: string;
position: number;
/** Wie oft der Wert im Bestand verwendet wird. */
used: number;
}
export interface OptionField {
field: string;
label: string;
values: OptionValue[];
}
export const optionsApi = {
list: () => get<{ fields: OptionField[] }>('/options'),
create: (field: string, value: string) => post<{ id: number }>('/options', { field, value }),
rename: (id: number, value: string) => patch<{ ok: true; renamed: number }>(`/options/${id}`, { value }),
remove: (id: number) => del<{ ok: true }>(`/options/${id}`),
};
export interface NetworkData {
companies: Array<Company & { id: number }>;
contacts: Array<{
id: number;
name: string;
position: string | null;
avatar: string | null;
kind: string;
status: string;
company_id: number | null;
}>;
edges: Array<Record<string, unknown> & { type: 'employment' | 'former' | 'contact' | 'group' }>;
groups: Array<{ name: string; count: number }>;
}
export const networkApi = {
load: (params: Record<string, unknown> = {}) => get<NetworkData>(`/network${query(params)}`),
};
export const connectionsApi = {
list: () => get<{ connections: Connection[] }>('/connections'),
create: (contact_a: number, contact_b: number, kind?: string) =>
post<Connection>('/connections', { contact_a, contact_b, kind }),
update: (id: number, kind: string) => patch<{ ok: true }>(`/connections/${id}`, { kind }),
remove: (id: number) => del<{ ok: true }>(`/connections/${id}`),
};
export interface DashboardData {
counts: Record<string, number>;
contacts_by_status: Array<{ status: string; count: number }>;
companies_by_industry: Array<{ industry: string; count: number }>;
tenders_by_stage: Array<{ stage: string; count: number }>;
recent: HistoryEntry[];
}
export const dashboardApi = {
load: () => get<DashboardData>('/dashboard'),
};