This commit is contained in:
michael committed 2026-09-11 10:41:07 +02:00
1 parent 07ef8ef025
commit 87d6c9b3ce
10 files changed
+2925 -112

No files matched your search

+189 -11
View File
@@ -42,6 +42,54 @@ type DeleteSessionResult = {
};
};
export type ZammadSyncExistingFields = {
title: boolean;
organization: boolean;
type: boolean;
projectName: boolean;
budget: 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;
@@ -58,15 +106,46 @@ export class ApiError extends Error {
}
}
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(url, {
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
...options.headers
},
...options
});
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);
}
throw error;
} finally {
if (timeout) {
window.clearTimeout(timeout);
}
}
if (!response.ok) {
const body = await response.json().catch(() => ({}));
@@ -266,6 +345,89 @@ export function syncZammadOrganizations(payload: { baseUrl?: string; apiKey?: st
});
}
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");
}
@@ -324,14 +486,18 @@ export function deleteRecurringBilling(billingId: string) {
});
}
export function getProjects(includeClosed = false) {
export function getProjects(includeClosed = false, hidden = false) {
const params = new URLSearchParams();
if (includeClosed) {
params.set("includeClosed", "true");
}
return request<{ projects: ProjectSummary[] }>(`/api/projects${params.size ? `?${params}` : ""}`);
if (hidden) {
params.set("hidden", "true");
}
return request<{ projects: ProjectSummary[]; hiddenCount: number; totalCount: number }>(`/api/projects${params.size ? `?${params}` : ""}`);
}
export function getProject(projectId: string) {
@@ -405,6 +571,18 @@ export function deleteProject(projectId: string) {
});
}
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();