diff --git a/backend/src/auth.ts b/backend/src/auth.ts index c01a7c1..7f1a7af 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_article_type_id: number; }; declare global { @@ -87,7 +88,8 @@ function publicUser(user: AuthUser) { session_billing_prompt_enabled: Boolean(user.session_billing_prompt_enabled), 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_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15), + zammad_article_type_id: Number(user.zammad_article_type_id ?? 6) }; } @@ -149,7 +151,8 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio u.session_billing_prompt_enabled, u.zammad_session_note_default_enabled, u.zammad_time_unit_rounding_enabled, - u.zammad_time_unit_rounding_minutes + u.zammad_time_unit_rounding_minutes, + u.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/index.ts b/backend/src/index.ts index 3ca5553..4f49ad4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -240,6 +240,20 @@ function parseZammadTimeUnitRoundingMinutes(value: unknown, fallback = 15) { return minutes; } +function parseZammadArticleTypeId(value: unknown, fallback = 6) { + if (value === undefined || value === null || value === "") { + return fallback ?? 6; + } + + const typeId = Number(value); + + if (![1, 2, 3, 4, 5, 6].includes(typeId)) { + throw badRequest("zammadArticleTypeId must be 1, 2, 3, 4, 5 or 6"); + } + + return typeId; +} + function optionalTicketSessionMonth(value: unknown) { if (value === undefined || value === null || value === "" || value === "all") { return null; @@ -635,12 +649,14 @@ type ZammadSessionArticlePayload = { body: string; content_type: "text/plain"; time_unit: number; + accounted_time_type_id: number; }; function zammadSessionArticlePayload(input: { zammadTicketId: string | number; activity: string; timeUnit: number; + articleTypeId: number; }): ZammadSessionArticlePayload { const ticketId = Number(input.zammadTicketId); @@ -655,7 +671,8 @@ function zammadSessionArticlePayload(input: { subject: "TicketTracker Session", body: input.activity, content_type: "text/plain", - time_unit: input.timeUnit + time_unit: input.timeUnit, + accounted_time_type_id: input.articleTypeId }; } @@ -672,6 +689,7 @@ async function enqueueZammadSessionArticle(client: pg.PoolClient, input: { zammadTicketId: string | number | null; activity: string; timeUnit: number; + articleTypeId: number; error: string; }) { await client.query( @@ -684,14 +702,16 @@ async function enqueueZammadSessionArticle(client: pg.PoolClient, input: { zammad_ticket_id, activity, time_unit, + zammad_article_type_id, last_error ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) 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, time_unit = EXCLUDED.time_unit, + zammad_article_type_id = EXCLUDED.zammad_article_type_id, last_error = EXCLUDED.last_error, updated_at = now(); `, @@ -703,11 +723,55 @@ async function enqueueZammadSessionArticle(client: pg.PoolClient, input: { input.zammadTicketId, input.activity, input.timeUnit, + input.articleTypeId, input.error.slice(0, 1000) ] ); } +async function recordZammadSessionArticleSent(client: pg.PoolClient, input: { + userId: string; + sessionId: string | number; + ticketId: string | number; + ticketNumber: string; + zammadTicketId: string | number; + activity: string; + timeUnit: number; + articleTypeId: number; + articleId: number | null; +}) { + await client.query( + ` + INSERT INTO zammad_session_article_queue ( + user_id, + session_id, + ticket_id, + ticket_number, + zammad_ticket_id, + activity, + time_unit, + zammad_article_type_id, + status, + sent_at, + zammad_article_id, + last_error + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'sent', now(), $9, NULL); + `, + [ + input.userId, + input.sessionId, + input.ticketId, + input.ticketNumber, + input.zammadTicketId, + input.activity, + input.timeUnit, + input.articleTypeId, + input.articleId + ] + ); +} + function zammadArticleId(payload: unknown) { return isPlainObject(payload) ? numberOrNull(payload.id) : null; } @@ -721,6 +785,7 @@ type ZammadSessionArticleQueueRow = { zammad_ticket_id: string | null; activity: string; time_unit: string | number; + zammad_article_type_id: number | null; }; async function retryZammadSessionArticleQueueRow(row: ZammadSessionArticleQueueRow) { @@ -750,7 +815,8 @@ async function retryZammadSessionArticleQueueRow(row: ZammadSessionArticleQueueR const payload = zammadSessionArticlePayload({ zammadTicketId, activity: row.activity, - timeUnit: Number(row.time_unit) + timeUnit: Number(row.time_unit), + articleTypeId: Number(row.zammad_article_type_id ?? 6) }); const zammadResponse = await postZammadSessionArticleForUser(row.user_id, payload); const articleId = zammadArticleId(zammadResponse); @@ -1013,6 +1079,7 @@ type SessionCreationInput = { requestedProjectTeamspaceEntryId: number | null; billingStatus: "billable" | "billed" | "non_billable" | null; createZammadArticle: boolean; + zammadArticleTypeId: number; startedAt: Date; endedAt: Date; durationSeconds: number; @@ -1034,6 +1101,7 @@ async function createSessionForUser(input: SessionCreationInput) { requestedProjectTeamspaceEntryId, billingStatus, createZammadArticle, + zammadArticleTypeId, startedAt, endedAt, durationSeconds, @@ -1251,6 +1319,18 @@ async function createSessionForUser(input: SessionCreationInput) { ); const session = sessionResult.rows[0]; + const zammadArticle: { + requested: boolean; + status: "not_requested" | "sent" | "queued"; + error: string | null; + articleId: number | null; + } = { + requested: createZammadArticle, + status: createZammadArticle ? "queued" : "not_requested", + error: null, + articleId: null + }; + if (createZammadArticle) { const timeUnit = await zammadTimeUnitForUser(userId, durationSeconds); let zammadArticleTicketId = ticket.zammad_ticket_id; @@ -1276,9 +1356,23 @@ async function createSessionForUser(input: SessionCreationInput) { const payload = zammadSessionArticlePayload({ zammadTicketId: zammadArticleTicketId, activity, - timeUnit + 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 }); - await postZammadSessionArticleForUser(userId, payload); } catch (error) { zammadArticleError = errorMessage(error); } @@ -1287,6 +1381,8 @@ async function createSessionForUser(input: SessionCreationInput) { } if (zammadArticleError) { + zammadArticle.status = "queued"; + zammadArticle.error = zammadArticleError; await enqueueZammadSessionArticle(client, { userId, sessionId: session.id, @@ -1295,6 +1391,7 @@ async function createSessionForUser(input: SessionCreationInput) { zammadTicketId: zammadArticleTicketId, activity, timeUnit, + articleTypeId: zammadArticleTypeId, error: zammadArticleError }); } @@ -1319,7 +1416,8 @@ async function createSessionForUser(input: SessionCreationInput) { return { ticket, - session + session, + zammadArticle }; }); } @@ -1675,6 +1773,7 @@ async function createZammadEmailSessionFromWebhook(payload: unknown, deliveryId: requestedProjectTeamspaceEntryId: null, billingStatus: null, createZammadArticle: false, + zammadArticleTypeId: 6, startedAt, endedAt, durationSeconds, @@ -4433,6 +4532,27 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke recurring_billing_id, recurring_billing_slot_id, recurring_occurrence_date, + ( + SELECT status + FROM zammad_session_article_queue + WHERE session_id = sessions.id + ORDER BY updated_at DESC + LIMIT 1 + ) AS zammad_article_status, + ( + SELECT zammad_article_id::text + FROM zammad_session_article_queue + WHERE session_id = sessions.id + ORDER BY updated_at DESC + LIMIT 1 + ) AS zammad_article_id, + ( + SELECT zammad_article_type_id + FROM zammad_session_article_queue + WHERE session_id = sessions.id + ORDER BY updated_at DESC + LIMIT 1 + ) AS zammad_article_type_id, project_teamspace_entry_id::text AS project_teamspace_entry_id, ( SELECT teamspace_project @@ -4591,10 +4711,20 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon s.recurring_billing_id, s.recurring_billing_slot_id, s.recurring_occurrence_date, + za.status AS zammad_article_status, + za.zammad_article_id::text AS zammad_article_id, + za.zammad_article_type_id, s.project_teamspace_entry_id::text AS project_teamspace_entry_id, pte.teamspace_project AS project_teamspace_project FROM sessions s LEFT JOIN project_teamspace_entries pte ON pte.id = s.project_teamspace_entry_id + LEFT JOIN LATERAL ( + SELECT status, zammad_article_id, zammad_article_type_id + FROM zammad_session_article_queue + WHERE session_id = s.id + ORDER BY updated_at DESC + LIMIT 1 + ) za ON true WHERE ${sessionWhere} ORDER BY s.started_at ASC; `, @@ -4841,11 +4971,12 @@ 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; }>( ` 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; + 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; `, [username, displayName, await hashPassword(password)] ); @@ -4906,6 +5037,7 @@ 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; }>( ` SELECT @@ -4921,7 +5053,8 @@ app.post("/api/auth/login", async (req, res) => { session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, - zammad_time_unit_rounding_minutes + zammad_time_unit_rounding_minutes, + zammad_article_type_id FROM users WHERE lower(username) = lower($1); `, @@ -4971,7 +5104,8 @@ app.post("/api/auth/login", async (req, res) => { session_billing_prompt_enabled: user.session_billing_prompt_enabled, 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_time_unit_rounding_minutes: user.zammad_time_unit_rounding_minutes, + zammad_article_type_id: user.zammad_article_type_id } }); }); @@ -5189,6 +5323,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 zammadArticleTypeId = user.role === "user" + ? parseZammadArticleTypeId(req.body.zammadArticleTypeId, user.zammad_article_type_id) + : 6; if (newPassword && newPassword.length < 6) { throw badRequest("newPassword must be at least 6 characters"); @@ -5221,9 +5358,10 @@ 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, 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; + 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; `, [ username, @@ -5238,7 +5376,8 @@ app.patch("/api/auth/me", async (req, res) => { sessionBillingPromptEnabled, zammadSessionNoteDefaultEnabled, zammadTimeUnitRoundingEnabled, - zammadTimeUnitRoundingMinutes + zammadTimeUnitRoundingMinutes, + zammadArticleTypeId ] ); @@ -5266,7 +5405,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, 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_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_article_type_id, created_at, updated_at FROM users ORDER BY username ASC; ` @@ -5290,7 +5429,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, 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_article_type_id, created_at, updated_at; `, [username, displayName, await hashPassword(password), "user", active] ); @@ -5351,7 +5490,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, 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_article_type_id, created_at, updated_at; `, [username, displayName, active, password ? await hashPassword(password) : null, userId] ); @@ -6329,6 +6468,7 @@ app.post("/api/sessions", requireUser, async (req, res) => { const requestedProjectTeamspaceEntryId = parseOptionalTeamspaceEntryId(req.body.projectTeamspaceEntryId); const billingStatus = req.body.billingStatus === undefined ? null : parseBillingStatus(req.body.billingStatus); const createZammadArticle = optionalBoolean(req.body.createZammadArticle, false); + const zammadArticleTypeId = parseZammadArticleTypeId(req.body.zammadArticleTypeId, user.zammad_article_type_id); const startedAt = parseIsoDate(req.body.startedAt, "startedAt"); const endedAt = parseIsoDate(req.body.endedAt, "endedAt"); const durationSeconds = parsePositiveInteger(req.body.durationSeconds, "durationSeconds"); @@ -6343,6 +6483,7 @@ app.post("/api/sessions", requireUser, async (req, res) => { requestedProjectTeamspaceEntryId, billingStatus, createZammadArticle, + zammadArticleTypeId, startedAt, endedAt, durationSeconds @@ -6362,6 +6503,7 @@ async function zammadSessionArticleQueueResponse(userId: string) { q.zammad_ticket_id::text AS zammad_ticket_id, q.activity, q.time_unit::float AS time_unit, + q.zammad_article_type_id, q.attempt_count, q.last_error, q.last_attempt_at, @@ -6398,7 +6540,8 @@ app.post("/api/zammad/session-article-queue/retry", requireUser, async (req, res ticket_number, zammad_ticket_id::text AS zammad_ticket_id, activity, - time_unit::text AS time_unit + time_unit::text AS time_unit, + zammad_article_type_id FROM zammad_session_article_queue WHERE user_id = $1 AND status = 'pending' diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index 4fd5327..0c9bbcd 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -91,11 +91,18 @@ 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_article_type_id INTEGER DEFAULT 6;"); + await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id DROP NOT NULL;"); await query(` ALTER TABLE users DROP CONSTRAINT IF EXISTS users_zammad_time_unit_rounding_minutes_valid, ADD CONSTRAINT users_zammad_time_unit_rounding_minutes_valid CHECK (zammad_time_unit_rounding_minutes IN (5, 10, 15, 30)); `); + 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)); + `); await query(` CREATE TABLE IF NOT EXISTS auth_sessions ( @@ -266,6 +273,7 @@ export async function migrate() { zammad_ticket_id BIGINT, activity TEXT NOT NULL, time_unit NUMERIC(10, 2) NOT NULL, + zammad_article_type_id INTEGER DEFAULT 6, status TEXT NOT NULL DEFAULT 'pending', attempt_count INTEGER NOT NULL DEFAULT 0, last_error TEXT, @@ -277,6 +285,13 @@ 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 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(` + 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)); + `); await query(` CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_session_article_queue_pending_session ON zammad_session_article_queue(session_id) diff --git a/docs/user-guide.md b/docs/user-guide.md index e760bab..d4cc006 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -84,7 +84,7 @@ Beim Klick auf `Beenden` stoppt der Timer sofort. Die vorher im Timer erfasste T Die Bewertung hat drei Optionen: `---`, `Abrechenbar` und `Nicht abrechenbar`. `---` ist der Standard und laesst die Session offen. Wenn im Profil die Bewertungsabfrage aktiv ist, fragt TicketTracker beim Beenden eines Timers mit `---` noch einmal nach. -Im Abschlussdialog kann die Taetigkeit als interner Zammad-Article an das Ticket uebertragen werden. Der Article wird mit `internal: true` angelegt und enthaelt die Taetigkeit sowie den `time_unit`-Wert aus der gemessenen Zeit abzueglich Pausen. +Im Abschlussdialog kann die Taetigkeit als interner Zammad-Article an das Ticket uebertragen werden. Der Article wird mit `internal: true` angelegt und enthaelt die Taetigkeit sowie den `time_unit`-Wert aus der gemessenen Zeit abzueglich Pausen. Wenn die interne Zammad-Notiz aktiv ist, kann dort auch der Zammad-Zeitbuchungstyp gewaehlt werden. Wenn Zammad beim Speichern nicht erreichbar ist, wird die Session trotzdem in TicketTracker gespeichert. Der Zammad-Article wird dann in einer Parktabelle vorgemerkt und kann spaeter im Profil erneut gesendet werden. @@ -117,7 +117,7 @@ Das Taetigkeitsfeld ist mehrzeilig. Zeilenumbrueche bleiben in der Auswertung er Mit Bewertung `---` bleibt eine nachgetragene Session offen. `Abrechenbar` und `Nicht abrechenbar` werden direkt an der neuen Session gespeichert. -Wenn die interne Zammad-Notiz aktiviert ist, wird die Taetigkeit auch beim Nachtragen als interner Zammad-Article uebertragen. Bei Zammad-Fehlern wird der Eintrag wie beim Timerabschluss geparkt und kann spaeter im Profil gesendet werden. +Wenn die interne Zammad-Notiz aktiviert ist, wird die Taetigkeit auch beim Nachtragen als interner Zammad-Article uebertragen. Der Zammad-Typ kann dabei angepasst werden. Bei Zammad-Fehlern wird der Eintrag wie beim Timerabschluss geparkt und kann spaeter im Profil gesendet werden. Das Ende muss nach dem Beginn liegen. Die Dauer wird automatisch berechnet und auf Minuten gerundet. @@ -534,6 +534,7 @@ Unter `Profil` kann jeder Benutzer seine eigenen Daten pflegen: - persoenlicher Zammad API-Token - Bewertungsabfrage beim Timerabschluss - interne Zammad-Notiz beim Timerabschluss +- Standard-Typ fuer interne Zammad-Notizen - Zammad-Zeitaufrundung fuer `time_unit` Passwortaenderungen erfordern das aktuelle Passwort. Bleiben die Passwortfelder leer, werden nur Benutzername und voller Name gespeichert. @@ -546,7 +547,9 @@ Der persoenliche Zammad Token ist unabhaengig vom Admin-Token fuer den Organisat Die Bewertungsabfrage beim Timerabschluss kann im Profil ein- oder ausgeschaltet werden. Sie erscheint nur, wenn der Timer beim Beenden noch auf Bewertung `---` steht. -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. +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. 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. diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 990b110..60a61a0 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -32,6 +32,7 @@ type CreateSessionPayload = { projectTeamspaceEntryId?: string | null; billingStatus?: BillingStatus; createZammadArticle?: boolean; + zammadArticleTypeId?: number; }; type DeleteSessionResult = { @@ -46,6 +47,15 @@ type DeleteSessionResult = { }; }; +export type CreateSessionResult = { + zammadArticle?: { + requested: boolean; + status: "not_requested" | "sent" | "queued"; + error: string | null; + articleId: number | null; + }; +}; + export type ZammadSyncExistingFields = { title: boolean; organization: boolean; @@ -205,6 +215,7 @@ export function updateCurrentUser(payload: { zammadSessionNoteDefaultEnabled?: boolean; zammadTimeUnitRoundingEnabled?: boolean; zammadTimeUnitRoundingMinutes?: number; + zammadArticleTypeId?: number; }) { return request<{ user: AuthUser }>("/api/auth/me", { method: "PATCH", @@ -682,7 +693,7 @@ export function deleteTodo(todoId: string) { } export function createSession(payload: CreateSessionPayload) { - return request("/api/sessions", { + return request("/api/sessions", { method: "POST", body: JSON.stringify(payload) }); diff --git a/frontend/src/help-content.ts b/frontend/src/help-content.ts index 6740817..07c1bf4 100644 --- a/frontend/src/help-content.ts +++ b/frontend/src/help-content.ts @@ -121,7 +121,7 @@ export const helpSections: HelpSection[] = [ "Wenn du einen Timer auswählst, zeigt die Timerseite dessen Details inklusive gemessener Zeit und Tätigkeitsfeld.", "Pausierte Timer behalten ihre Tätigkeit. Entfernst du einen Timer, wird auch die noch nicht gespeicherte Tätigkeit verworfen.", "Beim Klick auf Beenden wird die Stoppuhr sofort angehalten. Die bereits im Timer erfasste Tätigkeit wird beim Speichern an die Session gehängt.", - "Im Abschlussdialog kann die Tätigkeit als interne Zammad-Notiz übertragen werden. Der Article wird intern erstellt und bekommt die getrackte Zeit als time_unit.", + "Im Abschlussdialog kann die Tätigkeit als interne Zammad-Notiz übertragen werden. Der Article wird intern erstellt und bekommt die getrackte Zeit als time_unit sowie den gewählten Zammad-Typ.", "Wenn Zammad beim Speichern nicht erreichbar ist, bleibt die Session trotzdem gespeichert. Die interne Notiz wird geparkt und kann später im Profil erneut gesendet werden.", "Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Art.", "Bei Projekt-Tickets mit mehreren Teamspace-Einträgen zeigt der Beenden-Dialog eine Auswahl. Der Standard-Eintrag ist dort mit Standard gekennzeichnet.", @@ -146,7 +146,7 @@ export const helpSections: HelpSection[] = [ "Die Organisation muss aus der synchronisierten Organisationsliste gewählt werden. Freitext ist absichtlich gesperrt.", "Die Tätigkeit ist eine Textarea. Zeilenumbrüche bleiben erhalten und werden in der Ticketdetailansicht wieder ausgegeben.", "Mit Bewertung --- bleibt die Session offen. Abrechenbar oder Nicht abrechenbar werden direkt an der neuen Session gespeichert.", - "Die interne Zammad-Notiz kann auch beim Nachtragen gesetzt werden. Bei Zammad-Fehlern wird sie geparkt und kann später im Profil gesendet werden.", + "Die interne Zammad-Notiz kann auch beim Nachtragen gesetzt werden. Ist sie aktiv, kann der Zammad-Typ angepasst werden. Bei Zammad-Fehlern wird sie geparkt und kann später im Profil gesendet werden.", "In der Ticketdetailansicht kann eine Session direkt zum geöffneten Ticket nachgetragen werden. Ticketnummer und Stammdaten sind dort schon vorbelegt beziehungsweise aus dem Ticket abgeleitet.", "Bei Consulting-Projekten mit mehreren Teamspace-Einträgen ist der passende Teamspace-Eintrag Pflicht. Bei nur einem Eintrag wird er automatisch gesetzt." ] @@ -539,7 +539,8 @@ export const helpSections: HelpSection[] = [ "Die Token-Prüfung läuft nur, wenn der Token im Profil geändert oder entfernt wird. Normale Profiländerungen prüfen Zammad nicht erneut.", "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.", + "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.", "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/types.ts b/frontend/src/types.ts index b2ff70a..7ac8f35 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_article_type_id: number; }; export type DashboardWidgetType = "dayControl" | "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes"; @@ -75,6 +76,7 @@ export type ZammadSessionArticleQueueItem = { zammad_ticket_id: string | null; activity: string; time_unit: number; + zammad_article_type_id: number | null; attempt_count: number; last_error: string | null; last_attempt_at: string | null; @@ -338,6 +340,9 @@ export type SessionEntry = { recurring_billing_id: string | null; recurring_billing_slot_id: string | null; recurring_occurrence_date: string | null; + zammad_article_status?: "pending" | "sent" | null; + zammad_article_id?: string | null; + zammad_article_type_id?: number | null; }; export type TicketPeriod = { diff --git a/frontend/src/views/ProfilePage.tsx b/frontend/src/views/ProfilePage.tsx index ef2a557..ad8d516 100644 --- a/frontend/src/views/ProfilePage.tsx +++ b/frontend/src/views/ProfilePage.tsx @@ -9,6 +9,7 @@ import { HelpLink } from "@/components/HelpLink"; import { getZammadSessionArticleQueue, retryZammadSessionArticleQueue, updateCurrentUser } from "../api"; import { formatDateTime, formatMinutes } from "../format"; import type { AuthUser, ZammadSessionArticleQueueItem } from "../types"; +import { zammadArticleTypeFromValue, zammadArticleTypeLabel, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types"; type ProfilePageProps = { currentUser: AuthUser; @@ -28,6 +29,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled); const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled); const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes); + const [zammadArticleTypeId, setZammadArticleTypeId] = useState(currentUser.zammad_article_type_id ?? 6); const [zammadQueue, setZammadQueue] = useState([]); const [loadingZammadQueue, setLoadingZammadQueue] = useState(false); const [retryingZammadQueue, setRetryingZammadQueue] = useState(false); @@ -43,6 +45,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled); setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled); setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes); + setZammadArticleTypeId(currentUser.zammad_article_type_id ?? 6); }, [currentUser]); async function loadZammadQueue() { @@ -125,7 +128,8 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { sessionBillingPromptEnabled, zammadSessionNoteDefaultEnabled, zammadTimeUnitRoundingEnabled, - zammadTimeUnitRoundingMinutes + zammadTimeUnitRoundingMinutes, + zammadArticleTypeId } : {}) }; @@ -334,41 +338,63 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { /> {zammadSessionNoteDefaultEnabled ? ( -
-
-
- -
- -

Ohne Rundung wird die konkrete Zeit als Stundenwert mit 2 Nachkommastellen gesendet.

-
-
- -
- {zammadTimeUnitRoundingEnabled ? ( -
-
diff --git a/frontend/src/views/TicketDetailPage.tsx b/frontend/src/views/TicketDetailPage.tsx index babf303..9650d22 100644 --- a/frontend/src/views/TicketDetailPage.tsx +++ b/frontend/src/views/TicketDetailPage.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Copy, EyeOff, FolderKanban, Pencil, Play, Plus, RotateCcw, Save, Star, Trash2 } from "lucide-react"; +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 { toast } from "sonner"; import { Alert } from "@/components/ui/alert"; @@ -30,10 +30,12 @@ import { updateProject, updateProjectStatus } from "../api"; +import type { CreateSessionResult } 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 { cn } from "@/lib/utils"; type TicketDetailPageProps = { @@ -63,6 +65,7 @@ type TicketSessionFormState = { activity: string; billingStatus: BillingStatus; createZammadArticle: boolean; + zammadArticleTypeId: ZammadArticleTypeId; workType: WorkType; day: string; startTime: string; @@ -96,6 +99,7 @@ function emptyTicketSessionForm(): TicketSessionFormState { activity: "", billingStatus: null, createZammadArticle: false, + zammadArticleTypeId: 6, workType: "support", day: currentDay(), startTime: "09:00", @@ -103,6 +107,19 @@ function emptyTicketSessionForm(): TicketSessionFormState { }; } +function notifyZammadArticleResult(result: CreateSessionResult) { + if (result.zammadArticle?.status === "sent") { + toast.success("Zammad-Notiz übertragen"); + 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." + }); + } +} + function projectEntryId() { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); @@ -599,16 +616,21 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS useEffect(() => { setTicketSession((current) => { - if (current.activity || current.billingStatus || current.createZammadArticle === currentUser.zammad_session_note_default_enabled) { + if ( + current.activity || + current.billingStatus || + (current.createZammadArticle === currentUser.zammad_session_note_default_enabled && current.zammadArticleTypeId === currentUser.zammad_article_type_id) + ) { return current; } return { ...current, - createZammadArticle: currentUser.zammad_session_note_default_enabled + createZammadArticle: currentUser.zammad_session_note_default_enabled, + zammadArticleTypeId: currentUser.zammad_article_type_id }; }); - }, [currentUser.zammad_session_note_default_enabled]); + }, [currentUser.zammad_article_type_id, currentUser.zammad_session_note_default_enabled]); useEffect(() => { setProjectDescriptionExpanded(false); @@ -1031,7 +1053,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS setSavingTicketSession(true); try { - await createSession({ + const result = await createSession({ ticketNumber: ticket.ticket_number, organizationId: effectiveOrganizationId, activity: ticketSession.activity, @@ -1041,11 +1063,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS durationSeconds, projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null, billingStatus: ticketSession.billingStatus, - createZammadArticle: ticketSession.createZammadArticle + createZammadArticle: ticketSession.createZammadArticle, + zammadArticleTypeId: ticketSession.zammadArticleTypeId }); toast.success("Session nachgetragen", { description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet." }); + notifyZammadArticleResult(result); setTicketSession({ ...emptyTicketSessionForm(), organizationId: ticket.organization_id ?? "", @@ -1054,7 +1078,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS workType: ticket.work_type ?? "support", day: ticketSession.day, billingStatus: null, - createZammadArticle: currentUser.zammad_session_note_default_enabled + createZammadArticle: currentUser.zammad_session_note_default_enabled, + zammadArticleTypeId: currentUser.zammad_article_type_id }); await load(); } catch (error) { @@ -1842,7 +1867,16 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS

{session.activity}

{session.organization_name ?? session.customer_name} - {session.work_type === "support" ? "Support" : "Consulting"} + {session.zammad_article_status ? ( + + {session.zammad_article_status === "sent" ? ( + + ) : ( + {session.work_type === "support" ? "Support" : "Consulting"} + )} Erfasst {formatDateTime(session.created_at)}
@@ -2009,6 +2043,25 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS Überträgt die Tätigkeit inklusive time_unit nach Zammad oder parkt sie bei Fehlern. + {ticketSession.createZammadArticle ? ( +
+ + +
+ ) : null}