Files
tickettracker/frontend/src/api.ts
T
2026-09-15 09:51:30 +00:00

839 lines
23 KiB
TypeScript

import type {
AdminUser,
AuthUser,
BillingStatus,
DashboardPreference,
Organization,
PeriodOverview,
PeriodType,
ProjectSummary,
RecurringBilling,
StatisticsOverview,
TicketMeta,
TicketPeriod,
TicketTodo,
TicketSearchResult,
TodoPriority,
TodoStatus,
WorkType
} from "./types";
type CreateSessionPayload = {
ticketNumber: string;
organizationId: string;
activity: string;
workType: WorkType;
startedAt: string;
endedAt: string;
durationSeconds: number;
zammadLookup?: boolean;
projectTeamspaceEntryId?: string | null;
};
type DeleteSessionResult = {
deleted: {
id: string;
ticket_id: string;
started_at: string;
userTicketEmpty: boolean;
ticketDeleted: boolean;
projectDeleteAvailable: boolean;
project_id: string | null;
};
};
export type ZammadSyncExistingFields = {
title: boolean;
organization: boolean;
type: boolean;
projectName: boolean;
budget: boolean;
overrideBudget: boolean;
};
export type ZammadSyncChange = {
field: string;
from: unknown;
to: unknown;
};
export type ZammadSyncItem = {
ticketNumber: string;
action: "update" | "create" | "skip" | "error";
reason: string | null;
local: Record<string, unknown> | null;
zammad: Record<string, unknown> | null;
changes: ZammadSyncChange[];
};
export type ZammadSyncResult = {
mode: "preview" | "applied";
checked: number;
updated: number;
created: number;
skipped: number;
missingInZammad: number;
organizationMissing: number;
markedForCloseReview: number;
convertedToConsulting: number;
convertedToSupport: number;
errors: Array<{ ticketNumber: string; error: string }>;
items: ZammadSyncItem[];
};
export type ZammadSyncJob = {
id: string;
userId: string;
status: "running" | "complete" | "error";
result: ZammadSyncResult | null;
error: string | null;
startedAt: string;
finishedAt: string | null;
};
export type RestoreValidationResult = {
valid: boolean;
checkedTables: number;
issues: string[];
};
export class ApiError extends Error {
constructor(
message: string,
public status: number
) {
super(message);
this.name = "ApiError";
}
}
type ApiRequestInit = RequestInit & {
timeoutMs?: number;
};
async function request<T>(url: string, options: ApiRequestInit = {}): Promise<T> {
const { timeoutMs, signal, ...fetchOptions } = options;
const controller = timeoutMs ? new AbortController() : null;
const timeout = controller
? window.setTimeout(() => {
controller.abort();
}, timeoutMs)
: null;
if (signal && controller) {
signal.addEventListener("abort", () => controller.abort(), { once: true });
}
let response: Response;
try {
response = await fetch(url, {
signal: controller?.signal ?? signal,
...fetchOptions,
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
...fetchOptions.headers
}
});
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw new ApiError("Anfrage hat zu lange gedauert", 408);
}
if (error instanceof TypeError) {
throw new ApiError("Verbindung zum Server wurde unterbrochen. Bitte prüfen, ob Backend und Frontend laufen und ob das Backup kleiner als das Upload-Limit ist.", 503);
}
throw error;
} finally {
if (timeout) {
window.clearTimeout(timeout);
}
}
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new ApiError(body.error ?? "Anfrage fehlgeschlagen", response.status);
}
return response.json() as Promise<T>;
}
async function requestRestoreEndpoint<T>(path: string, options: ApiRequestInit) {
return request<T>(path, options);
}
function backupValidationSql(sql: string) {
const lines = sql.replace(/^\uFEFF/, "").split(/\r?\n/);
const marker = lines.find((line) => line.includes("TicketTracker Datenbankexport")) ?? "-- TicketTracker Datenbankexport";
const structuralLines = lines.filter((line) => /^--\s*(?:[a-z_][a-z0-9_]*:\s*\d+\s+Datensatz|columns\s+[a-z_][a-z0-9_]*:)/i.test(line.trim()));
return [marker, ...structuralLines].join("\n");
}
export function login(payload: { username: string; password: string }) {
return request<{ user: AuthUser }>("/api/auth/login", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function logout() {
return request<{ ok: boolean }>("/api/auth/logout", {
method: "POST"
});
}
export function getCurrentUser() {
return request<{ user: AuthUser }>("/api/auth/me");
}
export function updateCurrentUser(payload: {
username: string;
displayName: string;
currentPassword?: string;
newPassword?: string;
zammadApiToken?: string;
}) {
return request<{ user: AuthUser }>("/api/auth/me", {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function getDashboardPreference() {
return request<{ preference: Partial<DashboardPreference> }>("/api/dashboard/preferences");
}
export function saveDashboardPreference(preference: DashboardPreference) {
return request<{ preference: Partial<DashboardPreference> }>("/api/dashboard/preferences", {
method: "PUT",
body: JSON.stringify({ preference })
});
}
export function addFavoriteTicket(ticketNumber: string) {
return request<{ preference: Partial<DashboardPreference> }>("/api/dashboard/favorites", {
method: "POST",
body: JSON.stringify({ ticketNumber })
});
}
export function removeFavoriteTicket(ticketNumber: string) {
return request<{ preference: Partial<DashboardPreference> }>(`/api/dashboard/favorites/${encodeURIComponent(ticketNumber)}`, {
method: "DELETE"
});
}
export function getSetupStatus() {
return request<{ required: boolean }>("/api/setup/status");
}
export function createSetupAdmin(payload: { username: string; displayName: string; password: string }) {
return request<{ user: AuthUser }>("/api/setup/admin", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function restoreSetupBackup(sql: string) {
return requestRestoreEndpoint<{ ok: boolean }>("/api/setup/restore", {
method: "POST",
timeoutMs: 10 * 60 * 1000,
headers: { "Content-Type": "text/plain; charset=utf-8" },
body: sql
});
}
export function validateSetupBackup(sql: string) {
return requestRestoreEndpoint<RestoreValidationResult>("/api/setup/restore/validate", {
method: "POST",
timeoutMs: 60_000,
headers: { "Content-Type": "text/plain; charset=utf-8" },
body: backupValidationSql(sql)
});
}
export function getAdminUsers() {
return request<{ users: AdminUser[] }>("/api/admin/users");
}
export function createAdminUser(payload: {
username: string;
displayName: string;
password: string;
active: boolean;
}) {
return request<{ user: AdminUser }>("/api/admin/users", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateAdminUser(
userId: string,
payload: {
username: string;
displayName: string;
password?: string;
active: boolean;
}
) {
return request<{ user: AdminUser }>(`/api/admin/users/${userId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export async function downloadDatabaseExport() {
const response = await fetch("/api/admin/export/database", {
credentials: "same-origin"
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new ApiError(body.error ?? "Export fehlgeschlagen", response.status);
}
const contentDisposition = response.headers.get("content-disposition") ?? "";
const filenameMatch = contentDisposition.match(/filename="([^"]+)"/);
return {
blob: await response.blob(),
filename: filenameMatch?.[1] ?? `tickettracker-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.sql`
};
}
export function restoreDatabaseBackup(sql: string) {
return requestRestoreEndpoint<{ ok: boolean }>("/api/admin/restore/database", {
method: "POST",
timeoutMs: 10 * 60 * 1000,
headers: { "Content-Type": "text/plain; charset=utf-8" },
body: sql
});
}
export function validateDatabaseBackup(sql: string) {
return requestRestoreEndpoint<RestoreValidationResult>("/api/admin/restore/database/validate", {
method: "POST",
timeoutMs: 60_000,
headers: { "Content-Type": "text/plain; charset=utf-8" },
body: backupValidationSql(sql)
});
}
export async function downloadMonthExport(month: string) {
const response = await fetch(`/api/export/months/${month}`, {
credentials: "same-origin"
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new ApiError(body.error ?? "Export fehlgeschlagen", response.status);
}
const contentDisposition = response.headers.get("content-disposition") ?? "";
const filenameMatch = contentDisposition.match(/filename="([^"]+)"/);
return {
blob: await response.blob(),
filename: filenameMatch?.[1] ?? `tickettracker-monat-${month}.csv`
};
}
export function getOrganizations(search = "") {
const params = new URLSearchParams();
if (search.trim()) {
params.set("search", search.trim());
}
return request<{ organizations: Organization[] }>(`/api/organizations${params.size ? `?${params}` : ""}`);
}
export function getZammadSettings() {
return request<{ settings: { baseUrl: string; hasApiKey: boolean } }>("/api/admin/zammad/settings");
}
export function saveZammadSettings(payload: { baseUrl: string; apiKey?: string }) {
return request<{ settings: { baseUrl: string; hasApiKey: boolean } }>("/api/admin/zammad/settings", {
method: "PUT",
body: JSON.stringify(payload)
});
}
export function syncZammadOrganizations(payload: { baseUrl?: string; apiKey?: string }) {
return request<{ synced: number; skipped: number; removed: number; unlinkedTickets: number; unlinkedSessions: number }>("/api/admin/zammad/organizations/sync", {
method: "POST",
body: JSON.stringify(payload)
});
}
function wait(milliseconds: number) {
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
}
async function waitForZammadSyncJob(jobId: string) {
const startedAt = Date.now();
const timeoutMs = 10 * 60 * 1000;
for (;;) {
if (Date.now() - startedAt > timeoutMs) {
throw new ApiError("Zammad-Sync läuft länger als erwartet. Bitte Vorschau neu starten.", 408);
}
const { job } = await request<{ job: ZammadSyncJob }>(`/api/zammad-sync/jobs/${encodeURIComponent(jobId)}`, { timeoutMs: 20_000 });
if (job.status === "complete" && job.result) {
return job.result;
}
if (job.status === "error") {
throw new ApiError(job.error ?? "Zammad-Sync fehlgeschlagen", 500);
}
await wait(1200);
}
}
async function runZammadSyncJob(url: string, body: Record<string, unknown>) {
const { job } = await request<{ job: ZammadSyncJob }>(url, {
method: "POST",
timeoutMs: 20_000,
body: JSON.stringify(body)
});
const result = await waitForZammadSyncJob(job.id);
return { jobId: job.id, result };
}
export async function syncExistingZammadTickets(fields: ZammadSyncExistingFields, options: { dryRun?: boolean; ticketNumbers?: string[] } = {}) {
const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/existing", {
fields,
dryRun: options.dryRun ?? true,
ticketNumbers: options.ticketNumbers
});
return result;
}
export async function importOwnZammadConsultingTickets(options: { dryRun?: boolean; ticketNumbers?: string[] } = {}) {
const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", {
dryRun: options.dryRun ?? true,
ticketNumbers: options.ticketNumbers
});
return result;
}
export function previewExistingZammadTickets(fields: ZammadSyncExistingFields) {
return runZammadSyncJob("/api/zammad-sync/jobs/existing", {
fields,
dryRun: true
});
}
export function applyExistingZammadTicketsFromPreview(previewJobId: string, fields: ZammadSyncExistingFields, ticketNumbers: string[]) {
return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-existing`, {
fields,
ticketNumbers
});
}
export function previewOwnZammadConsultingTickets() {
return runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", {
dryRun: true
});
}
export function applyOwnZammadConsultingTicketsFromPreview(previewJobId: string, ticketNumbers: string[]) {
return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-consulting-import`, {
ticketNumbers
});
}
export function getRecurringBillings() {
return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings");
}
export function createRecurringBilling(payload: {
ticketNumber?: string | null;
organizationId: string;
activity: string;
workType: WorkType;
recurrenceType: "weekly" | "every_n_weeks";
intervalValue: number;
validFrom: string;
validUntil?: string | null;
slots: Array<{
weekday: number | null;
startTime: string;
durationMinutes: number;
}>;
}) {
return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateRecurringBilling(
billingId: string,
payload:
| { active: boolean }
| {
ticketNumber?: string | null;
organizationId: string;
activity: string;
workType: WorkType;
recurrenceType: "weekly" | "every_n_weeks";
intervalValue: number;
validFrom: string;
validUntil?: string | null;
slots: Array<{
id?: string;
weekday: number | null;
startTime: string;
durationMinutes: number;
}>;
}
) {
return request<{ recurringBillings: RecurringBilling[] }>(`/api/recurring-billings/${billingId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function deleteRecurringBilling(billingId: string) {
return request<{ recurringBillings: RecurringBilling[] }>(`/api/recurring-billings/${billingId}`, {
method: "DELETE"
});
}
export function getProjects(includeClosed = false, hidden = false) {
const params = new URLSearchParams();
if (includeClosed) {
params.set("includeClosed", "true");
}
if (hidden) {
params.set("hidden", "true");
}
return request<{ projects: ProjectSummary[]; hiddenCount: number; totalCount: number }>(`/api/projects${params.size ? `?${params}` : ""}`);
}
export function getProject(projectId: string) {
return request<{ project: ProjectSummary }>(`/api/projects/${projectId}`);
}
export function createProject(payload: {
ticketNumber: string;
title?: string | null;
organizationId?: string | null;
zammadLookup?: boolean;
teamspaceProject?: string | null;
teamspaceEntries?: Array<{
id?: string | null;
teamspaceProject?: string | null;
isDefault: boolean;
hourBudgetMinutes?: number | null;
budgetSyncDisabled?: boolean;
manualConsumptionMinutes?: number | null;
}>;
description: string;
goals: string;
startsOn?: string | null;
endsOn?: string | null;
hourBudgetMinutes?: number | null;
manualConsumptionMinutes?: number | null;
}) {
return request<{ project: ProjectSummary }>("/api/projects", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateProject(
projectId: string,
payload: {
ticketNumber: string;
title?: string | null;
organizationId: string;
teamspaceProject?: string | null;
teamspaceEntries?: Array<{
id?: string | null;
teamspaceProject?: string | null;
isDefault: boolean;
hourBudgetMinutes?: number | null;
budgetSyncDisabled?: boolean;
manualConsumptionMinutes?: number | null;
}>;
description: string;
goals: string;
startsOn?: string | null;
endsOn?: string | null;
hourBudgetMinutes?: number | null;
manualConsumptionMinutes?: number | null;
}
) {
return request<{ project: ProjectSummary }>(`/api/projects/${projectId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function updateProjectStatus(projectId: string, closed: boolean) {
return request<{ project: { id: string; ticket_id: string; closed_at: string | null } }>(`/api/projects/${projectId}/status`, {
method: "PATCH",
body: JSON.stringify({ closed })
});
}
export function deleteProject(projectId: string) {
return request<{ deleted: { id: string; ticket_id: string } }>(`/api/projects/${projectId}`, {
method: "DELETE"
});
}
export function hideProject(projectId: string) {
return request<{ hidden: { id: string } }>(`/api/projects/${projectId}/hide`, {
method: "POST"
});
}
export function unhideProject(projectId: string) {
return request<{ shown: { id: string } }>(`/api/projects/${projectId}/hide`, {
method: "DELETE"
});
}
export function getTodos(status?: TodoStatus | "all") {
const params = new URLSearchParams();
if (status && status !== "all") {
params.set("status", status);
}
return request<{ todos: TicketTodo[] }>(`/api/todos${params.size ? `?${params}` : ""}`);
}
export function createTicketTodo(
ticketId: string,
payload: {
title: string;
description: string;
priority: TodoPriority;
startsOn?: string | null;
endsOn?: string | null;
}
) {
return request<{ todo: TicketTodo }>(`/api/tickets/${ticketId}/todos`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateTodo(
todoId: string,
payload: {
title: string;
description: string;
priority: TodoPriority;
status: TodoStatus;
startsOn?: string | null;
endsOn?: string | null;
}
) {
return request<{ todo: TicketTodo }>(`/api/todos/${todoId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function updateTodoOrder(todoIds: string[]) {
return request<{ ok: boolean }>("/api/todos/order", {
method: "PUT",
body: JSON.stringify({ todoIds })
});
}
export function deleteTodo(todoId: string) {
return request<{ deleted: { id: string } }>(`/api/todos/${todoId}`, {
method: "DELETE"
});
}
export function createSession(payload: CreateSessionPayload) {
return request("/api/sessions", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function lookupTicket(ticketNumber: string, source: "local" | "zammad" = "local") {
const params = new URLSearchParams({ ticketNumber });
if (source === "zammad") {
params.set("source", source);
}
return request<{ ticket: TicketMeta | null }>(`/api/tickets/lookup?${params}`);
}
export function searchTickets(query: string) {
return request<{ tickets: TicketSearchResult[] }>(`/api/tickets/search?q=${encodeURIComponent(query)}`);
}
export function updateTicket(
ticketId: string,
payload: {
ticketNumber: string;
title?: string | null;
organizationId: string;
workType: WorkType;
removeProjectData?: boolean;
}
) {
return request<{ ticket: TicketMeta }>(`/api/tickets/${ticketId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function updateSessionDetails(
sessionId: string,
payload: {
organizationId: string;
activity: string;
workType: WorkType;
removeProjectData?: boolean;
projectTeamspaceEntryId?: string | null;
startedAt: string;
endedAt: string;
}
) {
return request(`/api/sessions/${sessionId}/details`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function updateTicketDayBilling(ticketId: string, day: string, billedMinutes: number | null) {
return request(`/api/tickets/${ticketId}/day-billings/${day}`, {
method: "PATCH",
body: JSON.stringify({ billedMinutes })
});
}
export function acknowledgeMissingTicketDayBilling(ticketId: string, day: string, acknowledged = true) {
return request(`/api/tickets/${ticketId}/day-billings/${day}/acknowledgement`, {
method: "PATCH",
body: JSON.stringify({ acknowledged })
});
}
export function getMonthOverview(month: string) {
return getPeriodOverview("month", month);
}
export function getTicketMonth(month: string, ticketId: string) {
return getTicketPeriod("month", month, ticketId);
}
function periodPath(type: PeriodType) {
return type === "month" ? "months" : "days";
}
export function getPeriodOverview(type: PeriodType, period: string) {
return request<PeriodOverview>(`/api/periods/${periodPath(type)}/${period}/overview`);
}
export function getStatisticsOverview(month: string) {
return request<StatisticsOverview>(`/api/statistics/months/${month}`);
}
export function getTicketPeriod(type: PeriodType, period: string, ticketId: string) {
return request<TicketPeriod>(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}`);
}
export function getTicketDetails(
ticketId: string,
filters: {
month?: string;
status?: "all" | "open" | "billed" | "non_billable";
source?: "all" | "manual" | "recurring";
} = {}
) {
const params = new URLSearchParams();
if (filters.month && filters.month !== "all") {
params.set("month", filters.month);
}
if (filters.status && filters.status !== "all") {
params.set("status", filters.status);
}
if (filters.source && filters.source !== "all") {
params.set("source", filters.source);
}
return request<TicketPeriod>(`/api/tickets/${ticketId}/details${params.size ? `?${params}` : ""}`);
}
export function updateSessionBilling(sessionId: string, billingStatus: BillingStatus) {
return request(`/api/sessions/${sessionId}/billing`, {
method: "PATCH",
body: JSON.stringify({ billingStatus })
});
}
export function deleteSession(sessionId: string) {
return request<DeleteSessionResult>(`/api/sessions/${sessionId}`, {
method: "DELETE"
});
}
export function closeTicketMonth(month: string, ticketId: string) {
return closeTicketPeriod("month", month, ticketId);
}
export function reopenTicketMonth(month: string, ticketId: string) {
return reopenTicketPeriod("month", month, ticketId);
}
export function closeMonth(month: string) {
return closePeriod("month", month);
}
export function reopenMonth(month: string) {
return reopenPeriod("month", month);
}
export function closeTicketPeriod(type: PeriodType, period: string, ticketId: string) {
return request(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}/close`, {
method: "POST"
});
}
export function reopenTicketPeriod(type: PeriodType, period: string, ticketId: string) {
return request(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}/reopen`, {
method: "POST"
});
}
export function closePeriod(type: PeriodType, period: string) {
return request(`/api/periods/${periodPath(type)}/${period}/close`, {
method: "POST"
});
}
export function reopenPeriod(type: PeriodType, period: string) {
return request(`/api/periods/${periodPath(type)}/${period}/reopen`, {
method: "POST"
});
}