diff --git a/backend/src/auth.ts b/backend/src/auth.ts index d820384..c01a7c1 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -18,6 +18,10 @@ export type AuthUser = { zammad_user_id: string | null; has_zammad_api_token: boolean; zammad_email_session_webhook_enabled: boolean; + session_billing_prompt_enabled: boolean; + zammad_session_note_default_enabled: boolean; + zammad_time_unit_rounding_enabled: boolean; + zammad_time_unit_rounding_minutes: number; }; declare global { @@ -79,7 +83,11 @@ function publicUser(user: AuthUser) { active: user.active, zammad_user_id: user.zammad_user_id ?? null, has_zammad_api_token: Boolean(user.has_zammad_api_token), - zammad_email_session_webhook_enabled: Boolean(user.zammad_email_session_webhook_enabled) + zammad_email_session_webhook_enabled: Boolean(user.zammad_email_session_webhook_enabled), + 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) }; } @@ -137,7 +145,11 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio u.active, u.zammad_user_id::text AS zammad_user_id, (u.zammad_api_token IS NOT NULL) AS has_zammad_api_token, - u.zammad_email_session_webhook_enabled + u.zammad_email_session_webhook_enabled, + u.session_billing_prompt_enabled, + u.zammad_session_note_default_enabled, + u.zammad_time_unit_rounding_enabled, + u.zammad_time_unit_rounding_minutes 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 3a22c04..3ca5553 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -226,6 +226,20 @@ function parseOptionalTeamspaceEntryId(value: unknown) { return parsePositiveInteger(value, "projectTeamspaceEntryId"); } +function parseZammadTimeUnitRoundingMinutes(value: unknown, fallback = 15) { + if (value === undefined || value === null || value === "") { + return fallback; + } + + const minutes = Number(value); + + if (![5, 10, 15, 30].includes(minutes)) { + throw badRequest("zammadTimeUnitRoundingMinutes must be 5, 10, 15 or 30"); + } + + return minutes; +} + function optionalTicketSessionMonth(value: unknown) { if (value === undefined || value === null || value === "" || value === "all") { return null; @@ -239,11 +253,11 @@ function optionalTicketSessionStatus(value: unknown) { return "all" as const; } - if (value === "open" || value === "billed" || value === "non_billable") { + if (value === "open" || value === "billable" || value === "billed" || value === "non_billable") { return value; } - throw badRequest("status must be all, open, billed or non_billable"); + throw badRequest("status must be all, open, billable, billed or non_billable"); } function optionalTicketSessionSource(value: unknown) { @@ -560,6 +574,204 @@ async function fetchZammadJson(baseUrl: string, apiToken: string, path: string) return response.json() as Promise; } +async function postZammadJson(baseUrl: string, apiToken: string, path: string, payload: Record) { + const response = await fetch(`${baseUrl}${path}`, { + method: "POST", + headers: { + Authorization: `Token token=${apiToken}`, + Accept: "application/json", + "Content-Type": "application/json" + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw Object.assign(new Error(`Zammad Article konnte nicht erstellt werden (${response.status})${body ? `: ${body.slice(0, 250)}` : ""}`), { status: 502 }); + } + + return response.json() as Promise; +} + +function zammadTimeUnit(durationSeconds: number, roundingEnabled: boolean, roundingMinutes: number) { + const minutes = durationSeconds / 60; + const roundedMinutes = roundingEnabled ? Math.ceil(minutes / roundingMinutes) * roundingMinutes : minutes; + return Number((roundedMinutes / 60).toFixed(2)); +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +async function zammadTimeUnitForUser(userId: string, durationSeconds: number) { + const userResult = await query<{ + zammad_time_unit_rounding_enabled: boolean; + zammad_time_unit_rounding_minutes: number; + }>( + ` + SELECT zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes + FROM users + WHERE id = $1; + `, + [userId] + ); + const userSettings = userResult.rows[0] ?? { + zammad_time_unit_rounding_enabled: false, + zammad_time_unit_rounding_minutes: 15 + }; + + return zammadTimeUnit( + durationSeconds, + Boolean(userSettings.zammad_time_unit_rounding_enabled), + Number(userSettings.zammad_time_unit_rounding_minutes ?? 15) + ); +} + +type ZammadSessionArticlePayload = { + ticket_id: number; + type: "note"; + internal: true; + subject: string; + body: string; + content_type: "text/plain"; + time_unit: number; +}; + +function zammadSessionArticlePayload(input: { + zammadTicketId: string | number; + activity: string; + timeUnit: number; +}): ZammadSessionArticlePayload { + const ticketId = Number(input.zammadTicketId); + + if (!Number.isInteger(ticketId) || ticketId < 1) { + throw badRequest("Ticket hat keine gültige Zammad-Ticket-ID"); + } + + return { + ticket_id: ticketId, + type: "note", + internal: true, + subject: "TicketTracker Session", + body: input.activity, + content_type: "text/plain", + time_unit: input.timeUnit + }; +} + +async function postZammadSessionArticleForUser(userId: string, payload: ZammadSessionArticlePayload) { + const credentials = await getZammadCredentialsForUser(userId); + return postZammadJson(credentials.baseUrl, credentials.apiToken, "/api/v1/ticket_articles", payload); +} + +async function enqueueZammadSessionArticle(client: pg.PoolClient, input: { + userId: string; + sessionId: string | number; + ticketId: string | number; + ticketNumber: string; + zammadTicketId: string | number | null; + activity: string; + timeUnit: number; + error: string; +}) { + await client.query( + ` + INSERT INTO zammad_session_article_queue ( + user_id, + session_id, + ticket_id, + ticket_number, + zammad_ticket_id, + activity, + time_unit, + last_error + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + 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, + last_error = EXCLUDED.last_error, + updated_at = now(); + `, + [ + input.userId, + input.sessionId, + input.ticketId, + input.ticketNumber, + input.zammadTicketId, + input.activity, + input.timeUnit, + input.error.slice(0, 1000) + ] + ); +} + +function zammadArticleId(payload: unknown) { + return isPlainObject(payload) ? numberOrNull(payload.id) : null; +} + +type ZammadSessionArticleQueueRow = { + id: string; + user_id: string; + session_id: string; + ticket_id: string; + ticket_number: string; + zammad_ticket_id: string | null; + activity: string; + time_unit: string | number; +}; + +async function retryZammadSessionArticleQueueRow(row: ZammadSessionArticleQueueRow) { + let zammadTicketId = row.zammad_ticket_id; + + if (!zammadTicketId) { + const zammadTicket = await fetchZammadTicketByNumber(row.user_id, row.ticket_number); + + if (!zammadTicket) { + throw new Error("Ticket wurde in Zammad nicht gefunden"); + } + + await withTransaction(async (client) => { + await importZammadTicketForUser(client, zammadTicket, row.user_id); + }); + zammadTicketId = String(zammadTicket.zammadTicketId); + await query( + ` + UPDATE zammad_session_article_queue + SET zammad_ticket_id = $1, updated_at = now() + WHERE id = $2; + `, + [zammadTicketId, row.id] + ); + } + + const payload = zammadSessionArticlePayload({ + zammadTicketId, + activity: row.activity, + timeUnit: Number(row.time_unit) + }); + const zammadResponse = await postZammadSessionArticleForUser(row.user_id, payload); + const articleId = zammadArticleId(zammadResponse); + + await query( + ` + UPDATE zammad_session_article_queue + SET status = 'sent', + sent_at = now(), + zammad_article_id = $1, + last_error = NULL, + last_attempt_at = now(), + updated_at = now() + WHERE id = $2; + `, + [articleId, row.id] + ); + + return articleId; +} + function zammadTicketCandidates(payload: unknown): Record[] { if (Array.isArray(payload)) { return payload.filter(isPlainObject); @@ -799,6 +1011,8 @@ type SessionCreationInput = { requestedWorkType: "support" | "consulting" | null; useZammadLookup: boolean; requestedProjectTeamspaceEntryId: number | null; + billingStatus: "billable" | "billed" | "non_billable" | null; + createZammadArticle: boolean; startedAt: Date; endedAt: Date; durationSeconds: number; @@ -818,6 +1032,8 @@ async function createSessionForUser(input: SessionCreationInput) { requestedWorkType, useZammadLookup, requestedProjectTeamspaceEntryId, + billingStatus, + createZammadArticle, startedAt, endedAt, durationSeconds, @@ -947,7 +1163,7 @@ async function createSessionForUser(input: SessionCreationInput) { throw badRequest("organizationId must reference an existing organization"); } - const ticketResult = await client.query<{ id: string; ticket_number: string; organization_id: string | null; customer_name: string | null; work_type: string | null }>( + const ticketResult = await client.query<{ id: string; ticket_number: string; organization_id: string | null; customer_name: string | null; work_type: string | null; zammad_ticket_id: string | null }>( ` INSERT INTO tickets (ticket_number, organization_id, customer_name, work_type) VALUES ($1, $2, $3, $4) @@ -962,7 +1178,7 @@ async function createSessionForUser(input: SessionCreationInput) { ) THEN 'consulting' ELSE COALESCE(tickets.work_type, EXCLUDED.work_type) END - RETURNING id, ticket_number, organization_id, customer_name, work_type; + RETURNING id, ticket_number, organization_id, customer_name, work_type, zammad_ticket_id::text AS zammad_ticket_id; `, [ticketNumber, organization.id, organization.name, existingTicket?.project_id ? "consulting" : requestedWorkType, userId] ); @@ -1011,9 +1227,11 @@ async function createSessionForUser(input: SessionCreationInput) { ended_at, duration_seconds, rounded_minutes, - project_teamspace_entry_id + project_teamspace_entry_id, + billing_status, + billing_updated_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, CASE WHEN $12::text IS NULL THEN NULL ELSE now() END) RETURNING *; `, [ @@ -1027,9 +1245,60 @@ async function createSessionForUser(input: SessionCreationInput) { endedAt.toISOString(), durationSeconds, roundedMinutes, - projectTeamspaceEntryId + projectTeamspaceEntryId, + billingStatus ] ); + const session = sessionResult.rows[0]; + + if (createZammadArticle) { + 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 + }); + await postZammadSessionArticleForUser(userId, payload); + } catch (error) { + zammadArticleError = errorMessage(error); + } + } else if (!zammadArticleError) { + zammadArticleError = "Ticket hat keine Zammad-Ticket-ID"; + } + + if (zammadArticleError) { + await enqueueZammadSessionArticle(client, { + userId, + sessionId: session.id, + ticketId: ticket.id, + ticketNumber, + zammadTicketId: zammadArticleTicketId, + activity, + timeUnit, + error: zammadArticleError + }); + } + } await reopenPeriodsForSession(client, ticket.id, startedAt, userId); if (workType === "consulting") { @@ -1044,13 +1313,13 @@ async function createSessionForUser(input: SessionCreationInput) { SET session_id = $1 WHERE id = $2; `, - [sessionResult.rows[0].id, eventId] + [session.id, eventId] ); } return { ticket, - session: sessionResult.rows[0] + session }; }); } @@ -1404,6 +1673,8 @@ async function createZammadEmailSessionFromWebhook(payload: unknown, deliveryId: requestedWorkType: null, useZammadLookup: true, requestedProjectTeamspaceEntryId: null, + billingStatus: null, + createZammadArticle: false, startedAt, endedAt, durationSeconds, @@ -3517,9 +3788,11 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use COUNT(*) FILTER (WHERE s.recurring_billing_id IS NULL)::int AS manual_session_count, COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'billed'), 0)::int AS total_minutes, 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, + 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, @@ -3634,6 +3907,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use sessions: totalSessions, minutes: totalMinutes, crmBilledMinutes: crmBillingResult.rows[0]?.crm_billed_minutes ?? 0, + billableMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.billable_minutes, 0), nonBillableMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.non_billable_minutes, 0), openMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.open_minutes, 0), openSessions: openSessions.length @@ -3714,9 +3988,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { COUNT(*)::int AS session_count, COALESCE(SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billed'), 0)::int AS total_minutes, 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, 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.recurring_billing_id IS NOT NULL)::int AS recurring_session_count, @@ -3756,9 +4032,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { COUNT(*)::int AS sessions, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS minutes, 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, 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, COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NOT NULL AND billing_status = 'billed'), 0)::int AS recurring_minutes, @@ -3780,9 +4058,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { COALESCE(day_sessions.session_count, 0)::int AS sessions, td.tracked_minutes::int AS total_minutes, COALESCE(day_sessions.billed_minutes, 0)::int AS billed_minutes, + COALESCE(day_sessions.billable_minutes, 0)::int AS billable_minutes, COALESCE(day_sessions.non_billable_minutes, 0)::int AS non_billable_minutes, COALESCE(day_sessions.open_minutes, 0)::int AS open_minutes, COALESCE(day_sessions.billed_sessions, 0)::int AS billed_sessions, + COALESCE(day_sessions.billable_sessions, 0)::int AS billable_sessions, COALESCE(day_sessions.non_billable_sessions, 0)::int AS non_billable_sessions, COALESCE(day_sessions.open_sessions, 0)::int AS open_sessions, COALESCE(SUM(tdb.crm_billed_minutes), 0)::int AS crm_billed_minutes @@ -3796,16 +4076,18 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { day, SUM(session_count)::int AS session_count, SUM(COALESCE(billed_minutes, 0))::int AS billed_minutes, + SUM(COALESCE(billable_minutes, 0))::int AS billable_minutes, SUM(COALESCE(non_billable_minutes, 0))::int AS non_billable_minutes, SUM(COALESCE(open_minutes, 0))::int AS open_minutes, SUM(billed_sessions)::int AS billed_sessions, + SUM(billable_sessions)::int AS billable_sessions, SUM(non_billable_sessions)::int AS non_billable_sessions, SUM(open_count)::int AS open_sessions FROM session_group GROUP BY day ) day_sessions ON day_sessions.day = td.day LEFT JOIN ticket_day_billing tdb ON tdb.day = td.day - GROUP BY td.day, td.tracked_minutes, day_sessions.session_count, day_sessions.billed_minutes, day_sessions.non_billable_minutes, day_sessions.open_minutes, day_sessions.billed_sessions, day_sessions.non_billable_sessions, day_sessions.open_sessions + GROUP BY td.day, td.tracked_minutes, day_sessions.session_count, day_sessions.billed_minutes, day_sessions.billable_minutes, day_sessions.non_billable_minutes, day_sessions.open_minutes, day_sessions.billed_sessions, day_sessions.billable_sessions, day_sessions.non_billable_sessions, day_sessions.open_sessions ORDER BY td.day ASC; `, [period.startIso, period.endIso, userId] @@ -3820,9 +4102,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { SUM(session_count)::int AS sessions, COALESCE(SUM(total_minutes), 0)::int AS total_minutes, COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes, + COALESCE(SUM(COALESCE(billable_minutes, 0)), 0)::int AS billable_minutes, COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes, COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes, COALESCE(SUM(billed_sessions), 0)::int AS billed_sessions, + COALESCE(SUM(billable_sessions), 0)::int AS billable_sessions, COALESCE(SUM(non_billable_sessions), 0)::int AS non_billable_sessions, COALESCE(SUM(open_count), 0)::int AS open_sessions, COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes @@ -3841,9 +4125,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { SUM(session_count)::int AS sessions, COALESCE(SUM(total_minutes), 0)::int AS total_minutes, COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes, + COALESCE(SUM(COALESCE(billable_minutes, 0)), 0)::int AS billable_minutes, COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes, COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes, COALESCE(SUM(billed_sessions), 0)::int AS billed_sessions, + COALESCE(SUM(billable_sessions), 0)::int AS billable_sessions, COALESCE(SUM(non_billable_sessions), 0)::int AS non_billable_sessions, COALESCE(SUM(open_count), 0)::int AS open_sessions, COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes @@ -3865,9 +4151,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { COUNT(*)::int AS sessions, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS total_minutes, 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, 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, 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 @@ -3996,9 +4284,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { sessions: 0, minutes: 0, billed_minutes: 0, + billable_minutes: 0, non_billable_minutes: 0, open_minutes: 0, billed_sessions: 0, + billable_sessions: 0, non_billable_sessions: 0, open_sessions: 0, recurring_minutes: 0, @@ -4020,9 +4310,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { sessions: totals.sessions, minutes: totals.minutes, billedMinutes: totals.billed_minutes, + billableMinutes: totals.billable_minutes, nonBillableMinutes: totals.non_billable_minutes, openMinutes: totals.open_minutes, billedSessions: totals.billed_sessions, + billableSessions: totals.billable_sessions, nonBillableSessions: totals.non_billable_sessions, openSessions: totals.open_sessions, recurringMinutes: totals.recurring_minutes, @@ -4196,7 +4488,7 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke }; } -async function getTicketDetails(ticketId: number, userId: string, filters: { month: ParsedPeriod | null; status: "all" | "open" | "billed" | "non_billable"; source: "all" | "manual" | "recurring" }) { +async function getTicketDetails(ticketId: number, userId: string, filters: { month: ParsedPeriod | null; status: "all" | "open" | "billable" | "billed" | "non_billable"; source: "all" | "manual" | "recurring" }) { if (filters.month) { await ensureRecurringSessionsForUserPeriod(userId, filters.month.startIso, filters.month.endIso); } @@ -4545,11 +4837,15 @@ app.post("/api/setup/admin", async (req, res) => { zammad_user_id: string | null; has_zammad_api_token: boolean; zammad_email_session_webhook_enabled: boolean; + session_billing_prompt_enabled: boolean; + zammad_session_note_default_enabled: boolean; + zammad_time_unit_rounding_enabled: boolean; + zammad_time_unit_rounding_minutes: 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; + 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; `, [username, displayName, await hashPassword(password)] ); @@ -4606,6 +4902,10 @@ app.post("/api/auth/login", async (req, res) => { zammad_user_id: string | null; has_zammad_api_token: boolean; zammad_email_session_webhook_enabled: boolean; + session_billing_prompt_enabled: boolean; + zammad_session_note_default_enabled: boolean; + zammad_time_unit_rounding_enabled: boolean; + zammad_time_unit_rounding_minutes: number; }>( ` SELECT @@ -4617,7 +4917,11 @@ app.post("/api/auth/login", async (req, res) => { password_hash, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, - zammad_email_session_webhook_enabled + zammad_email_session_webhook_enabled, + session_billing_prompt_enabled, + zammad_session_note_default_enabled, + zammad_time_unit_rounding_enabled, + zammad_time_unit_rounding_minutes FROM users WHERE lower(username) = lower($1); `, @@ -4663,7 +4967,11 @@ app.post("/api/auth/login", async (req, res) => { active: user.active, zammad_user_id: user.zammad_user_id, has_zammad_api_token: user.has_zammad_api_token, - zammad_email_session_webhook_enabled: user.zammad_email_session_webhook_enabled + zammad_email_session_webhook_enabled: user.zammad_email_session_webhook_enabled, + 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 } }); }); @@ -4869,6 +5177,18 @@ app.patch("/api/auth/me", async (req, res) => { const zammadEmailSessionWebhookEnabled = user.role === "user" ? optionalBoolean(req.body.zammadEmailSessionWebhookEnabled, user.zammad_email_session_webhook_enabled) : false; + const sessionBillingPromptEnabled = user.role === "user" + ? optionalBoolean(req.body.sessionBillingPromptEnabled, user.session_billing_prompt_enabled) + : false; + const zammadSessionNoteDefaultEnabled = user.role === "user" + ? optionalBoolean(req.body.zammadSessionNoteDefaultEnabled, user.zammad_session_note_default_enabled) + : false; + const zammadTimeUnitRoundingEnabled = user.role === "user" + ? optionalBoolean(req.body.zammadTimeUnitRoundingEnabled, user.zammad_time_unit_rounding_enabled) + : false; + const zammadTimeUnitRoundingMinutes = user.role === "user" + ? parseZammadTimeUnitRoundingMinutes(req.body.zammadTimeUnitRoundingMinutes, user.zammad_time_unit_rounding_minutes) + : 15; if (newPassword && newPassword.length < 6) { throw badRequest("newPassword must be at least 6 characters"); @@ -4897,9 +5217,13 @@ app.patch("/api/auth/me", async (req, res) => { zammad_api_token = CASE WHEN $5::boolean THEN $6 ELSE zammad_api_token END, zammad_user_id = CASE WHEN $5::boolean THEN $7 ELSE zammad_user_id END, zammad_email_session_webhook_enabled = CASE WHEN $8::boolean THEN $9 ELSE zammad_email_session_webhook_enabled END, + session_billing_prompt_enabled = CASE WHEN $8::boolean THEN $10 ELSE session_billing_prompt_enabled END, + 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, 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; + 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; `, [ username, @@ -4910,7 +5234,11 @@ app.patch("/api/auth/me", async (req, res) => { zammadApiToken ? encryptSecret(zammadApiToken) : null, zammadUserId, user.role === "user", - zammadEmailSessionWebhookEnabled + zammadEmailSessionWebhookEnabled, + sessionBillingPromptEnabled, + zammadSessionNoteDefaultEnabled, + zammadTimeUnitRoundingEnabled, + zammadTimeUnitRoundingMinutes ] ); @@ -4938,7 +5266,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, 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, created_at, updated_at FROM users ORDER BY username ASC; ` @@ -4962,7 +5290,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, 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, created_at, updated_at; `, [username, displayName, await hashPassword(password), "user", active] ); @@ -5023,7 +5351,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, 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, created_at, updated_at; `, [username, displayName, active, password ? await hashPassword(password) : null, userId] ); @@ -5446,7 +5774,13 @@ app.get("/api/export/months/:month", requireUser, async (req, res) => { row.start_time, row.end_time, row.rounded_minutes, - row.billing_status === "billed" ? "Abgerechnet" : row.billing_status === "non_billable" ? "Nicht abrechenbar" : "Offen", + row.billing_status === "billed" + ? "Abgerechnet" + : row.billing_status === "billable" + ? "Abrechenbar" + : row.billing_status === "non_billable" + ? "Nicht abrechenbar" + : "Offen", row.teamspace_minutes ?? "", row.source === "fix" ? "Fixe Abrechnung" : "Manuell", row.activity @@ -5993,6 +6327,8 @@ app.post("/api/sessions", requireUser, async (req, res) => { const requestedWorkType = optionalWorkType(req.body.workType); const useZammadLookup = optionalBoolean(req.body.zammadLookup, false); 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 startedAt = parseIsoDate(req.body.startedAt, "startedAt"); const endedAt = parseIsoDate(req.body.endedAt, "endedAt"); const durationSeconds = parsePositiveInteger(req.body.durationSeconds, "durationSeconds"); @@ -6005,6 +6341,8 @@ app.post("/api/sessions", requireUser, async (req, res) => { requestedWorkType, useZammadLookup, requestedProjectTeamspaceEntryId, + billingStatus, + createZammadArticle, startedAt, endedAt, durationSeconds @@ -6013,6 +6351,94 @@ app.post("/api/sessions", requireUser, async (req, res) => { res.status(201).json(created); }); +async function zammadSessionArticleQueueResponse(userId: string) { + const result = await query( + ` + SELECT + q.id::text AS id, + q.session_id::text AS session_id, + q.ticket_id::text AS ticket_id, + q.ticket_number, + q.zammad_ticket_id::text AS zammad_ticket_id, + q.activity, + q.time_unit::float AS time_unit, + q.attempt_count, + q.last_error, + q.last_attempt_at, + q.created_at, + s.started_at, + s.rounded_minutes + FROM zammad_session_article_queue q + JOIN sessions s ON s.id = q.session_id + WHERE q.user_id = $1 + AND q.status = 'pending' + ORDER BY q.created_at ASC; + `, + [userId] + ); + + return result.rows; +} + +app.get("/api/zammad/session-article-queue", requireUser, async (req, res) => { + const userId = currentUser(req).id; + + res.json({ queue: await zammadSessionArticleQueueResponse(userId) }); +}); + +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 + FROM zammad_session_article_queue + WHERE user_id = $1 + AND status = 'pending' + ORDER BY created_at ASC + LIMIT 100; + `, + [userId] + ); + const results: Array<{ id: string; status: "sent" | "failed"; error?: string; zammadArticleId?: number | null }> = []; + + for (const row of pendingResult.rows) { + try { + 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] + ); + results.push({ id: row.id, status: "failed", error: message }); + } + } + + res.json({ + sent: results.filter((result) => result.status === "sent").length, + failed: results.filter((result) => result.status === "failed").length, + results, + queue: await zammadSessionArticleQueueResponse(userId) + }); +}); + app.get("/api/periods/:periodType/:period/overview", requireUser, async (req, res) => { const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period)); res.json(await getPeriodOverview(config, period, currentUser(req).id)); @@ -8213,6 +8639,7 @@ app.get("/api/months/:month/overview", requireUser, async (req, res) => { 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 = '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, tm.closed_at IS NOT NULL AS closed, diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index 10afad8..4fd5327 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -87,6 +87,15 @@ export async function migrate() { await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_api_token TEXT;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_user_id BIGINT;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_email_session_webhook_enabled BOOLEAN NOT NULL DEFAULT false;"); + await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS session_billing_prompt_enabled BOOLEAN NOT NULL DEFAULT true;"); + 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 + 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(` CREATE TABLE IF NOT EXISTS auth_sessions ( @@ -207,7 +216,7 @@ export async function migrate() { billing_updated_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT work_type_valid CHECK (work_type IN ('support', 'consulting')), - CONSTRAINT billing_status_valid CHECK (billing_status IS NULL OR billing_status IN ('billed', 'non_billable')), + CONSTRAINT billing_status_valid CHECK (billing_status IS NULL OR billing_status IN ('billable', 'billed', 'non_billable')), CONSTRAINT duration_non_negative CHECK (duration_seconds >= 0), CONSTRAINT rounded_minutes_positive CHECK (rounded_minutes >= 1), CONSTRAINT ended_after_started CHECK (ended_at >= started_at) @@ -221,6 +230,11 @@ 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 + DROP CONSTRAINT IF EXISTS billing_status_valid, + ADD CONSTRAINT billing_status_valid CHECK (billing_status IS NULL OR billing_status IN ('billable', 'billed', 'non_billable')); + `); await query(` CREATE TABLE IF NOT EXISTS zammad_email_session_events ( id BIGSERIAL PRIMARY KEY, @@ -242,6 +256,37 @@ export async function migrate() { ON zammad_email_session_events(delivery_id) WHERE delivery_id IS NOT NULL; `); + await query(` + CREATE TABLE IF NOT EXISTS zammad_session_article_queue ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_id BIGINT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + ticket_number TEXT NOT NULL, + zammad_ticket_id BIGINT, + activity TEXT NOT NULL, + time_unit NUMERIC(10, 2) NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + attempt_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + last_attempt_at TIMESTAMPTZ, + sent_at TIMESTAMPTZ, + zammad_article_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT zammad_session_article_queue_status_valid CHECK (status IN ('pending', 'sent')) + ); + `); + await query(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_session_article_queue_pending_session + ON zammad_session_article_queue(session_id) + WHERE status = 'pending'; + `); + await query(` + CREATE INDEX IF NOT EXISTS idx_zammad_session_article_queue_user_pending + ON zammad_session_article_queue(user_id, created_at) + WHERE status = 'pending'; + `); if (adminId) { await query("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]); } diff --git a/backend/src/validation.ts b/backend/src/validation.ts index 8684fc2..f0f8857 100644 --- a/backend/src/validation.ts +++ b/backend/src/validation.ts @@ -110,8 +110,8 @@ export function parseBillingStatus(value: unknown) { return null; } - if (value !== "billed" && value !== "non_billable") { - throw badRequest("billingStatus must be billed, non_billable or null"); + if (value !== "billable" && value !== "billed" && value !== "non_billable") { + throw badRequest("billingStatus must be billable, billed, non_billable or null"); } return value; diff --git a/docs/user-guide.md b/docs/user-guide.md index fb62d11..acff7de 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -71,19 +71,27 @@ Die Seite `Timer` ist fuer laufende Arbeit an Tickets gedacht. 1. Ticketnummer im Format `Ticket#XXXXXX` eingeben. 2. `Timer starten` klicken. 3. Der Timer startet. -4. Bei Bedarf kann der Timer pausiert, aktiviert, beendet oder entfernt werden. +4. Taetigkeit und optional Bewertung in den Details des ausgewaehlten Timers erfassen. +5. Bei Bedarf kann der Timer pausiert, aktiviert, beendet oder entfernt werden. Es koennen mehrere Timer vorbereitet sein, aber immer nur ein Timer laeuft aktiv. Wird ein anderer Timer aktiviert, werden alle anderen Timer automatisch pausiert. Alle laufenden und pausierten Timer stehen in der Seitenleiste. Dadurch bleiben sie auf allen Unterseiten sichtbar. -Beim Klick auf `Beenden` stoppt der Timer sofort. Das spaetere Ausfuellen des Modals verlaengert die gemessene Zeit nicht. +Die Taetigkeit und Bewertung werden im ausgewaehlten Timer erfasst. Pausierte Timer behalten diese Notizen. Wird ein Timer entfernt, werden auch die noch nicht gespeicherten Details verworfen. + +Beim Klick auf `Beenden` stoppt der Timer sofort. Die vorher im Timer erfasste Taetigkeit wird beim Speichern an die Session gehaengt. + +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. + +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. Beim Speichern nach dem Beenden gilt: - Existiert das Ticket bereits, werden Organisation und Art vom Ticket uebernommen. -- Dann muss nur noch die Taetigkeit eingetragen werden. -- Existiert das Ticket noch nicht, muessen Organisation, Taetigkeit und Art ausgefuellt werden. +- Existiert das Ticket noch nicht, muessen Organisation und Art ausgefuellt werden. Die gemessene Zeit wird auf Minuten gerundet. Pausen werden von der Gesamtdauer abgezogen. @@ -101,10 +109,13 @@ Beim Nachtragen werden angegeben: - Datum - Von-Uhrzeit - Bis-Uhrzeit +- Bewertung: `---`, `Abrechenbar` oder `Nicht abrechenbar` - Taetigkeit Das Taetigkeitsfeld ist mehrzeilig. Zeilenumbrueche bleiben in der Auswertung erhalten. +Mit Bewertung `---` bleibt eine nachgetragene Session offen. `Abrechenbar` und `Nicht abrechenbar` werden direkt an der neuen Session gespeichert. + Das Ende muss nach dem Beginn liegen. Die Dauer wird automatisch berechnet und auf Minuten gerundet. In der Ticketdetailansicht gibt es ebenfalls `Session nachtragen`. Dort wird die neue Session direkt dem geoeffneten Ticket zugeordnet. @@ -145,6 +156,7 @@ Die oberen Karten zeigen: - Tickets: Anzahl der Tickets im Zeitraum - Sessions: Anzahl der Sessions im Zeitraum - 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 @@ -306,6 +318,7 @@ Die Sessions sind pro Tag gruppiert. In jeder Tagesgruppe stehen: - Tagesdatum - `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 - `Teamspace`: Stunden, die fuer dieses Ticket an diesem Tag wirklich im CRM eingetragen wurden @@ -330,11 +343,12 @@ Jede einzelne Session ist innerhalb der Tagesgruppe aufklappbar. In der geschlos Jede Session kann bewertet werden als: - Abgerechnet +- Abrechenbar - Nicht abrechenbar Ein erneuter Klick auf den bereits gewaehlten Zustand nimmt die Bewertung zurueck. Dadurch wird die Session wieder offen. -Nicht abrechenbare Sessions gelten als bewertet. Sie blockieren den Monatsabschluss nicht. +Abrechenbare und nicht abrechenbare Sessions gelten als bewertet. Sie blockieren den Monatsabschluss nicht. `Abrechenbar` fliesst nicht in Teamspace-Statistiken ein; erst `Abgerechnet` zaehlt dort. Es gibt keinen Ticketabschluss. Tickets dienen nur als Gruppierung fuer Sessions. Abgeschlossen wird ausschliesslich der Monat. @@ -430,9 +444,9 @@ Die Seite `Statistiken` betrachtet immer einen Monat. Sie dient als Ueberblick f ### Kennzahlen -`Abgerechnet` zeigt die Summe aller Sessions mit Bewertung `Abgerechnet`. Nicht abrechenbare und offene Sessions sind darin nicht enthalten. Dieser Wert ist die Basis fuer den Vergleich mit Teamspace. +`Abgerechnet` zeigt die Summe aller Sessions mit Bewertung `Abgerechnet`. Abrechenbare, nicht abrechenbare und offene Sessions sind darin nicht enthalten. Dieser Wert ist die Basis fuer den Vergleich mit Teamspace. -Alle Statistik- und Vergleichswerte verwenden nur abgerechnete Zeiten. Nicht ausgewaehlte/offene und nicht abrechenbare Sessions werden nicht in Relationen, Graphen, Teamspace-Differenzen, Hotspots, Manuell/Fix-Werte oder Durchschnittswerte eingerechnet. +Teamspace-Statistiken und -Vergleiche verwenden nur abgerechnete Zeiten. Nicht ausgewaehlte/offene, abrechenbare und nicht abrechenbare Sessions werden nicht in Teamspace-Differenzen, Hotspots, Manuell/Fix-Werte oder Durchschnittswerte eingerechnet. `Teamspace` zeigt die Summe aller Teamspace-Tageswerte im Monat. Die Differenz wird immer so berechnet: @@ -449,13 +463,14 @@ Das bedeutet: Farben: - Abgerechnet: blau +- Abrechenbar: grau - Teamspace: fuchsia - Positive oder ausgeglichene Differenz: gruen - Negative Differenz: rot - Nicht abrechenbar: indigo - Offen: gelb/orange -`Nicht abrechenbar` zeigt Sessions, die bewusst nicht fakturiert werden. In den Statistiken wird dazu kein Stundenwert ausgegeben; diese Sessions werden nicht mit Teamspace verglichen. +`Abrechenbar` und `Nicht abrechenbar` zeigen bewertete Sessions, die nicht als abgerechnet gelten. Diese Sessions werden nicht mit Teamspace verglichen. `Offen` zeigt Sessions ohne Bewertung. Diese blockieren den Monatsabschluss. @@ -465,7 +480,7 @@ Diese Card kombiniert den Tagesverlauf und die Bewertungsqualitaet. - Balken zeigen abgerechnete Session-Zeit. - Die Linie zeigt Teamspace-Zeit. -- Der Bewertungsstand trennt abgerechnete, nicht abrechenbare und offene Sessions. +- Der Bewertungsstand trennt abgerechnete, abrechenbare, nicht abrechenbare und offene Sessions. - Der Teamspace-Abgleich zeigt die Gesamtdifferenz. - Manuell und Fix trennen manuell erfasste abrechenbare Sessions von automatisch erzeugten abrechenbaren Sessions. @@ -477,7 +492,7 @@ Sichtbar sind: - Organisation - Sessions -- Zeiten: Abgerechnet, nicht abrechenbar und Teamspace +- Zeiten: Abgerechnet, abrechenbar, nicht abrechenbar und Teamspace - Differenz - Anteil an der abgerechneten Monatszeit @@ -495,7 +510,7 @@ Sie zeigt je Typ: - Nicht abrechenbare Session-Zeit - Teamspace-Zeit - Differenz -- Anzahl abgerechnet, nicht abrechenbar und offen +- Anzahl abgerechnet, abrechenbar, nicht abrechenbar und offen ### Ticket-Hotspots @@ -511,6 +526,9 @@ Unter `Profil` kann jeder Benutzer seine eigenen Daten pflegen: - Voller Name - Passwort - persoenlicher Zammad API-Token +- Bewertungsabfrage beim Timerabschluss +- interne Zammad-Notiz beim Timerabschluss +- Zammad-Zeitaufrundung fuer `time_unit` Passwortaenderungen erfordern das aktuelle Passwort. Bleiben die Passwortfelder leer, werden nur Benutzername und voller Name gespeichert. @@ -520,6 +538,14 @@ Die Pruefung findet nur statt, wenn der Token im Profil neu eingetragen oder ent Der persoenliche Zammad Token ist unabhaengig vom Admin-Token fuer den Organisations-Sync. Der gleiche Token kann technisch in beiden Bereichen eingetragen sein, wird aber getrennt gespeichert und getrennt verwendet. Im Admin-Profil gibt es keinen persoenlichen Zammad-Token. +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. + +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. + +Unter `Geparkte Zammad-Notizen` zeigt das Profil interne Notizen, die wegen eines Zammad-Fehlers noch nicht uebertragen wurden. Mit `Senden` werden die offenen Eintraege erneut nach Zammad gepusht. + ## Adminbereich Der feste Admin ist nur fuer die Systemverwaltung vorgesehen. Er kann: @@ -562,7 +588,7 @@ 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 `Abgerechnet` oder `Nicht abrechenbar` markieren. +6. Pro Ticket jede Session als `Abrechenbar`, `Abgerechnet` oder `Nicht abrechenbar` markieren. 7. Pro Tag und Ticket die wirklich in Teamspace eingetragene Zeit im Feld `Teamspace` pflegen. 8. `Monatsabschluss` oeffnen. 9. Offene Bewertungen abarbeiten. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 42e282b..ea8f69a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1073,6 +1073,8 @@ export function App() { startedAt: now, pausedTotalMs: 0, pausedAt: null, + activity: "", + billingStatus: null, phase: "running" }; @@ -1472,6 +1474,7 @@ export function App() { selectedTimerId={selectedTimerId} setSelectedTimerId={setSelectedTimerId} tick={tick} + currentUser={currentUser} /> ) : null} {!isAdmin && route.page === "dashboard" ? : null} @@ -1492,6 +1495,7 @@ export function App() { selectedTimerId={selectedTimerId} setSelectedTimerId={setSelectedTimerId} tick={tick} + currentUser={currentUser} /> ) : null} {!isAdmin && route.page === "ticket" ? ( diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ed6fae5..990b110 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -16,7 +16,8 @@ import type { TicketSearchResult, TodoPriority, TodoStatus, - WorkType + WorkType, + ZammadSessionArticleQueueItem } from "./types"; type CreateSessionPayload = { @@ -29,6 +30,8 @@ type CreateSessionPayload = { durationSeconds: number; zammadLookup?: boolean; projectTeamspaceEntryId?: string | null; + billingStatus?: BillingStatus; + createZammadArticle?: boolean; }; type DeleteSessionResult = { @@ -198,6 +201,10 @@ export function updateCurrentUser(payload: { newPassword?: string; zammadApiToken?: string; zammadEmailSessionWebhookEnabled?: boolean; + sessionBillingPromptEnabled?: boolean; + zammadSessionNoteDefaultEnabled?: boolean; + zammadTimeUnitRoundingEnabled?: boolean; + zammadTimeUnitRoundingMinutes?: number; }) { return request<{ user: AuthUser }>("/api/auth/me", { method: "PATCH", @@ -681,6 +688,21 @@ export function createSession(payload: CreateSessionPayload) { }); } +export function getZammadSessionArticleQueue() { + return request<{ queue: ZammadSessionArticleQueueItem[] }>("/api/zammad/session-article-queue"); +} + +export function retryZammadSessionArticleQueue() { + return request<{ + sent: number; + failed: number; + queue: ZammadSessionArticleQueueItem[]; + results: Array<{ id: string; status: "sent" | "failed"; error?: string; zammadArticleId?: number | null }>; + }>("/api/zammad/session-article-queue/retry", { + method: "POST" + }); +} + export function lookupTicket(ticketNumber: string, source: "local" | "zammad" = "local") { const params = new URLSearchParams({ ticketNumber }); @@ -771,7 +793,7 @@ export function getTicketDetails( ticketId: string, filters: { month?: string; - status?: "all" | "open" | "billed" | "non_billable"; + status?: "all" | "open" | "billable" | "billed" | "non_billable"; source?: "all" | "manual" | "recurring"; } = {} ) { diff --git a/frontend/src/help-content.ts b/frontend/src/help-content.ts index f27e1ca..361b6ca 100644 --- a/frontend/src/help-content.ts +++ b/frontend/src/help-content.ts @@ -34,19 +34,20 @@ export const helpSections: HelpSection[] = [ title: "Monatsworkflow", description: "Der empfohlene Ablauf vom Start einer Arbeitssession bis zum sauberen Monatsabschluss.", quickItems: [ - "Während des Monats: Ticketnummer eingeben, Timer starten, Session beenden und Tätigkeit dokumentieren.", + "Während des Monats: Ticketnummer eingeben, Timer starten, Tätigkeit dokumentieren und Session beenden.", "Vergessene Zeiten: Session manuell auf dem Ticket oder als neues Ticket nachtragen.", "Am Monatsende: offene Bewertungen bearbeiten, Teamspace-Tageswerte pflegen, Kontrolle durchführen und Monat schließen." ], details: [ "Der normale Einstieg ist das Ticketfeld im Kopfbereich oder die Seite Session starten. Dort gibst du die Ticketnummer ein und startest den Timer mit Timer starten.", "Während der Arbeit läuft genau ein Timer aktiv. Wechselst du zu einem anderen Timer, pausiert TicketTracker die übrigen Timer automatisch.", - "Wenn die Arbeit erledigt ist, beendest du den Timer. Die Zeit stoppt sofort beim Klick auf Beenden; das anschließende Ausfüllen der Tätigkeit zählt nicht mehr zur Sessionzeit.", - "Im Beenden-Dialog trägst du die Tätigkeit ein. Wenn das Ticket bereits bekannt ist, übernimmt TicketTracker Organisation und Art automatisch. Bei neuen Tickets wählst du Organisation und Art zusätzlich aus.", + "Während der Timer läuft oder pausiert ist, trägst du die Tätigkeit direkt in den Timer-Details ein. Pausierte Timer behalten diese Notizen.", + "Wenn die Arbeit erledigt ist, beendest du den Timer. Die Zeit stoppt sofort beim Klick auf Beenden; die bereits erfasste Tätigkeit wird mit der Session gespeichert.", + "Wenn das Ticket bereits bekannt ist, übernimmt TicketTracker Organisation und Art automatisch. Bei neuen Tickets wählst du Organisation und Art beim Speichern zusätzlich aus.", "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 Abgerechnet oder Nicht abrechenbar. Ein erneuter Klick auf den aktiven Status nimmt die Bewertung zurück.", + "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 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.", @@ -59,7 +60,7 @@ export const helpSections: HelpSection[] = [ "Ticketfeld im Kopfbereich oder Session starten öffnen.", "Ticketnummer eingeben und Timer starten.", "Arbeit erledigen; bei Bedarf zwischen vorbereiteten Timern wechseln.", - "Timer beenden und Tätigkeit speichern.", + "Tätigkeit im Timer erfassen und Timer beenden.", "Vergessene Zeiten über Session nachtragen ergänzen.", "Tickets öffnen, Monat wählen und Einträge prüfen.", "Ticket öffnen, Sessions pro Tag bewerten und Teamspace-Tageswerte speichern.", @@ -71,8 +72,8 @@ export const helpSections: HelpSection[] = [ "Ticketnummer eingeben", "Timer starten", "Arbeit am Ticket erledigen", + "Tätigkeit im Timer eintragen", "Timer beenden", - "Tätigkeit eintragen", "Session wird dem Ticket und Benutzer gespeichert", "Optional: vergessene Session nachtragen", "Tickets öffnen und Ticket prüfen", @@ -111,17 +112,21 @@ export const helpSections: HelpSection[] = [ quickItems: [ "Ticketnummer eingeben und Timer starten.", "Aktivieren eines Timers pausiert alle anderen Timer automatisch.", - "Beim Beenden stoppt die Zeit sofort; Details werden danach gespeichert." + "Die Tätigkeit wird im ausgewählten Timer erfasst und beim Beenden gespeichert." ], details: [ "Neue Timer werden mit einer Ticketnummer im Format Ticket#XXXXXX gestartet.", "Alle laufenden und pausierten Timer bleiben in der Seitenleiste sichtbar, damit du auf jeder Unterseite sehen kannst, was vorbereitet oder aktiv ist.", "Wenn ein anderer Timer aktiviert wird, pausiert TicketTracker die übrigen Timer automatisch. Dadurch kann nie versehentlich auf zwei Tickets gleichzeitig Zeit laufen.", - "Beim Klick auf Beenden wird die Stoppuhr sofort angehalten. Das Speichern der Tätigkeit findet danach statt und verlängert die gemessene Zeit nicht.", - "Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Art. Dann muss beim Beenden nur noch die Tätigkeit eingetragen werden.", + "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.", + "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.", "Bei Projekt-Tickets mit genau einem Teamspace-Eintrag wird die Session automatisch diesem Eintrag zugeordnet.", - "Existiert das Ticket noch nicht, müssen Organisation, Tätigkeit und Art angegeben werden.", + "Existiert das Ticket noch nicht, müssen Organisation und Art angegeben werden.", "Pausen werden von der Gesamtdauer abgezogen. Die gespeicherte Zeit wird auf volle Minuten gerundet.", "Der Schnellstart im Kopfbereich funktioniert auf allen Unterseiten und nutzt dieselbe Timerlogik." ] @@ -131,7 +136,7 @@ export const helpSections: HelpSection[] = [ title: "Session Nachtragen", description: "Nachtragen ist für vergessene Zeiten gedacht, wenn kein Timer gelaufen ist.", quickItems: [ - "Datum, Von, Bis und Tätigkeit eintragen.", + "Datum, Von, Bis, Bewertung und Tätigkeit eintragen.", "Benutzer tragen eigene Sessions nach.", "Tätigkeiten sind mehrzeilig." ], @@ -140,7 +145,7 @@ export const helpSections: HelpSection[] = [ "Das Ende muss nach dem Beginn liegen. Übernacht-Sessions sind aktuell nicht vorgesehen und sollten als getrennte Einträge gepflegt werden.", "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.", - "Nachgetragene Sessions starten immer offen. Sie müssen später als Abgerechnet oder Nicht abrechenbar bewertet werden.", + "Mit Bewertung --- bleibt die Session offen. Abrechenbar oder Nicht abrechenbar werden direkt an der neuen Session gespeichert.", "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." ] @@ -214,7 +219,7 @@ export const helpSections: HelpSection[] = [ details: [ "Oben steht die Ticketnummer mit Copy-Icon, damit sie schnell in andere Systeme übernommen werden kann.", "Die Session-Einträge sind nach Kalendertagen gruppiert. Pro Tagesgruppe steht die Summe der abgerechneten Zeit.", - "Nicht abrechenbare und offene Zeiten werden neben der abgerechneten Tageszeit separat angezeigt.", + "Abrechenbare, nicht abrechenbare und offene Zeiten werden neben der abgerechneten Tageszeit separat angezeigt.", "Bei Projekt-Tickets mit mehreren Teamspace-Einträgen zeigt jede zugeordnete Session den gewählten Teamspace-Eintrag. Nicht zugeordnete Sessions werden hervorgehoben.", "Das Teamspace-Feld wird als Stundenwert gepflegt, zum Beispiel 1, 1,5 oder 2.25.", "Während der Eingabe wird der Teamspace-Wert nicht sofort gespeichert. Sobald eine Änderung vorliegt, erscheint ein Speichern-Button.", @@ -230,17 +235,18 @@ export const helpSections: HelpSection[] = [ { id: "sessions-bewerten", title: "Sessions Bewerten", - description: "Bewertungen bestimmen, ob eine Session abgerechnet wurde oder bewusst nicht abrechenbar ist.", + description: "Bewertungen bestimmen, ob eine Session offen, abrechenbar, abgerechnet oder bewusst nicht abrechenbar ist.", quickItems: [ "Jede Session braucht genau eine Bewertung.", - "Abgerechnet und Nicht abrechenbar können durch erneuten Klick zurückgenommen werden.", + "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.", "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.", - "Nicht abrechenbare Sessions zählen als bewertet. Sie blockieren den Monatsabschluss nicht.", + "Abrechenbare und nicht abrechenbare Sessions zählen als bewertet. Sie blockieren den Monatsabschluss nicht.", "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." ] @@ -378,12 +384,12 @@ export const helpSections: HelpSection[] = [ ], details: [ "Die Statistikseite betrachtet immer einen Monat.", - "Abgerechnet zeigt nur Sessions mit der Bewertung Abgerechnet. Offene Zeiten sind dort nicht enthalten.", - "Alle Statistik- und Vergleichswerte nutzen nur abgerechnete Zeiten.", - "Teamspace zeigt, wie viel Zeit für dieselben abrechenbaren Ticket-Tage im CRM eingetragen wurde.", + "Abgerechnet zeigt nur Sessions mit der Bewertung Abgerechnet. Abrechenbare, nicht abrechenbare und offene Zeiten sind dort nicht enthalten.", + "Teamspace-Vergleiche nutzen nur abgerechnete Zeiten.", + "Teamspace zeigt, wie viel Zeit für dieselben abgerechneten Ticket-Tage im CRM eingetragen wurde.", "Eine positive Differenz bedeutet: In Teamspace steht mehr Zeit als abgerechnet wurde.", "Eine negative Differenz bedeutet: In Teamspace steht weniger Zeit als abgerechnet wurde.", - "Offene Sessions werden nicht in Relationen, Graphen, Teamspace-Differenzen, Hotspots, Manuell/Fix-Werte oder Durchschnittswerte eingerechnet.", + "Abrechenbare, nicht abrechenbare und offene Sessions werden nicht in Teamspace-Differenzen, Hotspots, Manuell/Fix-Werte oder Durchschnittswerte eingerechnet.", "Der Monatsverlauf zeigt abgerechnete Session-Zeit als Balken und Teamspace als Linie.", "Organisationen, Typen und Ticket-Hotspots helfen dabei, Auffälligkeiten im Monat schnell zu finden." ] @@ -391,7 +397,7 @@ export const helpSections: HelpSection[] = [ { id: "statistik-getrackt", title: "Statistik: Abgerechnet", - description: "Diese Kennzahl zeigt die abrechenbare Session-Zeit im gewählten Monat.", + description: "Diese Kennzahl zeigt die abgerechnete Session-Zeit im gewählten Monat.", quickItems: [ "Summe aller Sessions mit Bewertung Abgerechnet.", "Zeigt zusätzlich die abgerechnete Sessionanzahl.", @@ -399,7 +405,7 @@ export const helpSections: HelpSection[] = [ ], details: [ "Abgerechnet umfasst nur Sessions des angemeldeten Benutzers, die aktiv als Abgerechnet markiert wurden.", - "Offene Sessions werden aus diesem Wert herausgehalten.", + "Abrechenbare, nicht abrechenbare und offene Sessions werden aus diesem Wert herausgehalten.", "Auch automatisch erzeugte Sessions aus fixen Abrechnungen zählen hier mit, sobald sie als Abgerechnet markiert wurden.", "Es werden keine offenen Zeiten in diesen Wert eingerechnet.", "Dieser Wert ist die Ausgangsbasis für Teamspace-Differenzen: Teamspace minus abgerechnete Session-Zeit ergibt die Differenz." @@ -419,7 +425,7 @@ export const helpSections: HelpSection[] = [ "Die Statistik summiert alle Teamspace-Werte des Monats für den angemeldeten Benutzer.", "Eine positive Differenz bedeutet, dass in Teamspace mehr Zeit steht als abgerechnet wurde.", "Eine negative Differenz bedeutet, dass in Teamspace weniger Zeit steht als abgerechnet wurde.", - "Offene Sessions fließen nicht in diese Differenz ein.", + "Abrechenbare, nicht abrechenbare und offene Sessions fließen nicht in diese Differenz ein.", "Fehlende Teamspace-Werte können im Monatsabschluss geprüft und bei Bedarf zur Kenntnis genommen werden." ] }, @@ -453,7 +459,7 @@ export const helpSections: HelpSection[] = [ "Der Monatsverlauf zeigt jeden Tag des Monats. Tage ohne Sessions bleiben leer, damit die Monatsstruktur sichtbar bleibt.", "Die blauen Balken stehen für abgerechnete Session-Zeit.", "Die fuchsiafarbene Linie steht für Teamspace-Zeit, sofern für den Tag Werte gepflegt wurden.", - "Der Bewertungsstand weist Abgerechnet und Offen getrennt aus.", + "Der Bewertungsstand weist Abgerechnet, Abrechenbar, Nicht abrechenbar und Offen getrennt aus.", "Der Teamspace-Abgleich zeigt die Gesamtdifferenz zwischen CRM-Werten und abgerechneter Session-Zeit.", "Manuell und Fix trennen selbst erfasste abrechenbare Sessions von automatisch erzeugten abrechenbaren Sessions aus fixen Abrechnungen." ] @@ -490,7 +496,7 @@ export const helpSections: HelpSection[] = [ "Support und Consulting werden getrennt summiert.", "Die Zeitwerte zeigen abgerechnete Sessions und Teamspace für den jeweiligen Typ.", "Die Differenz folgt derselben Logik wie überall: Teamspace minus abgerechnete Session-Zeit.", - "Die Zeile Abr. und Offen zeigt, wie viele Sessions dieses Typs bereits abgerechnet oder noch offen sind." + "Die Zeile Abr., Abrechenbar, Nicht abrechenbar und Offen zeigt, wie viele Sessions dieses Typs bereits abgerechnet, als abrechenbar markiert, bewusst nicht abrechenbar oder noch offen sind." ] }, { @@ -530,6 +536,10 @@ export const helpSections: HelpSection[] = [ "Die gespeicherte Zammad User-ID wird danach read-only im Profil angezeigt.", "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.", + "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.", "Admins können Benutzer ebenfalls verwalten, aber jeder Benutzer kann die eigenen Daten jederzeit selbst pflegen." ] diff --git a/frontend/src/timers.ts b/frontend/src/timers.ts index 8cff96e..3e1d759 100644 --- a/frontend/src/timers.ts +++ b/frontend/src/timers.ts @@ -1,3 +1,5 @@ +import type { BillingStatus } from "./types"; + export type TimerPhase = "running" | "paused"; export type TimerEntry = { @@ -8,6 +10,8 @@ export type TimerEntry = { isProject?: boolean; projectName?: string | null; ticketLookupDone?: boolean; + activity: string; + billingStatus: BillingStatus; startedAt: number; pausedTotalMs: number; pausedAt: number | null; @@ -59,23 +63,29 @@ export function normalizeTimers(timers: TimerEntry[]) { return timers .filter((timer) => ticketPattern.test(timer.ticketNumber)) .map((timer) => { + const normalizedTimer = { + ...timer, + activity: typeof timer.activity === "string" ? timer.activity : "", + billingStatus: timer.billingStatus === "billed" ? "billable" : timer.billingStatus === "billable" || timer.billingStatus === "non_billable" ? timer.billingStatus : null, + }; + if (timer.phase !== "running") { return { - ...timer, + ...normalizedTimer, phase: "paused" as const, - pausedAt: timer.pausedAt ?? now, + pausedAt: normalizedTimer.pausedAt ?? now, }; } if (!runningSeen) { runningSeen = true; return { - ...timer, + ...normalizedTimer, pausedAt: null, }; } - return pauseEntry(timer, now); + return pauseEntry(normalizedTimer, now); }); } @@ -106,13 +116,15 @@ export function readStoredTimers(userId: string): TimerEntry[] { } try { - const parsed = JSON.parse(legacy) as Omit; + const parsed = JSON.parse(legacy) as Omit & { activity?: string; billingStatus?: BillingStatus }; localStorage.removeItem(legacyStorageKey); return normalizeTimers([ { ...parsed, id: createClientId("timer"), + activity: typeof parsed.activity === "string" ? parsed.activity : "", + billingStatus: parsed.billingStatus === "billed" ? "billable" : parsed.billingStatus === "billable" || parsed.billingStatus === "non_billable" ? parsed.billingStatus : null, }, ]); } catch { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b0f4cd8..b2ff70a 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,5 +1,5 @@ export type WorkType = "support" | "consulting"; -export type BillingStatus = "billed" | "non_billable" | null; +export type BillingStatus = "billable" | "billed" | "non_billable" | null; export type PeriodType = "month" | "day"; export type TicketDetailPeriodType = PeriodType | "all"; export type UserRole = "admin" | "user"; @@ -13,6 +13,10 @@ export type AuthUser = { zammad_user_id: string | null; has_zammad_api_token: boolean; zammad_email_session_webhook_enabled: boolean; + session_billing_prompt_enabled: boolean; + zammad_session_note_default_enabled: boolean; + zammad_time_unit_rounding_enabled: boolean; + zammad_time_unit_rounding_minutes: number; }; export type DashboardWidgetType = "dayControl" | "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes"; @@ -63,6 +67,22 @@ export type AdminUser = AuthUser & { updated_at: string; }; +export type ZammadSessionArticleQueueItem = { + id: string; + session_id: string; + ticket_id: string; + ticket_number: string; + zammad_ticket_id: string | null; + activity: string; + time_unit: number; + attempt_count: number; + last_error: string | null; + last_attempt_at: string | null; + created_at: string; + started_at: string; + rounded_minutes: number; +}; + export type Organization = { id: string; zammad_id: string; @@ -231,10 +251,12 @@ export type TicketSummary = { session_count: number; total_minutes: number; crm_billed_minutes: number; + billable_minutes: number; non_billable_minutes: number; open_minutes: number; open_count: number; ticket_open_count: number; + billable_count: number; billed_count: number; non_billable_count: number; recurring_session_count: number; @@ -277,6 +299,7 @@ export type PeriodOverview = { sessions: number; minutes: number; crmBilledMinutes: number; + billableMinutes: number; nonBillableMinutes: number; openMinutes: number; openSessions: number; @@ -324,7 +347,7 @@ export type TicketPeriod = { closedAt: string | null; filters?: { month: string; - status: "all" | "open" | "billed" | "non_billable"; + status: "all" | "open" | "billable" | "billed" | "non_billable"; source: "all" | "manual" | "recurring"; }; availableMonths?: TicketAvailableMonth[]; @@ -361,9 +384,11 @@ export type StatisticsDailyBucket = { sessions: number; total_minutes: number; billed_minutes: number; + billable_minutes: number; non_billable_minutes: number; open_minutes: number; billed_sessions: number; + billable_sessions: number; non_billable_sessions: number; open_sessions: number; crm_billed_minutes: number; @@ -377,9 +402,11 @@ export type StatisticsGroup = { sessions: number; total_minutes: number; billed_minutes: number; + billable_minutes: number; non_billable_minutes: number; open_minutes: number; billed_sessions: number; + billable_sessions: number; non_billable_sessions: number; open_sessions: number; crm_billed_minutes: number; @@ -395,9 +422,11 @@ export type StatisticsTicket = { sessions: number; total_minutes: number; billed_minutes: number; + billable_minutes: number; non_billable_minutes: number; open_minutes: number; billed_sessions: number; + billable_sessions: number; non_billable_sessions: number; open_sessions: number; crm_billed_minutes: number; @@ -448,9 +477,11 @@ export type StatisticsOverview = { sessions: number; minutes: number; billedMinutes: number; + billableMinutes: number; nonBillableMinutes: number; openMinutes: number; billedSessions: number; + billableSessions: number; nonBillableSessions: number; openSessions: number; recurringMinutes: number; diff --git a/frontend/src/views/AnalysisPage.tsx b/frontend/src/views/AnalysisPage.tsx index d92d2f8..ad1cd80 100644 --- a/frontend/src/views/AnalysisPage.tsx +++ b/frontend/src/views/AnalysisPage.tsx @@ -443,6 +443,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) { { label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket }, { label: "Sessions", value: totals?.sessions ?? 0, icon: Users }, { label: "Abgerechnet", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 }, + { label: "Abrechenbar", value: formatMinutes(totals?.billableMinutes ?? 0), icon: UserCheck }, { label: "Nicht abrechenbar", value: formatMinutes(totals?.nonBillableMinutes ?? 0), icon: UserCheck }, { label: "Offen", value: totals?.openSessions ?? 0, detail: formatMinutes(totals?.openMinutes ?? 0), icon: UserCheck } ]; @@ -786,8 +787,8 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {

{formatMinutes(ticket.total_minutes)}

-

Sessions

-

{ticket.billed_count}

+

Abrechenbar

+

{ticket.billable_count}

+ + + +
+ {loadingZammadQueue ?

Lädt...

: null} + {!loadingZammadQueue && zammadQueue.length === 0 ?

Keine geparkten Zammad-Notizen.

: null} + {zammadQueue.slice(0, 5).map((item) => ( +
+
+ {item.ticket_number} + {formatDateTime(item.started_at)} + {formatMinutes(item.rounded_minutes)} + time_unit {item.time_unit} +
+ {item.last_error ?

{item.last_error}

: null} +
+ ))} + {zammadQueue.length > 5 ?

Weitere {zammadQueue.length - 5} Einträge werden beim Senden ebenfalls berücksichtigt.

: null} +
+ ) : null} diff --git a/frontend/src/views/StatisticsPage.tsx b/frontend/src/views/StatisticsPage.tsx index a2879ff..b8d201b 100644 --- a/frontend/src/views/StatisticsPage.tsx +++ b/frontend/src/views/StatisticsPage.tsx @@ -7,6 +7,7 @@ import { RotateCcw, Ticket, TrendingUp, + UserCheck, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -36,6 +37,7 @@ const teamspaceStrokeClass = "text-fuchsia-600 dark:text-fuchsia-300"; const trackedDotClass = "bg-sky-500 dark:bg-sky-400"; const teamspaceDotClass = "bg-fuchsia-500 dark:bg-fuchsia-400"; const openTextClass = "text-amber-700 dark:text-amber-300"; +const nonBillableTextClass = "text-indigo-700 dark:text-indigo-300"; const organizationPageSize = 8; function workTypeLabel(workType: WorkType | undefined) { @@ -115,6 +117,14 @@ function ColorLegend() { Teamspace + + + Abrechenbar + + + + Nicht abrechenbar + Differenz plus oder ausgeglichen @@ -307,7 +317,7 @@ function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organizat
{organization.organization_name ?? "Keine Organisation"}
- {organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet + {organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet · {organization.billable_sessions} abrechenbar
{organization.billed_sessions} @@ -338,6 +348,7 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket Abgerechnet {formatMinutes(ticket.total_minutes)} Teamspace {formatMinutes(ticket.crm_billed_minutes)} {ticket.billed_sessions} abgerechnet + {ticket.billable_sessions} abrechenbar {ticket.active_days} Tag(e) {formatTeamspaceDelta(ticket.crm_delta_minutes)} @@ -413,8 +424,13 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) { const totals = stats?.totals; const billedSessions = totals?.billedSessions ?? 0; const billedMinutes = totals?.billedMinutes ?? 0; + const billableSessions = totals?.billableSessions ?? 0; + const billableMinutes = totals?.billableMinutes ?? 0; + const nonBillableSessions = totals?.nonBillableSessions ?? 0; + const nonBillableMinutes = totals?.nonBillableMinutes ?? 0; const openSessions = totals?.openSessions ?? 0; const openMinutes = totals?.openMinutes ?? 0; + const evaluatedSessions = billedSessions + billableSessions + 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; @@ -460,7 +476,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) { -
+
+ - {strongestDay ?

Stärkster abrechenbarer Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}

: null} + {strongestDay ?

Stärkster abgerechneter Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}

: null}
Bewertungsstand - {billedSessions} abrechenbar + {evaluatedSessions} bewertet
Abgerechnet: {billedSessions} Session(s), {formatMinutes(billedMinutes)}
+
Abrechenbar: {billableSessions} Session(s), {formatMinutes(billableMinutes)}
+
Nicht abrechenbar: {nonBillableSessions} Session(s), {formatMinutes(nonBillableMinutes)}
Offen: {openSessions} Session(s), {formatMinutes(openMinutes)}
@@ -615,7 +641,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {

{organization.organization_name ?? "Keine Organisation"}

-

{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet

+

{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet · {organization.billable_sessions} abrechenbar

@@ -684,7 +710,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {

{workTypeLabel(group.work_type)}

-

{group.tickets} Ticket(s), {group.billed_sessions} abgerechnet

+

{group.tickets} Ticket(s), {group.billed_sessions} abgerechnet, {group.billable_sessions} abrechenbar

@@ -693,8 +719,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) { Abgerechnet {formatMinutes(group.total_minutes)} Teamspace {formatMinutes(group.crm_billed_minutes)}
-
+
Abr. {group.billed_sessions} / {formatMinutes(group.billed_minutes)} + Abrechb. {group.billable_sessions} / {formatMinutes(group.billable_minutes)} Offen {group.open_sessions} / {formatMinutes(group.open_minutes)}
diff --git a/frontend/src/views/TicketDetailPage.tsx b/frontend/src/views/TicketDetailPage.tsx index 1dcadc3..f5793cf 100644 --- a/frontend/src/views/TicketDetailPage.tsx +++ b/frontend/src/views/TicketDetailPage.tsx @@ -59,6 +59,7 @@ type TicketSessionFormState = { organizationName: string | null; projectTeamspaceEntryId: string; activity: string; + billingStatus: BillingStatus; workType: WorkType; day: string; startTime: string; @@ -90,6 +91,7 @@ function emptyTicketSessionForm(): TicketSessionFormState { organizationName: null, projectTeamspaceEntryId: "", activity: "", + billingStatus: null, workType: "support", day: currentDay(), startTime: "09:00", @@ -174,7 +176,17 @@ function BillingButtons({ const status = session.billing_status; return ( -
+
+
@@ -1657,7 +1697,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO

{group.label}

{group.sessions.length} Session(s)

-
+