import type { AppUser, BudgetPot, Customer, DirectoryUser, ManagedUser, Project, PublicSettings, Role, TimeEntry, } from '../types'; const TOKEN_KEY = 'pulse_token'; export function getToken(): string | null { try { return localStorage.getItem(TOKEN_KEY); } catch { return null; } } export function setToken(token: string) { try { localStorage.setItem(TOKEN_KEY, token); } catch { /* ohne localStorage bleibt die Sitzung nur bis zum Neuladen */ } } export function clearToken() { try { localStorage.removeItem(TOKEN_KEY); } catch { /* egal */ } } export class ApiError extends Error { status: number; constructor(message: string, status: number) { super(message); this.status = status; } } let onUnauthorized: (() => void) | null = null; export function setUnauthorizedHandler(handler: (() => void) | null) { onUnauthorized = handler; } async function request(path: string, options: RequestInit = {}): Promise { const headers = new Headers(options.headers); if (options.body && !headers.has('Content-Type')) 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 && !path.startsWith('/auth/login')) { clearToken(); onUnauthorized?.(); } const isJson = response.headers.get('content-type')?.includes('application/json'); const payload = isJson ? await response.json().catch(() => null) : null; if (!response.ok) { throw new ApiError(payload?.error || `Anfrage fehlgeschlagen (HTTP ${response.status}).`, response.status); } return payload as T; } const get = (path: string) => request(path); const send = (method: string, path: string, body?: unknown) => request(path, { method, body: body === undefined ? undefined : JSON.stringify(body) }); function qs(params: Record) { const search = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (v === undefined || v === null || v === '') continue; search.set(k, String(v)); } const s = search.toString(); return s ? `?${s}` : ''; } /** Laedt eine Datei (Export) mit Token und stoesst den Download an. */ export async function download(path: string, fallbackName: string, open = false) { const token = getToken(); let response: Response; try { response = await fetch(`/api${path}`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }); } catch { throw new ApiError('Server nicht erreichbar.', 0); } if (!response.ok) { const payload = await response.json().catch(() => null); throw new ApiError(payload?.error || `Export fehlgeschlagen (HTTP ${response.status}).`, response.status); } const blob = await response.blob(); const disposition = response.headers.get('content-disposition') || ''; const match = disposition.match(/filename\*=UTF-8''([^;]+)/); const name = match ? decodeURIComponent(match[1]) : fallbackName; const url = URL.createObjectURL(blob); if (open && blob.type === 'application/pdf') { window.open(url, '_blank', 'noopener'); } else { const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); } setTimeout(() => URL.revokeObjectURL(url), 60000); } export { qs }; /* ------------------------------------------------------------ Endpunkte -- */ export const authApi = { config: () => get<{ sso_enabled: boolean; sso_label: string; local_login: boolean }>('/auth/config'), login: (identifier: string, password: string) => send<{ token: string; user: AppUser }>('POST', '/auth/login', { identifier, password }), me: () => get<{ user: AppUser }>('/auth/me'), updateMe: (changes: Partial>) => send<{ user: AppUser }>('PATCH', '/auth/me', changes), changePassword: (current_password: string, new_password: string) => send<{ ok: true }>('POST', '/auth/change-password', { current_password, new_password }), }; export const settingsApi = { public: () => get('/settings/public'), all: () => get('/settings'), save: (changes: Partial) => send<{ ok: true }>('PUT', '/settings', changes), ssoTest: () => send<{ ok: true; sample: string[]; checked: number; eligible: number }>('POST', '/settings/sso/test'), aiTest: () => send<{ ok: true; model: string; answer: string }>('POST', '/settings/ai/test'), aiModels: (apiKey?: string) => send<{ models: AiModel[] }>('POST', '/settings/ai/models', { api_key: apiKey || undefined }), }; export interface ExportSettings { title: string; subtitle: string; accent: string; logo: string | null; header_right: string; footer_left: string; footer_center: string; footer_right: string; show_signature: boolean; signature_left: string; signature_right: string; } export interface SsoSettings { enabled: boolean; button_label: string; tenant_id: string; client_id: string; client_secret: string; client_secret_set?: boolean; configured?: boolean; redirect_uri: string; local_login: boolean; } export interface HrworksSettings { enabled: boolean; access_key: string; secret_access_key: string; secret_set?: boolean; configured?: boolean; project_code: string; window_days: number; type_map: Record; holiday_task: string; half_day_hours: number; last_run: string | null; last_result: { created: number; updated: number; removed: number; unchanged: number; hours: number; failed: number } | null; } export interface HrworksStatus { enabled: boolean; configured: boolean; project: { id: number; code: string; name: string; type: string; start_date: string } | null; project_code: string; window_days: number; holiday_task: string; half_day_hours: number; type_map: Record; last_run: string | null; last_result: HrworksSettings['last_result']; entries: number; hours: number; first_date: string | null; last_date: string | null; } export interface HrworksReport { dry_run: boolean; from: string; to: string; created: number; updated?: number; removed?: number; unchanged: number; planned_create: number; planned_update: number; planned_delete: number; hours: number; failed: { ref: string; error: string }[]; unmatched_persons: { person_id: string; name: string; email: string | null }[]; missing_tasks: string[]; per_person: Record>; } export const hrworksApi = { status: () => get('/hrworks/status'), test: () => send<{ ok: true; persons: number; persons_with_pulse_account: number; absence_types: { key: string; name: string }[] }>( 'POST', '/hrworks/test', {} ), sync: (apply = false) => send('POST', '/hrworks/sync', { apply }), entries: () => get<{ count: number; entries: unknown[] }>('/hrworks/entries'), removeAll: () => send<{ deleted: number }>('DELETE', '/hrworks/entries'), }; export interface AiModel { id: string; created: string | null; } export interface AiSettings { enabled: boolean; api_key: string; key_set?: boolean; configured?: boolean; model: string; } export interface AdminSettings extends PublicSettings { export: ExportSettings; sso: SsoSettings; hrworks: HrworksSettings; ai: AiSettings; } export type AdminSettingsInput = Omit & { export: Partial; sso: Partial & { clear_secret?: boolean }; hrworks: Partial & { clear_secret?: boolean }; ai: Partial & { clear_key?: boolean }; }; export interface AssistantAnswer { answer: string; model: string; tools: { name: string; args: Record }[]; } export const assistantApi = { status: () => get<{ available: boolean; enabled: boolean; model: string }>('/assistant/status'), ask: (question: string, verlauf: { frage: string; antwort: string }[]) => send('POST', '/assistant/ask', { question, verlauf }), }; export interface AdUser { id: string; name: string; given_name: string | null; surname: string | null; email: string; job_title: string | null; department: string | null; pulse_user_id: number | null; pulse_role: Role | null; pulse_active: boolean | null; local_match_id: number | null; } export const directoryApi = { list: (refresh = false) => get<{ configured: boolean; domains: string[]; fetched_at: string | null; users: AdUser[] }>(`/directory/ad-users${refresh ? '?refresh=1' : ''}`), import: (ad_ids: string[], role: Role, manager_id: number | null) => send<{ created: number[]; linked: number[]; skipped: string[] }>('POST', '/directory/import', { ad_ids, role, manager_id }), sync: () => send<{ total: number; eligible: number; linked: number; updated: number; deactivated: number }>('POST', '/directory/sync'), }; export const usersApi = { directory: () => get('/users/directory'), list: () => get<{ users: ManagedUser[]; assignable_roles: Role[] }>('/users'), create: (data: Record) => send('POST', '/users', data), update: (id: number, data: Record) => send('PUT', `/users/${id}`, data), remove: (id: number) => send<{ ok: true }>('DELETE', `/users/${id}`), }; export interface CustomerOverview extends Customer { projects: Array< Pick >; members: Project['members']; used_minutes: number; earned: number; open: number; monthly: number[]; } export const customersApi = { list: () => get('/customers'), overview: () => get<{ months: string[]; customers: CustomerOverview[] }>('/customers/overview'), create: (data: Partial) => send('POST', '/customers', data), update: (id: number, data: Partial) => send('PUT', `/customers/${id}`, data), remove: (id: number) => send<{ ok: true }>('DELETE', `/customers/${id}`), }; export type PotInput = Partial< Pick >; export const projectsApi = { list: (scope?: 'mine', userId?: number) => get(`/projects${qs({ scope, user_id: userId })}`), get: (id: number) => get(`/projects/${id}`), create: (data: Record) => send('POST', '/projects', data), update: (id: number, data: Record) => send('PUT', `/projects/${id}`, data), remove: (id: number) => send<{ ok: true }>('DELETE', `/projects/${id}`), setMembers: (id: number, user_ids: number[]) => send('PUT', `/projects/${id}/members`, { user_ids }), addPot: (id: number, pot: PotInput) => send('POST', `/projects/${id}/pots`, pot), updatePot: (id: number, potId: number, pot: PotInput) => send('PUT', `/projects/${id}/pots/${potId}`, pot), removePot: (id: number, potId: number) => send('DELETE', `/projects/${id}/pots/${potId}`), addTask: (id: number, task: { name: string; billable?: boolean; rate?: number | null }) => send('POST', `/projects/${id}/tasks`, task), updateTask: (id: number, taskId: number, task: Partial<{ name: string; billable: boolean; is_active: boolean; rate: number | null }>) => send('PUT', `/projects/${id}/tasks/${taskId}`, task), removeTask: (id: number, taskId: number) => send('DELETE', `/projects/${id}/tasks/${taskId}`), }; export const entriesApi = { list: (from: string, to: string, userId?: number, customerId?: number | null) => get(`/entries${qs({ from, to, user_id: userId, customer_id: customerId || undefined })}`), create: (data: { project_id: number; task_id: number | null; entry_date: string; minutes: number; note: string }) => send<{ entry: TimeEntry; warning: string | null }>('POST', '/entries', data), update: (id: number, data: Partial<{ project_id: number; task_id: number | null; entry_date: string; minutes: number; note: string }>) => send<{ entry: TimeEntry; warning: string | null }>('PUT', `/entries/${id}`, data), remove: (id: number) => send<{ ok: true }>('DELETE', `/entries/${id}`), }; export type Granularity = 'day' | 'week' | 'month' | 'quarter' | 'year'; export type RankPeriod = 'days' | 'week' | 'month' | 'quarter' | 'year'; export interface SeriesBucket { from: string; to: string; label: string; minutes: number; billable_minutes: number; non_billable_minutes: number; amount: number; } export interface DashboardData { granularity: Granularity; rank: RankPeriod; rank_range: { from: string; to: string }; scope: 'all' | 'team'; team_size: number; series: SeriesBucket[]; kpis: { current: SeriesBucket & { capacity_minutes: number; minutes_until_today: number }; previous: SeriesBucket | null; period_label: string; previous_label: string | null; }; ranking: Array<{ user_id: number; name: string; color: string | null; avatar: string | null; job_title: string | null; is_external: boolean; /** Nur bis heute - Grundlage der Auslastung. */ minutes_until_today: number; minutes: number; billable_minutes: number; non_billable_minutes: number; amount: number; entries: number; capacity_minutes: number; }>; projects: Array<{ project_id: number; name: string; customer_color: string | null; minutes: number; billable_minutes: number }>; recent: Array<{ id: number; user_id: number; user_name: string; user_color: string | null; user_avatar: string | null; project_display: string; customer_color: string | null; task_name: string | null; note: string | null; entry_date: string; minutes: number; billable: boolean; created_at: string; }>; } export const dashboardApi = { get: (granularity: Granularity, rank: RankPeriod) => get(`/dashboard${qs({ granularity, rank })}`), }; export interface GroupRow { key: string | number; name: string; color?: string | null; avatar?: string | null; minutes: number; billable_minutes: number; non_billable_minutes: number; amount: number; user_id?: number; customer_id?: number; project_id?: number; customer_name?: string; code?: string; type?: string; is_external?: boolean; capacity_minutes?: number; minutes_until_today?: number; utilization?: number | null; } export interface TimeReport { range: { from: string; to: string }; include_fixed: boolean; hide_non_billable: boolean; summary: { minutes: number; billable_minutes: number; non_billable_minutes: number; amount: number; amount_without_fixed: number; fixed_amount: number; entries: number; /** Nur bis heute gebucht - Grundlage der Auslastung. */ minutes_until_today: number; }; clients: GroupRow[]; projects: GroupRow[]; tasks: GroupRow[]; users: GroupRow[]; } export interface ProjectDetail { project: Project; totals: { minutes: number; billable_minutes: number; non_billable_minutes: number; amount: number }; weekly: Array<{ week_start: string; label: string; minutes: number; billable_minutes: number; cumulative_minutes: number | null }>; tasks: GroupRow[]; team: GroupRow[]; pots: Array; } export interface CustomerReport { customer: Customer; totals: { minutes: number; billable_minutes: number; non_billable_minutes: number; amount: number; earned: number; open: number; budget_value: number; }; series: Array<{ month: string; minutes: number; billable_minutes: number; amount: number }>; projects: Array>; team: GroupRow[]; tasks: GroupRow[]; } export const reportsApi = { time: (params: { from: string; to: string; customer_id?: number | null; team?: number | null; include_fixed?: boolean; hide_non_billable?: boolean }) => get( `/reports/time${qs({ ...params, include_fixed: params.include_fixed ? 1 : undefined, hide_non_billable: params.hide_non_billable ? 1 : undefined, })}` ), project: (id: number) => get(`/reports/project/${id}`), customer: (id: number) => get(`/reports/customer/${id}`), };