feat: perinco Pulse - Zeiterfassung fuer perinco
Erste Fassung, live unter https://pulse.perinco.de. Frontend React 19 + TypeScript + Tailwind 4 (Vite), Backend Express mit mysql2 auf MariaDB, Exporte als PDF (pdfkit), Excel (exceljs) und CSV. Aufbau, Designsystem und die sieben Themes kommen aus dem perinco CRM. Fachlich: - Buchung mit Wochen-, Monats- und Jahresansicht, Zeiten als 1:23 oder 1,5 - Projekte mit n Budgettoepfen: Laufzeit, Volumen, Sperre bei Ablauf oder aufgebrauchtem Budget, Buchung nur auf den passenden Topf - Kunden, Team mit Hierarchie, Reports im Harvest-Stil, Monatsexporte - Rollen user/lead/management/admin, doppelt geprueft (Menue und Backend) - SSO gegen Microsoft Entra ID, Uebernahme der AD-Benutzer per Drag & Drop Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+378
@@ -0,0 +1,378 @@
|
||||
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'>>) =>
|
||||
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'),
|
||||
};
|
||||
|
||||
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 AdminSettings extends PublicSettings {
|
||||
export: ExportSettings;
|
||||
sso: SsoSettings;
|
||||
}
|
||||
export type AdminSettingsInput = Omit<AdminSettings, 'export' | 'sso'> & {
|
||||
export: Partial<ExportSettings>;
|
||||
sso: Partial<SsoSettings> & { clear_secret?: boolean };
|
||||
};
|
||||
|
||||
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' | 'notes'>>;
|
||||
|
||||
export const projectsApi = {
|
||||
list: (scope?: 'mine') => get<Project[]>(`/projects${qs({ scope })}`),
|
||||
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 }) => send<Project>('POST', `/projects/${id}/tasks`, task),
|
||||
updateTask: (id: number, taskId: number, task: Partial<{ name: string; billable: boolean; is_active: boolean }>) =>
|
||||
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) => get<TimeEntry[]>(`/entries${qs({ from, to, user_id: userId })}`),
|
||||
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 };
|
||||
previous: SeriesBucket | null;
|
||||
period_label: string;
|
||||
previous_label: string | null;
|
||||
};
|
||||
ranking: Array<{
|
||||
user_id: number;
|
||||
name: string;
|
||||
color: string | null;
|
||||
job_title: string | null;
|
||||
is_external: boolean;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
utilization?: number | null;
|
||||
}
|
||||
|
||||
export interface TimeReport {
|
||||
range: { from: string; to: string };
|
||||
include_fixed: boolean;
|
||||
summary: {
|
||||
minutes: number;
|
||||
billable_minutes: number;
|
||||
non_billable_minutes: number;
|
||||
amount: number;
|
||||
amount_without_fixed: number;
|
||||
fixed_amount: number;
|
||||
entries: 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 }) =>
|
||||
get<TimeReport>(`/reports/time${qs({ ...params, include_fixed: params.include_fixed ? 1 : undefined })}`),
|
||||
project: (id: number) => get<ProjectDetail>(`/reports/project/${id}`),
|
||||
customer: (id: number) => get<CustomerReport>(`/reports/customer/${id}`),
|
||||
};
|
||||
Reference in New Issue
Block a user