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