Files
perinco-Pulse/src/lib/api.ts
T
schakal00783andClaude Opus 5.5 2f9c58f202 feat(ki): Modell aus Liste der verfuegbaren OpenAI-Modelle waehlen
Bisher musste der Administrator den Modellnamen abtippen - ein Tippfehler
fiel erst beim Test oder bei der ersten Frage auf, und welche Modelle der
Schluessel ueberhaupt darf, war in Pulse nicht zu sehen.

Einstellungen -> KI-Assistent zeigt jetzt ein Auswahlfeld. Die Liste kommt
ueber POST /settings/ai/models live von OpenAI (/v1/models), neueste
zuerst; Embedding-, Bild-, Audio-, Realtime-, Such- und
Moderationsmodelle fliegen raus. "Liste laden" nimmt einen gerade
eingetippten, noch nicht gespeicherten Schluessel mit. Das eingestellte
Modell bleibt waehlbar, auch wenn OpenAI es nicht mehr listet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:25:21 +02:00

489 lines
16 KiB
TypeScript

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<T>(path: string, options: RequestInit = {}): Promise<T> {
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 = <T>(path: string) => request<T>(path);
const send = <T>(method: string, path: string, body?: unknown) =>
request<T>(path, { method, body: body === undefined ? undefined : JSON.stringify(body) });
function qs(params: Record<string, string | number | boolean | null | undefined>) {
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<Pick<AppUser, 'theme' | 'theme_mode' | 'color' | 'avatar'>>) =>
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<PublicSettings>('/settings/public'),
all: () => get<AdminSettings>('/settings'),
save: (changes: Partial<AdminSettingsInput>) => 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<string, string>;
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<string, string>;
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<string, Record<string, { hours: number; days: number }>>;
}
export const hrworksApi = {
status: () => get<HrworksStatus>('/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<HrworksReport>('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<AdminSettings, 'export' | 'sso' | 'hrworks' | 'ai'> & {
export: Partial<ExportSettings>;
sso: Partial<SsoSettings> & { clear_secret?: boolean };
hrworks: Partial<HrworksSettings> & { clear_secret?: boolean };
ai: Partial<AiSettings> & { clear_key?: boolean };
};
export interface AssistantAnswer {
answer: string;
model: string;
tools: { name: string; args: Record<string, unknown> }[];
}
export const assistantApi = {
status: () => get<{ available: boolean; enabled: boolean; model: string }>('/assistant/status'),
ask: (question: string, verlauf: { frage: string; antwort: string }[]) =>
send<AssistantAnswer>('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<DirectoryUser[]>('/users/directory'),
list: () => get<{ users: ManagedUser[]; assignable_roles: Role[] }>('/users'),
create: (data: Record<string, unknown>) => send<AppUser>('POST', '/users', data),
update: (id: number, data: Record<string, unknown>) => send<AppUser>('PUT', `/users/${id}`, data),
remove: (id: number) => send<{ ok: true }>('DELETE', `/users/${id}`),
};
export interface CustomerOverview extends Customer {
projects: Array<
Pick<Project, 'id' | 'code' | 'name' | 'display_name' | 'type' | 'is_active' | 'used_minutes' | 'budget_hours' | 'earned' | 'open' | 'budget_value' | 'members' | 'end_date'>
>;
members: Project['members'];
used_minutes: number;
earned: number;
open: number;
monthly: number[];
}
export const customersApi = {
list: () => get<Customer[]>('/customers'),
overview: () => get<{ months: string[]; customers: CustomerOverview[] }>('/customers/overview'),
create: (data: Partial<Customer>) => send<Customer>('POST', '/customers', data),
update: (id: number, data: Partial<Customer>) => send<Customer>('PUT', `/customers/${id}`, data),
remove: (id: number) => send<{ ok: true }>('DELETE', `/customers/${id}`),
};
export type PotInput = Partial<
Pick<BudgetPot, 'label' | 'valid_from' | 'valid_to' | 'budget_amount' | 'rate' | 'budget_hours' | 'order_number' | 'notes'>
>;
export const projectsApi = {
list: (scope?: 'mine', userId?: number) => get<Project[]>(`/projects${qs({ scope, user_id: userId })}`),
get: (id: number) => get<Project>(`/projects/${id}`),
create: (data: Record<string, unknown>) => send<Project>('POST', '/projects', data),
update: (id: number, data: Record<string, unknown>) => send<Project>('PUT', `/projects/${id}`, data),
remove: (id: number) => send<{ ok: true }>('DELETE', `/projects/${id}`),
setMembers: (id: number, user_ids: number[]) => send<Project>('PUT', `/projects/${id}/members`, { user_ids }),
addPot: (id: number, pot: PotInput) => send<Project>('POST', `/projects/${id}/pots`, pot),
updatePot: (id: number, potId: number, pot: PotInput) => send<Project>('PUT', `/projects/${id}/pots/${potId}`, pot),
removePot: (id: number, potId: number) => send<Project>('DELETE', `/projects/${id}/pots/${potId}`),
addTask: (id: number, task: { name: string; billable?: boolean; rate?: number | null }) => send<Project>('POST', `/projects/${id}/tasks`, task),
updateTask: (id: number, taskId: number, task: Partial<{ name: string; billable: boolean; is_active: boolean; rate: number | null }>) =>
send<Project>('PUT', `/projects/${id}/tasks/${taskId}`, task),
removeTask: (id: number, taskId: number) => send<Project>('DELETE', `/projects/${id}/tasks/${taskId}`),
};
export const entriesApi = {
list: (from: string, to: string, userId?: number, customerId?: number | null) =>
get<TimeEntry[]>(`/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<DashboardData>(`/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<BudgetPot & { tasks: GroupRow[]; team: GroupRow[] }>;
}
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<Omit<ProjectDetail, 'weekly'>>;
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<TimeReport>(
`/reports/time${qs({
...params,
include_fixed: params.include_fixed ? 1 : undefined,
hide_non_billable: params.hide_non_billable ? 1 : undefined,
})}`
),
project: (id: number) => get<ProjectDetail>(`/reports/project/${id}`),
customer: (id: number) => get<CustomerReport>(`/reports/customer/${id}`),
};