diff --git a/.env_dev b/.env_dev index e8e43e0..18d13c0 100644 --- a/.env_dev +++ b/.env_dev @@ -1,7 +1,8 @@ PP_PASSWORD='$ouzs4D$@RAi9' -DATABASE_URL=postgresql://tickettracker:${PP_PASSWORD}@172.22.0.2:5432/tickettracker_dev +DATABASE_URL=postgresql://tickettracker:${PP_PASSWORD}@172.22.0.3:5432/tickettracker_dev APP_PORT=3911 TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest COOKIE_SECURE=false DB_POOL_SIZE=10 ZAMMAD_EMAIL_WEBHOOK_TOKEN='sPXJc87R@ySW3uTFKjGFMrti8BcDkNv8mN2g71hRkdg=' +SECRET_ENCRYPTION_KEY='7qDJ1bcJprs$eSv57c2QXmQKh4kT^kb/w0vPBeJ4z^4=' \ No newline at end of file diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 7f1a7af..7d92b1a 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -22,6 +22,7 @@ export type AuthUser = { zammad_session_note_default_enabled: boolean; zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_minutes: number; + zammad_atoss_email_sync_enabled: boolean; zammad_article_type_id: number; }; @@ -89,6 +90,7 @@ function publicUser(user: AuthUser) { zammad_session_note_default_enabled: Boolean(user.zammad_session_note_default_enabled), zammad_time_unit_rounding_enabled: Boolean(user.zammad_time_unit_rounding_enabled), zammad_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15), + zammad_atoss_email_sync_enabled: Boolean(user.zammad_atoss_email_sync_enabled ?? true), zammad_article_type_id: Number(user.zammad_article_type_id ?? 6) }; } @@ -152,7 +154,8 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio u.zammad_session_note_default_enabled, u.zammad_time_unit_rounding_enabled, u.zammad_time_unit_rounding_minutes, - u.zammad_article_type_id + u.zammad_atoss_email_sync_enabled, + COALESCE(u.zammad_article_type_id, 6) AS zammad_article_type_id FROM auth_sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1 diff --git a/backend/src/databaseExport.ts b/backend/src/databaseExport.ts index a27eca3..bb3f06f 100644 --- a/backend/src/databaseExport.ts +++ b/backend/src/databaseExport.ts @@ -22,6 +22,7 @@ export const databaseExportTables = [ "recurring_billing_slots", "recurring_billing_exceptions", "sessions", + "active_timers", "ticket_month_closures", "month_closures", "ticket_day_closures", diff --git a/backend/src/index.ts b/backend/src/index.ts index 4f49ad4..1f568fb 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -25,6 +25,7 @@ import { parsePositiveInteger, parseTicketNumber, parseTrackableTicketNumber, + parseWeek, parseUsername, parseWorkType, requireString, @@ -38,7 +39,10 @@ const frontendDist = path.resolve(__dirname, "../../frontend/dist"); const serveFrontend = process.env.SERVE_FRONTEND !== "false"; const requestBodyLimit = process.env.REQUEST_BODY_LIMIT ?? "250mb"; const zammadEmailWebhookToken = process.env.ZAMMAD_EMAIL_WEBHOOK_TOKEN ?? process.env.ZAMMAD_WEBHOOK_TOKEN ?? ""; +const zammadRequestTimeoutMs = positiveIntegerFromEnv("ZAMMAD_REQUEST_TIMEOUT_MS", 30_000); +const atossSupportEmail = "support.atc@atoss.com"; +app.set("etag", false); app.use(cors()); app.use(express.text({ type: ["text/plain", "text/sql"], limit: requestBodyLimit })); app.use(express.json({ limit: requestBodyLimit })); @@ -63,6 +67,31 @@ function logDebug(event: string, details: Record = {}) { console.info(`[tickettracker:${event}] ${JSON.stringify(details)}`); } +function positiveIntegerFromEnv(name: string, fallback: number) { + const value = Number(process.env[name]); + return Number.isInteger(value) && value > 0 ? value : fallback; +} + +async function zammadFetch(url: string, init: RequestInit = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), zammadRequestTimeoutMs); + + try { + return await fetch(url, { + ...init, + signal: controller.signal + }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw Object.assign(new Error(`Zammad Anfrage hat länger als ${Math.round(zammadRequestTimeoutMs / 1000)} Sekunden gedauert`), { status: 504 }); + } + + throw Object.assign(new Error(`Zammad ist nicht erreichbar: ${errorMessage(error)}`), { status: 502 }); + } finally { + clearTimeout(timeout); + } +} + process.on("uncaughtException", (error) => { logDebug("process", { event: "uncaughtException", @@ -84,6 +113,8 @@ app.use("/api", (req, res, next) => { const requestId = randomUUID(); const startedAt = Date.now(); + res.set("Cache-Control", "no-store"); + logDebug("request-start", { requestId, method: req.method, @@ -179,6 +210,166 @@ function optionalString(value: unknown) { return trimmed.length > 0 ? trimmed : null; } +function optionalSessionActivity(value: unknown) { + if (value === undefined || value === null) { + return ""; + } + + if (typeof value !== "string") { + throw badRequest("activity must be a string"); + } + + return value; +} + +const zammadInlineImageMaxBytes = 8 * 1024 * 1024; +const zammadInlineImagesMaxBytes = 24 * 1024 * 1024; +const zammadArticleHtmlMaxLength = 36 * 1024 * 1024; +const zammadInlineImageMimeTypes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); + +function decodeBasicHtmlEntities(value: string) { + return value + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, "\"") + .replace(/'/gi, "'"); +} + +function htmlToPlainText(value: string) { + return decodeBasicHtmlEntities( + value + .replace(/<\s*br\s*\/?>/gi, "\n") + .replace(/<\/\s*(div|p|li|blockquote|pre)\s*>/gi, "\n") + .replace(/<[^>]*>/g, "") + ).trim(); +} + +function normalizeZammadInlineImageDataUri(value: string, byteCounter: { total: number }) { + const match = /^data:(image\/(?:png|jpe?g|gif|webp));base64,([A-Za-z0-9+/=\s]+)$/i.exec(value.trim()); + + if (!match) { + throw badRequest("Inline-Bilder müssen PNG, JPEG, GIF oder WebP als Base64 Data-URI sein"); + } + + const mimeType = match[1].toLowerCase() === "image/jpg" ? "image/jpeg" : match[1].toLowerCase(); + const base64 = match[2].replace(/\s/g, ""); + + if (!zammadInlineImageMimeTypes.has(mimeType)) { + throw badRequest("Inline-Bilder müssen PNG, JPEG, GIF oder WebP sein"); + } + + const bytes = Buffer.byteLength(base64, "base64"); + + if (bytes <= 0 || bytes > zammadInlineImageMaxBytes) { + throw badRequest("Ein Inline-Bild ist zu groß"); + } + + byteCounter.total += bytes; + + if (byteCounter.total > zammadInlineImagesMaxBytes) { + throw badRequest("Die Inline-Bilder sind zusammen zu groß"); + } + + return `data:${mimeType};base64,${base64}`; +} + +function textStyleTagsFromHtmlStyle(style: string) { + const normalized = style.toLowerCase(); + const tags: string[] = []; + + if (/font-weight\s*:\s*(bold|[7-9]00)\b/.test(normalized)) { + tags.push("strong"); + } + + if (/font-style\s*:\s*italic\b/.test(normalized)) { + tags.push("em"); + } + + if (/text-decoration(?:-line)?\s*:[^;]*underline/.test(normalized)) { + tags.push("u"); + } + + if (/text-decoration(?:-line)?\s*:[^;]*(line-through|strike)/.test(normalized)) { + tags.push("s"); + } + + return tags; +} + +function normalizeStyledTextTag(tag: string) { + const styleMatch = /\bstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(tag); + const tags = textStyleTagsFromHtmlStyle(styleMatch?.[1] ?? styleMatch?.[2] ?? ""); + + return { + open: tags.map((name) => `<${name}>`).join(""), + close: tags.slice().reverse().map((name) => ``).join("") + }; +} + +function sanitizeZammadArticleHtml(value: string) { + if (value.length > zammadArticleHtmlMaxLength) { + throw badRequest("Zammad-Notiz ist zu groß"); + } + + const byteCounter = { total: 0 }; + const images: string[] = []; + const imagePlaceholderPrefix = "ZAMMAD_INLINE_IMAGE_PLACEHOLDER_"; + const withoutImages = value + .replace(//g, "") + .replace(/<\s*(script|style)\b[\s\S]*?<\/\s*\1\s*>/gi, "") + .replace(/]*>/gi, (tag) => { + const srcMatch = /\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(tag); + const src = srcMatch?.[1] ?? srcMatch?.[2] ?? srcMatch?.[3] ?? ""; + + if (!src) { + return ""; + } + + const image = ``; + const index = images.push(image) - 1; + return `${imagePlaceholderPrefix}${index}_`; + }); + + const normalizedFormatting = withoutImages.replace(/]*>[\s\S]*?<\/span>/gi, (tag) => { + const openTag = /^]*>/i.exec(tag)?.[0] ?? ""; + const tags = normalizeStyledTextTag(openTag); + + if (!tags.open) { + return tag; + } + + return `${tags.open}${tag.replace(/^]*>/i, "").replace(/<\/span>$/i, "")}${tags.close}`; + }); + + const sanitized = normalizedFormatting + .replace(/<\s*(\/?)\s*(br|div|p|span|b|strong|i|em|u|s|strike|ul|ol|li|blockquote|pre|code|h1|h2|h3)\b[^>]*>/gi, (_tag, closing: string, rawName: string) => { + const name = rawName.toLowerCase(); + return name === "br" ? "
" : `<${closing ? "/" : ""}${name}>`; + }) + .replace(/<[^>]*>/g, "") + .replace(new RegExp(`${imagePlaceholderPrefix}(\\d+)_`, "g"), (_placeholder, index: string) => images[Number(index)] ?? ""); + + if (!htmlToPlainText(sanitized) && images.length === 0) { + return null; + } + + return sanitized; +} + +function optionalZammadArticleHtml(value: unknown) { + if (value === undefined || value === null || value === "") { + return null; + } + + if (typeof value !== "string") { + throw badRequest("zammadArticleBody must be a string"); + } + + return sanitizeZammadArticleHtml(value); +} + function optionalNullableString(value: unknown) { if (value === null || value === undefined) { return null; @@ -429,6 +620,41 @@ type ZammadSyncResult = { items: ZammadSyncItem[]; }; +type ZammadMailSessionArticle = { + articleId: number; + zammadTicketId: number; + ticketNumber: string; + createdById: number; + typeId: number; + createdAt: Date; + timeUnit: number; + durationSeconds: number; + subject: string | null; + to: string | null; + activity: string; +}; + +type ZammadMailArticleCandidate = Omit; + +type TicketZammadMailSessionItem = { + articleId: number; + ticketNumber: string; + subject: string | null; + activity: string; + createdAt: string; + endedAt: string; + timeUnit: number; + durationSeconds: number; +}; + +type TicketZammadMailSessionResult = { + checked: number; + created: number; + skipped: number; + items: TicketZammadMailSessionItem[]; + errors: Array<{ articleId: number; error: string }>; +}; + async function getAppSetting(key: string) { const result = await query<{ value: string }>("SELECT value FROM app_settings WHERE key = $1;", [key]); const value = result.rows[0]?.value ?? null; @@ -457,7 +683,7 @@ async function fetchZammadUserIdForToken(apiToken: string) { } const baseUrl = parseZammadBaseUrl(savedBaseUrl); - const response = await fetch(`${baseUrl}/api/v1/users/me`, { + const response = await zammadFetch(`${baseUrl}/api/v1/users/me`, { headers: { Authorization: `Token token=${apiToken}`, Accept: "application/json" @@ -466,7 +692,11 @@ async function fetchZammadUserIdForToken(apiToken: string) { if (!response.ok) { const body = await response.text().catch(() => ""); - throw Object.assign(new Error(`Zammad Token konnte nicht geprüft werden (${response.status})${body ? `: ${body.slice(0, 250)}` : ""}`), { status: 502 }); + const status = response.status === 401 || response.status === 403 ? 400 : 502; + const message = response.status === 401 || response.status === 403 + ? "Zammad Token ist ungültig oder gehört nicht zu dieser Zammad-Instanz" + : `Zammad Token konnte nicht geprüft werden (${response.status})`; + throw Object.assign(new Error(`${message}${body ? `: ${body.slice(0, 250)}` : ""}`), { status }); } const payload = (await response.json()) as { id?: unknown }; @@ -573,7 +803,7 @@ async function getZammadCredentialsForUser(userId: string) { } async function fetchZammadJson(baseUrl: string, apiToken: string, path: string) { - const response = await fetch(`${baseUrl}${path}`, { + const response = await zammadFetch(`${baseUrl}${path}`, { headers: { Authorization: `Token token=${apiToken}`, Accept: "application/json" @@ -589,7 +819,7 @@ async function fetchZammadJson(baseUrl: string, apiToken: string, path: string) } async function postZammadJson(baseUrl: string, apiToken: string, path: string, payload: Record) { - const response = await fetch(`${baseUrl}${path}`, { + const response = await zammadFetch(`${baseUrl}${path}`, { method: "POST", headers: { Authorization: `Token token=${apiToken}`, @@ -647,14 +877,15 @@ type ZammadSessionArticlePayload = { internal: true; subject: string; body: string; - content_type: "text/plain"; + content_type: "text/plain" | "text/html"; time_unit: number; accounted_time_type_id: number; }; function zammadSessionArticlePayload(input: { zammadTicketId: string | number; - activity: string; + body: string; + contentType: "text/plain" | "text/html"; timeUnit: number; articleTypeId: number; }): ZammadSessionArticlePayload { @@ -669,13 +900,21 @@ function zammadSessionArticlePayload(input: { type: "note", internal: true, subject: "TicketTracker Session", - body: input.activity, - content_type: "text/plain", + body: input.body, + content_type: input.contentType, time_unit: input.timeUnit, accounted_time_type_id: input.articleTypeId }; } +function zammadSessionArticleBodyForDelivery(body: string, contentType: "text/plain" | "text/html") { + if (contentType !== "text/html") { + return body; + } + + return `
${body}
`; +} + async function postZammadSessionArticleForUser(userId: string, payload: ZammadSessionArticlePayload) { const credentials = await getZammadCredentialsForUser(userId); return postZammadJson(credentials.baseUrl, credentials.apiToken, "/api/v1/ticket_articles", payload); @@ -688,6 +927,8 @@ async function enqueueZammadSessionArticle(client: pg.PoolClient, input: { ticketNumber: string; zammadTicketId: string | number | null; activity: string; + articleBody: string; + articleContentType: "text/plain" | "text/html"; timeUnit: number; articleTypeId: number; error: string; @@ -701,15 +942,19 @@ async function enqueueZammadSessionArticle(client: pg.PoolClient, input: { ticket_number, zammad_ticket_id, activity, + article_body, + article_content_type, time_unit, zammad_article_type_id, last_error ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (session_id) WHERE status = 'pending' DO UPDATE SET zammad_ticket_id = COALESCE(EXCLUDED.zammad_ticket_id, zammad_session_article_queue.zammad_ticket_id), activity = EXCLUDED.activity, + article_body = EXCLUDED.article_body, + article_content_type = EXCLUDED.article_content_type, time_unit = EXCLUDED.time_unit, zammad_article_type_id = EXCLUDED.zammad_article_type_id, last_error = EXCLUDED.last_error, @@ -722,6 +967,8 @@ async function enqueueZammadSessionArticle(client: pg.PoolClient, input: { input.ticketNumber, input.zammadTicketId, input.activity, + input.articleBody, + input.articleContentType, input.timeUnit, input.articleTypeId, input.error.slice(0, 1000) @@ -736,6 +983,8 @@ async function recordZammadSessionArticleSent(client: pg.PoolClient, input: { ticketNumber: string; zammadTicketId: string | number; activity: string; + articleBody: string; + articleContentType: "text/plain" | "text/html"; timeUnit: number; articleTypeId: number; articleId: number | null; @@ -749,6 +998,8 @@ async function recordZammadSessionArticleSent(client: pg.PoolClient, input: { ticket_number, zammad_ticket_id, activity, + article_body, + article_content_type, time_unit, zammad_article_type_id, status, @@ -756,7 +1007,7 @@ async function recordZammadSessionArticleSent(client: pg.PoolClient, input: { zammad_article_id, last_error ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'sent', now(), $9, NULL); + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'sent', now(), $11, NULL); `, [ input.userId, @@ -765,6 +1016,8 @@ async function recordZammadSessionArticleSent(client: pg.PoolClient, input: { input.ticketNumber, input.zammadTicketId, input.activity, + input.articleBody, + input.articleContentType, input.timeUnit, input.articleTypeId, input.articleId @@ -784,10 +1037,141 @@ type ZammadSessionArticleQueueRow = { ticket_number: string; zammad_ticket_id: string | null; activity: string; + article_body: string | null; + article_content_type: "text/plain" | "text/html" | null; time_unit: string | number; - zammad_article_type_id: number | null; + zammad_article_type_id: number; }; +async function postImmediateZammadSessionArticle(input: { + userId: string; + sessionId: string | number; + ticketId: string | number; + ticketNumber: string; + zammadTicketId: string | number | null; + activity: string; + articleBody: string; + articleContentType: "text/plain" | "text/html"; + timeUnit: number; + articleTypeId: number; +}) { + let zammadTicketId = input.zammadTicketId; + + if (!zammadTicketId) { + const zammadTicket = await fetchZammadTicketByNumber(input.userId, input.ticketNumber); + + if (!zammadTicket) { + throw new Error("Ticket wurde in Zammad nicht gefunden"); + } + + await withTransaction(async (client) => { + await importZammadTicketForUser(client, zammadTicket, input.userId); + }); + zammadTicketId = String(zammadTicket.zammadTicketId); + } + + const payload = zammadSessionArticlePayload({ + zammadTicketId, + body: zammadSessionArticleBodyForDelivery(input.articleBody, input.articleContentType), + contentType: input.articleContentType, + timeUnit: input.timeUnit, + articleTypeId: input.articleTypeId + }); + const zammadResponse = await postZammadSessionArticleForUser(input.userId, payload); + const articleId = zammadArticleId(zammadResponse); + + await withTransaction(async (client) => { + await recordZammadSessionArticleSent(client, { + ...input, + zammadTicketId, + articleId + }); + }); + + return articleId; +} + +async function pendingZammadSessionArticleQueueRows(userId: string | null, limit: number) { + return query( + ` + SELECT + id::text AS id, + user_id::text AS user_id, + session_id::text AS session_id, + ticket_id::text AS ticket_id, + ticket_number, + zammad_ticket_id::text AS zammad_ticket_id, + activity, + article_body, + article_content_type, + time_unit::text AS time_unit, + zammad_article_type_id + FROM zammad_session_article_queue + WHERE status = 'pending' + AND ($1::bigint IS NULL OR user_id = $1::bigint) + ORDER BY last_attempt_at ASC NULLS FIRST, created_at ASC + LIMIT $2; + `, + [userId, limit] + ); +} + +async function recordZammadSessionArticleFailure(rowId: string, error: unknown) { + const message = errorMessage(error); + + await query( + ` + UPDATE zammad_session_article_queue + SET attempt_count = attempt_count + 1, + last_error = $1, + last_attempt_at = now(), + updated_at = now() + WHERE id = $2 + AND status = 'pending'; + `, + [message.slice(0, 1000), rowId] + ); + + return message; +} + +let zammadSessionArticleQueueDraining = false; +let zammadSessionArticleQueueDrainRequested = false; + +async function drainZammadSessionArticleQueue(userId: string | null = null, limit = 100) { + if (zammadSessionArticleQueueDraining) { + zammadSessionArticleQueueDrainRequested = true; + return; + } + + zammadSessionArticleQueueDraining = true; + + try { + do { + zammadSessionArticleQueueDrainRequested = false; + const pendingResult = await pendingZammadSessionArticleQueueRows(userId, limit); + + for (const row of pendingResult.rows) { + try { + await retryZammadSessionArticleQueueRow(row); + } catch (error) { + await recordZammadSessionArticleFailure(row.id, error); + } + } + } while (zammadSessionArticleQueueDrainRequested); + } finally { + zammadSessionArticleQueueDraining = false; + } +} + +function scheduleZammadSessionArticleQueueDrain(userId: string | null = null) { + setTimeout(() => { + void drainZammadSessionArticleQueue(userId).catch((error) => { + logDebug("zammad-session-article-queue-drain-failed", { error: errorMessage(error) }); + }); + }, 0); +} + async function retryZammadSessionArticleQueueRow(row: ZammadSessionArticleQueueRow) { let zammadTicketId = row.zammad_ticket_id; @@ -814,7 +1198,8 @@ async function retryZammadSessionArticleQueueRow(row: ZammadSessionArticleQueueR const payload = zammadSessionArticlePayload({ zammadTicketId, - activity: row.activity, + body: zammadSessionArticleBodyForDelivery(row.article_body ?? row.activity, row.article_content_type === "text/html" ? "text/html" : "text/plain"), + contentType: row.article_content_type === "text/html" ? "text/html" : "text/plain", timeUnit: Number(row.time_unit), articleTypeId: Number(row.zammad_article_type_id ?? 6) }); @@ -838,6 +1223,98 @@ async function retryZammadSessionArticleQueueRow(row: ZammadSessionArticleQueueR return articleId; } +async function sendZammadSessionArticleForSession(userId: string, sessionId: number, articleTypeId: number) { + const sessionResult = await query<{ + session_id: string; + ticket_id: string; + ticket_number: string; + zammad_ticket_id: string | null; + activity: string; + activity_html: string | null; + duration_seconds: string | number; + sent_article_id: string | null; + source_article_id: string | null; + }>( + ` + SELECT + s.id::text AS session_id, + t.id::text AS ticket_id, + t.ticket_number, + t.zammad_ticket_id::text AS zammad_ticket_id, + s.activity, + s.activity_html, + s.duration_seconds, + ( + SELECT zammad_article_id::text + FROM zammad_email_session_events + WHERE session_id = s.id + ORDER BY id DESC + LIMIT 1 + ) AS source_article_id, + ( + SELECT zammad_article_id::text + FROM zammad_session_article_queue + WHERE session_id = s.id + AND status = 'sent' + ORDER BY updated_at DESC + LIMIT 1 + ) AS sent_article_id + FROM sessions s + JOIN tickets t ON t.id = s.ticket_id + WHERE s.id = $1 + AND s.user_id = $2; + `, + [sessionId, userId] + ); + const session = sessionResult.rows[0]; + + if (!session) { + const error = new Error("Session not found") as Error & { status?: number }; + error.status = 404; + throw error; + } + + if (!session.activity.trim() && !session.activity_html) { + throw badRequest("Ohne Tätigkeit kann keine Zammad-Notiz gesendet werden"); + } + + if (session.source_article_id !== null) { + throw badRequest("Importierte Zammad-Mails können nicht erneut als Zammad-Notiz gesendet werden"); + } + + if (session.sent_article_id !== null) { + return { + requested: true, + status: "sent" as const, + error: null, + articleId: numberOrNull(session.sent_article_id) + }; + } + + const timeUnit = await zammadTimeUnitForUser(userId, Number(session.duration_seconds)); + const articleBody = session.activity_html ?? session.activity; + const articleContentType = session.activity_html ? "text/html" as const : "text/plain" as const; + const articleId = await postImmediateZammadSessionArticle({ + userId, + sessionId: session.session_id, + ticketId: session.ticket_id, + ticketNumber: session.ticket_number, + zammadTicketId: session.zammad_ticket_id, + activity: session.activity, + articleBody, + articleContentType, + timeUnit, + articleTypeId + }); + + return { + requested: true, + status: "sent" as const, + error: null, + articleId + }; +} + function zammadTicketCandidates(payload: unknown): Record[] { if (Array.isArray(payload)) { return payload.filter(isPlainObject); @@ -1073,6 +1550,7 @@ type SessionCreationInput = { userId: string; ticketNumber: string; activity: string; + zammadArticleHtml: string | null; requestedOrganizationId: number | null; requestedWorkType: "support" | "consulting" | null; useZammadLookup: boolean; @@ -1095,6 +1573,7 @@ async function createSessionForUser(input: SessionCreationInput) { userId, ticketNumber, activity, + zammadArticleHtml, requestedOrganizationId, requestedWorkType, useZammadLookup, @@ -1158,7 +1637,7 @@ async function createSessionForUser(input: SessionCreationInput) { ); } - return withTransaction(async (client) => { + const created = await withTransaction(async (client) => { let eventId: string | null = null; if (sourceEvent && (sourceEvent.zammadArticleId || sourceEvent.deliveryId)) { @@ -1289,6 +1768,7 @@ async function createSessionForUser(input: SessionCreationInput) { organization_id, customer_name, activity, + activity_html, work_type, user_id, started_at, @@ -1299,7 +1779,7 @@ async function createSessionForUser(input: SessionCreationInput) { billing_status, billing_updated_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, CASE WHEN $12::text IS NULL THEN NULL ELSE now() END) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, CASE WHEN $13::text IS NULL THEN NULL ELSE now() END) RETURNING *; `, [ @@ -1307,6 +1787,7 @@ async function createSessionForUser(input: SessionCreationInput) { organization.id, organization.name, activity, + zammadArticleHtml, workType, userId, startedAt.toISOString(), @@ -1319,81 +1800,58 @@ async function createSessionForUser(input: SessionCreationInput) { ); const session = sessionResult.rows[0]; + const zammadArticleBody = zammadArticleHtml ?? activity; + const zammadArticleContentType = zammadArticleHtml ? "text/html" as const : "text/plain" as const; + const shouldCreateZammadArticle = createZammadArticle && (activity.trim().length > 0 || Boolean(zammadArticleHtml)); const zammadArticle: { requested: boolean; - status: "not_requested" | "sent" | "queued"; + status: "not_requested" | "sent" | "failed"; error: string | null; articleId: number | null; } = { - requested: createZammadArticle, - status: createZammadArticle ? "queued" : "not_requested", + requested: shouldCreateZammadArticle, + status: "not_requested", error: null, articleId: null }; + let zammadArticleDelivery: { + userId: string; + sessionId: string | number; + ticketId: string | number; + ticketNumber: string; + zammadTicketId: string | number | null; + activity: string; + articleBody: string; + articleContentType: "text/plain" | "text/html"; + timeUnit: number; + articleTypeId: number; + } | null = null; - if (createZammadArticle) { + if (shouldCreateZammadArticle) { const timeUnit = await zammadTimeUnitForUser(userId, durationSeconds); let zammadArticleTicketId = ticket.zammad_ticket_id; let zammadArticleError: string | null = null; - if (!zammadArticleTicketId && useZammadLookup) { - try { - const articleZammadTicket = await fetchZammadTicketByNumber(userId, ticketNumber); - - if (articleZammadTicket) { - await importZammadTicketForUser(client, articleZammadTicket, userId); - zammadArticleTicketId = String(articleZammadTicket.zammadTicketId); - } else { - zammadArticleError = "Ticket wurde in Zammad nicht gefunden"; - } - } catch (error) { - zammadArticleError = errorMessage(error); - } - } - - if (!zammadArticleError && zammadArticleTicketId) { - try { - const payload = zammadSessionArticlePayload({ - zammadTicketId: zammadArticleTicketId, - activity, - timeUnit, - articleTypeId: zammadArticleTypeId - }); - const zammadResponse = await postZammadSessionArticleForUser(userId, payload); - zammadArticle.status = "sent"; - zammadArticle.articleId = zammadArticleId(zammadResponse); - await recordZammadSessionArticleSent(client, { - userId, - sessionId: session.id, - ticketId: ticket.id, - ticketNumber, - zammadTicketId: zammadArticleTicketId, - activity, - timeUnit, - articleTypeId: zammadArticleTypeId, - articleId: zammadArticle.articleId - }); - } catch (error) { - zammadArticleError = errorMessage(error); - } - } else if (!zammadArticleError) { + if (!zammadArticleTicketId && !useZammadLookup) { zammadArticleError = "Ticket hat keine Zammad-Ticket-ID"; } if (zammadArticleError) { - zammadArticle.status = "queued"; + zammadArticle.status = "failed"; zammadArticle.error = zammadArticleError; - await enqueueZammadSessionArticle(client, { + } else { + zammadArticleDelivery = { userId, sessionId: session.id, ticketId: ticket.id, ticketNumber, zammadTicketId: zammadArticleTicketId, activity, + articleBody: zammadArticleBody, + articleContentType: zammadArticleContentType, timeUnit, - articleTypeId: zammadArticleTypeId, - error: zammadArticleError - }); + articleTypeId: zammadArticleTypeId + }; } } @@ -1417,9 +1875,26 @@ async function createSessionForUser(input: SessionCreationInput) { return { ticket, session, - zammadArticle + zammadArticle, + zammadArticleDelivery }; }); + + if (created.zammadArticleDelivery) { + try { + const articleId = await postImmediateZammadSessionArticle(created.zammadArticleDelivery); + created.zammadArticle.status = "sent"; + created.zammadArticle.error = null; + created.zammadArticle.articleId = articleId; + } catch (error) { + created.zammadArticle.status = "failed"; + created.zammadArticle.error = errorMessage(error); + created.zammadArticle.articleId = null; + } + } + + const { zammadArticleDelivery: _zammadArticleDelivery, ...response } = created; + return response; } function timingSafeStringEqual(left: string, right: string) { @@ -1547,13 +2022,24 @@ function parseZammadWebhookDurationSeconds(payload: unknown) { return Math.round(accountedTime * 60 * 60); } - const minuteCandidates = [ - isPlainObject(payload) ? payload.durationMinutes : null, - isPlainObject(payload) ? payload.duration_minutes : null, + const hourCandidates = [ article.time_unit, ticket.time_unit ]; + for (const candidate of hourCandidates) { + const hours = parsePositiveNumber(candidate); + + if (hours) { + return Math.round(hours * 60 * 60); + } + } + + const minuteCandidates = [ + isPlainObject(payload) ? payload.durationMinutes : null, + isPlainObject(payload) ? payload.duration_minutes : null + ]; + for (const candidate of minuteCandidates) { const minutes = parsePositiveNumber(candidate); @@ -1576,6 +2062,37 @@ function extractRecipientAddress(value: unknown) { return match?.[0] ?? raw; } +function recipientValueText(value: unknown) { + return Array.isArray(value) ? value.map(stringValue).filter(Boolean).join(", ") : stringValue(value); +} + +function recipientValueContainsEmail(value: unknown, email: string) { + const raw = recipientValueText(value); + + if (!raw) { + return false; + } + + const normalizedEmail = email.toLowerCase(); + const addresses = raw.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi) ?? []; + + if (addresses.some((address) => address.toLowerCase() === normalizedEmail)) { + return true; + } + + return raw.toLowerCase().includes(normalizedEmail); +} + +function zammadArticleTargetsAtossSupport(article: Record) { + return recipientValueContainsEmail(article.to, atossSupportEmail); +} + +function zammadWebhookTargetsAtossSupport(payload: unknown) { + const article = zammadWebhookObject(payload, "article"); + return recipientValueContainsEmail(isPlainObject(payload) ? payload.to : null, atossSupportEmail) + || recipientValueContainsEmail(article.to, atossSupportEmail); +} + function zammadWebhookRecipient(payload: unknown) { const ticket = zammadWebhookObject(payload, "ticket"); const article = zammadWebhookObject(payload, "article"); @@ -1698,11 +2215,12 @@ async function createZammadEmailSessionFromWebhook(payload: unknown, deliveryId: return { created: false, skipped: true, reason: "missing-zammad-user-id" }; } - const userResult = await query<{ id: string; enabled: boolean; has_zammad_api_token: boolean }>( + const userResult = await query<{ id: string; enabled: boolean; has_zammad_api_token: boolean; zammad_atoss_email_sync_enabled: boolean }>( ` SELECT id::text AS id, zammad_email_session_webhook_enabled AS enabled, + zammad_atoss_email_sync_enabled, (zammad_api_token IS NOT NULL) AS has_zammad_api_token FROM users WHERE role = 'user' @@ -1747,6 +2265,10 @@ async function createZammadEmailSessionFromWebhook(payload: unknown, deliveryId: return { created: false, skipped: true, reason: "missing-recipient" }; } + if (!user.zammad_atoss_email_sync_enabled && zammadWebhookTargetsAtossSupport(payload)) { + return { created: false, skipped: true, reason: "atoss-email-disabled" }; + } + const payloadGroupId = parseZammadWebhookGroupId(payload); if (payloadGroupId && !isSupportedZammadGroupId(payloadGroupId)) { return { created: false, skipped: true, reason: "unsupported-zammad-type" }; @@ -1767,6 +2289,7 @@ async function createZammadEmailSessionFromWebhook(payload: unknown, deliveryId: userId: user.id, ticketNumber, activity: `eMail an ${recipient} gesendet`, + zammadArticleHtml: null, requestedOrganizationId: null, requestedWorkType: null, useZammadLookup: true, @@ -1970,6 +2493,124 @@ function optionalBoolean(value: unknown, fallback: boolean) { return value; } +type ActiveTimerPhase = "running" | "paused"; + +type ActiveTimerRow = { + id: string; + ticket_number: string; + organization_name: string | null; + work_type: "support" | "consulting" | null; + is_project: boolean; + project_name: string | null; + ticket_lookup_done: boolean; + activity: string; + activity_html: string | null; + billing_status: "billable" | "billed" | "non_billable" | null; + started_at: string; + paused_total_ms: string | number; + paused_at: string | null; + phase: ActiveTimerPhase; +}; + +const activeTimerSelect = ` + SELECT + id::text AS id, + ticket_number, + organization_name, + work_type, + is_project, + project_name, + ticket_lookup_done, + activity, + activity_html, + billing_status, + started_at, + paused_total_ms, + paused_at, + phase + FROM active_timers +`; + +function serializeActiveTimer(row: ActiveTimerRow) { + return { + id: row.id, + ticketNumber: row.ticket_number, + organizationName: row.organization_name, + workType: row.work_type, + isProject: row.is_project, + projectName: row.project_name, + ticketLookupDone: row.ticket_lookup_done, + activity: row.activity, + zammadArticleBody: row.activity_html ?? "", + billingStatus: row.billing_status === "billed" ? "billable" : row.billing_status, + startedAt: new Date(row.started_at).getTime(), + pausedTotalMs: Number(row.paused_total_ms), + pausedAt: row.paused_at ? new Date(row.paused_at).getTime() : null, + phase: row.phase + }; +} + +async function activeTimersForUser(client: Pick, userId: string) { + const result = await client.query( + ` + ${activeTimerSelect} + WHERE user_id = $1 + ORDER BY + CASE WHEN phase = 'running' THEN 0 ELSE 1 END, + ticket_number ASC; + `, + [userId] + ); + + return result.rows.map(serializeActiveTimer); +} + +function parseTimerPhase(value: unknown, fallback: ActiveTimerPhase = "running") { + if (value === undefined || value === null || value === "") { + return fallback; + } + + if (value !== "running" && value !== "paused") { + throw badRequest("phase must be running or paused"); + } + + return value; +} + +function parseClientTimerDate(value: unknown, field: string) { + if (typeof value === "number" && Number.isFinite(value)) { + const date = new Date(value); + + if (!Number.isNaN(date.getTime())) { + return date; + } + } + + return parseIsoDate(value, field); +} + +function parseOptionalClientTimerDate(value: unknown, field: string) { + if (value === undefined || value === null || value === "") { + return null; + } + + return parseClientTimerDate(value, field); +} + +function parseNonNegativeMilliseconds(value: unknown, field: string, fallback = 0) { + if (value === undefined || value === null || value === "") { + return fallback; + } + + const number = Number(value); + + if (!Number.isInteger(number) || number < 0) { + throw badRequest(`${field} must be a non-negative integer`); + } + + return number; +} + const dashboardPreferenceKey = "dashboard"; function parseDashboardPreferenceValue(value: unknown) { @@ -2096,6 +2737,7 @@ function parseProjectTeamspaceEntries( return { id: parseOptionalTeamspaceEntryId(entry.id), teamspaceProject: optionalString(entry.teamspaceProject), + teamspaceDescription: optionalString(entry.teamspaceDescription) ?? "", isDefault, hourBudgetMinutes: parseOptionalNonNegativeInteger(entry.hourBudgetMinutes, "teamspaceEntries.hourBudgetMinutes"), budgetSyncDisabled: optionalBoolean(entry.budgetSyncDisabled, false), @@ -2691,12 +3333,29 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro 'id', pte.id::text, 'project_id', pte.project_id::text, 'teamspace_project', pte.teamspace_project, + 'teamspace_description', pte.teamspace_description, 'is_default', pte.is_default, 'hour_budget_minutes', pte.hour_budget_minutes, 'budget_sync_disabled', pte.budget_sync_disabled, 'manual_consumption_minutes', pte.manual_consumption_minutes, - 'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END, - 'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes, + 'teamspace_consumption_minutes', COALESCE(( + SELECT SUM(tdb.billed_minutes)::int + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND ( + tdb.project_teamspace_entry_id = pte.id + OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + ), 0), + 'consumption_minutes', COALESCE(( + SELECT SUM(tdb.billed_minutes)::int + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND ( + tdb.project_teamspace_entry_id = pte.id + OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + ), 0) + pte.manual_consumption_minutes, 'display_order', pte.display_order ) ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC @@ -2761,9 +3420,9 @@ async function resolveProjectTeamspaceEntryForSession( return null; } - const entriesResult = await client.query<{ id: string }>( + const entriesResult = await client.query<{ id: string; is_default: boolean }>( ` - SELECT id::text AS id + SELECT id::text AS id, is_default FROM project_teamspace_entries WHERE project_id = $1 ORDER BY is_default DESC, display_order ASC, id ASC; @@ -2779,6 +3438,12 @@ async function resolveProjectTeamspaceEntryForSession( return Number(entriesResult.rows[0].id); } + const defaultEntries = entriesResult.rows.filter((entry) => entry.is_default); + + if (!requestedEntryId && defaultEntries.length === 1) { + return Number(defaultEntries[0].id); + } + if (!requestedEntryId) { throw badRequest("projectTeamspaceEntryId is required when a project has multiple Teamspace entries"); } @@ -2867,27 +3532,11 @@ async function showProjectsForUserTicket(client: QueryClient, ticketId: string, } async function deleteProjectDataForTicket( - client: { query: (text: string, params?: unknown[]) => Promise }, + client: QueryClient, ticketId: string, userId: string ) { - await client.query( - ` - DELETE FROM ticket_day_billing_acknowledgements - WHERE ticket_id = $1 - AND user_id = $2; - `, - [ticketId, userId] - ); - - await client.query( - ` - DELETE FROM ticket_day_billings - WHERE ticket_id = $1 - AND user_id = $2; - `, - [ticketId, userId] - ); + await mergeProjectTeamspaceBillingsToDefault(client, ticketId, userId); await client.query( ` @@ -2900,8 +3549,7 @@ async function deleteProjectDataForTicket( } async function deleteAllProjectDataForTicket(client: QueryClient, ticketId: string) { - await client.query("DELETE FROM ticket_day_billing_acknowledgements WHERE ticket_id = $1;", [ticketId]); - await client.query("DELETE FROM ticket_day_billings WHERE ticket_id = $1;", [ticketId]); + await mergeProjectTeamspaceBillingsToDefault(client, ticketId); await client.query("DELETE FROM projects WHERE ticket_id = $1;", [ticketId]); } @@ -2951,6 +3599,42 @@ function zammadSyncSelectedTicketNumbers(value: unknown) { return new Set(value.map((item) => requireString(item, "ticketNumber"))); } +function zammadSyncSelectedArticleIds(value: unknown) { + if (value === undefined || value === null) { + return null; + } + + if (!Array.isArray(value)) { + throw badRequest("articleIds must be an array"); + } + + const ids = value + .map((item) => numberOrNull(item)) + .filter((item): item is number => Boolean(item)); + + return new Set(ids); +} + +function zammadMailSyncDaysBack(value: unknown) { + const days = value === undefined || value === null || value === "" ? 30 : Number(value); + + if (!Number.isInteger(days) || days < 1 || days > 3650) { + throw badRequest("daysBack must be between 1 and 3650"); + } + + return days; +} + +function zammadMailSyncPerPage(value: unknown) { + const perPage = value === undefined || value === null || value === "" ? 300 : Number(value); + + if (!Number.isInteger(perPage) || perPage < 1 || perPage > 1000) { + throw badRequest("perPage must be between 1 and 1000"); + } + + return perPage; +} + function emptyZammadSyncResult(mode: "preview" | "applied" = "applied"): ZammadSyncResult { return { mode, @@ -3181,6 +3865,7 @@ async function getProjectSnapshotForTicketOwner(client: QueryClient, ticketId: s json_build_object( 'id', ordered.id::text, 'teamspace_project', ordered.teamspace_project, + 'teamspace_description', ordered.teamspace_description, 'is_default', ordered.is_default, 'hour_budget_minutes', ordered.hour_budget_minutes, 'budget_sync_disabled', ordered.budget_sync_disabled, @@ -3188,7 +3873,7 @@ async function getProjectSnapshotForTicketOwner(client: QueryClient, ticketId: s ) ) AS teamspace_entries FROM ( - SELECT id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, display_order + SELECT id, teamspace_project, teamspace_description, is_default, hour_budget_minutes, budget_sync_disabled, display_order FROM project_teamspace_entries WHERE project_id = p.id ORDER BY is_default DESC, display_order ASC, id ASC @@ -3245,6 +3930,10 @@ function formatTeamspaceProjectList(projects: Array) return normalizedTeamspaceProjectList(projects).join(" + "); } +function isBlankTeamspaceProject(value: string | null | undefined) { + return !value?.trim(); +} + function teamspaceProjectDiff(localProjects: Array, zammadProjects: Array) { const local = normalizedTeamspaceProjectList(localProjects); const zammad = normalizedTeamspaceProjectList(zammadProjects); @@ -3345,6 +4034,121 @@ async function ensureZammadPendingTodo(client: QueryClient, ticketId: string, us ); } +async function mergeTeamspaceEntriesIntoTarget(client: QueryClient, sourceEntryIds: string[], targetEntryId: string) { + const sourceIds = sourceEntryIds.filter((id) => id !== targetEntryId); + + if (sourceIds.length === 0) { + return; + } + + await client.query( + ` + WITH source_totals AS ( + SELECT + COALESCE(SUM(manual_consumption_minutes), 0)::int AS manual_consumption_minutes, + COALESCE(SUM(hour_budget_minutes) FILTER (WHERE hour_budget_minutes IS NOT NULL), NULL)::int AS hour_budget_minutes, + BOOL_OR(budget_sync_disabled) AS budget_sync_disabled + FROM project_teamspace_entries + WHERE id = ANY($1::bigint[]) + ) + UPDATE project_teamspace_entries target + SET hour_budget_minutes = COALESCE(target.hour_budget_minutes, source_totals.hour_budget_minutes), + budget_sync_disabled = target.budget_sync_disabled OR COALESCE(source_totals.budget_sync_disabled, false), + manual_consumption_minutes = target.manual_consumption_minutes + source_totals.manual_consumption_minutes, + updated_at = now() + FROM source_totals + WHERE target.id = $2::bigint; + `, + [sourceIds, targetEntryId] + ); + + await client.query( + ` + UPDATE sessions + SET project_teamspace_entry_id = $2::bigint + WHERE project_teamspace_entry_id = ANY($1::bigint[]); + `, + [sourceIds, targetEntryId] + ); + + await client.query( + ` + WITH moved AS ( + SELECT ticket_id, user_id, day, SUM(billed_minutes)::int AS billed_minutes + FROM ticket_day_billings + WHERE project_teamspace_entry_id = ANY($1::bigint[]) + GROUP BY ticket_id, user_id, day + ), + deleted AS ( + DELETE FROM ticket_day_billings + WHERE project_teamspace_entry_id = ANY($1::bigint[]) + ) + INSERT INTO ticket_day_billings (ticket_id, user_id, project_teamspace_entry_id, day, billed_minutes) + SELECT ticket_id, user_id, $2::bigint, day, billed_minutes + FROM moved + ON CONFLICT (ticket_id, user_id, day, project_teamspace_entry_id) WHERE project_teamspace_entry_id IS NOT NULL + DO UPDATE SET billed_minutes = ticket_day_billings.billed_minutes + EXCLUDED.billed_minutes, + updated_at = now(); + `, + [sourceIds, targetEntryId] + ); + + await client.query( + ` + DELETE FROM project_teamspace_entries + WHERE id = ANY($1::bigint[]); + `, + [sourceIds] + ); +} + +async function assignUnassignedProjectSessionsToTeamspaceEntry(client: QueryClient, projectId: string, targetEntryId: string) { + await client.query( + ` + UPDATE sessions s + SET project_teamspace_entry_id = $2::bigint + FROM projects p + WHERE p.id = $1 + AND s.ticket_id = p.ticket_id + AND s.user_id = p.user_id + AND s.work_type = 'consulting' + AND s.project_teamspace_entry_id IS NULL; + `, + [projectId, targetEntryId] + ); +} + +async function mergeProjectTeamspaceBillingsToDefault(client: QueryClient, ticketId: string, userId: string | null = null) { + await client.query( + ` + WITH moved AS ( + SELECT tdb.ticket_id, tdb.user_id, tdb.day, SUM(tdb.billed_minutes)::int AS billed_minutes + FROM ticket_day_billings tdb + JOIN project_teamspace_entries pte ON pte.id = tdb.project_teamspace_entry_id + JOIN projects p ON p.id = pte.project_id + WHERE p.ticket_id = $1 + AND ($2::bigint IS NULL OR tdb.user_id = $2) + GROUP BY tdb.ticket_id, tdb.user_id, tdb.day + ), + deleted AS ( + DELETE FROM ticket_day_billings tdb + USING project_teamspace_entries pte, projects p + WHERE pte.id = tdb.project_teamspace_entry_id + AND p.id = pte.project_id + AND p.ticket_id = $1 + AND ($2::bigint IS NULL OR tdb.user_id = $2) + ) + INSERT INTO ticket_day_billings (ticket_id, user_id, project_teamspace_entry_id, day, billed_minutes) + SELECT ticket_id, user_id, NULL, day, billed_minutes + FROM moved + ON CONFLICT (ticket_id, user_id, day) WHERE project_teamspace_entry_id IS NULL + DO UPDATE SET billed_minutes = ticket_day_billings.billed_minutes + EXCLUDED.billed_minutes, + updated_at = now(); + `, + [ticketId, userId] + ); +} + async function syncProjectTeamspaceEntriesFromZammad( client: QueryClient, projectId: string, @@ -3393,15 +4197,56 @@ async function syncProjectTeamspaceEntriesFromZammad( return; } - let defaultEntry = existingResult.rows.find((entry) => entry.is_default) ?? existingResult.rows[0]; + let existingEntries = existingResult.rows; + let defaultEntry = existingEntries.find((entry) => entry.is_default) ?? existingEntries[0]; if (!defaultEntry.is_default) { await client.query("UPDATE project_teamspace_entries SET is_default = (id = $2), updated_at = now() WHERE project_id = $1;", [projectId, defaultEntry.id]); defaultEntry = { ...defaultEntry, is_default: true }; + existingEntries = existingEntries.map((entry) => ({ ...entry, is_default: entry.id === defaultEntry.id })); } - const existingTeamspaceProjects = new Set(normalizedTeamspaceProjectList(existingResult.rows.map((entry) => entry.teamspace_project))); - let nextDisplayOrder = Math.max(...existingResult.rows.map((entry) => Number(entry.display_order ?? 0)), 0) + 1024; + const blankEntries = existingEntries.filter((entry) => isBlankTeamspaceProject(entry.teamspace_project)); + const firstZammadProject = zammadProjects[0]; + const firstZammadEntry = existingEntries.find((entry) => entry.teamspace_project?.trim() === firstZammadProject); + + if (blankEntries.length > 0 && firstZammadProject) { + if (firstZammadEntry) { + await mergeTeamspaceEntriesIntoTarget(client, blankEntries.map((entry) => entry.id), firstZammadEntry.id); + await client.query("UPDATE project_teamspace_entries SET is_default = (id = $2), updated_at = now() WHERE project_id = $1;", [projectId, firstZammadEntry.id]); + await assignUnassignedProjectSessionsToTeamspaceEntry(client, projectId, firstZammadEntry.id); + defaultEntry = { ...firstZammadEntry, is_default: true }; + existingEntries = existingEntries + .filter((entry) => !blankEntries.some((blank) => blank.id === entry.id)) + .map((entry) => ({ ...entry, is_default: entry.id === firstZammadEntry.id })); + } else if (isBlankTeamspaceProject(defaultEntry.teamspace_project)) { + const additionalBlankEntryIds = blankEntries.filter((entry) => entry.id !== defaultEntry.id).map((entry) => entry.id); + + if (additionalBlankEntryIds.length > 0) { + await mergeTeamspaceEntriesIntoTarget(client, additionalBlankEntryIds, defaultEntry.id); + } + + await client.query( + ` + UPDATE project_teamspace_entries + SET teamspace_project = $2, + hour_budget_minutes = CASE WHEN $3::boolean THEN $4 ELSE hour_budget_minutes END, + budget_sync_disabled = CASE WHEN $3::boolean THEN false ELSE budget_sync_disabled END, + updated_at = now() + WHERE id = $1::bigint; + `, + [defaultEntry.id, firstZammadProject, syncBudget, budgetMinutes] + ); + await assignUnassignedProjectSessionsToTeamspaceEntry(client, projectId, defaultEntry.id); + defaultEntry = { ...defaultEntry, teamspace_project: firstZammadProject, is_default: true }; + existingEntries = existingEntries + .filter((entry) => !additionalBlankEntryIds.includes(entry.id)) + .map((entry) => entry.id === defaultEntry.id ? defaultEntry : entry); + } + } + + const existingTeamspaceProjects = new Set(normalizedTeamspaceProjectList(existingEntries.map((entry) => entry.teamspace_project))); + let nextDisplayOrder = Math.max(...existingEntries.map((entry) => Number(entry.display_order ?? 0)), 0) + 1024; for (const teamspaceProject of zammadProjects) { if (existingTeamspaceProjects.has(teamspaceProject)) { @@ -3609,6 +4454,634 @@ async function planConsultingImport(client: QueryClient, zammadTicket: ZammadTic }; } +function zammadArticleCandidates(payload: unknown): Record[] { + if (Array.isArray(payload)) { + return payload.filter(isPlainObject); + } + + if (!isPlainObject(payload)) { + return []; + } + + if (numberOrNull(payload.id)) { + return [payload]; + } + + const candidates = [ + payload.ticket_articles, + payload.articles, + payload.items, + payload.result, + payload.results + ]; + + for (const candidate of candidates) { + if (Array.isArray(candidate)) { + return candidate.filter(isPlainObject); + } + + if (isPlainObject(candidate)) { + const values = Object.values(candidate).filter(isPlainObject); + + if (values.length > 0) { + return values; + } + } + } + + const assets = isPlainObject(payload.assets) ? payload.assets : null; + const ticketArticleAssets = isPlainObject(assets?.TicketArticle) + ? assets.TicketArticle + : isPlainObject(assets?.ticket_articles) + ? assets.ticket_articles + : null; + + if (ticketArticleAssets) { + const values = Object.values(ticketArticleAssets).filter(isPlainObject); + + if (values.length > 0) { + return values; + } + } + + const objectValues = Object.values(payload).filter(isPlainObject); + if (objectValues.length > 0) { + return objectValues; + } + + return []; +} + +function timeZoneOffsetMs(date: Date, timeZone: string) { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23" + }).formatToParts(date); + const value = (type: string) => Number(parts.find((part) => part.type === type)?.value); + const localAsUtc = Date.UTC(value("year"), value("month") - 1, value("day"), value("hour"), value("minute"), value("second")); + + return localAsUtc - date.getTime(); +} + +function berlinDayStartUtc(year: number, month: number, day: number) { + const utcGuess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0)); + const offset = timeZoneOffsetMs(utcGuess, "Europe/Berlin"); + + return new Date(utcGuess.getTime() - offset); +} + +function zammadMailSyncCutoff(daysBack: number) { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit" + }).formatToParts(new Date()); + const value = (type: string) => Number(parts.find((part) => part.type === type)?.value); + const localDate = new Date(Date.UTC(value("year"), value("month") - 1, value("day"))); + localDate.setUTCDate(localDate.getUTCDate() - Math.max(0, daysBack - 1)); + + return berlinDayStartUtc(localDate.getUTCFullYear(), localDate.getUTCMonth() + 1, localDate.getUTCDate()); +} + +function zammadMailActivity(article: Record) { + const subject = optionalString(article.subject); + return subject ? `eMail: ${subject}` : "eMail aus Zammad"; +} + +function zammadMailArticleCandidateFromRaw( + rawArticle: Record, + ticketNumber: string, + zammadTicketId: number, + zammadUserId: number, + cutoff: Date +): ZammadMailArticleCandidate | null { + const articleId = numberOrNull(rawArticle.id); + const ticketId = numberOrNull(rawArticle.ticket_id); + const createdById = numberOrNull(rawArticle.created_by_id); + const typeId = numberOrNull(rawArticle.type_id); + const senderId = numberOrNull(rawArticle.sender_id); + const createdAtRaw = optionalString(rawArticle.created_at); + const createdAt = createdAtRaw ? new Date(createdAtRaw) : null; + + if (!articleId || ticketId !== zammadTicketId || createdById !== zammadUserId || typeId !== 1 || senderId !== 1 || !createdAt || Number.isNaN(createdAt.getTime()) || createdAt < cutoff) { + return null; + } + + const subject = optionalString(rawArticle.subject); + const to = optionalString(recipientValueText(rawArticle.to)); + + return { + articleId, + zammadTicketId, + ticketNumber, + createdById, + typeId, + createdAt, + subject, + to, + activity: zammadMailActivity(rawArticle) + }; +} + +function zammadMailSessionSnapshot(article: ZammadMailSessionArticle, zammadTicket: ZammadTicketImport | null = null) { + const endedAt = new Date(article.createdAt.getTime() + article.durationSeconds * 1000); + + return { + articleId: article.articleId, + ticketId: article.zammadTicketId, + ticketNumber: article.ticketNumber, + createdById: article.createdById, + typeId: article.typeId, + createdAt: article.createdAt.toISOString(), + endedAt: endedAt.toISOString(), + timeUnit: article.timeUnit, + durationSeconds: article.durationSeconds, + subject: article.subject, + to: article.to, + activity: article.activity, + ticket: zammadTicket ? zammadSyncSnapshot(zammadTicket) : null + }; +} + +function zammadMailSessionArticleFromSnapshot(snapshot: Record | null): ZammadMailSessionArticle | null { + if (!snapshot) { + return null; + } + + const articleId = numberOrNull(snapshot.articleId); + const zammadTicketId = numberOrNull(snapshot.ticketId); + const ticketNumber = optionalString(snapshot.ticketNumber); + const createdById = numberOrNull(snapshot.createdById); + const typeId = numberOrNull(snapshot.typeId); + const createdAtRaw = optionalString(snapshot.createdAt); + const createdAt = createdAtRaw ? new Date(createdAtRaw) : null; + const timeUnit = parsePositiveNumber(snapshot.timeUnit); + const durationSeconds = numberOrNull(snapshot.durationSeconds); + const activity = optionalString(snapshot.activity); + + if (!articleId || !zammadTicketId || !ticketNumber || !createdById || typeId !== 1 || !createdAt || Number.isNaN(createdAt.getTime()) || !timeUnit || !durationSeconds || !activity) { + return null; + } + + return { + articleId, + zammadTicketId, + ticketNumber, + createdById, + typeId, + createdAt, + timeUnit, + durationSeconds, + subject: optionalString(snapshot.subject), + to: optionalString(snapshot.to), + activity + }; +} + +function zammadMailSessionTicketFromSnapshot(snapshot: Record | null) { + const ticketSnapshot = isPlainObject(snapshot?.ticket) ? snapshot.ticket : null; + return zammadTicketFromSyncSnapshot(ticketSnapshot); +} + +function localMailSessionSnapshot(localTicket: LocalZammadSyncTicket | null) { + if (!localTicket) { + return null; + } + + return { + ticketId: localTicket.id, + ticketNumber: localTicket.ticket_number, + organizationId: localTicket.organization_id, + organizationName: localTicket.organization_name ?? localTicket.customer_name, + workType: localTicket.work_type, + zammadTicketId: localTicket.zammad_ticket_id + }; +} + +async function fetchZammadMailSessionArticleCandidatesForUser(input: { + userId: string; + baseUrl: string; + apiToken: string; + zammadUserId: number; + daysBack: number; + perPage: number; + atossEmailSyncEnabled: boolean; + selectedArticleIds: Set | null; +}) { + const cutoff = zammadMailSyncCutoff(input.daysBack); + const cutoffIso = cutoff.toISOString(); + const basePath = `/api/v1/ticket_articles?sort_by=id&order_by=desc&type_id=1&created_by_id=${input.zammadUserId}&per_page=${input.perPage}`; + + async function fetchPage(page: number) { + const payload = await fetchZammadJson(input.baseUrl, input.apiToken, `${basePath}&page=${page}`); + return zammadArticleCandidates(payload) + .filter((article) => numberOrNull(article.created_by_id) === input.zammadUserId && numberOrNull(article.type_id) === 1); + } + + function pageOldestDate(articles: Record[]) { + const dates = articles + .map((article) => optionalString(article.created_at)) + .filter((value): value is string => Boolean(value)) + .sort(); + + return dates[0] ?? null; + } + + const collected = new Map>(); + + function collect(articles: Record[]) { + for (const article of articles) { + const articleId = numberOrNull(article.id); + const createdAtRaw = optionalString(article.created_at); + const createdAt = createdAtRaw ? new Date(createdAtRaw) : null; + + if ( + !articleId + || numberOrNull(article.sender_id) !== 1 + || (!input.atossEmailSyncEnabled && zammadArticleTargetsAtossSupport(article)) + || Boolean(input.selectedArticleIds && !input.selectedArticleIds.has(articleId)) + || !createdAt + || Number.isNaN(createdAt.getTime()) + || createdAt < cutoff + ) { + continue; + } + + collected.set(articleId, article); + } + } + + for (let pageNumber = 1; pageNumber <= 1_000_000; pageNumber += 1) { + const articles = await fetchPage(pageNumber); + + if (articles.length === 0) { + break; + } + + collect(articles); + + const oldest = pageOldestDate(articles); + if (!oldest || oldest < cutoffIso || articles.length < input.perPage) { + break; + } + } + + const articles = Array.from(collected.values()).sort((left, right) => Number(numberOrNull(left.id) ?? 0) - Number(numberOrNull(right.id) ?? 0)); + const importedArticleIds = await importedZammadMailArticleIds( + { query }, + input.userId, + articles.map((article) => Number(numberOrNull(article.id) ?? 0)).filter((id) => id > 0) + ); + + return articles.filter((article) => { + const id = numberOrNull(article.id); + return Boolean(id && !importedArticleIds.has(id)); + }); +} + +function zammadTimeAccountingCandidates(payload: unknown): Record[] { + if (Array.isArray(payload)) { + return payload.filter(isPlainObject); + } + + if (!isPlainObject(payload)) { + return []; + } + + if (numberOrNull(payload.id)) { + return [payload]; + } + + const candidates = [ + payload.time_accountings, + payload.accountings, + payload.items, + payload.result, + payload.results + ]; + + for (const candidate of candidates) { + if (Array.isArray(candidate)) { + return candidate.filter(isPlainObject); + } + } + + const objectValues = Object.values(payload).filter(isPlainObject); + if (objectValues.length > 0) { + return objectValues; + } + + return []; +} + +function zammadTimeAccountingArticleId(accounting: Record) { + const ticketArticle = isPlainObject(accounting.ticket_article) ? accounting.ticket_article : null; + + return numberOrNull(accounting.ticket_article_id) + ?? numberOrNull(accounting.article_id) + ?? numberOrNull(ticketArticle?.id); +} + +async function fetchZammadTimeUnitForArticle(input: { + baseUrl: string; + apiToken: string; + ticketId: number; + articleId: number; +}) { + const payload = await fetchZammadJson(input.baseUrl, input.apiToken, `/api/v1/tickets/${input.ticketId}/time_accountings`); + const accountings = zammadTimeAccountingCandidates(payload); + const matching = accountings.find((accounting) => zammadTimeAccountingArticleId(accounting) === input.articleId); + const fallback = accountings.length === 1 ? accountings[0] : null; + const timeUnit = parsePositiveNumber(matching?.time_unit ?? fallback?.time_unit); + + return timeUnit; +} + +function ticketZammadMailSessionItem(article: ZammadMailSessionArticle): TicketZammadMailSessionItem { + const endedAt = new Date(article.createdAt.getTime() + article.durationSeconds * 1000); + + return { + articleId: article.articleId, + ticketNumber: article.ticketNumber, + subject: article.subject, + activity: article.activity, + createdAt: article.createdAt.toISOString(), + endedAt: endedAt.toISOString(), + timeUnit: article.timeUnit, + durationSeconds: article.durationSeconds + }; +} + +function zammadMailSessionArticleFromByTicketRaw( + rawArticle: Record, + ticketNumber: string, + zammadTicketId: number, + zammadUserId: number, + cutoff: Date +): ZammadMailSessionArticle | null { + const candidate = zammadMailArticleCandidateFromRaw(rawArticle, ticketNumber, zammadTicketId, zammadUserId, cutoff); + const timeUnit = parsePositiveNumber(rawArticle.time_unit); + + if (!candidate || !timeUnit) { + return null; + } + + return { + ...candidate, + timeUnit, + durationSeconds: Math.max(60, Math.round(timeUnit * 60 * 60)) + }; +} + +async function runTicketZammadMailSessionImport( + userId: string, + ticketId: number, + daysBack: number, + dryRun: boolean, + selectedArticleIds: Set | null +): Promise { + const result: TicketZammadMailSessionResult = { + checked: 0, + created: 0, + skipped: 0, + items: [], + errors: [] + }; + const ticketResult = await query<{ + id: string; + ticket_number: string; + organization_id: string | null; + work_type: "support" | "consulting" | null; + zammad_ticket_id: string | null; + }>( + ` + SELECT id::text AS id, ticket_number, organization_id::text AS organization_id, work_type, zammad_ticket_id::text AS zammad_ticket_id + FROM tickets + WHERE id = $1; + `, + [ticketId] + ); + const ticket = ticketResult.rows[0]; + + if (!ticket) { + throw Object.assign(new Error("Ticket not found"), { status: 404 }); + } + + const zammadTicketId = numberOrNull(ticket.zammad_ticket_id); + + if (!zammadTicketId) { + throw badRequest("Ticket hat keine Zammad-Ticket-ID"); + } + + if (!ticket.organization_id) { + throw badRequest("Ticket hat keine synchronisierte Organisation"); + } + + if (ticket.work_type !== "support" && ticket.work_type !== "consulting") { + throw badRequest("Ticket muss Support oder Consulting sein"); + } + + const userResult = await query<{ zammad_user_id: string | null; zammad_atoss_email_sync_enabled: boolean }>( + "SELECT zammad_user_id::text AS zammad_user_id, zammad_atoss_email_sync_enabled FROM users WHERE id = $1;", + [userId] + ); + const zammadUserId = numberOrNull(userResult.rows[0]?.zammad_user_id); + const atossEmailSyncEnabled = Boolean(userResult.rows[0]?.zammad_atoss_email_sync_enabled ?? true); + + if (!zammadUserId) { + throw badRequest("Zammad User-ID fehlt. Bitte den persönlichen Zammad-Token im Profil speichern."); + } + + const { baseUrl, apiToken } = await getZammadCredentialsForUser(userId); + const cutoff = zammadMailSyncCutoff(daysBack); + const payload = await fetchZammadJson(baseUrl, apiToken, `/api/v1/ticket_articles/by_ticket/${zammadTicketId}`); + const rawArticles = zammadArticleCandidates(payload); + + for (const rawArticle of rawArticles) { + const articleId = numberOrNull(rawArticle.id); + + if (!articleId || (selectedArticleIds && !selectedArticleIds.has(articleId))) { + continue; + } + + if (!atossEmailSyncEnabled && zammadArticleTargetsAtossSupport(rawArticle)) { + continue; + } + + const article = zammadMailSessionArticleFromByTicketRaw(rawArticle, ticket.ticket_number, zammadTicketId, zammadUserId, cutoff); + + if (!article) { + continue; + } + + result.checked += 1; + + if (await zammadMailArticleAlreadyImported({ query }, userId, article.articleId)) { + result.skipped += 1; + continue; + } + + if (dryRun) { + result.items.push(ticketZammadMailSessionItem(article)); + continue; + } + + try { + const startedAt = article.createdAt; + const endedAt = new Date(startedAt.getTime() + article.durationSeconds * 1000); + const created = await createSessionForUser({ + userId, + ticketNumber: ticket.ticket_number, + activity: article.activity, + zammadArticleHtml: null, + requestedOrganizationId: numberOrNull(ticket.organization_id), + requestedWorkType: ticket.work_type, + useZammadLookup: false, + requestedProjectTeamspaceEntryId: null, + billingStatus: "billable", + createZammadArticle: false, + zammadArticleTypeId: 6, + startedAt, + endedAt, + durationSeconds: article.durationSeconds, + zammadTicket: null, + sourceEvent: { + zammadArticleId: article.articleId, + deliveryId: null + } + }); + + if ("duplicate" in created) { + result.skipped += 1; + continue; + } + + result.created += 1; + result.items.push(ticketZammadMailSessionItem(article)); + } catch (error) { + result.skipped += 1; + result.errors.push({ articleId: article.articleId, error: errorMessage(error) }); + } + } + + return result; +} + +async function zammadMailArticleAlreadyImported(client: QueryClient, userId: string, articleId: number) { + const result = await client.query<{ exists: boolean }>( + ` + SELECT EXISTS ( + SELECT 1 + FROM zammad_email_session_events + WHERE user_id = $1 + AND zammad_article_id = $2 + ) AS exists; + `, + [userId, articleId] + ); + + return Boolean(result.rows[0]?.exists); +} + +async function importedZammadMailArticleIds(client: QueryClient, userId: string, articleIds: number[]) { + if (articleIds.length === 0) { + return new Set(); + } + + const result = await client.query<{ zammad_article_id: string }>( + ` + SELECT zammad_article_id::text AS zammad_article_id + FROM zammad_email_session_events + WHERE user_id = $1 + AND zammad_article_id = ANY($2::bigint[]); + `, + [userId, articleIds] + ); + + return new Set(result.rows.map((row) => Number(row.zammad_article_id)).filter((id) => Number.isFinite(id))); +} + +async function planZammadMailSessionImport( + client: QueryClient, + userId: string, + localTicket: LocalZammadSyncTicket | null, + zammadTicket: ZammadTicketImport, + article: ZammadMailSessionArticle +): Promise { + const localSnapshot = localMailSessionSnapshot(localTicket); + const zammadSnapshot = zammadMailSessionSnapshot(article, zammadTicket); + + if (await zammadMailArticleAlreadyImported(client, userId, article.articleId)) { + return { + ticketNumber: article.ticketNumber, + action: "skip", + reason: "Mail-Artikel wurde bereits als Session importiert.", + local: localSnapshot, + zammad: zammadSnapshot, + changes: [] + }; + } + + const organization = localTicket?.organization_id + ? await getOrganizationByZammadId(client, Number(localTicket.organization_id)) + : await getOrganizationByZammadId(client, zammadTicket.organizationId); + + if (!organization) { + return { + ticketNumber: article.ticketNumber, + action: "skip", + reason: "Zammad-Organisation ist lokal nicht synchronisiert.", + local: localSnapshot, + zammad: zammadSnapshot, + changes: [] + }; + } + + const workType = localTicket?.work_type ?? zammadTicket.workType; + + if (!workType) { + return { + ticketNumber: article.ticketNumber, + action: "skip", + reason: "Ticket hat keine Art.", + local: localSnapshot, + zammad: zammadSnapshot, + changes: [] + }; + } + + const startedAt = article.createdAt; + const endedAt = new Date(startedAt.getTime() + article.durationSeconds * 1000); + + return { + ticketNumber: article.ticketNumber, + action: "create", + reason: localTicket ? "Mail-Session wird angelegt." : "Ticket und Mail-Session werden angelegt.", + local: localSnapshot, + zammad: zammadSnapshot, + changes: [ + ...(localTicket ? [] : [ + { field: "Ticket", from: null, to: zammadTicket.ticketNumber }, + { field: "Organisation", from: null, to: organization.id }, + { field: "Typ", from: null, to: zammadTicket.workType } + ]), + { field: "Session", from: null, to: "aus Zammad-eMail" }, + { field: "Von", from: null, to: startedAt.toISOString() }, + { field: "Bis", from: null, to: endedAt.toISOString() }, + { field: "Dauer", from: null, to: article.durationSeconds }, + { field: "Tätigkeit", from: null, to: article.activity }, + { field: "Bewertung", from: null, to: "billable" } + ] + }; +} + function updateZammadSyncResultFromItem(result: ZammadSyncResult, item: ZammadSyncItem, localWorkType: "support" | "consulting" | null = null) { result.items.push(item); @@ -3889,14 +5362,14 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use ticket_crm.crm_billed_minutes, COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'billable'), 0)::int AS billable_minutes, COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'non_billable'), 0)::int AS non_billable_minutes, - COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status IS NULL), 0)::int AS open_minutes, - COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS open_count, + COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status IS NULL OR s.billing_status = 'billable'), 0)::int AS open_minutes, + COUNT(*) FILTER (WHERE s.billing_status IS NULL OR s.billing_status = 'billable')::int AS open_count, COUNT(*) FILTER (WHERE s.billing_status = 'billable')::int AS billable_count, COUNT(*) FILTER (WHERE s.billing_status = 'billed')::int AS billed_count, COUNT(*) FILTER (WHERE s.billing_status = 'non_billable')::int AS non_billable_count, false AS requires_closure, - COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS ticket_open_count, - (COUNT(*) FILTER (WHERE s.billing_status IS NULL) = 0) AS closed, + COUNT(*) FILTER (WHERE s.billing_status IS NULL OR s.billing_status = 'billable')::int AS ticket_open_count, + (COUNT(*) FILTER (WHERE s.billing_status IS NULL OR s.billing_status = 'billable') = 0) AS closed, NULL::timestamptz AS closed_at FROM tickets t JOIN sessions s ON s.ticket_id = t.id @@ -3936,6 +5409,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use so.name AS organization_name, COALESCE(so.name, s.customer_name) AS customer_name, s.activity, + s.activity_html AS zammad_article_body, s.work_type, s.started_at, s.rounded_minutes, @@ -3946,7 +5420,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz AND s.user_id = $3 - AND s.billing_status IS NULL + AND (s.billing_status IS NULL OR s.billing_status = 'billable') ORDER BY s.started_at ASC; `, [period.startIso, period.endIso, userId] @@ -4017,7 +5491,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use }; } -async function getStatisticsOverview(period: ParsedPeriod, userId: string) { +async function getStatisticsOverview(period: ParsedPeriod, userId: string, periodType: "month" | "week" = "month") { await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso); const baseCte = ` @@ -4062,8 +5536,8 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { td.ticket_number, td.day, td.tracked_minutes, - COALESCE(tdb.billed_minutes, 0)::int AS crm_billed_minutes, - tdb.billed_minutes IS NOT NULL AS has_crm_value, + COALESCE(SUM(tdb.billed_minutes), 0)::int AS crm_billed_minutes, + COUNT(tdb.billed_minutes) > 0 AS has_crm_value, ack.acknowledged_at IS NOT NULL AS missing_crm_acknowledged, ack.acknowledged_at AS missing_crm_acknowledged_at FROM ticket_day td @@ -4075,6 +5549,12 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { ON ack.ticket_id = td.ticket_id AND ack.user_id = $3 AND ack.day = td.day + GROUP BY + td.ticket_id, + td.ticket_number, + td.day, + td.tracked_minutes, + ack.acknowledged_at ), session_group AS ( SELECT @@ -4089,11 +5569,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billed')::int AS billed_minutes, SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billable')::int AS billable_minutes, SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_minutes, - SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status IS NULL)::int AS open_minutes, + SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status IS NULL OR sb.billing_status = 'billable')::int AS open_minutes, COUNT(*) FILTER (WHERE sb.billing_status = 'billed')::int AS billed_sessions, COUNT(*) FILTER (WHERE sb.billing_status = 'billable')::int AS billable_sessions, COUNT(*) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_sessions, - COUNT(*) FILTER (WHERE sb.billing_status IS NULL)::int AS open_count, + COUNT(*) FILTER (WHERE sb.billing_status IS NULL OR sb.billing_status = 'billable')::int AS open_count, COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NOT NULL)::int AS recurring_session_count, COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NULL)::int AS manual_session_count FROM session_base sb @@ -4133,11 +5613,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billable'), 0)::int AS billable_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes, - COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes, + COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL OR billing_status = 'billable'), 0)::int AS open_minutes, COUNT(*) FILTER (WHERE billing_status = 'billed')::int AS billed_sessions, COUNT(*) FILTER (WHERE billing_status = 'billable')::int AS billable_sessions, COUNT(*) FILTER (WHERE billing_status = 'non_billable')::int AS non_billable_sessions, - COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions, + COUNT(*) FILTER (WHERE billing_status IS NULL OR billing_status = 'billable')::int AS open_sessions, COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NOT NULL AND billing_status = 'billed'), 0)::int AS recurring_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NULL AND billing_status = 'billed'), 0)::int AS manual_minutes, COUNT(*) FILTER (WHERE recurring_billing_id IS NOT NULL AND billing_status = 'billed')::int AS recurring_sessions, @@ -4252,11 +5732,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billable'), 0)::int AS billable_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes, - COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes, + COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL OR billing_status = 'billable'), 0)::int AS open_minutes, COUNT(*) FILTER (WHERE billing_status = 'billed')::int AS billed_sessions, COUNT(*) FILTER (WHERE billing_status = 'billable')::int AS billable_sessions, COUNT(*) FILTER (WHERE billing_status = 'non_billable')::int AS non_billable_sessions, - COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions, + COUNT(*) FILTER (WHERE billing_status IS NULL OR billing_status = 'billable')::int AS open_sessions, COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing tdb WHERE tdb.ticket_id = session_base.ticket_id), 0)::int AS crm_billed_minutes FROM session_base GROUP BY ticket_id, ticket_number @@ -4283,7 +5763,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz AND s.user_id = $3 - AND s.billing_status IS NULL + AND (s.billing_status IS NULL OR s.billing_status = 'billable') ORDER BY s.started_at ASC; `, [period.startIso, period.endIso, userId] @@ -4375,7 +5855,9 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { `, [period.startIso, period.endIso, userId] ), - query<{ closed_at: string }>("SELECT closed_at FROM month_closures WHERE month = $1::date AND user_id = $2;", [period.start, userId]) + periodType === "month" + ? query<{ closed_at: string }>("SELECT closed_at FROM month_closures WHERE month = $1::date AND user_id = $2;", [period.start, userId]) + : Promise.resolve({ rows: [] as Array<{ closed_at: string }> }) ]); const totals = totalsResult.rows[0] ?? { @@ -4400,7 +5882,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { }; return { - periodType: "month", + periodType, period: period.label, closed: closureResult.rows.length > 0, closedAt: closureResult.rows[0]?.closed_at ?? null, @@ -4485,7 +5967,7 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke LEFT JOIN LATERAL ( SELECT COUNT(*)::int AS session_count, - COUNT(*) FILTER (WHERE sm.billing_status IS NULL)::int AS open_count, + COUNT(*) FILTER (WHERE sm.billing_status IS NULL OR sm.billing_status = 'billable')::int AS open_count, COUNT(*) FILTER (WHERE sm.recurring_billing_id IS NULL)::int AS manual_session_count FROM sessions sm WHERE sm.ticket_id = t.id @@ -4521,6 +6003,7 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke customer_name ) AS customer_name, activity, + activity_html AS zammad_article_body, work_type, started_at, ended_at, @@ -4532,6 +6015,14 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke recurring_billing_id, recurring_billing_slot_id, recurring_occurrence_date, + ( + SELECT zammad_article_id::text + FROM zammad_email_session_events + WHERE session_id = sessions.id + AND zammad_article_id IS NOT NULL + ORDER BY created_at DESC + LIMIT 1 + ) AS zammad_source_article_id, ( SELECT status FROM zammad_session_article_queue @@ -4571,7 +6062,7 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke const dayBillingResult = await query( ` - SELECT day::text AS day, billed_minutes + SELECT day::text AS day, project_teamspace_entry_id::text AS project_teamspace_entry_id, billed_minutes FROM ticket_day_billings WHERE ticket_id = $1 AND user_id = $2 @@ -4583,7 +6074,7 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke ); const sessions = sessionsResult.rows; - const openCount = sessions.filter((session: any) => session.billing_status === null).length; + const openCount = sessions.filter((session: any) => session.billing_status === null || session.billing_status === "billable").length; const manualSessionCount = sessions.filter((session: any) => session.recurring_billing_id === null).length; const recurringSessionCount = sessions.length - manualSessionCount; const ticket = ticketResult.rows[0] as any; @@ -4642,7 +6133,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon LEFT JOIN LATERAL ( SELECT COUNT(*)::int AS session_count, - COUNT(*) FILTER (WHERE sm.billing_status IS NULL)::int AS open_count, + COUNT(*) FILTER (WHERE sm.billing_status IS NULL OR sm.billing_status = 'billable')::int AS open_count, COUNT(*) FILTER (WHERE sm.recurring_billing_id IS NULL)::int AS manual_session_count FROM sessions sm WHERE sm.ticket_id = t.id @@ -4667,7 +6158,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon } if (filters.status === "open") { - where.push("s.billing_status IS NULL"); + where.push("(s.billing_status IS NULL OR s.billing_status = 'billable')"); } else if (filters.status !== "all") { params.push(filters.status); where.push(`s.billing_status = $${params.length}`); @@ -4700,6 +6191,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon s.customer_name ) AS customer_name, s.activity, + s.activity_html AS zammad_article_body, s.work_type, s.started_at, s.ended_at, @@ -4711,6 +6203,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon s.recurring_billing_id, s.recurring_billing_slot_id, s.recurring_occurrence_date, + ze.zammad_article_id::text AS zammad_source_article_id, za.status AS zammad_article_status, za.zammad_article_id::text AS zammad_article_id, za.zammad_article_type_id, @@ -4725,6 +6218,14 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon ORDER BY updated_at DESC LIMIT 1 ) za ON true + LEFT JOIN LATERAL ( + SELECT zammad_article_id + FROM zammad_email_session_events + WHERE session_id = s.id + AND zammad_article_id IS NOT NULL + ORDER BY created_at DESC + LIMIT 1 + ) ze ON true WHERE ${sessionWhere} ORDER BY s.started_at ASC; `, @@ -4737,7 +6238,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon FROM sessions s WHERE ${sessionWhere} ) - SELECT tdb.day::text AS day, tdb.billed_minutes + SELECT tdb.day::text AS day, tdb.project_teamspace_entry_id::text AS project_teamspace_entry_id, tdb.billed_minutes FROM ticket_day_billings tdb JOIN filtered_days fd ON fd.day = tdb.day WHERE tdb.ticket_id = $1 @@ -4821,12 +6322,29 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon 'id', pte.id::text, 'project_id', pte.project_id::text, 'teamspace_project', pte.teamspace_project, + 'teamspace_description', pte.teamspace_description, 'is_default', pte.is_default, 'hour_budget_minutes', pte.hour_budget_minutes, 'budget_sync_disabled', pte.budget_sync_disabled, 'manual_consumption_minutes', pte.manual_consumption_minutes, - 'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END, - 'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes, + 'teamspace_consumption_minutes', COALESCE(( + SELECT SUM(tdb.billed_minutes)::int + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND ( + tdb.project_teamspace_entry_id = pte.id + OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + ), 0), + 'consumption_minutes', COALESCE(( + SELECT SUM(tdb.billed_minutes)::int + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND ( + tdb.project_teamspace_entry_id = pte.id + OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + ), 0) + pte.manual_consumption_minutes, 'display_order', pte.display_order ) ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC @@ -4894,7 +6412,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon ]); const sessions = sessionsResult.rows; - const openCount = sessions.filter((session: any) => session.billing_status === null).length; + const openCount = sessions.filter((session: any) => session.billing_status === null || session.billing_status === "billable").length; const manualSessionCount = sessions.filter((session: any) => session.recurring_billing_id === null).length; const recurringSessionCount = sessions.length - manualSessionCount; const ticket = ticketResult.rows[0] as any; @@ -4971,12 +6489,13 @@ app.post("/api/setup/admin", async (req, res) => { zammad_session_note_default_enabled: boolean; zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_minutes: number; - zammad_article_type_id: number | null; + zammad_atoss_email_sync_enabled: boolean; + zammad_article_type_id: number; }>( ` INSERT INTO users (username, display_name, password_hash, role, active) VALUES ($1, $2, $3, 'admin', true) - RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_article_type_id; + RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id; `, [username, displayName, await hashPassword(password)] ); @@ -5037,7 +6556,8 @@ app.post("/api/auth/login", async (req, res) => { zammad_session_note_default_enabled: boolean; zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_minutes: number; - zammad_article_type_id: number | null; + zammad_atoss_email_sync_enabled: boolean; + zammad_article_type_id: number; }>( ` SELECT @@ -5054,7 +6574,8 @@ app.post("/api/auth/login", async (req, res) => { zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, - zammad_article_type_id + zammad_atoss_email_sync_enabled, + COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id FROM users WHERE lower(username) = lower($1); `, @@ -5105,7 +6626,8 @@ app.post("/api/auth/login", async (req, res) => { zammad_session_note_default_enabled: user.zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled: user.zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes: user.zammad_time_unit_rounding_minutes, - zammad_article_type_id: user.zammad_article_type_id + zammad_atoss_email_sync_enabled: user.zammad_atoss_email_sync_enabled, + zammad_article_type_id: Number(user.zammad_article_type_id ?? 6) } }); }); @@ -5180,7 +6702,7 @@ app.get("/api/dashboard/day-control", requireUser, async (req, res) => { AND own_projects.user_id = $1 ) AS has_project, COUNT(s.id) FILTER (WHERE s.user_id = $1)::int AS own_session_count, - COUNT(s.id) FILTER (WHERE s.user_id = $1 AND s.billing_status IS NULL)::int AS open_session_count, + COUNT(s.id) FILTER (WHERE s.user_id = $1 AND (s.billing_status IS NULL OR s.billing_status = 'billable'))::int AS open_session_count, COALESCE(todo_stats.open_todo_count, 0)::int AS open_todo_count, COALESCE(todo_stats.due_todo_count, 0)::int AS due_todo_count FROM tickets t @@ -5229,7 +6751,7 @@ app.get("/api/dashboard/day-control", requireUser, async (req, res) => { FROM sessions open_sessions WHERE open_sessions.ticket_id = t.id AND open_sessions.user_id = $1 - AND open_sessions.billing_status IS NULL + AND (open_sessions.billing_status IS NULL OR open_sessions.billing_status = 'billable') ) ) GROUP BY @@ -5323,6 +6845,9 @@ app.patch("/api/auth/me", async (req, res) => { const zammadTimeUnitRoundingMinutes = user.role === "user" ? parseZammadTimeUnitRoundingMinutes(req.body.zammadTimeUnitRoundingMinutes, user.zammad_time_unit_rounding_minutes) : 15; + const zammadAtossEmailSyncEnabled = user.role === "user" + ? optionalBoolean(req.body.zammadAtossEmailSyncEnabled, user.zammad_atoss_email_sync_enabled) + : true; const zammadArticleTypeId = user.role === "user" ? parseZammadArticleTypeId(req.body.zammadArticleTypeId, user.zammad_article_type_id) : 6; @@ -5358,10 +6883,11 @@ app.patch("/api/auth/me", async (req, res) => { zammad_session_note_default_enabled = CASE WHEN $8::boolean THEN $11 ELSE zammad_session_note_default_enabled END, zammad_time_unit_rounding_enabled = CASE WHEN $8::boolean THEN $12 ELSE zammad_time_unit_rounding_enabled END, zammad_time_unit_rounding_minutes = CASE WHEN $8::boolean THEN $13 ELSE zammad_time_unit_rounding_minutes END, - zammad_article_type_id = CASE WHEN $8::boolean THEN $14 ELSE zammad_article_type_id END, + zammad_atoss_email_sync_enabled = CASE WHEN $8::boolean THEN $14 ELSE zammad_atoss_email_sync_enabled END, + zammad_article_type_id = CASE WHEN $8::boolean THEN $15 ELSE zammad_article_type_id END, updated_at = now() WHERE id = $4 - RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_article_type_id; + RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id; `, [ username, @@ -5377,6 +6903,7 @@ app.patch("/api/auth/me", async (req, res) => { zammadSessionNoteDefaultEnabled, zammadTimeUnitRoundingEnabled, zammadTimeUnitRoundingMinutes, + zammadAtossEmailSyncEnabled, zammadArticleTypeId ] ); @@ -5405,7 +6932,7 @@ app.patch("/api/auth/me", async (req, res) => { app.get("/api/admin/users", requireAdmin, async (_req, res) => { const result = await query( ` - SELECT id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_article_type_id, created_at, updated_at + SELECT id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_time_unit_rounding_minutes, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, created_at, updated_at FROM users ORDER BY username ASC; ` @@ -5429,7 +6956,7 @@ app.post("/api/admin/users", requireAdmin, async (req, res) => { ` INSERT INTO users (username, display_name, password_hash, role, active) VALUES ($1, $2, $3, $4, $5) - RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_article_type_id, created_at, updated_at; + RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, created_at, updated_at; `, [username, displayName, await hashPassword(password), "user", active] ); @@ -5490,7 +7017,7 @@ app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => { password_hash = COALESCE($4, password_hash), updated_at = now() WHERE id = $5 - RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_article_type_id, created_at, updated_at; + RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, created_at, updated_at; `, [username, displayName, active, password ? await hashPassword(password) : null, userId] ); @@ -5585,7 +7112,7 @@ app.post("/api/admin/zammad/organizations/sync", requireAdmin, async (req, res) throw badRequest("apiKey is required"); } - const response = await fetch(`${baseUrl}/api/v1/organizations`, { + const response = await zammadFetch(`${baseUrl}/api/v1/organizations`, { headers: { Authorization: `Token token=${apiKey}`, Accept: "application/json" @@ -6458,10 +7985,234 @@ app.patch("/api/admin/sessions/:sessionId/owner", requireAdmin, async (_req, res res.status(410).json({ error: "Session-Verwaltung ist für den festen Admin deaktiviert" }); }); +app.get("/api/timers", requireUser, async (req, res) => { + const user = currentUser(req); + res.json({ timers: await activeTimersForUser(pool, user.id) }); +}); + +app.post("/api/timers", requireUser, async (req, res) => { + const user = currentUser(req); + const ticketNumber = parseTicketNumber(req.body.ticketNumber); + + if (await ticketHasRecurringBilling({ query }, ticketNumber, user.id)) { + res.status(409).json({ error: "Für fixe Abrechnungen dürfen keine manuellen Sessions gestartet werden. Bitte die Definition bearbeiten." }); + return; + } + + const phase = parseTimerPhase(req.body.phase); + const startedAt = req.body.startedAt === undefined ? new Date() : parseClientTimerDate(req.body.startedAt, "startedAt"); + const pausedAt = phase === "paused" ? parseOptionalClientTimerDate(req.body.pausedAt, "pausedAt") ?? new Date() : null; + const pausedTotalMs = parseNonNegativeMilliseconds(req.body.pausedTotalMs, "pausedTotalMs"); + const activity = typeof req.body.activity === "string" ? req.body.activity : ""; + const billingStatus = req.body.billingStatus === undefined ? null : parseBillingStatus(req.body.billingStatus); + const organizationName = optionalNullableString(req.body.organizationName); + const workType = optionalWorkType(req.body.workType); + const isProject = optionalBoolean(req.body.isProject, false); + const projectName = optionalNullableString(req.body.projectName); + const ticketLookupDone = optionalBoolean(req.body.ticketLookupDone, false); + const activityHtml = optionalZammadArticleHtml(req.body.zammadArticleBody); + + const timers = await withTransaction(async (client) => { + if (phase === "running") { + await client.query( + ` + UPDATE active_timers + SET phase = 'paused', + paused_at = COALESCE(paused_at, now()), + updated_at = now() + WHERE user_id = $1 + AND ticket_number <> $2 + AND phase = 'running'; + `, + [user.id, ticketNumber] + ); + } + + await client.query( + ` + INSERT INTO active_timers ( + user_id, + ticket_number, + organization_name, + work_type, + is_project, + project_name, + ticket_lookup_done, + activity, + activity_html, + billing_status, + started_at, + paused_total_ms, + paused_at, + phase + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + ON CONFLICT (user_id, ticket_number) + DO UPDATE SET + organization_name = EXCLUDED.organization_name, + work_type = EXCLUDED.work_type, + is_project = EXCLUDED.is_project, + project_name = EXCLUDED.project_name, + ticket_lookup_done = EXCLUDED.ticket_lookup_done, + activity = EXCLUDED.activity, + activity_html = EXCLUDED.activity_html, + billing_status = EXCLUDED.billing_status, + started_at = EXCLUDED.started_at, + paused_total_ms = EXCLUDED.paused_total_ms, + paused_at = EXCLUDED.paused_at, + phase = EXCLUDED.phase, + updated_at = now(); + `, + [ + user.id, + ticketNumber, + organizationName, + workType, + isProject, + projectName, + ticketLookupDone, + activity, + activityHtml, + billingStatus, + startedAt.toISOString(), + pausedTotalMs, + pausedAt?.toISOString() ?? null, + phase + ] + ); + + return activeTimersForUser(client, user.id); + }); + + res.status(201).json({ timers }); +}); + +app.patch("/api/timers/:timerId", requireUser, async (req, res) => { + const user = currentUser(req); + const timerId = parsePositiveInteger(req.params.timerId, "timerId"); + + const timers = await withTransaction(async (client) => { + const currentResult = await client.query( + ` + ${activeTimerSelect} + WHERE id = $1 + AND user_id = $2 + FOR UPDATE; + `, + [timerId, user.id] + ); + const current = currentResult.rows[0]; + + if (!current) { + const error = new Error("Timer not found") as Error & { status?: number }; + error.status = 404; + throw error; + } + + const now = new Date(); + const nextPhase = parseTimerPhase(req.body.phase, current.phase); + const currentPausedTotalMs = Number(current.paused_total_ms); + const currentPausedAt = current.paused_at ? new Date(current.paused_at) : null; + const nextPausedTotalMs = nextPhase === "running" && current.phase === "paused" && currentPausedAt + ? currentPausedTotalMs + now.getTime() - currentPausedAt.getTime() + : currentPausedTotalMs; + const nextPausedAt = nextPhase === "paused" + ? current.phase === "paused" + ? currentPausedAt ?? now + : now + : null; + const activity = req.body.activity === undefined + ? current.activity + : typeof req.body.activity === "string" + ? req.body.activity + : (() => { throw badRequest("activity must be a string"); })(); + const activityHtml = req.body.zammadArticleBody === undefined ? current.activity_html : optionalZammadArticleHtml(req.body.zammadArticleBody); + const billingStatus = req.body.billingStatus === undefined ? current.billing_status : parseBillingStatus(req.body.billingStatus); + const organizationName = req.body.organizationName === undefined ? current.organization_name : optionalNullableString(req.body.organizationName); + const workType = req.body.workType === undefined ? current.work_type : optionalWorkType(req.body.workType); + const isProject = req.body.isProject === undefined ? current.is_project : optionalBoolean(req.body.isProject, current.is_project); + const projectName = req.body.projectName === undefined ? current.project_name : optionalNullableString(req.body.projectName); + const ticketLookupDone = req.body.ticketLookupDone === undefined ? current.ticket_lookup_done : optionalBoolean(req.body.ticketLookupDone, current.ticket_lookup_done); + + if (nextPhase === "running") { + await client.query( + ` + UPDATE active_timers + SET phase = 'paused', + paused_at = COALESCE(paused_at, $3), + updated_at = now() + WHERE user_id = $1 + AND id <> $2 + AND phase = 'running'; + `, + [user.id, timerId, now.toISOString()] + ); + } + + await client.query( + ` + UPDATE active_timers + SET organization_name = $3, + work_type = $4, + is_project = $5, + project_name = $6, + ticket_lookup_done = $7, + activity = $8, + activity_html = $9, + billing_status = $10, + paused_total_ms = $11, + paused_at = $12, + phase = $13, + updated_at = now() + WHERE id = $1 + AND user_id = $2; + `, + [ + timerId, + user.id, + organizationName, + workType, + isProject, + projectName, + ticketLookupDone, + activity, + activityHtml, + billingStatus, + nextPausedTotalMs, + nextPausedAt?.toISOString() ?? null, + nextPhase + ] + ); + + return activeTimersForUser(client, user.id); + }); + + res.json({ timers }); +}); + +app.delete("/api/timers/:timerId", requireUser, async (req, res) => { + const user = currentUser(req); + const timerId = parsePositiveInteger(req.params.timerId, "timerId"); + const timers = await withTransaction(async (client) => { + await client.query( + ` + DELETE FROM active_timers + WHERE id = $1 + AND user_id = $2; + `, + [timerId, user.id] + ); + + return activeTimersForUser(client, user.id); + }); + + res.json({ timers }); +}); + app.post("/api/sessions", requireUser, async (req, res) => { const user = currentUser(req); const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber); - const activity = requireString(req.body.activity, "activity"); + const activity = optionalSessionActivity(req.body.activity); const requestedOrganizationId = req.body.organizationId === undefined || req.body.organizationId === null || req.body.organizationId === "" ? null : parseOrganizationId(req.body.organizationId); const requestedWorkType = optionalWorkType(req.body.workType); const useZammadLookup = optionalBoolean(req.body.zammadLookup, false); @@ -6477,6 +8228,7 @@ app.post("/api/sessions", requireUser, async (req, res) => { userId: user.id, ticketNumber, activity, + zammadArticleHtml: optionalZammadArticleHtml(req.body.zammadArticleBody), requestedOrganizationId, requestedWorkType, useZammadLookup, @@ -6492,6 +8244,15 @@ app.post("/api/sessions", requireUser, async (req, res) => { res.status(201).json(created); }); +app.post("/api/sessions/:sessionId/zammad-article", requireUser, async (req, res) => { + const user = currentUser(req); + const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId"); + const zammadArticleTypeId = parseZammadArticleTypeId(req.body.zammadArticleTypeId, user.zammad_article_type_id); + const zammadArticle = await sendZammadSessionArticleForSession(user.id, sessionId, zammadArticleTypeId); + + res.json({ zammadArticle }); +}); + async function zammadSessionArticleQueueResponse(userId: string) { const result = await query( ` @@ -6530,26 +8291,7 @@ app.get("/api/zammad/session-article-queue", requireUser, async (req, res) => { app.post("/api/zammad/session-article-queue/retry", requireUser, async (req, res) => { const userId = currentUser(req).id; - const pendingResult = await query( - ` - SELECT - id::text AS id, - user_id::text AS user_id, - session_id::text AS session_id, - ticket_id::text AS ticket_id, - ticket_number, - zammad_ticket_id::text AS zammad_ticket_id, - activity, - time_unit::text AS time_unit, - zammad_article_type_id - FROM zammad_session_article_queue - WHERE user_id = $1 - AND status = 'pending' - ORDER BY created_at ASC - LIMIT 100; - `, - [userId] - ); + const pendingResult = await pendingZammadSessionArticleQueueRows(userId, 100); const results: Array<{ id: string; status: "sent" | "failed"; error?: string; zammadArticleId?: number | null }> = []; for (const row of pendingResult.rows) { @@ -6557,19 +8299,7 @@ app.post("/api/zammad/session-article-queue/retry", requireUser, async (req, res const articleId = await retryZammadSessionArticleQueueRow(row); results.push({ id: row.id, status: "sent", zammadArticleId: articleId }); } catch (error) { - const message = errorMessage(error); - await query( - ` - UPDATE zammad_session_article_queue - SET attempt_count = attempt_count + 1, - last_error = $1, - last_attempt_at = now(), - updated_at = now() - WHERE id = $2 - AND status = 'pending'; - `, - [message.slice(0, 1000), row.id] - ); + const message = await recordZammadSessionArticleFailure(row.id, error); results.push({ id: row.id, status: "failed", error: message }); } } @@ -6592,6 +8322,11 @@ app.get("/api/statistics/months/:month", requireUser, async (req, res) => { res.json(await getStatisticsOverview(month, currentUser(req).id)); }); +app.get("/api/statistics/weeks/:week", requireUser, async (req, res) => { + const week = parseWeek(String(req.params.week)); + res.json(await getStatisticsOverview(week, currentUser(req).id, "week")); +}); + app.get("/api/tickets/lookup", requireUser, async (req, res) => { const ticketNumber = parseTicketNumber(req.query.ticketNumber); const userId = currentUser(req).id; @@ -6641,10 +8376,11 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => { COALESCE( json_agg( json_build_object( - 'id', pte.id::text, - 'project_id', pte.project_id::text, - 'teamspace_project', pte.teamspace_project, - 'is_default', pte.is_default, + 'id', pte.id::text, + 'project_id', pte.project_id::text, + 'teamspace_project', pte.teamspace_project, + 'teamspace_description', pte.teamspace_description, + 'is_default', pte.is_default, 'hour_budget_minutes', pte.hour_budget_minutes, 'budget_sync_disabled', pte.budget_sync_disabled, 'manual_consumption_minutes', pte.manual_consumption_minutes, @@ -7127,6 +8863,280 @@ async function runConsultingZammadImportFromPreview(userId: string, previewResul return result; } +async function localTicketForZammadMailSync(zammadTicket: ZammadTicketImport) { + const result = await query( + ` + SELECT + t.id::text AS id, + t.ticket_number, + t.title, + t.organization_id::text AS organization_id, + o.name AS organization_name, + t.customer_name, + t.work_type, + t.zammad_ticket_id, + t.zammad_group_id, + t.zammad_priority_id, + t.zammad_state_id, + t.zammad_owner_id, + t.zammad_preferences + FROM tickets t + LEFT JOIN organizations o ON o.zammad_id = t.organization_id + WHERE t.ticket_number = $1 + OR t.zammad_ticket_id = $2 + ORDER BY (t.ticket_number = $1) DESC, (t.zammad_ticket_id = $2) DESC, t.id ASC + LIMIT 1; + `, + [zammadTicket.ticketNumber, zammadTicket.zammadTicketId] + ); + + return result.rows[0] ?? null; +} + +async function zammadTicketForMailArticle( + baseUrl: string, + apiToken: string, + ticketId: number, + cache: Map +) { + if (cache.has(ticketId)) { + return cache.get(ticketId) ?? null; + } + + const detail = await fetchZammadJson(baseUrl, apiToken, `/api/v1/tickets/${ticketId}`); + const zammadTicket = isPlainObject(detail) ? zammadTicketImportFromRaw(detail) : null; + cache.set(ticketId, zammadTicket); + return zammadTicket; +} + +async function runZammadMailSessionImport(userId: string, daysBack: number, perPage: number, dryRun: boolean, selectedArticleIds: Set | null) { + const result = emptyZammadSyncResult(dryRun ? "preview" : "applied"); + const userResult = await query<{ zammad_user_id: string | null; zammad_atoss_email_sync_enabled: boolean }>( + "SELECT zammad_user_id::text AS zammad_user_id, zammad_atoss_email_sync_enabled FROM users WHERE id = $1;", + [userId] + ); + const zammadUserId = numberOrNull(userResult.rows[0]?.zammad_user_id); + const atossEmailSyncEnabled = Boolean(userResult.rows[0]?.zammad_atoss_email_sync_enabled ?? true); + + if (!zammadUserId) { + throw badRequest("Zammad User-ID fehlt. Bitte den persönlichen Zammad-Token im Profil speichern."); + } + + const { baseUrl, apiToken } = await getZammadCredentialsForUser(userId); + const cutoff = zammadMailSyncCutoff(daysBack); + const ticketCache = new Map(); + const rawArticles = await fetchZammadMailSessionArticleCandidatesForUser({ + userId, + baseUrl, + apiToken, + zammadUserId, + daysBack, + perPage, + atossEmailSyncEnabled, + selectedArticleIds + }); + + for (const rawArticle of rawArticles) { + const articleId = numberOrNull(rawArticle.id); + + if (!articleId || (selectedArticleIds && !selectedArticleIds.has(articleId))) { + continue; + } + + try { + if (await zammadMailArticleAlreadyImported({ query }, userId, articleId)) { + continue; + } + + const ticketId = numberOrNull(rawArticle.ticket_id); + + if (!ticketId) { + continue; + } + + const zammadTicket = await zammadTicketForMailArticle(baseUrl, apiToken, ticketId, ticketCache); + + if (!zammadTicket || !isSupportedZammadGroupId(zammadTicket.zammadGroupId)) { + continue; + } + + const candidate = zammadMailArticleCandidateFromRaw(rawArticle, zammadTicket.ticketNumber, zammadTicket.zammadTicketId, zammadUserId, cutoff); + + if (!candidate) { + continue; + } + + const timeUnit = await fetchZammadTimeUnitForArticle({ + baseUrl, + apiToken, + ticketId: zammadTicket.zammadTicketId, + articleId: candidate.articleId + }); + const article = timeUnit ? { + ...candidate, + timeUnit, + durationSeconds: Math.max(60, Math.round(timeUnit * 60 * 60)) + } : null; + + if (!article) { + continue; + } + + result.checked += 1; + const localTicket = await localTicketForZammadMailSync(zammadTicket); + const plan = await planZammadMailSessionImport({ query }, userId, localTicket, zammadTicket, article); + + if (plan.action !== "create") { + updateZammadSyncResultFromItem(result, plan, localTicket?.work_type ?? zammadTicket.workType); + continue; + } + + if (!dryRun) { + const startedAt = article.createdAt; + const endedAt = new Date(startedAt.getTime() + article.durationSeconds * 1000); + const created = await createSessionForUser({ + userId, + ticketNumber: zammadTicket.ticketNumber, + activity: article.activity, + zammadArticleHtml: null, + requestedOrganizationId: localTicket?.organization_id ? numberOrNull(localTicket.organization_id) : zammadTicket.organizationId, + requestedWorkType: localTicket?.work_type ?? zammadTicket.workType, + useZammadLookup: false, + requestedProjectTeamspaceEntryId: null, + billingStatus: "billable", + createZammadArticle: false, + zammadArticleTypeId: 6, + startedAt, + endedAt, + durationSeconds: article.durationSeconds, + zammadTicket: localTicket ? null : zammadTicket, + sourceEvent: { + zammadArticleId: article.articleId, + deliveryId: null + } + }); + + if ("duplicate" in created) { + updateZammadSyncResultFromItem(result, { + ...plan, + action: "skip", + reason: "Mail-Artikel wurde bereits als Session importiert.", + changes: [] + }, localTicket?.work_type ?? zammadTicket.workType); + continue; + } + } + + updateZammadSyncResultFromItem(result, plan, localTicket?.work_type ?? zammadTicket.workType); + } catch (error) { + updateZammadSyncResultFromItem(result, { + ticketNumber: `Article#${articleId}`, + action: "error", + reason: error instanceof Error ? error.message : "Unbekannter Fehler", + local: null, + zammad: null, + changes: [] + }); + } + } + + return result; +} + +async function runZammadMailSessionImportFromPreview(userId: string, previewResult: ZammadSyncResult, selectedArticleIds: Set) { + const result = emptyZammadSyncResult("applied"); + const userResult = await query<{ zammad_atoss_email_sync_enabled: boolean }>( + "SELECT zammad_atoss_email_sync_enabled FROM users WHERE id = $1;", + [userId] + ); + const atossEmailSyncEnabled = Boolean(userResult.rows[0]?.zammad_atoss_email_sync_enabled ?? true); + + for (const item of previewResult.items) { + const article = zammadMailSessionArticleFromSnapshot(item.zammad); + + if (!article || !selectedArticleIds.has(article.articleId)) { + continue; + } + + if (!atossEmailSyncEnabled && recipientValueContainsEmail(article.to, atossSupportEmail)) { + continue; + } + + result.checked += 1; + + try { + const zammadTicket = zammadMailSessionTicketFromSnapshot(item.zammad); + + if (!zammadTicket || !isSupportedZammadGroupId(zammadTicket.zammadGroupId)) { + updateZammadSyncResultFromItem(result, { + ticketNumber: item.ticketNumber, + action: "skip", + reason: "Zammad-Ticket ist nicht Support/Consulting.", + local: null, + zammad: zammadMailSessionSnapshot(article), + changes: [] + }); + continue; + } + + const localTicket = await localTicketForZammadMailSync(zammadTicket); + const plan = await planZammadMailSessionImport({ query }, userId, localTicket, zammadTicket, article); + + if (plan.action !== "create") { + updateZammadSyncResultFromItem(result, plan, localTicket?.work_type ?? zammadTicket.workType); + continue; + } + + const startedAt = article.createdAt; + const endedAt = new Date(startedAt.getTime() + article.durationSeconds * 1000); + const created = await createSessionForUser({ + userId, + ticketNumber: zammadTicket.ticketNumber, + activity: article.activity, + zammadArticleHtml: null, + requestedOrganizationId: localTicket?.organization_id ? numberOrNull(localTicket.organization_id) : zammadTicket.organizationId, + requestedWorkType: localTicket?.work_type ?? zammadTicket.workType, + useZammadLookup: false, + requestedProjectTeamspaceEntryId: null, + billingStatus: "billable", + createZammadArticle: false, + zammadArticleTypeId: 6, + startedAt, + endedAt, + durationSeconds: article.durationSeconds, + zammadTicket: localTicket ? null : zammadTicket, + sourceEvent: { + zammadArticleId: article.articleId, + deliveryId: null + } + }); + + if ("duplicate" in created) { + updateZammadSyncResultFromItem(result, { + ...plan, + action: "skip", + reason: "Mail-Artikel wurde bereits als Session importiert.", + changes: [] + }, localTicket?.work_type ?? zammadTicket.workType); + continue; + } + + updateZammadSyncResultFromItem(result, plan, localTicket?.work_type ?? zammadTicket.workType); + } catch (error) { + updateZammadSyncResultFromItem(result, { + ticketNumber: item.ticketNumber, + action: "error", + reason: error instanceof Error ? error.message : "Unbekannter Fehler", + local: item.local, + zammad: item.zammad, + changes: [] + }); + } + } + + return result; +} + type ZammadSyncJob = { id: string; userId: string; @@ -7217,6 +9227,17 @@ app.post("/api/zammad-sync/jobs/consulting-import", requireUser, async (req, res res.status(202).json({ job }); }); +app.post("/api/zammad-sync/jobs/mail-sessions", requireUser, async (req, res) => { + const userId = currentUser(req).id; + const dryRun = optionalBoolean(req.body.dryRun, true); + const daysBack = zammadMailSyncDaysBack(req.body.daysBack); + const perPage = zammadMailSyncPerPage(req.body.perPage); + const selectedArticleIds = zammadSyncSelectedArticleIds(req.body.articleIds); + const job = createZammadSyncJob(userId, () => runZammadMailSessionImport(userId, daysBack, perPage, dryRun, selectedArticleIds)); + + res.status(202).json({ job }); +}); + app.post("/api/zammad-sync/jobs/:jobId/apply-existing", requireUser, async (req, res) => { const userId = currentUser(req).id; const previewJob = zammadSyncJobs.get(String(req.params.jobId)); @@ -7258,6 +9279,26 @@ app.post("/api/zammad-sync/jobs/:jobId/apply-consulting-import", requireUser, as res.status(202).json({ job }); }); +app.post("/api/zammad-sync/jobs/:jobId/apply-mail-sessions", requireUser, async (req, res) => { + const userId = currentUser(req).id; + const previewJob = zammadSyncJobs.get(String(req.params.jobId)); + + if (!previewJob || previewJob.userId !== userId || previewJob.status !== "complete" || !previewJob.result || previewJob.result.mode !== "preview") { + res.status(404).json({ error: "Vorschau-Lauf nicht gefunden" }); + return; + } + + const selectedArticleIds = zammadSyncSelectedArticleIds(req.body.articleIds); + + if (!selectedArticleIds || selectedArticleIds.size === 0) { + throw badRequest("articleIds must contain at least one article"); + } + + const job = createZammadSyncJob(userId, () => runZammadMailSessionImportFromPreview(userId, previewJob.result!, selectedArticleIds)); + + res.status(202).json({ job }); +}); + app.get("/api/zammad-sync/jobs/:jobId", requireUser, async (req, res) => { const job = zammadSyncJobs.get(String(req.params.jobId)); @@ -7387,12 +9428,29 @@ app.get("/api/projects", requireUser, async (req, res) => { 'id', pte.id::text, 'project_id', pte.project_id::text, 'teamspace_project', pte.teamspace_project, + 'teamspace_description', pte.teamspace_description, 'is_default', pte.is_default, 'hour_budget_minutes', pte.hour_budget_minutes, 'budget_sync_disabled', pte.budget_sync_disabled, 'manual_consumption_minutes', pte.manual_consumption_minutes, - 'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END, - 'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes, + 'teamspace_consumption_minutes', COALESCE(( + SELECT SUM(tdb.billed_minutes)::int + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND ( + tdb.project_teamspace_entry_id = pte.id + OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + ), 0), + 'consumption_minutes', COALESCE(( + SELECT SUM(tdb.billed_minutes)::int + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND ( + tdb.project_teamspace_entry_id = pte.id + OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + ), 0) + pte.manual_consumption_minutes, 'display_order', pte.display_order ) ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC @@ -7527,12 +9585,29 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { 'id', pte.id::text, 'project_id', pte.project_id::text, 'teamspace_project', pte.teamspace_project, + 'teamspace_description', pte.teamspace_description, 'is_default', pte.is_default, 'hour_budget_minutes', pte.hour_budget_minutes, 'budget_sync_disabled', pte.budget_sync_disabled, 'manual_consumption_minutes', pte.manual_consumption_minutes, - 'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END, - 'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes, + 'teamspace_consumption_minutes', COALESCE(( + SELECT SUM(tdb.billed_minutes)::int + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND ( + tdb.project_teamspace_entry_id = pte.id + OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + ), 0), + 'consumption_minutes', COALESCE(( + SELECT SUM(tdb.billed_minutes)::int + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND ( + tdb.project_teamspace_entry_id = pte.id + OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + ), 0) + pte.manual_consumption_minutes, 'display_order', pte.display_order ) ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC @@ -7615,6 +9690,7 @@ app.post("/api/projects", requireUser, async (req, res) => { ? zammadTeamspaceProjects.map((teamspaceProjectName, index) => ({ id: null, teamspaceProject: teamspaceProjectName, + teamspaceDescription: "", isDefault: index === 0, hourBudgetMinutes: index === 0 ? currentDefaultTeamspaceEntry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes : null, budgetSyncDisabled: index === 0 && (currentDefaultTeamspaceEntry.budgetSyncDisabled || defaultBudgetManuallyChanged), @@ -7794,16 +9870,17 @@ app.post("/api/projects", requireUser, async (req, res) => { const project = projectResult.rows[0]; if (zammadTicket) { + await mergeProjectTeamspaceBillingsToDefault(client, project.ticket_id, projectOwnerUserId); await client.query("DELETE FROM project_teamspace_entries WHERE project_id = $1;", [project.id]); } for (const entry of teamspaceEntries) { await client.query( ` - INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order) - VALUES ($1, $2, $3, $4, $5, $6, $7); + INSERT INTO project_teamspace_entries (project_id, teamspace_project, teamspace_description, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8); `, - [project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.budgetSyncDisabled, entry.manualConsumptionMinutes, entry.displayOrder] + [project.id, entry.teamspaceProject, entry.teamspaceDescription, entry.isDefault, entry.hourBudgetMinutes, entry.budgetSyncDisabled, entry.manualConsumptionMinutes, entry.displayOrder] ); } await autoAssignSingleProjectTeamspaceEntry(client, project.ticket_id); @@ -7811,6 +9888,7 @@ app.post("/api/projects", requireUser, async (req, res) => { project.teamspace_entries = teamspaceEntries.map((entry, index) => ({ id: `new-${index}`, teamspace_project: entry.teamspaceProject, + teamspace_description: entry.teamspaceDescription, is_default: entry.isDefault, hour_budget_minutes: entry.hourBudgetMinutes, budget_sync_disabled: entry.budgetSyncDisabled, @@ -8000,17 +10078,18 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { ` UPDATE project_teamspace_entries SET teamspace_project = $1, - is_default = $2, - hour_budget_minutes = $3, - budget_sync_disabled = $4, - manual_consumption_minutes = $5, - display_order = $6, + teamspace_description = $2, + is_default = $3, + hour_budget_minutes = $4, + budget_sync_disabled = $5, + manual_consumption_minutes = $6, + display_order = $7, updated_at = now() - WHERE id = $7 - AND project_id = $8 + WHERE id = $8 + AND project_id = $9 RETURNING id::text AS id; `, - [entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.budgetSyncDisabled, entry.manualConsumptionMinutes, entry.displayOrder, entry.id, project.id] + [entry.teamspaceProject, entry.teamspaceDescription, entry.isDefault, entry.hourBudgetMinutes, entry.budgetSyncDisabled, entry.manualConsumptionMinutes, entry.displayOrder, entry.id, project.id] ); if (updatedEntry.rowCount === 0) { @@ -8021,17 +10100,26 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { } else { const insertedEntry = await client.query<{ id: string }>( ` - INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO project_teamspace_entries (project_id, teamspace_project, teamspace_description, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id::text AS id; `, - [project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.budgetSyncDisabled, entry.manualConsumptionMinutes, entry.displayOrder] + [project.id, entry.teamspaceProject, entry.teamspaceDescription, entry.isDefault, entry.hourBudgetMinutes, entry.budgetSyncDisabled, entry.manualConsumptionMinutes, entry.displayOrder] ); savedEntryIds.push(insertedEntry.rows[0].id); } } + const defaultSavedEntryId = savedEntryIds[teamspaceEntries.findIndex((entry) => entry.isDefault)] ?? savedEntryIds[0]; + const removedEntryIds = currentTeamspaceEntries.rows + .map((entry) => entry.id) + .filter((id) => !savedEntryIds.includes(id)); + + if (removedEntryIds.length > 0 && defaultSavedEntryId) { + await mergeTeamspaceEntriesIntoTarget(client, removedEntryIds, defaultSavedEntryId); + } + await client.query( ` DELETE FROM project_teamspace_entries @@ -8045,6 +10133,7 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { project.teamspace_entries = teamspaceEntries.map((entry, index) => ({ id: savedEntryIds[index] ?? `new-${index}`, teamspace_project: entry.teamspaceProject, + teamspace_description: entry.teamspaceDescription, is_default: entry.isDefault, hour_budget_minutes: entry.hourBudgetMinutes, budget_sync_disabled: entry.budgetSyncDisabled, @@ -8430,6 +10519,25 @@ app.get("/api/tickets/:ticketId/details", requireUser, async (req, res) => { res.json(result); }); +app.get("/api/tickets/:ticketId/zammad-mail-sessions", requireUser, async (req, res) => { + const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); + const daysBack = zammadMailSyncDaysBack(req.query.daysBack); + + res.json(await runTicketZammadMailSessionImport(currentUser(req).id, ticketId, daysBack, true, null)); +}); + +app.post("/api/tickets/:ticketId/zammad-mail-sessions", requireUser, async (req, res) => { + const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); + const daysBack = zammadMailSyncDaysBack(req.body.daysBack); + const selectedArticleIds = zammadSyncSelectedArticleIds(req.body.articleIds); + + if (!selectedArticleIds || selectedArticleIds.size === 0) { + throw badRequest("articleIds must contain at least one article"); + } + + res.json(await runTicketZammadMailSessionImport(currentUser(req).id, ticketId, daysBack, false, selectedArticleIds)); +}); + app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber); @@ -8555,6 +10663,7 @@ app.patch("/api/tickets/:ticketId/day-billings/:day", requireUser, async (req, r const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const day = parseDay(String(req.params.day)); const billedMinutes = parseOptionalBilledMinutes(req.body.billedMinutes); + const projectTeamspaceEntryId = parseOptionalTeamspaceEntryId(req.body.projectTeamspaceEntryId); const userId = currentUser(req).id; const monthClosureResult = await query<{ closed_at: string }>( @@ -8572,6 +10681,34 @@ app.patch("/api/tickets/:ticketId/day-billings/:day", requireUser, async (req, r return; } + if (projectTeamspaceEntryId !== null) { + const entryResult = await query<{ id: string }>( + ` + SELECT pte.id::text AS id + FROM project_teamspace_entries pte + JOIN projects p ON p.id = pte.project_id + WHERE pte.id = $1 + AND p.ticket_id = $2 + AND ( + p.user_id = $3 + OR EXISTS ( + SELECT 1 + FROM sessions viewer_sessions + WHERE viewer_sessions.ticket_id = p.ticket_id + AND viewer_sessions.user_id = $3 + ) + ) + LIMIT 1; + `, + [projectTeamspaceEntryId, ticketId, userId] + ); + + if (entryResult.rowCount === 0) { + res.status(404).json({ error: "Teamspace-Eintrag nicht gefunden" }); + return; + } + } + const sessionResult = await query<{ id: string }>( ` SELECT id @@ -8579,9 +10716,13 @@ app.patch("/api/tickets/:ticketId/day-billings/:day", requireUser, async (req, r WHERE ticket_id = $1 AND user_id = $2 AND (started_at AT TIME ZONE 'Europe/Berlin')::date = $3::date + AND ( + $4::bigint IS NULL + OR project_teamspace_entry_id = $4::bigint + ) LIMIT 1; `, - [ticketId, userId, day.start] + [ticketId, userId, day.start, projectTeamspaceEntryId] ); if (sessionResult.rowCount === 0) { @@ -8595,25 +10736,40 @@ app.patch("/api/tickets/:ticketId/day-billings/:day", requireUser, async (req, r DELETE FROM ticket_day_billings WHERE ticket_id = $1 AND user_id = $2 - AND day = $3::date; + AND day = $3::date + AND ( + ($4::bigint IS NULL AND project_teamspace_entry_id IS NULL) + OR project_teamspace_entry_id = $4::bigint + ); `, - [ticketId, userId, day.start] + [ticketId, userId, day.start, projectTeamspaceEntryId] ); res.json({ dayBilling: null }); return; } - const result = await query( - ` - INSERT INTO ticket_day_billings (ticket_id, user_id, day, billed_minutes) - VALUES ($1, $2, $3::date, $4) - ON CONFLICT (ticket_id, user_id, day) - DO UPDATE SET billed_minutes = EXCLUDED.billed_minutes, updated_at = now() - RETURNING day::text AS day, billed_minutes; - `, - [ticketId, userId, day.start, billedMinutes] - ); + const result = projectTeamspaceEntryId === null + ? await query( + ` + INSERT INTO ticket_day_billings (ticket_id, user_id, project_teamspace_entry_id, day, billed_minutes) + VALUES ($1, $2, NULL, $3::date, $4) + ON CONFLICT (ticket_id, user_id, day) WHERE project_teamspace_entry_id IS NULL + DO UPDATE SET billed_minutes = EXCLUDED.billed_minutes, updated_at = now() + RETURNING day::text AS day, project_teamspace_entry_id::text AS project_teamspace_entry_id, billed_minutes; + `, + [ticketId, userId, day.start, billedMinutes] + ) + : await query( + ` + INSERT INTO ticket_day_billings (ticket_id, user_id, project_teamspace_entry_id, day, billed_minutes) + VALUES ($1, $2, $3, $4::date, $5) + ON CONFLICT (ticket_id, user_id, day, project_teamspace_entry_id) WHERE project_teamspace_entry_id IS NOT NULL + DO UPDATE SET billed_minutes = EXCLUDED.billed_minutes, updated_at = now() + RETURNING day::text AS day, project_teamspace_entry_id::text AS project_teamspace_entry_id, billed_minutes; + `, + [ticketId, userId, projectTeamspaceEntryId, day.start, billedMinutes] + ); await query( ` @@ -8707,7 +10863,7 @@ app.post("/api/periods/:periodType/:period/close", requireUser, async (req, res) t.id AS ticket_id, t.ticket_number, COUNT(s.id)::int AS session_count, - COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS open_count, + COUNT(*) FILTER (WHERE s.billing_status IS NULL OR s.billing_status = 'billable')::int AS open_count, COUNT(*) FILTER (WHERE s.recurring_billing_id IS NULL)::int AS manual_session_count, false AS ticket_closed FROM tickets t @@ -8715,7 +10871,7 @@ app.post("/api/periods/:periodType/:period/close", requireUser, async (req, res) WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz AND s.user_id = $3 GROUP BY t.id, t.ticket_number - HAVING COUNT(*) FILTER (WHERE s.billing_status IS NULL) > 0 + HAVING COUNT(*) FILTER (WHERE s.billing_status IS NULL OR s.billing_status = 'billable') > 0 ORDER BY t.ticket_number ASC; `, [period.startIso, period.endIso, userId] @@ -8781,7 +10937,7 @@ app.get("/api/months/:month/overview", requireUser, async (req, res) => { t.work_type, COUNT(s.id)::int AS session_count, COALESCE(SUM(s.rounded_minutes), 0)::int AS total_minutes, - COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS open_count, + COUNT(*) FILTER (WHERE s.billing_status IS NULL OR s.billing_status = 'billable')::int AS open_count, COUNT(*) FILTER (WHERE s.billing_status = 'billable')::int AS billable_count, COUNT(*) FILTER (WHERE s.billing_status = 'billed')::int AS billed_count, COUNT(*) FILTER (WHERE s.billing_status = 'non_billable')::int AS non_billable_count, @@ -8811,7 +10967,7 @@ app.get("/api/months/:month/overview", requireUser, async (req, res) => { JOIN tickets t ON t.id = s.ticket_id WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz - AND s.billing_status IS NULL + AND (s.billing_status IS NULL OR s.billing_status = 'billable') ORDER BY s.started_at ASC; `, [month.startIso, month.endIso] @@ -8891,7 +11047,7 @@ app.get("/api/months/:month/tickets/:ticketId", requireUser, async (req, res) => ); const sessions = sessionsResult.rows; - const openCount = sessions.filter((session: any) => session.billing_status === null).length; + const openCount = sessions.filter((session: any) => session.billing_status === null || session.billing_status === "billable").length; res.json({ month: month.label, @@ -8970,14 +11126,15 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { const userId = currentUser(req).id; const organizationId = parseOrganizationId(req.body.organizationId); const activity = requireString(req.body.activity, "activity"); + const activityHtml = optionalZammadArticleHtml(req.body.zammadArticleBody); const workType = parseWorkType(req.body.workType); const removeProjectData = optionalBoolean(req.body.removeProjectData, false); const requestedProjectTeamspaceEntryId = parseOptionalTeamspaceEntryId(req.body.projectTeamspaceEntryId); const startedAt = parseIsoDate(req.body.startedAt, "startedAt"); const endedAt = parseIsoDate(req.body.endedAt, "endedAt"); - if (endedAt <= startedAt) { - throw badRequest("endedAt must be after startedAt"); + if (endedAt < startedAt) { + throw badRequest("endedAt must not be before startedAt"); } const durationSeconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000); @@ -9081,20 +11238,22 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { SET organization_id = $1, customer_name = $2, activity = $3, - work_type = $4, - started_at = $5, - ended_at = $6, - duration_seconds = $7, - rounded_minutes = $8, - project_teamspace_entry_id = $9 - WHERE id = $10 - AND user_id = $11 + activity_html = $4, + work_type = $5, + started_at = $6, + ended_at = $7, + duration_seconds = $8, + rounded_minutes = $9, + project_teamspace_entry_id = $10 + WHERE id = $11 + AND user_id = $12 RETURNING *; `, [ organization.id, organization.name, activity, + activityHtml, effectiveWorkType, startedAt.toISOString(), endedAt.toISOString(), @@ -9306,7 +11465,7 @@ app.post("/api/months/:month/tickets/:ticketId/close", requireUser, async (req, ` SELECT COUNT(*)::int AS session_count, - COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_count + COUNT(*) FILTER (WHERE billing_status IS NULL OR billing_status = 'billable')::int AS open_count FROM sessions WHERE ticket_id = $1 AND started_at >= $2::timestamptz @@ -9382,14 +11541,14 @@ app.post("/api/months/:month/close", requireUser, async (req, res) => { t.id AS ticket_id, t.ticket_number, COUNT(s.id)::int AS session_count, - COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS open_count, + COUNT(*) FILTER (WHERE s.billing_status IS NULL OR s.billing_status = 'billable')::int AS open_count, tm.closed_at IS NOT NULL AS ticket_closed FROM tickets t JOIN sessions s ON s.ticket_id = t.id LEFT JOIN ticket_month_closures tm ON tm.ticket_id = t.id AND tm.month = $1::date WHERE s.started_at >= $2::timestamptz AND s.started_at < $3::timestamptz GROUP BY t.id, t.ticket_number, tm.closed_at - HAVING COUNT(*) FILTER (WHERE s.billing_status IS NULL) > 0 OR tm.closed_at IS NULL + HAVING COUNT(*) FILTER (WHERE s.billing_status IS NULL OR s.billing_status = 'billable') > 0 OR tm.closed_at IS NULL ORDER BY t.ticket_number ASC; `, [month.start, month.startIso, month.endIso] diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index 0c9bbcd..c6f3fa1 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -91,8 +91,11 @@ export async function migrate() { await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_session_note_default_enabled BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_enabled BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_minutes INTEGER NOT NULL DEFAULT 15;"); + await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_atoss_email_sync_enabled BOOLEAN NOT NULL DEFAULT true;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_article_type_id INTEGER DEFAULT 6;"); - await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id DROP NOT NULL;"); + await query("UPDATE users SET zammad_article_type_id = 6 WHERE zammad_article_type_id IS NULL;"); + await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET DEFAULT 6;"); + await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET NOT NULL;"); await query(` ALTER TABLE users DROP CONSTRAINT IF EXISTS users_zammad_time_unit_rounding_minutes_valid, @@ -101,7 +104,7 @@ export async function migrate() { await query(` ALTER TABLE users DROP CONSTRAINT IF EXISTS users_zammad_article_type_id_valid, - ADD CONSTRAINT users_zammad_article_type_id_valid CHECK (zammad_article_type_id IS NULL OR zammad_article_type_id IN (1, 2, 3, 4, 5, 6)); + ADD CONSTRAINT users_zammad_article_type_id_valid CHECK (zammad_article_type_id IN (1, 2, 3, 4, 5, 6)); `); await query(` @@ -237,6 +240,86 @@ export async function migrate() { await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_date DATE;"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_key TEXT;"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS project_teamspace_entry_id BIGINT;"); + await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS activity_html TEXT;"); + await query(` + CREATE TABLE IF NOT EXISTS active_timers ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + ticket_number TEXT NOT NULL, + organization_name TEXT, + work_type TEXT, + is_project BOOLEAN NOT NULL DEFAULT false, + project_name TEXT, + ticket_lookup_done BOOLEAN NOT NULL DEFAULT false, + activity TEXT NOT NULL DEFAULT '', + activity_html TEXT, + billing_status TEXT, + started_at TIMESTAMPTZ NOT NULL, + paused_total_ms BIGINT NOT NULL DEFAULT 0, + paused_at TIMESTAMPTZ, + phase TEXT NOT NULL DEFAULT 'running', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT active_timers_ticket_number_format CHECK (ticket_number ~ '^Ticket#[0-9]{6}$'), + CONSTRAINT active_timers_work_type_valid CHECK (work_type IS NULL OR work_type IN ('support', 'consulting')), + CONSTRAINT active_timers_billing_status_valid CHECK (billing_status IS NULL OR billing_status IN ('billable', 'billed', 'non_billable')), + CONSTRAINT active_timers_phase_valid CHECK (phase IN ('running', 'paused')), + CONSTRAINT active_timers_paused_total_non_negative CHECK (paused_total_ms >= 0), + CONSTRAINT active_timers_paused_state_valid CHECK ( + (phase = 'running' AND paused_at IS NULL) + OR (phase = 'paused' AND paused_at IS NOT NULL) + ) + ); + `); + await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS organization_name TEXT;"); + await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS work_type TEXT;"); + await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS is_project BOOLEAN NOT NULL DEFAULT false;"); + await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS project_name TEXT;"); + await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS ticket_lookup_done BOOLEAN NOT NULL DEFAULT false;"); + await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS activity TEXT NOT NULL DEFAULT '';"); + await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS activity_html TEXT;"); + await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS billing_status TEXT;"); + await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS paused_total_ms BIGINT NOT NULL DEFAULT 0;"); + await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS paused_at TIMESTAMPTZ;"); + await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS phase TEXT NOT NULL DEFAULT 'running';"); + await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT now();"); + await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now();"); + await query(` + ALTER TABLE active_timers + DROP CONSTRAINT IF EXISTS active_timers_ticket_number_format, + ADD CONSTRAINT active_timers_ticket_number_format CHECK (ticket_number ~ '^Ticket#[0-9]{6}$'); + `); + await query(` + ALTER TABLE active_timers + DROP CONSTRAINT IF EXISTS active_timers_work_type_valid, + ADD CONSTRAINT active_timers_work_type_valid CHECK (work_type IS NULL OR work_type IN ('support', 'consulting')); + `); + await query(` + ALTER TABLE active_timers + DROP CONSTRAINT IF EXISTS active_timers_billing_status_valid, + ADD CONSTRAINT active_timers_billing_status_valid CHECK (billing_status IS NULL OR billing_status IN ('billable', 'billed', 'non_billable')); + `); + await query(` + ALTER TABLE active_timers + DROP CONSTRAINT IF EXISTS active_timers_phase_valid, + ADD CONSTRAINT active_timers_phase_valid CHECK (phase IN ('running', 'paused')); + `); + await query(` + ALTER TABLE active_timers + DROP CONSTRAINT IF EXISTS active_timers_paused_total_non_negative, + ADD CONSTRAINT active_timers_paused_total_non_negative CHECK (paused_total_ms >= 0); + `); + await query(` + ALTER TABLE active_timers + DROP CONSTRAINT IF EXISTS active_timers_paused_state_valid, + ADD CONSTRAINT active_timers_paused_state_valid CHECK ( + (phase = 'running' AND paused_at IS NULL) + OR (phase = 'paused' AND paused_at IS NOT NULL) + ); + `); + await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_active_timers_user_ticket ON active_timers(user_id, ticket_number);"); + await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_active_timers_single_running ON active_timers(user_id) WHERE phase = 'running';"); + await query("CREATE INDEX IF NOT EXISTS idx_active_timers_user_updated ON active_timers(user_id, updated_at);"); await query(` ALTER TABLE sessions DROP CONSTRAINT IF EXISTS billing_status_valid, @@ -263,6 +346,7 @@ export async function migrate() { ON zammad_email_session_events(delivery_id) WHERE delivery_id IS NOT NULL; `); + await query("CREATE INDEX IF NOT EXISTS idx_zammad_email_session_events_session ON zammad_email_session_events(session_id);"); await query(` CREATE TABLE IF NOT EXISTS zammad_session_article_queue ( id BIGSERIAL PRIMARY KEY, @@ -272,8 +356,10 @@ export async function migrate() { ticket_number TEXT NOT NULL, zammad_ticket_id BIGINT, activity TEXT NOT NULL, + article_body TEXT, + article_content_type TEXT NOT NULL DEFAULT 'text/plain', time_unit NUMERIC(10, 2) NOT NULL, - zammad_article_type_id INTEGER DEFAULT 6, + zammad_article_type_id INTEGER NOT NULL DEFAULT 6, status TEXT NOT NULL DEFAULT 'pending', attempt_count INTEGER NOT NULL DEFAULT 0, last_error TEXT, @@ -285,12 +371,22 @@ export async function migrate() { CONSTRAINT zammad_session_article_queue_status_valid CHECK (status IN ('pending', 'sent')) ); `); + await query("ALTER TABLE zammad_session_article_queue ADD COLUMN IF NOT EXISTS article_body TEXT;"); + await query("ALTER TABLE zammad_session_article_queue ADD COLUMN IF NOT EXISTS article_content_type TEXT NOT NULL DEFAULT 'text/plain';"); + await query("UPDATE zammad_session_article_queue SET article_body = activity WHERE article_body IS NULL;"); + await query(` + ALTER TABLE zammad_session_article_queue + DROP CONSTRAINT IF EXISTS zammad_session_article_queue_content_type_valid, + ADD CONSTRAINT zammad_session_article_queue_content_type_valid CHECK (article_content_type IN ('text/plain', 'text/html')); + `); await query("ALTER TABLE zammad_session_article_queue ADD COLUMN IF NOT EXISTS zammad_article_type_id INTEGER DEFAULT 6;"); - await query("ALTER TABLE zammad_session_article_queue ALTER COLUMN zammad_article_type_id DROP NOT NULL;"); + await query("UPDATE zammad_session_article_queue SET zammad_article_type_id = 6 WHERE zammad_article_type_id IS NULL;"); + await query("ALTER TABLE zammad_session_article_queue ALTER COLUMN zammad_article_type_id SET DEFAULT 6;"); + await query("ALTER TABLE zammad_session_article_queue ALTER COLUMN zammad_article_type_id SET NOT NULL;"); await query(` ALTER TABLE zammad_session_article_queue DROP CONSTRAINT IF EXISTS zammad_session_article_queue_type_id_valid, - ADD CONSTRAINT zammad_session_article_queue_type_id_valid CHECK (zammad_article_type_id IS NULL OR zammad_article_type_id IN (1, 2, 3, 4, 5, 6)); + ADD CONSTRAINT zammad_session_article_queue_type_id_valid CHECK (zammad_article_type_id IN (1, 2, 3, 4, 5, 6)); `); await query(` CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_session_article_queue_pending_session @@ -546,14 +642,18 @@ export async function migrate() { CREATE TABLE IF NOT EXISTS ticket_day_billings ( ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + project_teamspace_entry_id BIGINT, day DATE NOT NULL, billed_minutes INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (ticket_id, user_id, day), CONSTRAINT ticket_day_billings_minutes_non_negative CHECK (billed_minutes >= 0) ); `); + await query("ALTER TABLE ticket_day_billings ADD COLUMN IF NOT EXISTS project_teamspace_entry_id BIGINT;"); + await query("ALTER TABLE ticket_day_billings DROP CONSTRAINT IF EXISTS ticket_day_billings_pkey;"); + await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_ticket_day_billings_ticket_user_day_default ON ticket_day_billings(ticket_id, user_id, day) WHERE project_teamspace_entry_id IS NULL;"); + await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_ticket_day_billings_ticket_user_day_entry ON ticket_day_billings(ticket_id, user_id, day, project_teamspace_entry_id) WHERE project_teamspace_entry_id IS NOT NULL;"); await query(` CREATE TABLE IF NOT EXISTS ticket_day_billing_acknowledgements ( @@ -610,6 +710,7 @@ export async function migrate() { id BIGSERIAL PRIMARY KEY, project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, teamspace_project TEXT, + teamspace_description TEXT NOT NULL DEFAULT '', is_default BOOLEAN NOT NULL DEFAULT false, hour_budget_minutes INTEGER, budget_sync_disabled BOOLEAN NOT NULL DEFAULT false, @@ -622,9 +723,31 @@ export async function migrate() { ); `); + await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS teamspace_description TEXT NOT NULL DEFAULT '';"); await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS budget_sync_disabled BOOLEAN NOT NULL DEFAULT false;"); await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_teamspace_entries_default ON project_teamspace_entries(project_id) WHERE is_default;"); await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_entries_project_order ON project_teamspace_entries(project_id, display_order, id);"); + await query(` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'ticket_day_billings_project_teamspace_entry_fkey' + AND confdeltype <> 'n' + ) THEN + ALTER TABLE ticket_day_billings + DROP CONSTRAINT ticket_day_billings_project_teamspace_entry_fkey; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'ticket_day_billings_project_teamspace_entry_fkey' + ) THEN + ALTER TABLE ticket_day_billings + ADD CONSTRAINT ticket_day_billings_project_teamspace_entry_fkey + FOREIGN KEY (project_teamspace_entry_id) REFERENCES project_teamspace_entries(id) ON DELETE SET NULL; + END IF; + END $$; + `); await query(` CREATE TABLE IF NOT EXISTS hidden_user_projects ( user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -786,6 +909,7 @@ export async function migrate() { await query("CREATE INDEX IF NOT EXISTS idx_sessions_billing_status ON sessions(billing_status);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_closures_day ON ticket_day_closures(day);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_user_day ON ticket_day_billings(user_id, day);"); + await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_teamspace_entry ON ticket_day_billings(project_teamspace_entry_id);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billing_ack_user_day ON ticket_day_billing_acknowledgements(user_id, day);"); await query("CREATE INDEX IF NOT EXISTS idx_projects_user_ticket ON projects(user_id, ticket_id);"); await query("CREATE INDEX IF NOT EXISTS idx_projects_user_updated ON projects(user_id, updated_at DESC);"); diff --git a/backend/src/validation.ts b/backend/src/validation.ts index f0f8857..7e72d45 100644 --- a/backend/src/validation.ts +++ b/backend/src/validation.ts @@ -1,6 +1,7 @@ export const ticketPattern = /^Ticket#\d{6}$/; export const trackableTicketPattern = /^(Ticket#\d{6}|Fix#\d+)$/; export const monthPattern = /^\d{4}-\d{2}$/; +export const weekPattern = /^\d{4}-W\d{2}$/; export const dayPattern = /^\d{4}-\d{2}-\d{2}$/; export const usernamePattern = /^\S{3,40}$/u; @@ -87,6 +88,41 @@ export function parseDay(value: string) { }; } +export function parseWeek(value: string) { + if (!weekPattern.test(value)) { + throw badRequest("week must use YYYY-Www"); + } + + const year = Number(value.slice(0, 4)); + const week = Number(value.slice(6, 8)); + + if (!Number.isInteger(year) || !Number.isInteger(week) || week < 1 || week > 53) { + throw badRequest("week is invalid"); + } + + const jan4 = new Date(Date.UTC(year, 0, 4)); + const jan4Weekday = jan4.getUTCDay() || 7; + const weekStart = new Date(jan4); + weekStart.setUTCDate(jan4.getUTCDate() - jan4Weekday + 1 + (week - 1) * 7); + + const checkThursday = new Date(weekStart); + checkThursday.setUTCDate(weekStart.getUTCDate() + 3); + + if (checkThursday.getUTCFullYear() !== year) { + throw badRequest("week is invalid"); + } + + const next = new Date(weekStart); + next.setUTCDate(weekStart.getUTCDate() + 7); + + return { + label: value, + start: weekStart.toISOString().slice(0, 10), + startIso: weekStart.toISOString(), + endIso: next.toISOString() + }; +} + export function parsePositiveInteger(value: unknown, field: string) { const number = Number(value); diff --git a/compose.postgres.yml b/compose.postgres.yml index e7b534f..33975c9 100644 --- a/compose.postgres.yml +++ b/compose.postgres.yml @@ -8,7 +8,7 @@ services: POSTGRES_USER: ${POSTGRES_USER:-tickettracker} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD muss gesetzt sein} volumes: - - tickettracker_postgres_data:/var/lib/postgresql/data + - ${POSTGRES_DATA_PATH:-tickettracker_postgres_data}:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] interval: 10s diff --git a/docs/user-guide.md b/docs/user-guide.md index d4cc006..ed8f609 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -21,7 +21,7 @@ Es gibt drei zentrale Zeitebenen: - Session-Zeit: Die gemessene oder manuell erfasste Arbeitszeit einer einzelnen Taetigkeit. - Teamspace-Zeit: Die Zeit, die fuer ein Ticket an einem konkreten Tag wirklich im CRM beziehungsweise Teamspace eingetragen wurde. -- Monatsabschluss: Der Abschluss eines ganzen Monats, sobald alle Sessions des Monats bewertet sind. +- Monatsabschluss: Der Abschluss eines ganzen Monats, sobald alle Sessions des Monats final bewertet sind. Wichtig: Tickets werden nicht geschlossen. Tickets dienen nur als Gruppierung fuer Sessions. Abgeschlossen wird ausschliesslich der Monat. @@ -161,7 +161,7 @@ Die oberen Karten zeigen: - Abgerechnet: Summe der Sessions mit Bewertung `Abgerechnet` und, sofern vorhanden, die dazugehoerige Teamspace-Zeit als Vergleichswert - Abrechenbar: Summe der Sessions mit Bewertung `Abrechenbar`, noch ohne Teamspace-Wirkung - Nicht abrechenbar: Summe der Sessions mit Bewertung `Nicht abrechenbar` -- Offen: Anzahl unbewerteter Sessions +- Offen: Anzahl nicht final bewerteter Sessions, inklusive `Abrechenbar` Der Verlauf der abgerechneten Zeit liegt auf der Seite `Statistiken`. Dort wird der Monatsverlauf gemeinsam mit Teamspace und Bewertungsstand betrachtet. @@ -185,8 +185,8 @@ Neben der Ticketnummer gibt es ein Copy-Icon. Ein Klick kopiert die Ticketnummer Der Status bedeutet: -- `offen`: Es gibt noch unbewertete Sessions. -- `bewertet`: Alle Sessions dieses Tickets im gewaehlten Zeitraum sind bewertet. +- `offen`: Es gibt noch nicht final bewertete Sessions oder Sessions mit Status `Abrechenbar`. +- `bewertet`: Alle Sessions dieses Tickets im gewaehlten Zeitraum sind `Abgerechnet` oder `Nicht abrechenbar`. ### Filtern, Gruppieren und Sortieren @@ -326,7 +326,7 @@ Die Sessions sind pro Tag gruppiert. In jeder Tagesgruppe stehen: - `Tag abgerechnet`: Summe der als `Abgerechnet` bewerteten Session-Zeit dieses Tages - `Abrechenbar`: Summe der als `Abrechenbar` bewerteten Session-Zeit dieses Tages - `Nicht`: Summe der als `Nicht abrechenbar` bewerteten Session-Zeit dieses Tages -- `Offen`: Summe der noch unbewerteten Session-Zeit dieses Tages +- `Offen`: Summe der noch nicht final bewerteten Session-Zeit dieses Tages, inklusive `Abrechenbar` - `Teamspace`: Stunden, die fuer dieses Ticket an diesem Tag wirklich im CRM eingetragen wurden Das Teamspace-Feld wird in Stunden eingetragen, zum Beispiel: @@ -354,7 +354,7 @@ Jede Session kann bewertet werden als: Ein erneuter Klick auf den bereits gewaehlten Zustand nimmt die Bewertung zurueck. Dadurch wird die Session wieder offen. -Abrechenbare und nicht abrechenbare Sessions gelten als bewertet. Sie blockieren den Monatsabschluss nicht. `Abrechenbar` fliesst nicht in Teamspace-Statistiken ein; erst `Abgerechnet` zaehlt dort. +`Abgerechnet` und `Nicht abrechenbar` schliessen die Bewertung ab. `Abrechenbar` ist nur ein Zwischenstatus, gilt fuer Abschluss und offene Bewertungen weiterhin als offen und blockiert den Monatsabschluss. In Teamspace-Statistiken zaehlt erst `Abgerechnet`. Es gibt keinen Ticketabschluss. Tickets dienen nur als Gruppierung fuer Sessions. Abgeschlossen wird ausschliesslich der Monat. @@ -393,7 +393,7 @@ Der Monatsabschluss ist eine eigene Unterseite. Ein Monat kann abgeschlossen werden, wenn: -- Alle Sessions im Monat bewertet sind. +- Keine offenen Bewertungen mehr vorhanden sind. Sessions mit Status `Abrechenbar` gelten dabei weiterhin als offen. Einen separaten Tagesabschluss gibt es nicht. Die Tagesansicht ist nur eine gefilterte Kontrolle einzelner Tage. Bewertungen und Teamspace-Tageswerte wirken in Monats- und Tagesansicht auf dieselben Daten, abgeschlossen wird aber ausschliesslich der Monat. @@ -476,9 +476,9 @@ Farben: - Nicht abrechenbar: indigo - Offen: gelb/orange -`Abrechenbar` und `Nicht abrechenbar` zeigen bewertete Sessions, die nicht als abgerechnet gelten. Diese Sessions werden nicht mit Teamspace verglichen. +`Abrechenbar` zeigt Sessions im Zwischenstatus, die noch offen bleiben. `Nicht abrechenbar` zeigt final bewertete Sessions, die nicht als abgerechnet gelten. Beide werden nicht mit Teamspace verglichen. -`Offen` zeigt Sessions ohne Bewertung. Diese blockieren den Monatsabschluss. +`Offen` zeigt Sessions ohne finalen Status, also auch `Abrechenbar`. Diese blockieren den Monatsabschluss. ### Monatsverlauf und Abrechnungsqualitaet @@ -486,7 +486,7 @@ Diese Card kombiniert den Tagesverlauf und die Bewertungsqualitaet. - Balken zeigen abgerechnete Session-Zeit. - Die Linie zeigt Teamspace-Zeit. -- Der Bewertungsstand trennt abgerechnete, abrechenbare, nicht abrechenbare und offene Sessions. +- Der Bewertungsstand trennt abgerechnete, abrechenbare, nicht abrechenbare und offene Sessions. Abrechenbar bleibt dabei zusaetzlich offen. - Der Teamspace-Abgleich zeigt die Gesamtdifferenz. - Manuell und Fix trennen manuell erfasste abrechenbare Sessions von automatisch erzeugten abrechenbaren Sessions. @@ -549,7 +549,7 @@ Die Bewertungsabfrage beim Timerabschluss kann im Profil ein- oder ausgeschaltet Die interne Zammad-Notiz beim Timerabschluss steuert, ob die Checkbox im Abschlussdialog standardmaessig aktiv ist. Ist sie aktiv, legt TicketTracker beim Speichern einen internen Zammad-Article zum Ticket an. Nur dann werden auch der Standard-Typ und die Zeitaufrundung im Profil angezeigt. -Der Standard-Typ belegt den Zammad-Zeitbuchungstyp fuer neue interne Notizen vor. Beim Timerabschluss und beim Nachtragen kann der Typ vor dem Speichern noch geaendert werden. +Der Standard-Typ belegt den Zammad-Zeitbuchungstyp fuer neue interne Notizen vor. Es muss immer eine Kategorie gewaehlt sein; falls noch kein Profilwert gespeichert ist, verwendet TicketTracker `Nicht kategorisiert`. Beim Timerabschluss und beim Nachtragen kann der Typ vor dem Speichern noch geaendert werden. Fuer `time_unit` kann optional eine Aufrundung aktiviert werden. Dann wird die tatsaechlich getrackte Zeit immer auf 5, 10, 15 oder 30 Minuten aufgerundet und als Stundenwert an Zammad gesendet. Ohne Rundung wird der konkrete Stundenwert mit zwei Nachkommastellen gesendet. @@ -597,12 +597,12 @@ Neue Organisationen werden hinzugefuegt, geaenderte Namen aktualisiert und nicht 3. Am Monatsende `Tickets` oeffnen. 4. Monat auswaehlen. 5. Offene Sessions pruefen. -6. Pro Ticket jede Session als `Abrechenbar`, `Abgerechnet` oder `Nicht abrechenbar` markieren. +6. Pro Ticket jede Session final als `Abgerechnet` oder `Nicht abrechenbar` markieren. `Abrechenbar` kann als Zwischenstatus stehen bleiben, blockiert dann aber den Abschluss. 7. Pro Tag und Ticket die wirklich in Teamspace eingetragene Zeit im Feld `Teamspace` pflegen. 8. `Monatsabschluss` oeffnen. 9. Offene Bewertungen abarbeiten. 10. Tage ohne Teamspace-Wert pruefen oder zur Kenntnis nehmen. -11. Wenn keine Sessions im Monat mehr offen sind, Monat abschliessen. +11. Wenn keine Sessions im Monat mehr offen sind und keine Session mehr auf `Abrechenbar` steht, Monat abschliessen. ## Hinweise diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7777260..3f0d661 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -67,10 +67,25 @@ import { import { usePreferencesStore } from "@/stores/preferences/preferences-provider"; import { cn } from "./lib/utils"; -import { ApiError, deleteTodo, getCurrentUser, getRecurringBillings, getSetupStatus, getTodos, logout, lookupTicket, searchTickets, updateTodo } from "./api"; +import { + ApiError, + createActiveTimer, + deleteActiveTimer, + deleteTodo, + getActiveTimers, + getCurrentUser, + getRecurringBillings, + getSetupStatus, + getTodos, + logout, + lookupTicket, + searchTickets, + updateActiveTimer, + updateTodo +} from "./api"; import { currentDay, formatDateTime, formatMinutes, formatTimer } from "./format"; import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "./recurring-billings"; -import { activeElapsedMs, createClientId, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers"; +import { activeElapsedMs, readStoredTimers, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers"; import { formatTodoPeriod, isTodoActiveOnDay, isTodoFixedOnDay, isTodoOverdue } from "./todos"; import { AdminUsersPage } from "./views/AdminUsersPage"; import { AnalysisPage } from "./views/AnalysisPage"; @@ -447,12 +462,12 @@ type SidebarTimersProps = { timers: TimerEntry[]; selectedTimerId: string | null; setSelectedTimerId: Dispatch>; - setTimers: Dispatch>; tick: number; + onToggleTimer: (timerId: string) => void; onOpenTimer: () => void; }; -function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers, tick, onOpenTimer }: SidebarTimersProps) { +function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, tick, onToggleTimer, onOpenTimer }: SidebarTimersProps) { const sortedTimers = useMemo( () => [...timers].sort((first, second) => { @@ -473,17 +488,8 @@ function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers, return; } - const now = Date.now(); setSelectedTimerId(timerId); - setTimers((current) => - current.map((entry) => { - if (entry.id !== timerId) { - return pauseEntry(entry, now); - } - - return timer.phase === "running" ? pauseEntry(entry, now) : resumeEntry(entry, now); - }), - ); + onToggleTimer(timerId); } return ( @@ -783,6 +789,7 @@ export function App() { const [draggedOpenTicketKey, setDraggedOpenTicketKey] = useState(null); const [sidebarTodos, setSidebarTodos] = useState([]); const openTicketsListRef = useRef(null); + const hydratingTimerIds = useRef(new Set()); const navigate = useCallback( (to: string) => { @@ -860,10 +867,48 @@ export function App() { return; } - setTimers(readStoredTimers(currentUser.id)); + let cancelled = false; + const userId = currentUser.id; + setTimersLoaded(false); + setTimers([]); setOpenTickets(readOpenTickets(currentUser.id)); setTimerOwnerId(currentUser.id); - setTimersLoaded(true); + + async function loadTimers() { + try { + let serverTimers = (await getActiveTimers()).timers; + const storedTimers = readStoredTimers(userId); + + if (serverTimers.length === 0 && storedTimers.length > 0) { + for (const timer of storedTimers) { + serverTimers = (await createActiveTimer(timer)).timers; + } + } + + localStorage.removeItem(storageKeyForUser(userId)); + + if (!cancelled) { + setTimers(serverTimers); + } + } catch (error) { + if (!cancelled) { + setTimers(readStoredTimers(userId)); + toast.error("Timer konnten nicht geladen werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } + } finally { + if (!cancelled) { + setTimersLoaded(true); + } + } + } + + void loadTimers(); + + return () => { + cancelled = true; + }; }, [currentUser]); useEffect(() => { @@ -887,8 +932,6 @@ export function App() { return; } - localStorage.setItem(storageKeyForUser(currentUser.id), JSON.stringify(timers)); - if (timers.length === 0) { setSelectedTimerId(null); return; @@ -899,6 +942,31 @@ export function App() { } }, [currentUser, timers, selectedTimerId, timersLoaded, timerOwnerId]); + const refreshActiveTimers = useCallback(async () => { + if (!currentUser || currentUser.role === "admin") { + return; + } + + try { + const result = await getActiveTimers(); + setTimers(result.timers); + } catch { + // Keep the currently displayed timers if the refresh fails. + } + }, [currentUser]); + + useEffect(() => { + if (!currentUser || currentUser.role === "admin" || !timersLoaded || timerOwnerId !== currentUser.id) { + return; + } + + window.addEventListener("focus", refreshActiveTimers); + + return () => { + window.removeEventListener("focus", refreshActiveTimers); + }; + }, [currentUser, refreshActiveTimers, timersLoaded, timerOwnerId]); + const loadSidebarTodos = useCallback(async () => { if (!currentUser || currentUser.role === "admin") { return; @@ -938,60 +1006,77 @@ export function App() { return; } - const timersToHydrate = timers.filter((timer) => !timer.ticketLookupDone); + const timersToHydrate = timers.filter((timer) => !timer.ticketLookupDone && !hydratingTimerIds.current.has(timer.id)); if (timersToHydrate.length === 0) { return; } let cancelled = false; + timersToHydrate.forEach((timer) => hydratingTimerIds.current.add(timer.id)); async function hydrateTimers() { - const results = await Promise.all( - timersToHydrate.map(async (timer) => { - try { - const result = await lookupTicket(timer.ticketNumber); - return { - id: timer.id, - organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null, - workType: result.ticket?.work_type ?? null, - isProject: Boolean(result.ticket?.is_project), - projectName: result.ticket?.project_name ?? null - }; - } catch { - return { - id: timer.id, - organizationName: null, - workType: null, - isProject: false, - projectName: null - }; - } - }) - ); + try { + const results = await Promise.all( + timersToHydrate.map(async (timer) => { + try { + const result = await lookupTicket(timer.ticketNumber); + return { + id: timer.id, + organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null, + workType: result.ticket?.work_type ?? null, + isProject: Boolean(result.ticket?.is_project), + projectName: result.ticket?.project_name ?? null + }; + } catch { + return { + id: timer.id, + organizationName: null, + workType: null, + isProject: false, + projectName: null + }; + } + }) + ); - if (cancelled) { - return; + if (cancelled) { + return; + } + + setTimers((current) => + current.map((timer) => { + const result = results.find((entry) => entry.id === timer.id); + + if (!result) { + return timer; + } + + return { + ...timer, + organizationName: result.organizationName, + workType: result.workType, + isProject: result.isProject, + projectName: result.projectName, + ticketLookupDone: true + }; + }) + ); + + await Promise.all( + results.map((result) => + updateActiveTimer(result.id, { + organizationName: result.organizationName, + workType: result.workType, + isProject: result.isProject, + projectName: result.projectName, + ticketLookupDone: true + }).catch(() => undefined) + ) + ); + } finally { + timersToHydrate.forEach((timer) => hydratingTimerIds.current.delete(timer.id)); } - - setTimers((current) => - current.map((timer) => { - const result = results.find((entry) => entry.id === timer.id); - - if (!result) { - return timer; - } - - return { - ...timer, - organizationName: result.organizationName, - workType: result.workType, - isProject: result.isProject, - projectName: result.projectName, - ticketLookupDone: true - }; - }) - ); } void hydrateTimers(); @@ -1006,6 +1091,25 @@ export function App() { return () => window.clearInterval(interval); }, []); + async function toggleActiveTimer(timerId: string) { + const timer = timers.find((entry) => entry.id === timerId); + + if (!timer) { + return; + } + + try { + const result = await updateActiveTimer(timerId, { + phase: timer.phase === "running" ? "paused" : "running" + }); + setTimers(result.timers); + } catch (error) { + toast.error("Timer konnte nicht aktualisiert werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } + } + useEffect(() => { if (!currentUser || currentUser.role !== "admin") { return; @@ -1026,14 +1130,14 @@ export function App() { return false; } - const ticketNumber = rawTicketNumber.trim(); + const ticketNumber = rawTicketNumber.trim(); - if (!ticketPattern.test(ticketNumber)) { + if (!ticketPattern.test(ticketNumber)) { toast.error("Ticketnummer prüfen", { description: "Das Format muss Ticket#XXXXXX sein." }); - return false; - } + return false; + } const [ticketResult, recurringResult] = await Promise.all([ lookupTicket(ticketNumber).catch(() => ({ ticket: null })), @@ -1041,7 +1145,9 @@ export function App() { ]); if (ticketHasRecurringBilling(ticketResult.ticket, recurringResult.recurringBillings) || isRecurringBillingTicketNumber(ticketNumber, recurringResult.recurringBillings)) { - setTimers((current) => current.filter((timer) => timer.ticketNumber !== ticketNumber)); + const existingRecurringTimer = timers.find((timer) => timer.ticketNumber === ticketNumber); + const result = existingRecurringTimer ? await deleteActiveTimer(existingRecurringTimer.id).catch(() => null) : null; + setTimers((current) => result?.timers ?? current.filter((timer) => timer.ticketNumber !== ticketNumber)); toast.error("Fixe Abrechnung", { description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gestartet werden. Bitte die Definition bearbeiten." }); @@ -1052,38 +1158,49 @@ export function App() { const now = Date.now(); if (existingTimer) { - setTimers((current) => - current.map((timer) => (timer.id === existingTimer.id ? resumeEntry(timer, now) : pauseEntry(timer, now))) - ); - setSelectedTimerId(existingTimer.id); - toast.info("Bestehender Timer aktiviert", { - description: ticketNumber - }); - return true; + try { + const result = await updateActiveTimer(existingTimer.id, { phase: "running" }); + setTimers(result.timers); + setSelectedTimerId(existingTimer.id); + toast.info("Bestehender Timer aktiviert", { + description: ticketNumber + }); + return true; + } catch (error) { + toast.error("Timer konnte nicht aktiviert werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + return false; + } } - const newTimer: TimerEntry = { - id: createClientId("timer"), - ticketNumber, - organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null, - workType: ticketResult.ticket?.work_type ?? null, - isProject: Boolean(ticketResult.ticket?.is_project), - projectName: ticketResult.ticket?.project_name ?? null, - ticketLookupDone: true, - startedAt: now, - pausedTotalMs: 0, - pausedAt: null, - activity: "", - billingStatus: null, - phase: "running" - }; - - setTimers((current) => [...current.map((timer) => pauseEntry(timer, now)), newTimer]); - setSelectedTimerId(newTimer.id); - toast.success("Timer gestartet", { - description: ticketResult.ticket?.is_project ? `${ticketNumber} ist ein Projekt` : ticketNumber - }); - return true; + try { + const result = await createActiveTimer({ + ticketNumber, + organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null, + workType: ticketResult.ticket?.work_type ?? null, + isProject: Boolean(ticketResult.ticket?.is_project), + projectName: ticketResult.ticket?.project_name ?? null, + ticketLookupDone: true, + startedAt: now, + pausedTotalMs: 0, + pausedAt: null, + activity: "", + billingStatus: null, + phase: "running" + }); + setTimers(result.timers); + setSelectedTimerId(result.timers.find((timer) => timer.ticketNumber === ticketNumber)?.id ?? null); + toast.success("Timer gestartet", { + description: ticketResult.ticket?.is_project ? `${ticketNumber} ist ein Projekt` : ticketNumber + }); + return true; + } catch (error) { + toast.error("Timer konnte nicht gestartet werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + return false; + } } async function handleLogout() { @@ -1281,8 +1398,8 @@ export function App() { timers={timers} selectedTimerId={selectedTimerId} setSelectedTimerId={setSelectedTimerId} - setTimers={setTimers} tick={tick} + onToggleTimer={(timerId) => void toggleActiveTimer(timerId)} onOpenTimer={() => navigate("/timer")} /> @@ -1380,7 +1497,7 @@ export function App() { - v1.0.1-rc1 + v1.0.10-rc1 diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 60a61a0..359a715 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -19,11 +19,13 @@ import type { 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; @@ -35,6 +37,22 @@ type CreateSessionPayload = { zammadArticleTypeId?: number; }; +type CreateTimerPayload = Omit & { + 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; @@ -50,7 +68,7 @@ type DeleteSessionResult = { export type CreateSessionResult = { zammadArticle?: { requested: boolean; - status: "not_requested" | "sent" | "queued"; + status: "not_requested" | "sent" | "failed"; error: string | null; articleId: number | null; }; @@ -106,6 +124,25 @@ export type ZammadSyncJob = { finishedAt: string | null; }; +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; @@ -215,6 +252,7 @@ export function updateCurrentUser(payload: { zammadSessionNoteDefaultEnabled?: boolean; zammadTimeUnitRoundingEnabled?: boolean; zammadTimeUnitRoundingMinutes?: number; + zammadAtossEmailSyncEnabled?: boolean; zammadArticleTypeId?: number; }) { return request<{ user: AuthUser }>("/api/auth/me", { @@ -479,6 +517,20 @@ export function applyOwnZammadConsultingTicketsFromPreview(previewJobId: string, }, 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 getRecurringBillings() { return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings"); } @@ -564,6 +616,7 @@ export function createProject(payload: { teamspaceEntries?: Array<{ id?: string | null; teamspaceProject?: string | null; + teamspaceDescription?: string | null; isDefault: boolean; hourBudgetMinutes?: number | null; budgetSyncDisabled?: boolean; @@ -592,6 +645,7 @@ export function updateProject( teamspaceEntries?: Array<{ id?: string | null; teamspaceProject?: string | null; + teamspaceDescription?: string | null; isDefault: boolean; hourBudgetMinutes?: number | null; budgetSyncDisabled?: boolean; @@ -699,6 +753,37 @@ export function createSession(payload: CreateSessionPayload) { }); } +export function resendSessionZammadArticle(sessionId: string, payload: { zammadArticleTypeId?: number } = {}) { + return request(`/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"); } @@ -749,6 +834,7 @@ export function updateSessionDetails( payload: { organizationId: string; activity: string; + zammadArticleBody?: string | null; workType: WorkType; removeProjectData?: boolean; projectTeamspaceEntryId?: string | null; @@ -762,10 +848,10 @@ export function updateSessionDetails( }); } -export function updateTicketDayBilling(ticketId: string, day: string, billedMinutes: number | null) { +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 }) + body: JSON.stringify({ billedMinutes, projectTeamspaceEntryId: projectTeamspaceEntryId ?? null }) }); } @@ -792,8 +878,8 @@ export function getPeriodOverview(type: PeriodType, period: string) { return request(`/api/periods/${periodPath(type)}/${period}/overview`); } -export function getStatisticsOverview(month: string) { - return request(`/api/statistics/months/${month}`); +export function getStatisticsOverview(period: string, type: "month" | "week" = "month") { + return request(`/api/statistics/${type === "week" ? "weeks" : "months"}/${period}`); } export function getTicketPeriod(type: PeriodType, period: string, ticketId: string) { @@ -825,6 +911,18 @@ export function getTicketDetails( return request(`/api/tickets/${ticketId}/details${params.size ? `?${params}` : ""}`); } +export function previewTicketZammadMailSessions(ticketId: string, daysBack: number) { + const params = new URLSearchParams({ daysBack: String(daysBack) }); + return request(`/api/tickets/${ticketId}/zammad-mail-sessions?${params}`); +} + +export function importTicketZammadMailSessions(ticketId: string, daysBack: number, articleIds: number[]) { + return request(`/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", diff --git a/frontend/src/components/RichActivityEditor.tsx b/frontend/src/components/RichActivityEditor.tsx new file mode 100644 index 0000000..ae807e1 --- /dev/null +++ b/frontend/src/components/RichActivityEditor.tsx @@ -0,0 +1,283 @@ +import { Bold, Heading1, Heading2, Heading3, Italic, List, ListOrdered, RemoveFormatting, Strikethrough, Underline } from "lucide-react"; +import { type ClipboardEvent, type KeyboardEvent, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; + +type RichActivityEditorProps = { + id: string; + value: string; + htmlValue: string; + disabled?: boolean; + placeholder?: string; + onChange: (value: string, htmlValue: string) => void; +}; + +function escapeHtml(value: string) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function textToEditorHtml(value: string) { + return value + .split(/\r?\n/) + .map((line) => `
${line ? escapeHtml(line) : "
"}
`) + .join(""); +} + +function fileToDataUrl(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener("load", () => resolve(String(reader.result ?? ""))); + reader.addEventListener("error", () => reject(reader.error ?? new Error("Bild konnte nicht gelesen werden"))); + reader.readAsDataURL(file); + }); +} + +function currentSelectionRange() { + const selection = window.getSelection(); + return selection && selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null; +} + +function restoreSelectionRange(range: Range | null) { + if (!range) { + return; + } + + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); +} + +function editorPlainText(element: HTMLElement) { + return element.innerText.replace(/\u00a0/g, " "); +} + +function hasInlineImage(element: HTMLElement) { + return Boolean(element.querySelector("img[src^='data:image/']")); +} + +function normalizedEditorHtml(element: HTMLElement) { + const html = element.innerHTML; + return html && (editorPlainText(element).trim() || hasInlineImage(element)) ? html : ""; +} + +function editorButtonClass(active = false) { + return cn( + "inline-flex size-8 items-center justify-center rounded-sm text-sm font-semibold text-zinc-100 outline-none transition-colors hover:bg-zinc-700 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", + active && "bg-zinc-700" + ); +} + +export function RichActivityEditor({ + id, + value, + htmlValue, + disabled = false, + placeholder = "Tätigkeit beschreiben, Screenshot mit Strg+V einfügen", + onChange +}: RichActivityEditorProps) { + const editorRef = useRef(null); + const [selectedImage, setSelectedImage] = useState(null); + + useEffect(() => { + const editor = editorRef.current; + + if (!editor) { + return; + } + + const nextHtml = htmlValue || (value ? textToEditorHtml(value) : ""); + + if (editor.innerHTML !== nextHtml) { + editor.innerHTML = nextHtml; + } + }, [htmlValue, value]); + + function clearSelectedImage() { + selectedImage?.classList.remove("ring-2", "ring-ring", "ring-offset-2"); + setSelectedImage(null); + } + + function emitChange() { + const editor = editorRef.current; + + if (!editor) { + return; + } + + onChange(editorPlainText(editor), normalizedEditorHtml(editor)); + } + + function focusEditor() { + editorRef.current?.focus(); + } + + function runCommand(command: string, value?: string) { + if (disabled) { + return; + } + + focusEditor(); + document.execCommand("styleWithCSS", false, "false"); + document.execCommand(command, false, value); + emitChange(); + } + + function selectImage(image: HTMLImageElement) { + clearSelectedImage(); + image.classList.add("ring-2", "ring-ring", "ring-offset-2"); + setSelectedImage(image); + + const range = document.createRange(); + range.selectNode(image); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } + + function removeSelectedImage() { + const image = selectedImage; + + if (!image) { + return; + } + + image.remove(); + setSelectedImage(null); + emitChange(); + } + + function handleKeyDown(event: KeyboardEvent) { + if ((event.key === "Delete" || event.key === "Backspace") && selectedImage) { + event.preventDefault(); + removeSelectedImage(); + return; + } + + if (event.key === "Tab" && !disabled) { + event.preventDefault(); + document.execCommand("insertText", false, " "); + emitChange(); + } + } + + async function handlePaste(event: ClipboardEvent) { + if (disabled) { + return; + } + + const imageFiles = Array.from(event.clipboardData.items) + .filter((item) => item.kind === "file" && item.type.startsWith("image/")) + .map((item) => item.getAsFile()) + .filter((file): file is File => Boolean(file)); + + if (imageFiles.length === 0) { + const text = event.clipboardData.getData("text/plain"); + + if (text) { + event.preventDefault(); + document.execCommand("styleWithCSS", false, "false"); + document.execCommand("insertText", false, text); + emitChange(); + } + + return; + } + + event.preventDefault(); + clearSelectedImage(); + + let insertionRange = currentSelectionRange(); + const text = event.clipboardData.getData("text/plain").trim(); + if (text) { + document.execCommand("styleWithCSS", false, "false"); + document.execCommand("insertText", false, `${text}\n`); + insertionRange = currentSelectionRange(); + } + + try { + for (const file of imageFiles) { + const dataUrl = await fileToDataUrl(file); + restoreSelectionRange(insertionRange); + document.execCommand("insertHTML", false, `
`); + insertionRange = currentSelectionRange(); + } + emitChange(); + } catch (error) { + toast.error("Screenshot konnte nicht eingefügt werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } + } + + const toolbarDisabled = disabled; + + return ( +
+
+ + + + + +
+
{ + clearSelectedImage(); + emitChange(); + }} + onClick={(event) => { + const target = event.target; + + if (target instanceof HTMLImageElement) { + selectImage(target); + return; + } + + clearSelectedImage(); + }} + onKeyDown={handleKeyDown} + onPaste={(event) => void handlePaste(event)} + /> +
+ ); +} diff --git a/frontend/src/format.ts b/frontend/src/format.ts index 3bbb5d7..495725d 100644 --- a/frontend/src/format.ts +++ b/frontend/src/format.ts @@ -53,6 +53,17 @@ export function currentMonth() { return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; } +export function currentWeek() { + const now = new Date(); + const date = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())); + const weekday = date.getUTCDay() || 7; + date.setUTCDate(date.getUTCDate() + 4 - weekday); + const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); + const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7); + + return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`; +} + export function currentDay() { const now = new Date(); return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; diff --git a/frontend/src/help-content.ts b/frontend/src/help-content.ts index 07c1bf4..0814356 100644 --- a/frontend/src/help-content.ts +++ b/frontend/src/help-content.ts @@ -25,7 +25,7 @@ export const helpSections: HelpSection[] = [ "Eine Session ist immer einem Benutzer zugeordnet. In den normalen Auswertungen sieht jeder Benutzer nur seine eigenen Sessions.", "Ticketdaten wie Organisation und Art können aus bestehenden Tickets übernommen werden, auch wenn ein anderer Benutzer das Ticket schon bearbeitet hat.", "Consulting-Projekte können einen oder mehrere Teamspace-Einträge haben. Einer davon ist immer der Standard-Eintrag.", - "Tickets werden nicht abgeschlossen. Sie gruppieren Sessions. Relevant für den Abschluss ist nur, ob alle Sessions eines Monats bewertet sind.", + "Tickets werden nicht abgeschlossen. Sie gruppieren Sessions. Relevant für den Abschluss ist nur, ob alle Sessions eines Monats final bewertet sind.", "Der Monatsabschluss sperrt den Monat gegen versehentliche Änderungen. Muss doch noch etwas korrigiert werden, kann der Monat wieder geöffnet werden." ] }, @@ -47,14 +47,14 @@ export const helpSections: HelpSection[] = [ "Hat ein Consulting-Projekt mehrere Teamspace-Einträge, wählst du beim Speichern der Session den passenden Eintrag aus. Bei genau einem Teamspace-Eintrag ordnet TicketTracker automatisch zu.", "Vergessene Sessions trägst du auf der Seite Session starten im Bereich Session nachtragen nach. Wenn das Ticket bereits in der Ticketliste geöffnet ist, nutzt du in der Ticketdetailansicht ebenfalls Session nachtragen; dann ist die Zuordnung zum Ticket bereits vorbelegt.", "Unter Tickets wählst du Monat oder Tag, öffnest ein Ticket über Öffnen und siehst dort alle Sessions nach Tag gruppiert.", - "Im Ticket bewertest du jede Session mit Abrechenbar, Abgerechnet oder Nicht abrechenbar. Ein erneuter Klick auf den aktiven Status nimmt die Bewertung zurück.", + "Im Ticket setzt du je Session den Status Abrechenbar, Abgerechnet oder Nicht abrechenbar. Abrechenbar bleibt eine offene Bewertung, bis daraus Abgerechnet oder Nicht abrechenbar wird.", "Im Ticket steht pro Tag das Feld Teamspace. Dort trägst du die Stunden ein, die du für genau dieses Ticket an genau diesem Tag wirklich im CRM beziehungsweise Teamspace erfasst hast. Nach einer Änderung erscheint der Speichern-Button.", "Die Teamspace-Zeit ist ein Tageswert pro Ticket und Benutzer. Monats- und Tagesansicht zeigen denselben Wert; er muss also nur einmal gepflegt werden.", "Am Monatsende öffnest du Monatsabschluss. Dort findest du offene Bewertungen gruppiert nach Ticket, Tage ohne Teamspace-Wert und die Schaltfläche Monat abschließen.", "Offene Bewertungen sind Pflicht: Öffne das Ticket aus der Liste, bewerte die Sessions und kehre zum Monatsabschluss zurück.", "Tage ohne Teamspace-Wert sind eine Prüfliste. Wenn die Arbeit wirklich nicht in Teamspace eingetragen werden soll, nutzt du Zur Kenntnis. Mit Zur Kenntnis anzeigen blendest du solche Einträge wieder ein und kannst die Kenntnisnahme zurücknehmen.", "Kontrolliere vor dem Abschluss die Kennzahl Teamspace im Monatsabschluss. Idealerweise entspricht dieser Wert der Summe, die du im CRM beziehungsweise Teamspace für den Monat eingetragen hast.", - "Wenn alle Sessions bewertet sind und die Teamspace-Kontrolle passt, schließt du den Monat mit Monat abschließen. Danach sind Bewertungen und Änderungen für den Monat geschützt, bis der Monat wieder geöffnet wird." + "Wenn keine offenen Bewertungen mehr vorhanden sind und die Teamspace-Kontrolle passt, schließt du den Monat mit Monat abschließen. Danach sind Bewertungen und Änderungen für den Monat geschützt, bis der Monat wieder geöffnet wird." ], workflow: [ "Ticketfeld im Kopfbereich oder Session starten öffnen.", @@ -239,16 +239,16 @@ export const helpSections: HelpSection[] = [ title: "Sessions Bewerten", description: "Bewertungen bestimmen, ob eine Session offen, abrechenbar, abgerechnet oder bewusst nicht abrechenbar ist.", quickItems: [ - "Jede Session braucht genau eine Bewertung.", + "Jede Session braucht vor dem Monatsabschluss einen finalen Status.", "Abrechenbar, Abgerechnet und Nicht abrechenbar können durch erneuten Klick zurückgenommen werden.", "Offene Bewertungen blockieren den Monatsabschluss." ], details: [ - "Abrechenbar bedeutet: Die Session ist bewertet, aber noch nicht als abgerechnet/Teamspace-relevant markiert.", + "Abrechenbar bedeutet: Die Session ist als potenziell abrechenbar markiert, bleibt aber für Abschluss und offene Bewertungen noch offen.", "Abgerechnet bedeutet: Die Session wurde für die Abrechnung beziehungsweise im CRM berücksichtigt.", "Nicht abrechenbar bedeutet: Die Session gehört zum Arbeitsnachweis, wird aber nicht abgerechnet.", "Ein erneuter Klick auf den aktiven Bewertungsbutton entfernt die Bewertung und macht die Session wieder offen.", - "Abrechenbare und nicht abrechenbare Sessions zählen als bewertet. Sie blockieren den Monatsabschluss nicht.", + "Nur Abgerechnet und Nicht abrechenbar schließen die Bewertung ab. Abrechenbar blockiert den Monatsabschluss weiterhin.", "Wenn ein Monat abgeschlossen ist, sind Bewertungen gesperrt. Öffne den Monat zuerst wieder, wenn du etwas korrigieren musst.", "Die offenen Bewertungen findest du gebündelt im Monatsabschluss." ] @@ -345,7 +345,7 @@ export const helpSections: HelpSection[] = [ "Der Monat kann wieder geöffnet werden." ], details: [ - "Ein Monat kann abgeschlossen werden, sobald alle Sessions dieses Monats bewertet sind.", + "Ein Monat kann abgeschlossen werden, sobald keine offenen Bewertungen mehr vorhanden sind.", "Tage ohne Teamspace-Wert blockieren den Abschluss nicht automatisch. Sie sind eine Prüfliste für CRM-Abgleich und Nacharbeit.", "Wenn eine Session bewusst nicht in Teamspace eingetragen wird, kann der fehlende Tageswert zur Kenntnis genommen werden.", "Zur Kenntnis genommene Einträge können über die Checkbox Zur Kenntnis anzeigen wieder eingeblendet werden.", @@ -441,9 +441,9 @@ export const helpSections: HelpSection[] = [ "Die Klärung erfolgt in der Ticketdetailansicht." ], details: [ - "Offen umfasst alle Sessions des Monats ohne Bewertung.", - "Diese Sessions müssen vor dem Monatsabschluss bewertet werden.", - "Die offene Zeit zeigt, wie viele Minuten noch bewertet werden müssen.", + "Offen umfasst alle Sessions des Monats ohne finalen Status sowie Sessions mit dem Zwischenstatus Abrechenbar.", + "Diese Sessions müssen vor dem Monatsabschluss auf Abgerechnet oder Nicht abrechenbar gesetzt werden.", + "Die offene Zeit zeigt, wie viele Minuten noch final bewertet werden müssen.", "Der Monatsabschluss listet offene Bewertungen gruppiert nach Ticket, damit du direkt in die passende Ticketdetailansicht springen kannst.", "Wird eine Bewertung zurückgenommen, steigt die offene Anzahl wieder." ] @@ -540,7 +540,7 @@ export const helpSections: HelpSection[] = [ "Der persönliche Zammad Token ist vom Admin-Token für den Organisations-Sync getrennt. Er wird für ticketbezogene Zammad-Abgleiche, Consulting-Importe und die Zuordnung zum Zammad-Owner verwendet.", "Die Bewertungsabfrage beim Timerabschluss kann im Profil ein- oder ausgeschaltet werden. Sie erscheint nur, wenn der Timer noch auf --- steht.", "Die interne Zammad-Notiz beim Timerabschluss steuert, ob die Checkbox im Abschlussdialog standardmäßig aktiv ist. Nur dann werden Standard-Typ und Zeitaufrundung angezeigt.", - "Der Standard-Typ belegt die Zammad-type_id für interne Notizen vor und kann beim Timerabschluss oder Nachtragen noch geändert werden.", + "Der Standard-Typ belegt die Zammad-Zeitbuchungskategorie für interne Notizen vor. Es gibt keine leere Auswahl; ohne gespeicherten Profilwert wird Nicht kategorisiert verwendet.", "Für die an Zammad gesendete time_unit kann optional eine Aufrundung auf 5, 10, 15 oder 30 Minuten aktiviert werden. Ohne Rundung wird die konkrete Zeit mit zwei Nachkommastellen gesendet.", "Geparkte Zammad-Notizen listet interne Notizen, die wegen eines Zammad-Fehlers noch nicht übertragen wurden. Über Senden werden sie gesammelt nach Zammad gepusht.", "Im Admin-Profil wird kein persönlicher Zammad-Token angezeigt.", diff --git a/frontend/src/payload.json b/frontend/src/payload.json new file mode 100644 index 0000000..25cb07d --- /dev/null +++ b/frontend/src/payload.json @@ -0,0 +1,97 @@ + Datensatz aus api/v1/ticket_articles/ + + { + "id": 2, + "ticket_id": 2, + "type_id": 1, + "sender_id": 1, + "from": "", + "to": "", + "cc": null, + "subject": "Test Bestellung", + "reply_to": null, + "message_id": "", + "message_id_md5": "", + "in_reply_to": null, + "content_type": "text/html", + "body": "", + "internal": false, + "preferences": { + "send-auto-response": true, + "is-auto-response": false + }, + "updated_by_id": 3, + "created_by_id": 3, + "origin_by_id": null, + "created_at": "2023-01-06T11:17:03.919Z", + "updated_at": "2023-01-06T11:17:03.919Z", + "detected_language": null, + "ai_stored_result_ids": [], + "attachments": [ + { + "id": 3, + "store_file_id": 3, + "filename": "message.html", + "size": "5733", + "preferences": { + "content-alternative": true, + "original-format": true, + "Mime-Type": "text/html", + "Charset": "utf-8" + } + } + ], + "body_rendering_error": false + } + + + Datensatz aus api/v1/ticket_articles/{id]} +{ + "id": 2, + "ticket_id": 2, + "type_id": 1, + "sender_id": 1, + "from": "", + "to": "", + "cc": null, + "subject": "", + "reply_to": null, + "message_id": "", + "message_id_md5": "a9cc698b80f75fe08ca6d2dc6dec69ae", + "in_reply_to": null, + "content_type": "text/html", + "body": "", + "internal": false, + "preferences": { + "send-auto-response": true, + "is-auto-response": false + }, + "updated_by_id": 3, + "created_by_id": 3, + "origin_by_id": null, + "created_at": "2023-01-06T11:17:03.919Z", + "updated_at": "2023-01-06T11:17:03.919Z", + "detected_language": null, + "ai_stored_result_ids": [], + "attachments": [ + { + "id": 3, + "store_file_id": 3, + "filename": "message.html", + "size": "5733", + "preferences": { + "content-alternative": true, + "original-format": true, + "Mime-Type": "text/html", + "Charset": "utf-8" + } + } + ], + "body_rendering_error": false, + "created_by": "", + "updated_by": "", + "type": "email", + "sender": "Agent", + "ai_stored_results": [], + "time_unit": null +} \ No newline at end of file diff --git a/frontend/src/timers.ts b/frontend/src/timers.ts index 3e1d759..159eab1 100644 --- a/frontend/src/timers.ts +++ b/frontend/src/timers.ts @@ -11,6 +11,7 @@ export type TimerEntry = { projectName?: string | null; ticketLookupDone?: boolean; activity: string; + zammadArticleBody?: string; billingStatus: BillingStatus; startedAt: number; pausedTotalMs: number; @@ -66,6 +67,7 @@ export function normalizeTimers(timers: TimerEntry[]) { const normalizedTimer = { ...timer, activity: typeof timer.activity === "string" ? timer.activity : "", + zammadArticleBody: typeof timer.zammadArticleBody === "string" ? timer.zammadArticleBody : "", billingStatus: timer.billingStatus === "billed" ? "billable" : timer.billingStatus === "billable" || timer.billingStatus === "non_billable" ? timer.billingStatus : null, }; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 7ac8f35..42ac01f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -17,6 +17,7 @@ export type AuthUser = { zammad_session_note_default_enabled: boolean; zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_minutes: number; + zammad_atoss_email_sync_enabled: boolean; zammad_article_type_id: number; }; @@ -76,7 +77,7 @@ export type ZammadSessionArticleQueueItem = { zammad_ticket_id: string | null; activity: string; time_unit: number; - zammad_article_type_id: number | null; + zammad_article_type_id: number; attempt_count: number; last_error: string | null; last_attempt_at: string | null; @@ -210,6 +211,7 @@ export type ProjectTeamspaceEntry = { id: string; project_id?: string; teamspace_project: string | null; + teamspace_description?: string | null; is_default: boolean; hour_budget_minutes: number | null; budget_sync_disabled?: boolean; @@ -313,6 +315,7 @@ export type PeriodOverview = { export type TicketDayBilling = { day: string; + project_teamspace_entry_id?: string | null; billed_minutes: number; }; @@ -327,6 +330,7 @@ export type SessionEntry = { organization_name: string | null; customer_name: string; activity: string; + zammad_article_body?: string | null; work_type: WorkType; project_teamspace_entry_id?: string | null; project_teamspace_project?: string | null; @@ -340,9 +344,10 @@ export type SessionEntry = { recurring_billing_id: string | null; recurring_billing_slot_id: string | null; recurring_occurrence_date: string | null; + zammad_source_article_id?: string | null; zammad_article_status?: "pending" | "sent" | null; zammad_article_id?: string | null; - zammad_article_type_id?: number | null; + zammad_article_type_id?: number; }; export type TicketPeriod = { @@ -473,7 +478,7 @@ export type StatisticsTeamspaceDay = { }; export type StatisticsOverview = { - periodType: "month"; + periodType: "month" | "week"; period: string; closed: boolean; closedAt: string | null; diff --git a/frontend/src/views/DashboardPage.tsx b/frontend/src/views/DashboardPage.tsx index e940427..764a988 100644 --- a/frontend/src/views/DashboardPage.tsx +++ b/frontend/src/views/DashboardPage.tsx @@ -1632,7 +1632,7 @@ function WidgetContent(props: WidgetContentProps) { /> ) : null} ({ key: session.id, title: session.ticket_number, @@ -1722,9 +1722,9 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP const checklistItems = [ { key: "ratings", - label: "Sessions bewertet", + label: "Bewertungen final", done: todayOpenRatings.length === 0, - action: todayOpenRatings.length === 0 ? "Keine offenen Bewertungen." : `${todayOpenRatings.length} heutige Bewertung(en) setzen.` + action: todayOpenRatings.length === 0 ? "Keine offenen Bewertungen." : `${todayOpenRatings.length} heutige Bewertung(en) finalisieren.` }, { key: "todos", @@ -1831,7 +1831,7 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP items={afterworkTickets.slice(0, 4).map((ticket) => ({ key: `today-ticket-${ticket.id}`, title: ticket.title || ticket.ticket_number, - detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.billable_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`, + detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`, note: dayControlTodaySessionReason(ticket), onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`) }))} diff --git a/frontend/src/views/MonthlyClosePage.tsx b/frontend/src/views/MonthlyClosePage.tsx index dfc6901..05dc381 100644 --- a/frontend/src/views/MonthlyClosePage.tsx +++ b/frontend/src/views/MonthlyClosePage.tsx @@ -22,7 +22,7 @@ import { Input } from "@/components/ui/input"; import { CopyTicketButton } from "@/components/CopyTicketButton"; import { HelpLink } from "@/components/HelpLink"; import { acknowledgeMissingTicketDayBilling, closePeriod, getStatisticsOverview, reopenPeriod } from "../api"; -import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format"; +import { currentMonth, currentWeek, formatDate, formatDateTime, formatMinutes } from "../format"; import type { StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview, StatisticsTeamspaceDay } from "../types"; type MonthlyClosePageProps = { @@ -32,6 +32,17 @@ type MonthlyClosePageProps = { const trackedTextClass = "text-sky-700 dark:text-sky-300"; const teamspaceTextClass = "text-emerald-700 dark:text-emerald-300"; const showAcknowledgedMissingCrmStorageKey = "tickettracker.monthlyClose.showAcknowledgedMissingCrm"; +const monthlyCloseViewModeStorageKey = "tickettracker.monthlyClose.viewMode"; + +type MonthlyCloseViewMode = "month" | "week"; + +function readMonthlyCloseViewMode(): MonthlyCloseViewMode { + if (typeof window === "undefined") { + return "month"; + } + + return window.localStorage.getItem(monthlyCloseViewModeStorageKey) === "week" ? "week" : "month"; +} function formatSignedMinutes(minutes: number) { if (minutes === 0) { @@ -63,6 +74,7 @@ type OpenTicketGroup = { ticketId: string; ticketNumber: string; organizationName: string; + month: string; openCount: number; totalMinutes: number; }; @@ -83,6 +95,7 @@ function groupOpenSessions(sessions: StatisticsOpenSession[]) { ticketId: session.ticket_id, ticketNumber: session.ticket_number, organizationName: session.organization_name, + month: session.started_at.slice(0, 7), openCount: 1, totalMinutes: session.rounded_minutes }); @@ -91,7 +104,7 @@ function groupOpenSessions(sessions: StatisticsOpenSession[]) { return Array.from(groups.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" })); } -function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup; month: string; onNavigate: (to: string) => void }) { +function OpenTicketItem({ group, onNavigate }: { group: OpenTicketGroup; onNavigate: (to: string) => void }) { return (
@@ -104,7 +117,7 @@ function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup;

{group.organizationName}

{formatMinutes(group.totalMinutes)} offen zu bewerten

- @@ -185,7 +198,9 @@ function TeamspaceDayItem({ item, onNavigate }: { item: StatisticsTeamspaceDay; } export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { + const [viewMode, setViewMode] = useState(() => readMonthlyCloseViewMode()); const [month, setMonth] = useState(currentMonth()); + const [week, setWeek] = useState(currentWeek()); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(false); const [closing, setClosing] = useState(false); @@ -194,6 +209,11 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { const [showAcknowledgedMissingCrm, setShowAcknowledgedMissingCrm] = useState(false); const [showAcknowledgedMissingCrmLoaded, setShowAcknowledgedMissingCrmLoaded] = useState(false); const loadRequestId = useRef(0); + const period = viewMode === "month" ? month : week; + const periodLabel = viewMode === "month" ? "Monat" : "Woche"; + const periodAttentionLabel = viewMode === "month" ? "diesen Monat" : "diese Woche"; + const periodInLabel = viewMode === "month" ? "diesem Monat" : "dieser Woche"; + const isMonthView = viewMode === "month"; async function load() { const requestId = loadRequestId.current + 1; @@ -201,14 +221,14 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { setLoading(true); try { - const result = await getStatisticsOverview(month); + const result = await getStatisticsOverview(period, viewMode); if (requestId === loadRequestId.current) { setStats(result); } } catch (error) { if (requestId === loadRequestId.current) { - toast.error("Monatsabschluss konnte nicht geladen werden", { + toast.error("Abschlussansicht konnte nicht geladen werden", { description: error instanceof Error ? error.message : "Unbekannter Fehler" }); } @@ -221,7 +241,11 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { useEffect(() => { void load(); - }, [month]); + }, [period, viewMode]); + + useEffect(() => { + window.localStorage.setItem(monthlyCloseViewModeStorageKey, viewMode); + }, [viewMode]); useEffect(() => { setShowAcknowledgedMissingCrm(window.localStorage.getItem(showAcknowledgedMissingCrmStorageKey) === "true"); @@ -241,6 +265,22 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { setMonth(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`); } + function changeWeekBy(delta: number) { + const match = /^(\d{4})-W(\d{2})$/.exec(week); + const year = match ? Number(match[1]) : new Date().getFullYear(); + const weekNumber = match ? Number(match[2]) : 1; + const jan4 = new Date(Date.UTC(year, 0, 4)); + const jan4Weekday = jan4.getUTCDay() || 7; + const date = new Date(jan4); + date.setUTCDate(jan4.getUTCDate() - jan4Weekday + 1 + (weekNumber - 1 + delta) * 7); + const weekday = date.getUTCDay() || 7; + date.setUTCDate(date.getUTCDate() + 4 - weekday); + const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); + const nextWeek = Math.ceil(((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7); + + setWeek(`${date.getUTCFullYear()}-W${String(nextWeek).padStart(2, "0")}`); + } + async function finishMonth() { setClosing(true); try { @@ -308,14 +348,14 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { const days = showAcknowledgedMissingCrm ? [...missingCrmDays, ...acknowledgedMissingCrmDays] : [...missingCrmDays]; return days.sort((left, right) => left.day.localeCompare(right.day) || left.ticket_number.localeCompare(right.ticket_number, "de", { numeric: true, sensitivity: "base" })); }, [acknowledgedMissingCrmDays, missingCrmDays, showAcknowledgedMissingCrm]); - const canClose = Boolean(stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0); + const canClose = Boolean(isMonthView && stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0); const checklist = useMemo( () => [ { label: "Offene Bewertungen", ok: openSessions.length === 0, value: openSessions.length, - detail: openSessions.length === 0 ? "Alle Sessions sind bewertet." : `${openTicketGroups.length} Ticket(s) bearbeiten` + detail: openSessions.length === 0 ? "Keine offenen Bewertungen." : `${openTicketGroups.length} Ticket(s) bearbeiten` }, { label: "Teamspace geprüft", @@ -324,13 +364,13 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { detail: missingCrmDays.length === 0 ? "Keine offenen Teamspace-Prüfpunkte." : `${acknowledgedMissingCrmDays.length} zur Kenntnis genommen` }, { - label: "Monat abschließbar", - ok: Boolean(stats?.closed || canClose), - value: stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen", - detail: stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen." + label: isMonthView ? "Monat abschließbar" : "Wochenansicht", + ok: isMonthView ? Boolean(stats?.closed || canClose) : true, + value: isMonthView ? (stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen") : "Ansicht", + detail: isMonthView ? (stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen.") : "Abschluss bleibt monatlich." } ], - [acknowledgedMissingCrmDays.length, canClose, openSessions.length, openTicketGroups.length, missingCrmDays.length, stats?.closed] + [acknowledgedMissingCrmDays.length, canClose, isMonthView, openSessions.length, openTicketGroups.length, missingCrmDays.length, stats?.closed] ); return ( @@ -338,25 +378,46 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {

Monatsabschluss

-

Offene Bewertungen, Teamspace-Abgleich und der eigentliche Monatsabschluss.

+

Offene Bewertungen und Teamspace-Abgleich für {periodAttentionLabel}; der Abschluss selbst bleibt monatlich.

-
+
+
+ Ansicht +
+ + +
+
- + {loading ? lädt... : null}
- - setMonth(event.currentTarget.value)} /> -
- {stats?.closed ? ( + {!isMonthView ? ( + + Abschluss nur monatlich + + ) : stats?.closed ? (
- {stats?.closed ? ( + {isMonthView && stats?.closed ? ( Dieser Monat wurde am {formatDateTime(stats.closedAt!)} abgeschlossen. @@ -382,7 +443,12 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { 0 ? "warn" : "ok"} /> 0 ? "warn" : "ok"} /> - +
@@ -413,7 +479,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
Aufräumen - Alles, was für diesen Monat noch Aufmerksamkeit braucht. + Alles, was für {periodAttentionLabel} noch Aufmerksamkeit braucht.
@@ -435,7 +501,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { Offene Bewertungen
{openTicketGroups.map((group) => ( - + ))} {openTicketGroups.length === 0 ?

Keine offenen Bewertungen.

: null} @@ -479,7 +545,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { ))}
) : ( -

Für diesen Monat sind noch keine Teamspace-Werte zu abgerechneten Ticket-Tagen vorhanden.

+

Für {periodAttentionLabel} sind noch keine Teamspace-Werte zu abgerechneten Ticket-Tagen vorhanden.

)} @@ -490,8 +556,8 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
-

Keine Sessions in diesem Monat

-

Ein Monatsabschluss ist erst sinnvoll, wenn Sessions vorhanden sind.

+

Keine Sessions in {periodInLabel}

+

Die Kontrollansicht ist erst sinnvoll, wenn Sessions vorhanden sind.

diff --git a/frontend/src/views/ProfilePage.tsx b/frontend/src/views/ProfilePage.tsx index ad8d516..a053db2 100644 --- a/frontend/src/views/ProfilePage.tsx +++ b/frontend/src/views/ProfilePage.tsx @@ -25,6 +25,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { const [zammadApiToken, setZammadApiToken] = useState(""); const [zammadTokenDirty, setZammadTokenDirty] = useState(false); const [zammadEmailSessionWebhookEnabled, setZammadEmailSessionWebhookEnabled] = useState(currentUser.zammad_email_session_webhook_enabled); + const [zammadAtossEmailSyncEnabled, setZammadAtossEmailSyncEnabled] = useState(currentUser.zammad_atoss_email_sync_enabled); const [sessionBillingPromptEnabled, setSessionBillingPromptEnabled] = useState(currentUser.session_billing_prompt_enabled); const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled); const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled); @@ -41,6 +42,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { setZammadApiToken(""); setZammadTokenDirty(false); setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled); + setZammadAtossEmailSyncEnabled(currentUser.zammad_atoss_email_sync_enabled); setSessionBillingPromptEnabled(currentUser.session_billing_prompt_enabled); setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled); setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled); @@ -125,6 +127,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { ...(currentUser.role === "user" ? { zammadEmailSessionWebhookEnabled, + zammadAtossEmailSyncEnabled, sessionBillingPromptEnabled, zammadSessionNoteDefaultEnabled, zammadTimeUnitRoundingEnabled, @@ -299,6 +302,22 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { onCheckedChange={setZammadEmailSessionWebhookEnabled} />
+
+
+ +
+ +

Wenn aus, werden eMails an support.atc@atoss.com nicht übernommen.

+
+
+ +
diff --git a/frontend/src/views/ProjectsPage.tsx b/frontend/src/views/ProjectsPage.tsx index a3df0ee..7db8a90 100644 --- a/frontend/src/views/ProjectsPage.tsx +++ b/frontend/src/views/ProjectsPage.tsx @@ -27,6 +27,8 @@ import { zammadPriorityFilterOptions, zammadPriorityLabel, zammadPrioritySortRank, + zammadId, + zammadStateId, ZammadStateBadge, zammadStateFilterOptions, zammadStateLabel, @@ -60,6 +62,7 @@ type ProjectTeamspaceEntryForm = { type ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "consumption" | "consumptionPercent" | "remaining" | "budget"; type ProjectSortDirection = "asc" | "desc"; +type ProjectGroupBy = "none" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "budgetState" | "teamspace"; type ProjectFilterOption = MultiFilterOption; type ProjectViewSettings = { @@ -67,6 +70,8 @@ type ProjectViewSettings = { filterValues: string[]; organizationValues: string[]; standardTeamspaceOnly: boolean; + groupBy: ProjectGroupBy; + groupDirection: ProjectSortDirection; sortBy: ProjectSortBy; sortDirection: ProjectSortDirection; }; @@ -78,6 +83,8 @@ const defaultProjectViewSettings: ProjectViewSettings = { filterValues: ["open"], organizationValues: [], standardTeamspaceOnly: false, + groupBy: "none", + groupDirection: "asc", sortBy: "updated", sortDirection: "desc" }; @@ -97,6 +104,7 @@ const projectStaticFilterOptions: ProjectFilterOption[] = [ const projectStaticFilterValues = new Set(projectStaticFilterOptions.map((option) => option.value)); const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "zammadStatus", "zammadPriority", "consumption", "consumptionPercent", "remaining", "budget"]; +const projectGroupValues: ProjectGroupBy[] = ["none", "organization", "status", "zammadStatus", "zammadPriority", "budgetState", "teamspace"]; const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [ { value: "updated", label: "Aktualisierung" }, @@ -112,6 +120,16 @@ const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [ { value: "budget", label: "Kontingent" } ]; +const projectGroupOptions: Array<{ value: ProjectGroupBy; label: string }> = [ + { value: "none", label: "Keine Gruppierung" }, + { value: "organization", label: "Organisation" }, + { value: "status", label: "TT-Status" }, + { value: "zammadStatus", label: "Zammad-Status" }, + { value: "zammadPriority", label: "Zammad-Prio" }, + { value: "budgetState", label: "Kontingentstatus" }, + { value: "teamspace", label: "Standard-Teamspace" } +]; + function projectEntryId() { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); @@ -262,6 +280,8 @@ function readStoredProjectViewSettings(): ProjectViewSettings { filterValues, organizationValues: legacyProjectOrganizationValues(parsed as Partial & Record), standardTeamspaceOnly: legacyStandardTeamspaceOnly(parsed as Partial & Record), + groupBy: projectGroupValues.find((value) => value === parsed.groupBy) ?? defaultProjectViewSettings.groupBy, + groupDirection: parsed.groupDirection === "desc" ? "desc" : defaultProjectViewSettings.groupDirection, sortBy: projectSortValues.find((value) => value === parsed.sortBy) ?? defaultProjectViewSettings.sortBy, sortDirection: parsed.sortDirection === "asc" ? "asc" : defaultProjectViewSettings.sortDirection }; @@ -579,6 +599,117 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr return comparison * factor || compareText(left.ticket_number, right.ticket_number); } +type ProjectGroupMeta = { + key: string; + label: string; + sortLabel: string; + rank: number; +}; + +type ProjectGroup = ProjectGroupMeta & { + projects: ProjectSummary[]; + consumptionMinutes: number; + remainingMinutes: number; +}; + +function projectBudgetStateGroup(project: ProjectSummary, standardTeamspaceOnly: boolean): ProjectGroupMeta { + const budgetMinutes = projectBudget(project, standardTeamspaceOnly) ?? 0; + const remaining = projectRemaining(project, standardTeamspaceOnly); + + if (budgetMinutes <= 0) { + return { key: "budgetState:none", label: "Ohne Kontingent", sortLabel: "Ohne Kontingent", rank: 40 }; + } + + if (remaining !== null && remaining < 0) { + return { key: "budgetState:over", label: "Überzogen", sortLabel: "Überzogen", rank: 10 }; + } + + if (remaining === 0) { + return { key: "budgetState:used", label: "Aufgebraucht", sortLabel: "Aufgebraucht", rank: 30 }; + } + + return { key: "budgetState:remaining", label: "Mit Rest", sortLabel: "Mit Rest", rank: 20 }; +} + +function projectGroupMeta(project: ProjectSummary, groupBy: ProjectGroupBy, standardTeamspaceOnly: boolean): ProjectGroupMeta { + if (groupBy === "organization") { + const label = projectOrganizationLabel(project); + return { key: `organization:${projectOrganizationKey(project)}`, label, sortLabel: label, rank: 0 }; + } + + if (groupBy === "status") { + return project.closed_at + ? { key: "status:closed", label: "Geschlossen", sortLabel: "Geschlossen", rank: 20 } + : { key: "status:open", label: "Offen", sortLabel: "Offen", rank: 10 }; + } + + if (groupBy === "zammadStatus") { + const id = zammadStateId(project.zammad_state_id); + const label = id ? zammadStateLabel(id) : "Ohne Zammad-Status"; + + return { key: `zammadStatus:${id ?? "none"}`, label, sortLabel: label, rank: zammadStateSortRank(id) }; + } + + if (groupBy === "zammadPriority") { + const id = zammadId(project.zammad_priority_id); + const label = id ? zammadPriorityLabel(id) : "Ohne Zammad-Prio"; + + return { key: `zammadPriority:${id ?? "none"}`, label, sortLabel: label, rank: zammadPrioritySortRank(id) }; + } + + if (groupBy === "budgetState") { + return projectBudgetStateGroup(project, standardTeamspaceOnly); + } + + if (groupBy === "teamspace") { + const label = defaultProjectTeamspaceEntry(project)?.teamspace_project || "Ohne Teamspace-ID"; + + return { key: `teamspace:${label.toLocaleLowerCase("de-DE")}`, label, sortLabel: label, rank: 0 }; + } + + return { key: "all", label: "Alle Projekte", sortLabel: "Alle Projekte", rank: 0 }; +} + +function groupProjects(projects: ProjectSummary[], groupBy: ProjectGroupBy, groupDirection: ProjectSortDirection, standardTeamspaceOnly: boolean): ProjectGroup[] { + if (groupBy === "none") { + return [ + { + key: "all", + label: "Alle Projekte", + sortLabel: "Alle Projekte", + rank: 0, + projects, + consumptionMinutes: projects.reduce((sum, project) => sum + projectConsumption(project, standardTeamspaceOnly), 0), + remainingMinutes: projects.reduce((sum, project) => sum + Math.max(projectRemaining(project, standardTeamspaceOnly) ?? 0, 0), 0) + } + ]; + } + + const factor = groupDirection === "asc" ? 1 : -1; + + const groups = new Map(); + + for (const project of projects) { + const meta = projectGroupMeta(project, groupBy, standardTeamspaceOnly); + const group = groups.get(meta.key); + + if (group) { + group.projects.push(project); + group.consumptionMinutes += projectConsumption(project, standardTeamspaceOnly); + group.remainingMinutes += Math.max(projectRemaining(project, standardTeamspaceOnly) ?? 0, 0); + } else { + groups.set(meta.key, { + ...meta, + projects: [project], + consumptionMinutes: projectConsumption(project, standardTeamspaceOnly), + remainingMinutes: Math.max(projectRemaining(project, standardTeamspaceOnly) ?? 0, 0) + }); + } + } + + return Array.from(groups.values()).sort((left, right) => ((left.rank - right.rank) || compareText(left.sortLabel, right.sortLabel)) * factor); +} + function formFromProject(project: ProjectSummary): ProjectForm { const entries = projectTeamspaceEntries(project).map((entry, index) => ({ clientId: entry.id || projectEntryId(), @@ -967,12 +1098,18 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) { () => filteredProjects.reduce((sum, project) => sum + Math.max(projectRemaining(project, viewSettings.standardTeamspaceOnly) ?? 0, 0), 0), [filteredProjects, viewSettings.standardTeamspaceOnly] ); + const projectGroups = useMemo( + () => groupProjects(filteredProjects, viewSettings.groupBy, viewSettings.groupDirection, viewSettings.standardTeamspaceOnly), + [filteredProjects, viewSettings.groupBy, viewSettings.groupDirection, viewSettings.standardTeamspaceOnly] + ); const closedProjectsLoaded = projectShouldLoadClosed(viewSettings.filterValues); const activeFilterCount = [ viewSettings.query.trim() !== "", JSON.stringify(viewSettings.filterValues) !== JSON.stringify(defaultProjectViewSettings.filterValues), viewSettings.organizationValues.length > 0, viewSettings.standardTeamspaceOnly, + viewSettings.groupBy !== defaultProjectViewSettings.groupBy, + viewSettings.groupDirection !== defaultProjectViewSettings.groupDirection, viewSettings.sortBy !== defaultProjectViewSettings.sortBy, viewSettings.sortDirection !== defaultProjectViewSettings.sortDirection ].filter(Boolean).length; @@ -1286,7 +1423,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
- +
+
+ + +
+
+ +
+ + +
+
- +
- {filteredProjects.map((project) => { - const isEditing = editingProjectId === project.id; - const budgetedTeamspaceEntries = budgetedProjectTeamspaceEntries(project); - const consumptionMinutes = projectConsumption(project, viewSettings.standardTeamspaceOnly); - const budgetMinutes = projectBudget(project, viewSettings.standardTeamspaceOnly); - const remaining = projectRemaining(project, viewSettings.standardTeamspaceOnly); - const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly); - const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1; - const canEditProject = project.can_edit !== false; + {projectGroups.map((group) => ( +
+ {viewSettings.groupBy !== "none" ? ( +
+

{group.label}

+

+ {group.projects.length} Projekt(e) · {formatMinutes(group.consumptionMinutes)} Verbrauch · {formatMinutes(group.remainingMinutes)} Rest offen +

+
+ ) : null} + {group.projects.map((project) => { + const isEditing = editingProjectId === project.id; + const budgetedTeamspaceEntries = budgetedProjectTeamspaceEntries(project); + const consumptionMinutes = projectConsumption(project, viewSettings.standardTeamspaceOnly); + const budgetMinutes = projectBudget(project, viewSettings.standardTeamspaceOnly); + const remaining = projectRemaining(project, viewSettings.standardTeamspaceOnly); + const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly); + const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1; + const canEditProject = project.can_edit !== false; - return ( -
+ return ( +
- ); - })} + ); + })} +
+ ))} {!loading && filteredProjects.length === 0 ? ( diff --git a/frontend/src/views/StatisticsPage.tsx b/frontend/src/views/StatisticsPage.tsx index b8d201b..3a6a0d3 100644 --- a/frontend/src/views/StatisticsPage.tsx +++ b/frontend/src/views/StatisticsPage.tsx @@ -430,7 +430,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) { const nonBillableMinutes = totals?.nonBillableMinutes ?? 0; const openSessions = totals?.openSessions ?? 0; const openMinutes = totals?.openMinutes ?? 0; - const evaluatedSessions = billedSessions + billableSessions + nonBillableSessions; + const evaluatedSessions = billedSessions + nonBillableSessions; const crmCoverage = percentage(totals?.crmBilledMinutes ?? 0, totals?.minutes ?? 0); const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1); const topOrganization = stats?.organizations[0] ?? null; diff --git a/frontend/src/views/TicketDetailPage.tsx b/frontend/src/views/TicketDetailPage.tsx index 9650d22..cc42f2d 100644 --- a/frontend/src/views/TicketDetailPage.tsx +++ b/frontend/src/views/TicketDetailPage.tsx @@ -1,7 +1,7 @@ -import { ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Copy, EyeOff, FolderKanban, Pencil, Play, Plus, RotateCcw, Save, Send, Star, Trash2 } from "lucide-react"; -import { FormEvent, useEffect, useMemo, useState } from "react"; +import { AlertCircle, ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Copy, EyeOff, FolderKanban, MailCheck, MailPlus, Pencil, Play, Plus, RotateCcw, Save, Send, Star, Trash2 } from "lucide-react"; +import { type ClipboardEvent, FormEvent, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { Alert } from "@/components/ui/alert"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -12,6 +12,7 @@ import { Textarea } from "@/components/ui/textarea"; import { CopyTicketButton } from "@/components/CopyTicketButton"; import { HelpLink } from "@/components/HelpLink"; import { OrganizationSelect } from "@/components/OrganizationSelect"; +import { RichActivityEditor } from "@/components/RichActivityEditor"; import { TodoPanel } from "@/components/TodoPanel"; import { createSession, @@ -22,7 +23,10 @@ import { getDashboardPreference, getTicketDetails, getTicketPeriod, + importTicketZammadMailSessions, + previewTicketZammadMailSessions, removeFavoriteTicket, + resendSessionZammadArticle, updateSessionBilling, updateSessionDetails, updateTicket, @@ -30,12 +34,13 @@ import { updateProject, updateProjectStatus } from "../api"; -import type { CreateSessionResult } from "../api"; +import type { CreateSessionResult, TicketZammadMailSessionItem, TicketZammadMailSessionResult } from "../api"; import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format"; import { ticketHasRecurringBilling } from "../recurring-billings"; import type { AuthUser, BillingStatus, PeriodType, ProjectSummary, ProjectTeamspaceEntry, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types"; import { ZammadPriorityBadge, ZammadStateBadge } from "../zammad-meta"; import { zammadArticleTypeFromValue, zammadArticleTypeLabel, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types"; +import { getLocalStorageValue } from "@/lib/local-storage.client"; import { cn } from "@/lib/utils"; type TicketDetailPageProps = { @@ -63,6 +68,7 @@ type TicketSessionFormState = { organizationName: string | null; projectTeamspaceEntryId: string; activity: string; + zammadArticleBody: string; billingStatus: BillingStatus; createZammadArticle: boolean; zammadArticleTypeId: ZammadArticleTypeId; @@ -85,6 +91,7 @@ type ProjectEditFormState = { type ProjectTeamspaceEntryForm = { clientId: string; teamspaceProject: string; + teamspaceDescription: string; hourBudget: string; budgetSyncDisabled: boolean; manualConsumption: string; @@ -97,6 +104,7 @@ function emptyTicketSessionForm(): TicketSessionFormState { organizationName: null, projectTeamspaceEntryId: "", activity: "", + zammadArticleBody: "", billingStatus: null, createZammadArticle: false, zammadArticleTypeId: 6, @@ -113,13 +121,37 @@ function notifyZammadArticleResult(result: CreateSessionResult) { return; } - if (result.zammadArticle?.status === "queued") { - toast.warning("Zammad-Notiz geparkt", { - description: result.zammadArticle.error ?? "Die Session wurde gespeichert. Die Notiz kann im Profil später erneut gesendet werden." + if (result.zammadArticle?.status === "failed") { + toast.error("Zammad-Notiz nicht übertragen", { + description: result.zammadArticle.error ?? "Die Session wurde gespeichert, aber Zammad hat die Notiz nicht angenommen." }); } } +function zammadSyncStorageKey(userId: string) { + return `tickettracker.zammad-sync.${userId}`; +} + +function parseMailDaysBack(value: unknown) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 1 && parsed <= 3650 ? parsed : 30; +} + +function mailDaysBackFromZammadSyncSettings(userId: string) { + const raw = getLocalStorageValue(zammadSyncStorageKey(userId)); + + if (!raw) { + return 30; + } + + try { + const parsed = JSON.parse(raw) as { mailDaysBack?: unknown }; + return parseMailDaysBack(parsed.mailDaysBack); + } catch { + return 30; + } +} + function projectEntryId() { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); @@ -128,6 +160,62 @@ function projectEntryId() { return `entry-${Date.now()}-${Math.random().toString(16).slice(2)}`; } +function escapeHtml(value: string) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function textToEditorHtml(value: string) { + return value + .split(/\r?\n/) + .map((line) => `
${line ? escapeHtml(line) : "
"}
`) + .join(""); +} + +function fileToDataUrl(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener("load", () => resolve(String(reader.result ?? ""))); + reader.addEventListener("error", () => reject(reader.error ?? new Error("Bild konnte nicht gelesen werden"))); + reader.readAsDataURL(file); + }); +} + +function insertEditorHtml(html: string) { + document.execCommand("insertHTML", false, html); +} + +function currentSelectionRange() { + const selection = window.getSelection(); + return selection && selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null; +} + +function restoreSelectionRange(range: Range | null) { + if (!range) { + return; + } + + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); +} + +function editorPlainText(element: HTMLElement) { + return element.innerText.replace(/\u00a0/g, " ").trim(); +} + +function hasInlineImage(element: HTMLElement) { + return Boolean(element.querySelector("img[src^='data:image/']")); +} + +function normalizedEditorHtml(element: HTMLElement) { + const html = element.innerHTML.trim(); + return html && (editorPlainText(element) || hasInlineImage(element)) ? html : ""; +} + function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry[] { if (project.teamspace_entries?.length) { return project.teamspace_entries; @@ -137,6 +225,7 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry { id: `${project.id}-default`, teamspace_project: project.teamspace_project, + teamspace_description: "", is_default: true, hour_budget_minutes: project.hour_budget_minutes, manual_consumption_minutes: project.manual_consumption_minutes ?? 0, @@ -157,10 +246,19 @@ function teamspaceEntryOptionLabel(entry: { teamspace_project: string | null; is return entry.is_default ? `${label} (Standard)` : label; } +function teamspaceEntryDescription(entry: { teamspace_description?: string | null } | null | undefined) { + return entry?.teamspace_description?.trim() || ""; +} + +function teamspaceBillingKey(day: string, projectTeamspaceEntryId: string | null | undefined) { + return `${projectTeamspaceEntryId ?? "ticket"}::${day}`; +} + function projectEditForm(project: ProjectSummary): ProjectEditFormState { const entries = projectTeamspaceEntries(project).map((entry, index) => ({ clientId: entry.id || projectEntryId(), teamspaceProject: entry.teamspace_project ?? "", + teamspaceDescription: entry.teamspace_description ?? "", hourBudget: formatHoursInput(entry.hour_budget_minutes), budgetSyncDisabled: entry.budget_sync_disabled ?? false, manualConsumption: formatHoursInput(entry.manual_consumption_minutes), @@ -183,6 +281,102 @@ function combinedProjectDescription(project: ProjectSummary) { return [project.description, project.goals].map((part) => part.trim()).filter(Boolean).join("\n\n"); } +function InlineImageActivityEditor({ + id, + value, + htmlValue, + onChange +}: { + id: string; + value: string; + htmlValue: string; + onChange: (value: string, htmlValue: string) => void; +}) { + const editorRef = useRef(null); + + useEffect(() => { + const editor = editorRef.current; + + if (!editor) { + return; + } + + const nextHtml = htmlValue || (value ? textToEditorHtml(value) : ""); + + if (editor.innerHTML !== nextHtml) { + editor.innerHTML = nextHtml; + } + }, [htmlValue, value]); + + function emitChange() { + const editor = editorRef.current; + + if (!editor) { + return; + } + + onChange(editorPlainText(editor), normalizedEditorHtml(editor)); + } + + async function handlePaste(event: ClipboardEvent) { + const imageFiles = Array.from(event.clipboardData.items) + .filter((item) => item.kind === "file" && item.type.startsWith("image/")) + .map((item) => item.getAsFile()) + .filter((file): file is File => Boolean(file)); + + if (imageFiles.length === 0) { + const text = event.clipboardData.getData("text/plain"); + + if (text) { + event.preventDefault(); + document.execCommand("insertText", false, text); + emitChange(); + } + + return; + } + + event.preventDefault(); + + let insertionRange = currentSelectionRange(); + const text = event.clipboardData.getData("text/plain").trim(); + if (text) { + document.execCommand("insertText", false, `${text}\n`); + insertionRange = currentSelectionRange(); + } + + try { + for (const file of imageFiles) { + const dataUrl = await fileToDataUrl(file); + restoreSelectionRange(insertionRange); + insertEditorHtml(`
`); + insertionRange = currentSelectionRange(); + } + emitChange(); + } catch (error) { + toast.error("Screenshot konnte nicht eingefügt werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } + } + + return ( +
void handlePaste(event)} + /> + ); +} + function BillingButtons({ session, disabled, @@ -384,6 +578,7 @@ function normalizeProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[ { clientId: projectEntryId(), teamspaceProject: "", + teamspaceDescription: "", hourBudget: "", budgetSyncDisabled: false, manualConsumption: "", @@ -411,6 +606,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) { return { id: /^\d+$/.test(entry.clientId) ? entry.clientId : null, teamspaceProject: entry.teamspaceProject.trim() || null, + teamspaceDescription: entry.teamspaceDescription.trim() || null, isDefault: entry.isDefault, hourBudgetMinutes, budgetSyncDisabled: entry.budgetSyncDisabled, @@ -425,6 +621,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) { return parsedEntries as Array<{ id?: string | null; teamspaceProject: string | null; + teamspaceDescription: string | null; isDefault: boolean; hourBudgetMinutes: number | null; budgetSyncDisabled: boolean; @@ -453,13 +650,13 @@ function ProjectTeamspaceEntriesEditor({
-
{normalizedEntries.map((entry, index) => ( -
+
+
+ + updateEntry(entry.clientId, { teamspaceDescription: event.currentTarget.value })} + /> +
updateEntry(entry.clientId, { hourBudget: event.currentTarget.value, budgetSyncDisabled: true })} /> @@ -486,11 +692,12 @@ function ProjectTeamspaceEntriesEditor({ type="button" size="sm" variant={entry.isDefault ? "default" : "secondary"} + className="sm:self-end" onClick={() => onChange(normalizedEntries.map((candidate) => ({ ...candidate, isDefault: candidate.clientId === entry.clientId })))} > {entry.isDefault ? "Standard" : "Als Standard"} - @@ -519,14 +726,17 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS const [editSessionOrganizationId, setEditSessionOrganizationId] = useState(""); const [editSessionOrganizationName, setEditSessionOrganizationName] = useState(null); const [editSessionActivity, setEditSessionActivity] = useState(""); + const [editSessionZammadArticleBody, setEditSessionZammadArticleBody] = useState(""); const [editSessionWorkType, setEditSessionWorkType] = useState("support"); const [editSessionProjectTeamspaceEntryId, setEditSessionProjectTeamspaceEntryId] = useState(""); const [editSessionDay, setEditSessionDay] = useState(""); const [editSessionStartTime, setEditSessionStartTime] = useState(""); const [editSessionEndTime, setEditSessionEndTime] = useState(""); const [savingSessionDetails, setSavingSessionDetails] = useState(false); + const [sendingZammadSessionId, setSendingZammadSessionId] = useState(null); const [dayBillingInputs, setDayBillingInputs] = useState>({}); const [savingDayBillingKey, setSavingDayBillingKey] = useState(null); + const [selectedTeamspaceEntryId, setSelectedTeamspaceEntryId] = useState(null); const [ticketSession, setTicketSession] = useState(() => emptyTicketSessionForm()); const [savingTicketSession, setSavingTicketSession] = useState(false); const [globalMonthFilter, setGlobalMonthFilter] = useState(initialGlobalMonthFilter); @@ -547,6 +757,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS { clientId: projectEntryId(), teamspaceProject: "", + teamspaceDescription: "", hourBudget: "", budgetSyncDisabled: false, manualConsumption: "", @@ -558,6 +769,10 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS const [expandedSessionIds, setExpandedSessionIds] = useState([]); const [projectDescriptionExpanded, setProjectDescriptionExpanded] = useState(false); const [teamspacePreparation, setTeamspacePreparation] = useState<{ day: string; label: string; text: string } | null>(null); + const [mailPreview, setMailPreview] = useState(null); + const [mailSelection, setMailSelection] = useState>({}); + const [loadingMailPreview, setLoadingMailPreview] = useState(false); + const [importingMailSessions, setImportingMailSessions] = useState(false); async function load() { try { @@ -590,9 +805,15 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS }, [periodType, period, ticketId]); useEffect(() => { - const nextInputs = Object.fromEntries((data?.dayBillings ?? []).map((billing) => [billing.day, formatHoursInput(billing.billed_minutes)])); + const defaultEntryId = data?.project ? projectTeamspaceEntries(data.project).find((entry) => entry.is_default)?.id ?? null : null; + const nextInputs = Object.fromEntries( + (data?.dayBillings ?? []).map((billing) => [ + teamspaceBillingKey(billing.day, billing.project_teamspace_entry_id ?? defaultEntryId), + formatHoursInput(billing.billed_minutes) + ]) + ); setDayBillingInputs(nextInputs); - }, [data?.dayBillings]); + }, [data?.dayBillings, data?.project]); useEffect(() => { if (!data?.ticket) { @@ -636,6 +857,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS setProjectDescriptionExpanded(false); }, [data?.project?.id]); + useEffect(() => { + setMailPreview(null); + setMailSelection({}); + }, [ticketId]); + const ticket = data?.ticket; const project = data?.project ?? null; const isRecurringBillingTicket = ticketHasRecurringBilling(ticket); @@ -654,6 +880,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS const isProject = Boolean(project); const isGlobalTicket = periodType === "all"; const isPeriodClosed = Boolean(data?.closed); + const mailDaysBack = useMemo(() => mailDaysBackFromZammadSyncSettings(currentUser.id), [currentUser.id]); + const selectedMailArticleIds = useMemo( + () => (mailPreview?.items ?? []).filter((item) => mailSelection[item.articleId] !== false).map((item) => item.articleId), + [mailPreview?.items, mailSelection] + ); + const allMailSelected = Boolean(mailPreview?.items.length) && selectedMailArticleIds.length === mailPreview?.items.length; + const canPreviewMailSessions = Boolean(ticket?.work_type && currentUser.has_zammad_api_token && currentUser.zammad_user_id); const periodLabel = periodType === "month" ? "Monat" : periodType === "day" ? "Tag" : "Ticket"; const sessionsScopeLabel = isGlobalTicket ? globalMonthFilter === "all" @@ -663,22 +896,82 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS const teamspaceLabel = isGlobalTicket || periodType === "month" ? "Teamspace gesamt" : "Teamspace Tag"; const availableMonths = data?.availableMonths ?? []; const selectedMonthHasSessions = availableMonths.some((month) => month.month === globalMonthFilter); - const dayBillingMap = useMemo(() => new Map((data?.dayBillings ?? []).map((billing) => [billing.day, billing.billed_minutes])), [data?.dayBillings]); - const savedDayBillingInput = (day: string) => formatHoursInput(dayBillingMap.get(day)); - const isDayBillingDirty = (day: string) => (dayBillingInputs[day] ?? "") !== savedDayBillingInput(day); + const defaultProjectEntryId = projectEntries.find((entry) => entry.is_default)?.id ?? null; + const dayBillingMap = useMemo( + () => + new Map( + (data?.dayBillings ?? []).map((billing) => [ + teamspaceBillingKey(billing.day, billing.project_teamspace_entry_id ?? defaultProjectEntryId), + billing.billed_minutes + ]) + ), + [data?.dayBillings, defaultProjectEntryId] + ); + const savedDayBillingInput = (key: string) => formatHoursInput(dayBillingMap.get(key)); + const isDayBillingDirty = (key: string) => (dayBillingInputs[key] ?? "") !== savedDayBillingInput(key); + const unassignedTeamspaceSessions = useMemo( + () => (data?.sessions ?? []).filter((session) => session.work_type === "consulting" && !session.project_teamspace_entry_id), + [data?.sessions] + ); + const teamspaceTabs = useMemo(() => { + if (!requiresProjectTeamspaceEntry) { + return []; + } + + return [ + ...projectEntries.map((entry) => ({ + id: entry.id, + label: entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID"), + description: teamspaceEntryDescription(entry), + entry + })), + ...(unassignedTeamspaceSessions.length > 0 + ? [{ id: "unassigned", label: "Ohne Zuordnung", description: "", entry: null }] + : []) + ]; + }, [projectEntries, requiresProjectTeamspaceEntry, unassignedTeamspaceSessions.length]); + const activeTeamspaceEntryId = requiresProjectTeamspaceEntry + ? selectedTeamspaceEntryId && teamspaceTabs.some((tab) => tab.id === selectedTeamspaceEntryId) + ? selectedTeamspaceEntryId + : teamspaceTabs[0]?.id ?? null + : null; + const selectedTicketSessionTeamspaceEntry = projectEntries.find((entry) => entry.id === ticketSession.projectTeamspaceEntryId) ?? null; + const selectedEditSessionTeamspaceEntry = projectEntries.find((entry) => entry.id === editSessionProjectTeamspaceEntryId) ?? null; const sessionGroups = useMemo(() => { const groups = new Map< string, - { key: string; label: string; totalMinutes: number; billableMinutes: number; nonBillableMinutes: number; openMinutes: number; sessions: SessionEntry[] } + { + key: string; + day: string; + projectTeamspaceEntryId: string | null; + label: string; + totalMinutes: number; + billableMinutes: number; + nonBillableMinutes: number; + openMinutes: number; + sessions: SessionEntry[]; + } >(); for (const session of data?.sessions ?? []) { - const key = sessionDayKey(session.started_at); + if (requiresProjectTeamspaceEntry) { + if (activeTeamspaceEntryId === "unassigned") { + if (session.project_teamspace_entry_id) { + continue; + } + } else if (session.project_teamspace_entry_id !== activeTeamspaceEntryId) { + continue; + } + } + + const day = sessionDayKey(session.started_at); + const projectTeamspaceEntryId = requiresProjectTeamspaceEntry && activeTeamspaceEntryId !== "unassigned" ? activeTeamspaceEntryId : null; + const key = teamspaceBillingKey(day, projectTeamspaceEntryId); const group = groups.get(key); const billedMinutes = session.billing_status === "billed" ? session.rounded_minutes : 0; const billableMinutes = session.billing_status === "billable" ? session.rounded_minutes : 0; const nonBillableMinutes = session.billing_status === "non_billable" ? session.rounded_minutes : 0; - const openMinutes = session.billing_status === null ? session.rounded_minutes : 0; + const openMinutes = session.billing_status === null || session.billing_status === "billable" ? session.rounded_minutes : 0; if (group) { group.sessions.push(session); @@ -691,6 +984,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS groups.set(key, { key, + day, + projectTeamspaceEntryId, label: formatDate(session.started_at), totalMinutes: billedMinutes, billableMinutes, @@ -700,12 +995,17 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS }); } - return Array.from(groups.values()); - }, [data?.sessions]); + return Array.from(groups.values()) + .map((group) => ({ + ...group, + sessions: [...group.sessions].sort((left, right) => new Date(right.started_at).getTime() - new Date(left.started_at).getTime()) + })) + .sort((left, right) => right.key.localeCompare(left.key)); + }, [activeTeamspaceEntryId, data?.sessions, requiresProjectTeamspaceEntry]); const sessionIds = useMemo(() => sessionGroups.flatMap((group) => group.sessions.map((session) => session.id)), [sessionGroups]); const allSessionsExpanded = sessionIds.length > 0 && sessionIds.every((sessionId) => expandedSessionIds.includes(sessionId)); const periodTotalMinutes = sessionGroups.reduce((sum, group) => sum + group.totalMinutes, 0); - const periodTeamspaceMinutes = Array.from(dayBillingMap.values()).reduce((sum, minutes) => sum + Number(minutes ?? 0), 0); + const periodTeamspaceMinutes = (data?.dayBillings ?? []).reduce((sum, billing) => sum + Number(billing.billed_minutes ?? 0), 0); const ticketDataDirty = Boolean( ticket && (editTicketNumber !== ticket.ticket_number || @@ -796,6 +1096,94 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS } } + async function sendSessionToZammad(session: SessionEntry) { + if (!session.activity.trim() && !session.zammad_article_body) { + toast.warning("Keine Zammad-Notiz möglich", { + description: "Die Session hat keine Tätigkeit." + }); + return; + } + + setSendingZammadSessionId(session.id); + try { + const result = await resendSessionZammadArticle(session.id, { + zammadArticleTypeId: session.zammad_article_type_id ?? currentUser.zammad_article_type_id + }); + notifyZammadArticleResult(result); + await load(); + } catch (error) { + toast.error("Zammad-Notiz konnte nicht gesendet werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } finally { + setSendingZammadSessionId(null); + } + } + + async function previewTicketMailSessions() { + if (!ticket) { + return; + } + + if (!canPreviewMailSessions) { + toast.warning("Mail-Sync nicht bereit", { + description: "Dieses Ticket braucht eine Zammad-Ticket-ID und dein Profil einen gültigen Zammad-Token." + }); + return; + } + + setLoadingMailPreview(true); + try { + const result = await previewTicketZammadMailSessions(ticket.id, mailDaysBack); + setMailPreview(result); + setMailSelection(Object.fromEntries(result.items.map((item) => [item.articleId, true]))); + toast.success("Mail-Vorschau erstellt", { + description: `${result.items.length} Mail-Session(s) können importiert werden.` + }); + } catch (error) { + toast.error("Mail-Vorschau fehlgeschlagen", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } finally { + setLoadingMailPreview(false); + } + } + + async function importTicketMailSessions() { + if (!ticket || selectedMailArticleIds.length === 0) { + toast.info("Keine Mail-Sessions ausgewählt"); + return; + } + + setImportingMailSessions(true); + try { + const result = await importTicketZammadMailSessions(ticket.id, mailDaysBack, selectedMailArticleIds); + setMailPreview(null); + setMailSelection({}); + const firstError = result.errors[0]; + const description = firstError + ? `${result.created} angelegt, ${result.skipped} ohne Transfer. Article ${firstError.articleId}: ${firstError.error}` + : `${result.created} angelegt, ${result.skipped} ohne Transfer.`; + const notify = result.created === 0 && result.errors.length > 0 ? toast.error : toast.success; + notify("Mail-Sessions importiert", { description }); + await load(); + } catch (error) { + toast.error("Mail-Sessions konnten nicht importiert werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } finally { + setImportingMailSessions(false); + } + } + + function toggleMailSelection(articleId: number, checked: boolean) { + setMailSelection((current) => ({ ...current, [articleId]: checked })); + } + + function toggleAllMailSelection(checked: boolean) { + setMailSelection(Object.fromEntries((mailPreview?.items ?? []).map((item) => [item.articleId, checked]))); + } + function toggleSessionDetails(sessionId: string) { setExpandedSessionIds((current) => current.includes(sessionId) ? current.filter((id) => id !== sessionId) : [...current, sessionId] @@ -806,7 +1194,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS setExpandedSessionIds(allSessionsExpanded ? [] : sessionIds); } - async function saveDayBilling(day: string) { + async function saveDayBilling(group: (typeof sessionGroups)[number]) { if (!ticket) { return; } @@ -818,7 +1206,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS return; } - const parsedMinutes = parseHoursInput(dayBillingInputs[day] ?? ""); + const parsedMinutes = parseHoursInput(dayBillingInputs[group.key] ?? ""); if (parsedMinutes === undefined) { toast.error("Teamspace-Zeit prüfen", { @@ -827,15 +1215,15 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS return; } - const currentMinutes = dayBillingMap.get(day) ?? null; + const currentMinutes = dayBillingMap.get(group.key) ?? null; if (parsedMinutes === currentMinutes) { return; } - setSavingDayBillingKey(day); + setSavingDayBillingKey(group.key); try { - await updateTicketDayBilling(ticket.id, day, parsedMinutes); + await updateTicketDayBilling(ticket.id, group.day, parsedMinutes, group.projectTeamspaceEntryId); toast.success("Zeit gespeichert"); await load(); } catch (error) { @@ -1021,6 +1409,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS return; } + if (!ticketSession.activity.trim() && !ticketSession.zammadArticleBody) { + toast.error("Tätigkeit erfassen"); + return; + } + if (effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry && !ticketSession.projectTeamspaceEntryId) { toast.error("Teamspace-Eintrag wählen", { description: "Dieses Projekt hat mehrere Teamspace-Einträge." @@ -1036,8 +1429,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS return; } - if (endedAt <= startedAt) { - toast.error("Ende muss nach Beginn liegen"); + if (endedAt < startedAt) { + toast.error("Ende darf nicht vor Beginn liegen"); return; } @@ -1057,6 +1450,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS ticketNumber: ticket.ticket_number, organizationId: effectiveOrganizationId, activity: ticketSession.activity, + zammadArticleBody: ticketSession.zammadArticleBody || null, workType: effectiveWorkType, startedAt: startedAt.toISOString(), endedAt: endedAt.toISOString(), @@ -1075,6 +1469,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS organizationId: ticket.organization_id ?? "", organizationName: ticket.organization_name ?? null, projectTeamspaceEntryId: "", + zammadArticleBody: "", workType: ticket.work_type ?? "support", day: ticketSession.day, billingStatus: null, @@ -1096,6 +1491,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS setEditSessionOrganizationId(session.organization_id ?? ""); setEditSessionOrganizationName(session.organization_name ?? null); setEditSessionActivity(session.activity); + setEditSessionZammadArticleBody(session.zammad_article_body ?? ""); setEditSessionWorkType(session.work_type); setEditSessionProjectTeamspaceEntryId(session.project_teamspace_entry_id ?? ""); setEditSessionDay(toDateLocalValue(session.started_at)); @@ -1148,8 +1544,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS return; } - if (endedAt <= startedAt) { - toast.error("Ende muss nach Beginn liegen"); + if (endedAt < startedAt) { + toast.error("Ende darf nicht vor Beginn liegen"); return; } @@ -1170,6 +1566,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS await updateSessionDetails(editingSession.id, { organizationId: editSessionOrganizationId, activity: editSessionActivity, + zammadArticleBody: editSessionZammadArticleBody || null, workType: editSessionWorkType, removeProjectData, projectTeamspaceEntryId: editSessionWorkType === "consulting" && requiresProjectTeamspaceEntry ? editSessionProjectTeamspaceEntryId : null, @@ -1307,7 +1704,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS

-
+
{ticket?.ticket_number ? (
+ + +
+ + + Zammad-Mails importieren + + Eigene eMail-Artikel übernehmen. Rückblick: {mailDaysBack} Tag(e). +
+
+ {mailPreview?.items.length ? ( + + ) : null} + +
+
+ {!canPreviewMailSessions || mailPreview ? ( + + {!canPreviewMailSessions ? ( + + + Mail-Sync nicht bereit + Dieses Ticket braucht eine Support/Consulting-Art und dein Profil einen gültigen Zammad-Token. + + ) : null} + + {mailPreview ? ( +
+
+ + {mailPreview.skipped > 0 || mailPreview.errors.length > 0 ? ( + + {mailPreview.skipped} ohne Transfer, {mailPreview.errors.length} Fehler + + ) : null} +
+ + {mailPreview.items.length === 0 ? ( +

Keine importierbaren Mails für dieses Ticket gefunden.

+ ) : ( +
+ {mailPreview.items.map((item: TicketZammadMailSessionItem) => ( + + ))} +
+ )} +
+ ) : null} +
+ ) : null} +
+
@@ -1722,7 +2189,26 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS {data?.sessions.length ? ( -
+
+ {teamspaceTabs.length > 0 ? ( +
+ {teamspaceTabs.map((tab) => ( + + ))} +
+ ) : ( + + )} - - Abgerechnet {formatMinutes(group.totalMinutes)} - - - Abrechenbar {formatMinutes(group.billableMinutes)} - - 0 ? "warning" : "success"} className="flex h-7 w-full justify-center whitespace-nowrap"> - Offen {formatMinutes(group.openMinutes)} - -
+
h
- {group.nonBillableMinutes > 0 ? ( - - Nicht {formatMinutes(group.nonBillableMinutes)} - - ) : null}
{group.sessions.map((session) => { const expanded = expandedSessionIds.includes(session.id); + const zammadSent = session.zammad_article_status === "sent"; + const zammadPending = session.zammad_article_status === "pending"; + const zammadMailImported = Boolean(session.zammad_source_article_id); + const canSendZammad = !zammadMailImported && (session.activity.trim().length > 0 || Boolean(session.zammad_article_body)); + const sendingZammad = sendingZammadSessionId === session.id; return (
@@ -1827,17 +2312,16 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS {formatTimeRange(session.started_at, session.ended_at)} + {zammadMailImported ? ( + + + Mail + + ) : null}
{formatMinutes(session.rounded_minutes)} {session.recurring_billing_id ? fix : null} - {requiresProjectTeamspaceEntry ? ( - session.project_teamspace_entry_id ? ( - {session.project_teamspace_project || "Teamspace"} - ) : ( - ohne Teamspace - ) - ) : null}
setBilling(session.id, value)} /> -
+
+ {zammadMailImported ? ( +
{expanded ? (
-

{session.activity}

+

{session.activity || "Keine Tätigkeit erfasst."}

{session.organization_name ?? session.customer_name} - {session.zammad_article_status ? ( + {session.work_type === "support" ? "Support" : "Consulting"} + {zammadMailImported ? ( - {session.zammad_article_status === "sent" ? ( - - ) : ( - {session.work_type === "support" ? "Support" : "Consulting"} - )} + ) : null} + {!zammadMailImported ? ( + + {zammadSent ? + ) : null} Erfasst {formatDateTime(session.created_at)}
@@ -1884,6 +2402,25 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
); })} +
+ ))} @@ -1971,6 +2508,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS ))} + {teamspaceEntryDescription(selectedTicketSessionTeamspaceEntry) ? ( +

{teamspaceEntryDescription(selectedTicketSessionTeamspaceEntry)}

+ ) : null}
) : null} @@ -2025,12 +2565,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS -