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>
489 lines
16 KiB
TypeScript
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}`),
|
|
};
|