Files
tickettracker/frontend/src/api.ts
T
2026-10-13 10:43:53 +02:00

1169 lines
33 KiB
TypeScript

import type {
AdminUser,
AuthUser,
BillingStatus,
DashboardDayTicket,
DashboardPreference,
Organization,
PeriodOverview,
PeriodType,
ProjectTeamspaceSubentry,
ProjectSummary,
RecurringBilling,
StatisticsOverview,
StatisticsProjectFollowupOverview,
TicketMeta,
TicketPeriod,
TicketTodo,
TicketSearchResult,
TodoPriority,
TodoStatus,
WorkType,
ZammadSessionArticleQueueItem
} from "./types";
import type { TimerEntry, TimerPhase } from "./timers";
type CreateSessionPayload = {
ticketNumber: string;
organizationId: string;
activity: string;
zammadArticleBody?: string | null;
workType: WorkType;
startedAt: string;
endedAt: string;
durationSeconds: number;
zammadLookup?: boolean;
projectTeamspaceEntryId?: string | null;
billingStatus?: BillingStatus;
createZammadArticle?: boolean;
zammadArticleTypeId?: number;
};
type CreateTimerPayload = Omit<TimerEntry, "id"> & {
id?: string;
};
type UpdateTimerPayload = Partial<{
organizationName: string | null;
workType: TimerEntry["workType"];
isProject: boolean;
projectName: string | null;
ticketLookupDone: boolean;
activity: string;
zammadArticleBody?: string | null;
billingStatus: TimerEntry["billingStatus"];
phase: TimerPhase;
}>;
type DeleteSessionResult = {
deleted: {
id: string;
ticket_id: string;
started_at: string;
userTicketEmpty: boolean;
ticketDeleted: boolean;
projectDeleteAvailable: boolean;
project_id: string | null;
};
};
export type CreateSessionResult = {
zammadArticle?: {
requested: boolean;
status: "not_requested" | "sent" | "failed";
error: string | null;
articleId: number | null;
};
};
export type ZammadSyncExistingFields = {
title: boolean;
organization: boolean;
type: boolean;
projectName: boolean;
budget: boolean;
overrideBudget: boolean;
pendingTodo: 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 TeamspaceProjectIdSyncItem = {
entryId: string;
projectId: string;
ticketNumber: string;
teamspaceProject: string;
previousApiProjectId: string | null;
apiProjectId: string | null;
apiProjectUrl: string | null;
ttMonthBillableMinutes: number;
teamspaceMonthMinutes: number;
teamspaceMonthBillableMinutes: number;
teamspaceMonthEntryCount: number;
teamspaceMonthDeltaMinutes: number;
teamspaceMonthPositions: TeamspaceProjectIdSyncPosition[];
status: "updated" | "current" | "missing" | "ambiguous" | "excluded";
message: string | null;
};
export type TeamspaceProjectIdSyncPosition = {
apiProjectId: string;
projectId: string | null;
label: string;
isSubentry: boolean;
billingType?: string | null;
minutes: number;
billableMinutes: number;
entryCount: number;
days?: Array<{
day: string;
minutes: number;
billableMinutes: number;
entryCount: number;
}>;
};
export type TeamspaceProjectIdSyncResult = {
month: string;
checkedEntries: number;
matched: number;
updated: number;
alreadyCurrent: number;
missing: number;
ambiguous: number;
cleared: number;
remoteProjectsScanned: number;
remoteProjectIds: number;
remoteSubprojectsScanned: number;
subentriesSynced: number;
subentriesRemoved: number;
teamspaceTimeEntriesScanned: number;
teamspaceTimeEntriesMapped: number;
teamspaceTimeBillableMinutes: number;
teamspaceTimeDeltaMinutes: number;
duplicateRemoteProjectIds: string[];
items: TeamspaceProjectIdSyncItem[];
};
export type TeamspaceProjectSubentrySyncResult = {
projectId: string;
checkedEntries: number;
remoteProjectsScanned: number;
subentriesSynced: number;
subentriesRemoved: number;
monthsSynced: string[];
teamspaceTimeEntriesScanned: number;
teamspaceTimeEntriesMapped: number;
teamspaceTimeBillableMinutes: number;
};
export type TeamspaceAllSubentrySyncResult = {
checkedProjects: number;
checkedEntries: number;
remoteProjectsScanned: number;
subentriesSynced: number;
subentriesRemoved: number;
};
export type TicketZammadMailSessionItem = {
articleId: number;
ticketNumber: string;
subject: string | null;
activity: string;
createdAt: string;
endedAt: string;
timeUnit: number;
durationSeconds: number;
};
export type TicketZammadMailSessionResult = {
checked: number;
created: number;
skipped: number;
items: TicketZammadMailSessionItem[];
errors: Array<{ articleId: number; error: string }>;
};
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;
teamspaceDeviceId?: string;
teamspaceDevicePassword?: string;
zammadEmailSessionWebhookEnabled?: boolean;
sessionBillingPromptEnabled?: boolean;
teamspaceTimeTrackingEnabled?: boolean;
zammadSessionNoteDefaultEnabled?: boolean;
zammadTimeUnitRoundingEnabled?: boolean;
zammadTimeUnitRoundingMinutes?: number;
zammadAtossEmailSyncEnabled?: boolean;
zammadArticleTypeId?: number;
projectFollowupEnabled?: boolean;
projectFollowupWeeks?: number;
projectFollowupSecondEnabled?: boolean;
projectFollowupSecondWeeks?: number;
projectFollowupCompleteOnSession?: boolean;
}) {
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 getDashboardDayControl() {
return request<{ tickets: DashboardDayTicket[] }>("/api/dashboard/day-control");
}
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 getTeamspaceSettings() {
return request<{ settings: { baseUrl: string; credentialsUserId: string | null } }>("/api/admin/teamspace/settings");
}
export function saveTeamspaceSettings(payload: { baseUrl: string; credentialsUserId?: string | null }) {
return request<{ settings: { baseUrl: string; credentialsUserId: string | null } }>("/api/admin/teamspace/settings", {
method: "PUT",
body: JSON.stringify(payload)
});
}
export type TeamspaceProjectExclusion = {
teamspace_project: string;
teamspace_name: string | null;
customer_name: string | null;
note: string;
};
export function getTeamspaceProjectExclusions() {
return request<{ exclusions: TeamspaceProjectExclusion[] }>("/api/admin/teamspace/project-exclusions");
}
export function saveTeamspaceProjectExclusions(projects: string[]) {
return request<{ exclusions: TeamspaceProjectExclusion[] }>("/api/admin/teamspace/project-exclusions", {
method: "PUT",
body: JSON.stringify({ projects })
});
}
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));
}
export 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>, onJobStarted?: (job: ZammadSyncJob) => void) {
const { job } = await request<{ job: ZammadSyncJob }>(url, {
method: "POST",
timeoutMs: 20_000,
body: JSON.stringify(body)
});
onJobStarted?.(job);
const result = await waitForZammadSyncJob(job.id);
return { jobId: job.id, result };
}
export async function syncExistingZammadTickets(fields: ZammadSyncExistingFields, options: { dryRun?: boolean; ticketNumbers?: string[] } = {}, onJobStarted?: (job: ZammadSyncJob) => void) {
const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/existing", {
fields,
dryRun: options.dryRun ?? true,
ticketNumbers: options.ticketNumbers
}, onJobStarted);
return result;
}
export async function importOwnZammadConsultingTickets(options: { dryRun?: boolean; ticketNumbers?: string[] } = {}, onJobStarted?: (job: ZammadSyncJob) => void) {
const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", {
dryRun: options.dryRun ?? true,
ticketNumbers: options.ticketNumbers
}, onJobStarted);
return result;
}
export function syncExistingZammadTicketsDirect(fields: ZammadSyncExistingFields, onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob("/api/zammad-sync/jobs/existing", {
fields,
dryRun: false
}, onJobStarted);
}
export function importOwnZammadConsultingTicketsDirect(onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", {
dryRun: false
}, onJobStarted);
}
export function syncZammadMailSessionsDirect(daysBack: number, perPage: number, onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob("/api/zammad-sync/jobs/mail-sessions", {
dryRun: false,
daysBack,
perPage
}, onJobStarted);
}
export function previewExistingZammadTickets(fields: ZammadSyncExistingFields, onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob("/api/zammad-sync/jobs/existing", {
fields,
dryRun: true
}, onJobStarted);
}
export function applyExistingZammadTicketsFromPreview(previewJobId: string, fields: ZammadSyncExistingFields, ticketNumbers: string[], onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-existing`, {
fields,
ticketNumbers
}, onJobStarted);
}
export function previewOwnZammadConsultingTickets(onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", {
dryRun: true
}, onJobStarted);
}
export function applyOwnZammadConsultingTicketsFromPreview(previewJobId: string, ticketNumbers: string[], onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-consulting-import`, {
ticketNumbers
}, onJobStarted);
}
export function previewZammadMailSessions(daysBack: number, perPage: number, onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob("/api/zammad-sync/jobs/mail-sessions", {
dryRun: true,
daysBack,
perPage
}, onJobStarted);
}
export function applyZammadMailSessionsFromPreview(previewJobId: string, articleIds: number[], onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-mail-sessions`, {
articleIds
}, onJobStarted);
}
export function syncTeamspaceProjectIds(month?: string) {
return request<{ result: TeamspaceProjectIdSyncResult }>("/api/teamspace/project-ids/sync", {
method: "POST",
body: JSON.stringify(month ? { month } : {}),
timeoutMs: 5 * 60 * 1000
});
}
export function syncProjectTeamspaceSubentries(projectId: string, months?: string[]) {
return request<{ result: TeamspaceProjectSubentrySyncResult }>(`/api/projects/${projectId}/teamspace-subentries/sync`, {
method: "POST",
body: JSON.stringify(months?.length ? { months } : {}),
timeoutMs: 5 * 60 * 1000
});
}
export function syncAllTeamspaceSubentries() {
return request<{ result: TeamspaceAllSubentrySyncResult }>("/api/teamspace/subentries/sync", {
method: "POST",
timeoutMs: 5 * 60 * 1000
});
}
export function updateProjectTeamspaceSubentry(
subentryId: string,
payload: {
alias: string;
}
) {
return request<{ subentry: ProjectTeamspaceSubentry }>(`/api/project-teamspace-subentries/${subentryId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
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;
teamspaceDescription?: 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;
teamspaceDescription?: 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 acknowledgeProjectReview(projectId: string) {
return request<{ project: { id: string; ticket_id: string; close_review_required: boolean } }>(`/api/projects/${projectId}/review`, {
method: "PATCH",
body: JSON.stringify({ acknowledged: true })
});
}
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<CreateSessionResult>("/api/sessions", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function resendSessionZammadArticle(sessionId: string, payload: { zammadArticleTypeId?: number } = {}) {
return request<CreateSessionResult>(`/api/sessions/${encodeURIComponent(sessionId)}/zammad-article`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function getActiveTimers() {
return request<{ timers: TimerEntry[] }>("/api/timers");
}
export function createActiveTimer(payload: CreateTimerPayload) {
return request<{ timers: TimerEntry[] }>("/api/timers", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateActiveTimer(timerId: string, payload: UpdateTimerPayload) {
return request<{ timers: TimerEntry[] }>(`/api/timers/${encodeURIComponent(timerId)}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function deleteActiveTimer(timerId: string) {
return request<{ timers: TimerEntry[] }>(`/api/timers/${encodeURIComponent(timerId)}`, {
method: "DELETE"
});
}
export function getZammadSessionArticleQueue() {
return request<{ queue: ZammadSessionArticleQueueItem[] }>("/api/zammad/session-article-queue");
}
export function retryZammadSessionArticleQueue() {
return request<{
sent: number;
failed: number;
queue: ZammadSessionArticleQueueItem[];
results: Array<{ id: string; status: "sent" | "failed"; error?: string; zammadArticleId?: number | null }>;
}>("/api/zammad/session-article-queue/retry", {
method: "POST"
});
}
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;
zammadArticleBody?: string | null;
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,
projectTeamspaceEntryId?: string | null
) {
return request(`/api/tickets/${ticketId}/day-billings/${day}`, {
method: "PATCH",
body: JSON.stringify({
billedMinutes,
projectTeamspaceEntryId: projectTeamspaceEntryId ?? null
})
});
}
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(period: string, type: "month" | "week" = "month") {
return request<StatisticsOverview>(`/api/statistics/${type === "week" ? "weeks" : "months"}/${period}`);
}
export function getProjectFollowups() {
return request<StatisticsProjectFollowupOverview>("/api/statistics/project-followups");
}
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" | "billable" | "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 previewTicketZammadMailSessions(ticketId: string, daysBack: number) {
const params = new URLSearchParams({ daysBack: String(daysBack) });
return request<TicketZammadMailSessionResult>(`/api/tickets/${ticketId}/zammad-mail-sessions?${params}`);
}
export function importTicketZammadMailSessions(ticketId: string, daysBack: number, articleIds: number[]) {
return request<TicketZammadMailSessionResult>(`/api/tickets/${ticketId}/zammad-mail-sessions`, {
method: "POST",
body: JSON.stringify({ daysBack, articleIds })
});
}
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"
});
}