1169 lines
33 KiB
TypeScript
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"
|
|
});
|
|
}
|