From b67dbad0b11a0b5ec5367f45b832308a1d5de5ea Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Tue, 1 Sep 2026 10:23:51 +0200 Subject: [PATCH] Add global ticket history view --- backend/src/index.ts | 289 ++++++++++++++++++++++-- frontend/src/App.tsx | 29 ++- frontend/src/api.ts | 25 ++ frontend/src/types.ts | 14 +- frontend/src/views/TicketDetailPage.tsx | 103 +++++++-- 5 files changed, 421 insertions(+), 39 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index e0efb21..54d8e66 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -84,6 +84,38 @@ function parseOptionalBilledMinutes(value: unknown) { return minutes; } +function optionalTicketSessionMonth(value: unknown) { + if (value === undefined || value === null || value === "" || value === "all") { + return null; + } + + return parseMonth(String(value)); +} + +function optionalTicketSessionStatus(value: unknown) { + if (value === undefined || value === null || value === "" || value === "all") { + return "all" as const; + } + + if (value === "open" || value === "billed" || value === "non_billable") { + return value; + } + + throw badRequest("status must be all, open, billed or non_billable"); +} + +function optionalTicketSessionSource(value: unknown) { + if (value === undefined || value === null || value === "" || value === "all") { + return "all" as const; + } + + if (value === "manual" || value === "recurring") { + return value; + } + + throw badRequest("source must be all, manual or recurring"); +} + async function hasAdminUser() { const result = await query<{ exists: boolean }>("SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin') AS exists;"); return Boolean(result.rows[0]?.exists); @@ -1167,6 +1199,171 @@ 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" }) { + if (filters.month) { + await ensureRecurringSessionsForUserPeriod(userId, filters.month.startIso, filters.month.endIso); + } + + const ticketResult = await query( + ` + SELECT + t.id, + t.ticket_number, + t.organization_id, + o.name AS organization_name, + COALESCE(o.name, t.customer_name) AS customer_name, + t.work_type, + NULL::timestamptz AS closed_at, + NULL::timestamptz AS period_closed_at, + COALESCE(all_sessions.session_count, 0)::int AS ticket_session_count, + COALESCE(all_sessions.open_count, 0)::int AS ticket_open_count, + COALESCE(all_sessions.manual_session_count, 0)::int AS ticket_manual_session_count + FROM tickets t + LEFT JOIN organizations o ON o.zammad_id = t.organization_id + LEFT JOIN LATERAL ( + SELECT + COUNT(*)::int AS session_count, + COUNT(*) FILTER (WHERE sm.billing_status IS NULL)::int AS open_count, + COUNT(*) FILTER (WHERE sm.recurring_billing_id IS NULL)::int AS manual_session_count + FROM sessions sm + WHERE sm.ticket_id = t.id + AND sm.user_id = $2 + ) all_sessions ON true + WHERE t.id = $1; + `, + [ticketId, userId] + ); + + if (ticketResult.rowCount === 0) { + return null; + } + + const params: unknown[] = [ticketId, userId]; + const where = ["s.ticket_id = $1", "s.user_id = $2"]; + + if (filters.month) { + params.push(filters.month.startIso, filters.month.endIso); + where.push(`s.started_at >= $${params.length - 1}::timestamptz`); + where.push(`s.started_at < $${params.length}::timestamptz`); + } + + if (filters.status === "open") { + where.push("s.billing_status IS NULL"); + } else if (filters.status !== "all") { + params.push(filters.status); + where.push(`s.billing_status = $${params.length}`); + } + + if (filters.source === "manual") { + where.push("s.recurring_billing_id IS NULL"); + } else if (filters.source === "recurring") { + where.push("s.recurring_billing_id IS NOT NULL"); + } + + const sessionWhere = where.join(" AND "); + const [sessionsResult, dayBillingResult, monthsResult, closureResult] = await Promise.all([ + query( + ` + SELECT + s.id, + s.organization_id, + ( + SELECT name + FROM organizations + WHERE zammad_id = s.organization_id + ) AS organization_name, + COALESCE( + ( + SELECT name + FROM organizations + WHERE zammad_id = s.organization_id + ), + s.customer_name + ) AS customer_name, + s.activity, + s.work_type, + s.started_at, + s.ended_at, + s.duration_seconds, + s.rounded_minutes, + s.billing_status, + s.billing_updated_at, + s.created_at, + s.recurring_billing_id, + s.recurring_billing_slot_id, + s.recurring_occurrence_date + FROM sessions s + WHERE ${sessionWhere} + ORDER BY s.started_at ASC; + `, + params + ), + query( + ` + WITH filtered_days AS ( + SELECT DISTINCT (s.started_at AT TIME ZONE 'Europe/Berlin')::date AS day + FROM sessions s + WHERE ${sessionWhere} + ) + SELECT tdb.day::text AS day, tdb.billed_minutes + FROM ticket_day_billings tdb + JOIN filtered_days fd ON fd.day = tdb.day + WHERE tdb.ticket_id = $1 + AND tdb.user_id = $2 + ORDER BY tdb.day ASC; + `, + params + ), + query( + ` + SELECT + to_char(s.started_at AT TIME ZONE 'Europe/Berlin', 'YYYY-MM') AS month, + COUNT(*)::int AS session_count + FROM sessions s + WHERE s.ticket_id = $1 + AND s.user_id = $2 + GROUP BY month + ORDER BY month DESC; + `, + [ticketId, userId] + ), + filters.month + ? query<{ closed_at: string }>("SELECT closed_at FROM month_closures WHERE month = $1::date AND user_id = $2;", [filters.month.start, userId]) + : Promise.resolve({ rows: [] as { closed_at: string }[], rowCount: 0 }) + ]); + + const sessions = sessionsResult.rows; + const openCount = sessions.filter((session: any) => session.billing_status === null).length; + const manualSessionCount = sessions.filter((session: any) => session.recurring_billing_id === null).length; + const recurringSessionCount = sessions.length - manualSessionCount; + const ticket = ticketResult.rows[0] as any; + const closed = Boolean(filters.month && closureResult.rows.length > 0); + + return { + periodType: "all", + period: filters.month?.label ?? "all", + closed, + closedAt: closed ? closureResult.rows[0]?.closed_at ?? null : null, + filters: { + month: filters.month?.label ?? "all", + status: filters.status, + source: filters.source + }, + availableMonths: monthsResult.rows, + ticket: { + ...ticket, + requires_closure: false, + recurring_session_count: recurringSessionCount, + manual_session_count: manualSessionCount + }, + sessions, + dayBillings: dayBillingResult.rows, + canClose: false, + openCount, + ticketOpenCount: Number(ticket.ticket_open_count ?? 0) + }; +} + app.get("/api/setup/status", async (_req, res) => { res.json({ required: !(await hasAdminUser()) }); }); @@ -2737,6 +2934,22 @@ app.get("/api/tickets/search", requireUser, async (req, res) => { res.json({ tickets: result.rows }); }); +app.get("/api/tickets/:ticketId/details", requireUser, async (req, res) => { + const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); + const result = await getTicketDetails(ticketId, currentUser(req).id, { + month: optionalTicketSessionMonth(req.query.month), + status: optionalTicketSessionStatus(req.query.status), + source: optionalTicketSessionSource(req.query.source) + }); + + if (!result) { + res.status(404).json({ error: "Ticket not found" }); + return; + } + + res.json(result); +}); + app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber); @@ -3164,12 +3377,11 @@ app.patch("/api/sessions/:sessionId/billing", requireUser, async (req, res) => { s.ticket_id, date_trunc('month', s.started_at)::date AS month, date_trunc('day', s.started_at)::date AS day, - tm.closed_at AS ticket_month_closed_at + mc.closed_at AS month_closed_at FROM sessions s - LEFT JOIN ticket_month_closures tm - ON tm.ticket_id = s.ticket_id - AND tm.month = date_trunc('month', s.started_at)::date - AND tm.user_id = s.user_id + LEFT JOIN month_closures mc + ON mc.month = date_trunc('month', s.started_at AT TIME ZONE 'Europe/Berlin')::date + AND mc.user_id = s.user_id WHERE s.id = $1 AND s.user_id = $2; `, @@ -3181,8 +3393,8 @@ app.patch("/api/sessions/:sessionId/billing", requireUser, async (req, res) => { return; } - if (billingStatus !== null && lockResult.rows[0].ticket_month_closed_at) { - res.status(409).json({ error: "Ticket is closed for this period" }); + if (lockResult.rows[0].month_closed_at) { + res.status(409).json({ error: "Monat ist abgeschlossen" }); return; } @@ -3245,6 +3457,7 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { work_type: "support" | "consulting"; recurring_billing_id: string | null; recurring_work_type: "support" | "consulting" | null; + month_closed_at: string | null; }>( ` SELECT @@ -3253,9 +3466,13 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { s.started_at, s.work_type, s.recurring_billing_id, - rb.work_type AS recurring_work_type + rb.work_type AS recurring_work_type, + mc.closed_at AS month_closed_at FROM sessions s LEFT JOIN recurring_billings rb ON rb.id = s.recurring_billing_id + LEFT JOIN month_closures mc + ON mc.month = date_trunc('month', s.started_at AT TIME ZONE 'Europe/Berlin')::date + AND mc.user_id = s.user_id WHERE s.id = $1 AND s.user_id = $2 FOR UPDATE OF s; @@ -3268,6 +3485,20 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { } const current = currentResult.rows[0]; + const newMonthClosureResult = await client.query<{ closed_at: string }>( + ` + SELECT closed_at + FROM month_closures + WHERE month = date_trunc('month', $1::timestamptz AT TIME ZONE 'Europe/Berlin')::date + AND user_id = $2; + `, + [startedAt.toISOString(), userId] + ); + + if (current.month_closed_at || newMonthClosureResult.rows.length > 0) { + return "month-closed" as const; + } + const effectiveWorkType = current.recurring_billing_id ? current.recurring_work_type ?? current.work_type : workType; await reopenPeriodsForSession(client, current.ticket_id, new Date(current.started_at), userId); await reopenPeriodsForSession(client, current.ticket_id, startedAt, userId); @@ -3300,6 +3531,11 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { return; } + if (updated === "month-closed") { + res.status(409).json({ error: "Monat ist abgeschlossen" }); + return; + } + if (!updated) { res.status(404).json({ error: "Session not found" }); return; @@ -3313,28 +3549,46 @@ app.delete("/api/sessions/:sessionId", requireUser, async (req, res) => { const userId = currentUser(req).id; const deleted = await withTransaction(async (client) => { - const sessionResult = await client.query<{ + const currentResult = await client.query<{ id: string; ticket_id: string; started_at: string; recurring_billing_id: string | null; recurring_billing_slot_id: string | null; recurring_occurrence_date: string | null; + month_closed_at: string | null; }>( ` - DELETE FROM sessions - WHERE id = $1 - AND user_id = $2 - RETURNING id, ticket_id, started_at, recurring_billing_id, recurring_billing_slot_id, recurring_occurrence_date; + SELECT + s.id, + s.ticket_id, + s.started_at, + s.recurring_billing_id, + s.recurring_billing_slot_id, + s.recurring_occurrence_date, + mc.closed_at AS month_closed_at + FROM sessions s + LEFT JOIN month_closures mc + ON mc.month = date_trunc('month', s.started_at AT TIME ZONE 'Europe/Berlin')::date + AND mc.user_id = s.user_id + WHERE s.id = $1 + AND s.user_id = $2 + FOR UPDATE OF s; `, [sessionId, userId] ); - if (sessionResult.rows.length === 0) { + if (currentResult.rows.length === 0) { return null; } - const session = sessionResult.rows[0]; + const session = currentResult.rows[0]; + + if (session.month_closed_at) { + return "month-closed" as const; + } + + await client.query("DELETE FROM sessions WHERE id = $1 AND user_id = $2;", [sessionId, userId]); if (session.recurring_billing_id && session.recurring_billing_slot_id && session.recurring_occurrence_date) { await client.query( @@ -3388,6 +3642,11 @@ app.delete("/api/sessions/:sessionId", requireUser, async (req, res) => { return; } + if (deleted === "month-closed") { + res.status(409).json({ error: "Monat ist abgeschlossen" }); + return; + } + res.json({ deleted }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e63ffb5..44e3c7e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -70,7 +70,7 @@ import { StatisticsPage } from "./views/StatisticsPage"; import { SetupPage } from "./views/SetupPage"; import { TicketDetailPage } from "./views/TicketDetailPage"; import { TimerPage } from "./views/TimerPage"; -import type { AuthUser, PeriodType, TicketSearchResult } from "./types"; +import type { AuthUser, PeriodType, TicketDetailPeriodType, TicketSearchResult } from "./types"; import type { Dispatch, DragEvent, SetStateAction } from "react"; type NavMenuEntry = @@ -90,7 +90,7 @@ type OpenTicketNavItem = { key: string; ticketId: string; ticketNumber: string; - periodType: PeriodType; + periodType: TicketDetailPeriodType; period: string; path: string; dirty: boolean; @@ -111,13 +111,13 @@ function readOpenTickets(userId: string): OpenTicketNavItem[] { const parsed = JSON.parse(raw) as Partial[]; return parsed - .filter((item) => item.key && item.ticketId && item.ticketNumber && item.periodType && item.period && item.path) + .filter((item) => item.key && item.ticketId && item.ticketNumber && item.path) .map((item) => ({ key: String(item.key), ticketId: String(item.ticketId), ticketNumber: String(item.ticketNumber), - periodType: item.periodType === "day" ? "day" : "month", - period: String(item.period), + periodType: item.periodType === "day" ? "day" : item.periodType === "month" ? "month" : "all", + period: item.period ? String(item.period) : "all", path: String(item.path), dirty: false })); @@ -127,6 +127,17 @@ function readOpenTickets(userId: string): OpenTicketNavItem[] { } function routeFromPath(pathname: string) { + const ticketMatch = pathname.match(/^\/tickets\/(\d+)$/); + + if (ticketMatch) { + return { + page: "ticket" as const, + periodType: "all" as const, + period: "all", + ticketId: ticketMatch[1], + }; + } + const periodTicketMatch = pathname.match(/^\/analysis\/(month|day)\/([^/]+)\/tickets\/(\d+)$/); if (periodTicketMatch) { @@ -318,7 +329,7 @@ function HeaderTicketSearch({ className, onNavigate }: HeaderTicketSearchProps) }, [trimmedQuery]); function openTicket(ticket: TicketSearchResult) { - onNavigate(`/analysis/month/${ticket.latest_period}/tickets/${ticket.ticket_id}`); + onNavigate(`/tickets/${ticket.ticket_id}`); setQuery(""); setResults([]); setOpen(false); @@ -967,7 +978,11 @@ export function App() { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f83c2ee..8b5de4d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -373,6 +373,31 @@ export function getTicketPeriod(type: PeriodType, period: string, ticketId: stri return request(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}`); } +export function getTicketDetails( + ticketId: string, + filters: { + month?: string; + status?: "all" | "open" | "billed" | "non_billable"; + source?: "all" | "manual" | "recurring"; + } = {} +) { + const params = new URLSearchParams(); + + if (filters.month && filters.month !== "all") { + params.set("month", filters.month); + } + + if (filters.status && filters.status !== "all") { + params.set("status", filters.status); + } + + if (filters.source && filters.source !== "all") { + params.set("source", filters.source); + } + + return request(`/api/tickets/${ticketId}/details${params.size ? `?${params}` : ""}`); +} + export function updateSessionBilling(sessionId: string, billingStatus: BillingStatus) { return request(`/api/sessions/${sessionId}/billing`, { method: "PATCH", diff --git a/frontend/src/types.ts b/frontend/src/types.ts index bb5ba06..0160917 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,6 +1,7 @@ export type WorkType = "support" | "consulting"; export type BillingStatus = "billed" | "non_billable" | null; export type PeriodType = "month" | "day"; +export type TicketDetailPeriodType = PeriodType | "all"; export type UserRole = "admin" | "user"; export type AuthUser = { @@ -140,6 +141,11 @@ export type TicketDayBilling = { billed_minutes: number; }; +export type TicketAvailableMonth = { + month: string; + session_count: number; +}; + export type SessionEntry = { id: string; organization_id: string | null; @@ -160,10 +166,16 @@ export type SessionEntry = { }; export type TicketPeriod = { - periodType: PeriodType; + periodType: TicketDetailPeriodType; period: string; closed: boolean; closedAt: string | null; + filters?: { + month: string; + status: "all" | "open" | "billed" | "non_billable"; + source: "all" | "manual" | "recurring"; + }; + availableMonths?: TicketAvailableMonth[]; ticket: { id: string; ticket_number: string; diff --git a/frontend/src/views/TicketDetailPage.tsx b/frontend/src/views/TicketDetailPage.tsx index 64afaac..053b693 100644 --- a/frontend/src/views/TicketDetailPage.tsx +++ b/frontend/src/views/TicketDetailPage.tsx @@ -14,6 +14,7 @@ import { OrganizationSelect } from "@/components/OrganizationSelect"; import { createSession, deleteSession, + getTicketDetails, getTicketPeriod, updateSessionBilling, updateSessionDetails, @@ -21,11 +22,11 @@ import { updateTicketDayBilling } from "../api"; import { currentDay, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format"; -import type { BillingStatus, PeriodType, SessionEntry, TicketPeriod, WorkType } from "../types"; +import type { BillingStatus, PeriodType, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types"; import { cn } from "@/lib/utils"; type TicketDetailPageProps = { - periodType: PeriodType; + periodType: TicketDetailPeriodType; period: string; ticketId: string; onNavigate: (to: string) => void; @@ -33,7 +34,7 @@ type TicketDetailPageProps = { key: string; ticketId: string; ticketNumber: string; - periodType: PeriodType; + periodType: TicketDetailPeriodType; period: string; path: string; dirty: boolean; @@ -172,10 +173,21 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO const [savingDayBillingKey, setSavingDayBillingKey] = useState(null); const [ticketSession, setTicketSession] = useState(() => emptyTicketSessionForm()); const [savingTicketSession, setSavingTicketSession] = useState(false); + const [globalMonthFilter, setGlobalMonthFilter] = useState("all"); + const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billed" | "non_billable">("all"); + const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all"); async function load() { try { - setData(await getTicketPeriod(periodType, period, ticketId)); + setData( + periodType === "all" + ? await getTicketDetails(ticketId, { + month: globalMonthFilter, + status: globalStatusFilter, + source: globalSourceFilter + }) + : await getTicketPeriod(periodType, period, ticketId) + ); } catch (error) { toast.error("Ticket konnte nicht geladen werden", { description: error instanceof Error ? error.message : "Unbekannter Fehler" @@ -185,7 +197,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO useEffect(() => { void load(); - }, [periodType, period, ticketId]); + }, [periodType, period, ticketId, globalMonthFilter, globalStatusFilter, globalSourceFilter]); useEffect(() => { const nextInputs = Object.fromEntries((data?.dayBillings ?? []).map((billing) => [billing.day, formatHoursInput(billing.billed_minutes)])); @@ -210,8 +222,15 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO }, [data?.ticket]); const ticket = data?.ticket; + const isGlobalTicket = periodType === "all"; const isPeriodClosed = Boolean(data?.closed); - const periodLabel = periodType === "month" ? "Monat" : "Tag"; + const periodLabel = periodType === "month" ? "Monat" : periodType === "day" ? "Tag" : "Ticket"; + const sessionsScopeLabel = isGlobalTicket + ? globalMonthFilter === "all" + ? "im gesamten Ticketverlauf" + : `im Monat ${globalMonthFilter}` + : `im ${periodLabel.toLowerCase()} ${period}`; + const teamspaceLabel = isGlobalTicket || periodType === "month" ? "Teamspace gesamt" : "Teamspace Tag"; const dayBillingMap = useMemo(() => new Map((data?.dayBillings ?? []).map((billing) => [billing.day, billing.billed_minutes])), [data?.dayBillings]); const savedDayBillingInput = (day: string) => formatHoursInput(dayBillingMap.get(day)); const isDayBillingDirty = (day: string) => (dayBillingInputs[day] ?? "") !== savedDayBillingInput(day); @@ -264,15 +283,15 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO } onOpenTicketChange?.({ - key: `${periodType}:${period}:${ticket.id}`, + key: isGlobalTicket ? `ticket:${ticket.id}` : `${periodType}:${period}:${ticket.id}`, ticketId: ticket.id, ticketNumber: ticket.ticket_number, periodType, period, - path: `/analysis/${periodType}/${period}/tickets/${ticket.id}`, + path: isGlobalTicket ? `/tickets/${ticket.id}` : `/analysis/${periodType}/${period}/tickets/${ticket.id}`, dirty: hasUnsavedChanges }); - }, [ticket, periodType, period, hasUnsavedChanges, onOpenTicketChange]); + }, [ticket, periodType, period, isGlobalTicket, hasUnsavedChanges, onOpenTicketChange]); async function setBilling(sessionId: string, billingStatus: BillingStatus) { if (isPeriodClosed && billingStatus !== null) { @@ -525,9 +544,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO {ticket?.ticket_number ? : null} {periodType === "month" && data?.closed ? Monat geschlossen : null} -

- Sessions im {periodLabel.toLowerCase()} {period} -

+

Sessions {sessionsScopeLabel}

@@ -535,12 +552,66 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO - {periodType === "month" && data?.closed ? ( + {(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? ( Dieser Monat ist abgeschlossen. Bewertungen und Änderungen sind geschützt, bis du den Monat im Monatsabschluss wieder öffnest. ) : null} + {isGlobalTicket ? ( + + + Filter + Grenzt die Sessionliste dieses Tickets ein. Die Ticketdaten selbst bleiben global. + + +
+ + +
+
+ + +
+
+ + +
+
+
+ ) : null} +
@@ -600,11 +671,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
- {periodLabel} abgerechnet + Abgerechnet {formatMinutes(periodTotalMinutes)}
- {periodType === "month" ? "Teamspace gesamt" : "Teamspace Tag"} + {teamspaceLabel} {formatMinutes(periodTeamspaceMinutes)}
@@ -709,7 +780,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
{data?.sessions.length === 0 ? ( -

Keine Sessions für dieses Ticket im gewählten Zeitraum.

+

Keine Sessions für dieses Ticket im gewählten Filter.

) : null}