Add global ticket history view

This commit is contained in:
michael committed 2026-09-01 10:23:51 +02:00
1 parent 19cc37d42a
commit b67dbad0b1
5 files changed
+421 -39

No files matched your search

+274 -15
View File
@@ -84,6 +84,38 @@ function parseOptionalBilledMinutes(value: unknown) {
return minutes; 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() { async function hasAdminUser() {
const result = await query<{ exists: boolean }>("SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin') AS exists;"); const result = await query<{ exists: boolean }>("SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin') AS exists;");
return Boolean(result.rows[0]?.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) => { app.get("/api/setup/status", async (_req, res) => {
res.json({ required: !(await hasAdminUser()) }); res.json({ required: !(await hasAdminUser()) });
}); });
@@ -2737,6 +2934,22 @@ app.get("/api/tickets/search", requireUser, async (req, res) => {
res.json({ tickets: result.rows }); 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) => { app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => {
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber); const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber);
@@ -3164,12 +3377,11 @@ app.patch("/api/sessions/:sessionId/billing", requireUser, async (req, res) => {
s.ticket_id, s.ticket_id,
date_trunc('month', s.started_at)::date AS month, date_trunc('month', s.started_at)::date AS month,
date_trunc('day', s.started_at)::date AS day, 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 FROM sessions s
LEFT JOIN ticket_month_closures tm LEFT JOIN month_closures mc
ON tm.ticket_id = s.ticket_id ON mc.month = date_trunc('month', s.started_at AT TIME ZONE 'Europe/Berlin')::date
AND tm.month = date_trunc('month', s.started_at)::date AND mc.user_id = s.user_id
AND tm.user_id = s.user_id
WHERE s.id = $1 WHERE s.id = $1
AND s.user_id = $2; AND s.user_id = $2;
`, `,
@@ -3181,8 +3393,8 @@ app.patch("/api/sessions/:sessionId/billing", requireUser, async (req, res) => {
return; return;
} }
if (billingStatus !== null && lockResult.rows[0].ticket_month_closed_at) { if (lockResult.rows[0].month_closed_at) {
res.status(409).json({ error: "Ticket is closed for this period" }); res.status(409).json({ error: "Monat ist abgeschlossen" });
return; return;
} }
@@ -3245,6 +3457,7 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => {
work_type: "support" | "consulting"; work_type: "support" | "consulting";
recurring_billing_id: string | null; recurring_billing_id: string | null;
recurring_work_type: "support" | "consulting" | null; recurring_work_type: "support" | "consulting" | null;
month_closed_at: string | null;
}>( }>(
` `
SELECT SELECT
@@ -3253,9 +3466,13 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => {
s.started_at, s.started_at,
s.work_type, s.work_type,
s.recurring_billing_id, 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 FROM sessions s
LEFT JOIN recurring_billings rb ON rb.id = s.recurring_billing_id 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 WHERE s.id = $1
AND s.user_id = $2 AND s.user_id = $2
FOR UPDATE OF s; FOR UPDATE OF s;
@@ -3268,6 +3485,20 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => {
} }
const current = currentResult.rows[0]; 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; 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, new Date(current.started_at), userId);
await reopenPeriodsForSession(client, current.ticket_id, startedAt, userId); await reopenPeriodsForSession(client, current.ticket_id, startedAt, userId);
@@ -3300,6 +3531,11 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => {
return; return;
} }
if (updated === "month-closed") {
res.status(409).json({ error: "Monat ist abgeschlossen" });
return;
}
if (!updated) { if (!updated) {
res.status(404).json({ error: "Session not found" }); res.status(404).json({ error: "Session not found" });
return; return;
@@ -3313,28 +3549,46 @@ app.delete("/api/sessions/:sessionId", requireUser, async (req, res) => {
const userId = currentUser(req).id; const userId = currentUser(req).id;
const deleted = await withTransaction(async (client) => { const deleted = await withTransaction(async (client) => {
const sessionResult = await client.query<{ const currentResult = await client.query<{
id: string; id: string;
ticket_id: string; ticket_id: string;
started_at: string; started_at: string;
recurring_billing_id: string | null; recurring_billing_id: string | null;
recurring_billing_slot_id: string | null; recurring_billing_slot_id: string | null;
recurring_occurrence_date: string | null; recurring_occurrence_date: string | null;
month_closed_at: string | null;
}>( }>(
` `
DELETE FROM sessions SELECT
WHERE id = $1 s.id,
AND user_id = $2 s.ticket_id,
RETURNING id, ticket_id, started_at, recurring_billing_id, recurring_billing_slot_id, recurring_occurrence_date; 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] [sessionId, userId]
); );
if (sessionResult.rows.length === 0) { if (currentResult.rows.length === 0) {
return null; 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) { if (session.recurring_billing_id && session.recurring_billing_slot_id && session.recurring_occurrence_date) {
await client.query( await client.query(
@@ -3388,6 +3642,11 @@ app.delete("/api/sessions/:sessionId", requireUser, async (req, res) => {
return; return;
} }
if (deleted === "month-closed") {
res.status(409).json({ error: "Monat ist abgeschlossen" });
return;
}
res.json({ deleted }); res.json({ deleted });
}); });
+22 -7
View File
@@ -70,7 +70,7 @@ import { StatisticsPage } from "./views/StatisticsPage";
import { SetupPage } from "./views/SetupPage"; import { SetupPage } from "./views/SetupPage";
import { TicketDetailPage } from "./views/TicketDetailPage"; import { TicketDetailPage } from "./views/TicketDetailPage";
import { TimerPage } from "./views/TimerPage"; 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"; import type { Dispatch, DragEvent, SetStateAction } from "react";
type NavMenuEntry = type NavMenuEntry =
@@ -90,7 +90,7 @@ type OpenTicketNavItem = {
key: string; key: string;
ticketId: string; ticketId: string;
ticketNumber: string; ticketNumber: string;
periodType: PeriodType; periodType: TicketDetailPeriodType;
period: string; period: string;
path: string; path: string;
dirty: boolean; dirty: boolean;
@@ -111,13 +111,13 @@ function readOpenTickets(userId: string): OpenTicketNavItem[] {
const parsed = JSON.parse(raw) as Partial<OpenTicketNavItem>[]; const parsed = JSON.parse(raw) as Partial<OpenTicketNavItem>[];
return parsed 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) => ({ .map((item) => ({
key: String(item.key), key: String(item.key),
ticketId: String(item.ticketId), ticketId: String(item.ticketId),
ticketNumber: String(item.ticketNumber), ticketNumber: String(item.ticketNumber),
periodType: item.periodType === "day" ? "day" : "month", periodType: item.periodType === "day" ? "day" : item.periodType === "month" ? "month" : "all",
period: String(item.period), period: item.period ? String(item.period) : "all",
path: String(item.path), path: String(item.path),
dirty: false dirty: false
})); }));
@@ -127,6 +127,17 @@ function readOpenTickets(userId: string): OpenTicketNavItem[] {
} }
function routeFromPath(pathname: string) { 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+)$/); const periodTicketMatch = pathname.match(/^\/analysis\/(month|day)\/([^/]+)\/tickets\/(\d+)$/);
if (periodTicketMatch) { if (periodTicketMatch) {
@@ -318,7 +329,7 @@ function HeaderTicketSearch({ className, onNavigate }: HeaderTicketSearchProps)
}, [trimmedQuery]); }, [trimmedQuery]);
function openTicket(ticket: TicketSearchResult) { function openTicket(ticket: TicketSearchResult) {
onNavigate(`/analysis/month/${ticket.latest_period}/tickets/${ticket.ticket_id}`); onNavigate(`/tickets/${ticket.ticket_id}`);
setQuery(""); setQuery("");
setResults([]); setResults([]);
setOpen(false); setOpen(false);
@@ -967,7 +978,11 @@ export function App() {
<a <a
href={ticket.path} href={ticket.path}
className="flex min-w-0 flex-1 items-center gap-2 text-sm" className="flex min-w-0 flex-1 items-center gap-2 text-sm"
title={`${ticket.ticketNumber} (${ticket.periodType === "month" ? "Monat" : "Tag"} ${ticket.period})`} title={
ticket.periodType === "all"
? `${ticket.ticketNumber} (alle Sessions)`
: `${ticket.ticketNumber} (${ticket.periodType === "month" ? "Monat" : "Tag"} ${ticket.period})`
}
onClick={navHandler(ticket.path)} onClick={navHandler(ticket.path)}
> >
<span className={cn("size-2 shrink-0 rounded-full", ticket.dirty ? "bg-amber-400" : isActive ? "bg-primary-foreground" : "bg-muted-foreground/70")} /> <span className={cn("size-2 shrink-0 rounded-full", ticket.dirty ? "bg-amber-400" : isActive ? "bg-primary-foreground" : "bg-muted-foreground/70")} />
+25
View File
@@ -373,6 +373,31 @@ export function getTicketPeriod(type: PeriodType, period: string, ticketId: stri
return request<TicketPeriod>(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}`); return request<TicketPeriod>(`/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<TicketPeriod>(`/api/tickets/${ticketId}/details${params.size ? `?${params}` : ""}`);
}
export function updateSessionBilling(sessionId: string, billingStatus: BillingStatus) { export function updateSessionBilling(sessionId: string, billingStatus: BillingStatus) {
return request(`/api/sessions/${sessionId}/billing`, { return request(`/api/sessions/${sessionId}/billing`, {
method: "PATCH", method: "PATCH",
+13 -1
View File
@@ -1,6 +1,7 @@
export type WorkType = "support" | "consulting"; export type WorkType = "support" | "consulting";
export type BillingStatus = "billed" | "non_billable" | null; export type BillingStatus = "billed" | "non_billable" | null;
export type PeriodType = "month" | "day"; export type PeriodType = "month" | "day";
export type TicketDetailPeriodType = PeriodType | "all";
export type UserRole = "admin" | "user"; export type UserRole = "admin" | "user";
export type AuthUser = { export type AuthUser = {
@@ -140,6 +141,11 @@ export type TicketDayBilling = {
billed_minutes: number; billed_minutes: number;
}; };
export type TicketAvailableMonth = {
month: string;
session_count: number;
};
export type SessionEntry = { export type SessionEntry = {
id: string; id: string;
organization_id: string | null; organization_id: string | null;
@@ -160,10 +166,16 @@ export type SessionEntry = {
}; };
export type TicketPeriod = { export type TicketPeriod = {
periodType: PeriodType; periodType: TicketDetailPeriodType;
period: string; period: string;
closed: boolean; closed: boolean;
closedAt: string | null; closedAt: string | null;
filters?: {
month: string;
status: "all" | "open" | "billed" | "non_billable";
source: "all" | "manual" | "recurring";
};
availableMonths?: TicketAvailableMonth[];
ticket: { ticket: {
id: string; id: string;
ticket_number: string; ticket_number: string;
+87 -16
View File
@@ -14,6 +14,7 @@ import { OrganizationSelect } from "@/components/OrganizationSelect";
import { import {
createSession, createSession,
deleteSession, deleteSession,
getTicketDetails,
getTicketPeriod, getTicketPeriod,
updateSessionBilling, updateSessionBilling,
updateSessionDetails, updateSessionDetails,
@@ -21,11 +22,11 @@ import {
updateTicketDayBilling updateTicketDayBilling
} from "../api"; } from "../api";
import { currentDay, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format"; 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"; import { cn } from "@/lib/utils";
type TicketDetailPageProps = { type TicketDetailPageProps = {
periodType: PeriodType; periodType: TicketDetailPeriodType;
period: string; period: string;
ticketId: string; ticketId: string;
onNavigate: (to: string) => void; onNavigate: (to: string) => void;
@@ -33,7 +34,7 @@ type TicketDetailPageProps = {
key: string; key: string;
ticketId: string; ticketId: string;
ticketNumber: string; ticketNumber: string;
periodType: PeriodType; periodType: TicketDetailPeriodType;
period: string; period: string;
path: string; path: string;
dirty: boolean; dirty: boolean;
@@ -172,10 +173,21 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const [savingDayBillingKey, setSavingDayBillingKey] = useState<string | null>(null); const [savingDayBillingKey, setSavingDayBillingKey] = useState<string | null>(null);
const [ticketSession, setTicketSession] = useState<TicketSessionFormState>(() => emptyTicketSessionForm()); const [ticketSession, setTicketSession] = useState<TicketSessionFormState>(() => emptyTicketSessionForm());
const [savingTicketSession, setSavingTicketSession] = useState(false); 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() { async function load() {
try { 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) { } catch (error) {
toast.error("Ticket konnte nicht geladen werden", { toast.error("Ticket konnte nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler" description: error instanceof Error ? error.message : "Unbekannter Fehler"
@@ -185,7 +197,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
useEffect(() => { useEffect(() => {
void load(); void load();
}, [periodType, period, ticketId]); }, [periodType, period, ticketId, globalMonthFilter, globalStatusFilter, globalSourceFilter]);
useEffect(() => { useEffect(() => {
const nextInputs = Object.fromEntries((data?.dayBillings ?? []).map((billing) => [billing.day, formatHoursInput(billing.billed_minutes)])); 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]); }, [data?.ticket]);
const ticket = data?.ticket; const ticket = data?.ticket;
const isGlobalTicket = periodType === "all";
const isPeriodClosed = Boolean(data?.closed); 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 dayBillingMap = useMemo(() => new Map((data?.dayBillings ?? []).map((billing) => [billing.day, billing.billed_minutes])), [data?.dayBillings]);
const savedDayBillingInput = (day: string) => formatHoursInput(dayBillingMap.get(day)); const savedDayBillingInput = (day: string) => formatHoursInput(dayBillingMap.get(day));
const isDayBillingDirty = (day: string) => (dayBillingInputs[day] ?? "") !== savedDayBillingInput(day); const isDayBillingDirty = (day: string) => (dayBillingInputs[day] ?? "") !== savedDayBillingInput(day);
@@ -264,15 +283,15 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
} }
onOpenTicketChange?.({ onOpenTicketChange?.({
key: `${periodType}:${period}:${ticket.id}`, key: isGlobalTicket ? `ticket:${ticket.id}` : `${periodType}:${period}:${ticket.id}`,
ticketId: ticket.id, ticketId: ticket.id,
ticketNumber: ticket.ticket_number, ticketNumber: ticket.ticket_number,
periodType, periodType,
period, period,
path: `/analysis/${periodType}/${period}/tickets/${ticket.id}`, path: isGlobalTicket ? `/tickets/${ticket.id}` : `/analysis/${periodType}/${period}/tickets/${ticket.id}`,
dirty: hasUnsavedChanges dirty: hasUnsavedChanges
}); });
}, [ticket, periodType, period, hasUnsavedChanges, onOpenTicketChange]); }, [ticket, periodType, period, isGlobalTicket, hasUnsavedChanges, onOpenTicketChange]);
async function setBilling(sessionId: string, billingStatus: BillingStatus) { async function setBilling(sessionId: string, billingStatus: BillingStatus) {
if (isPeriodClosed && billingStatus !== null) { if (isPeriodClosed && billingStatus !== null) {
@@ -525,9 +544,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
{ticket?.ticket_number ? <CopyTicketButton ticketNumber={ticket.ticket_number} /> : null} {ticket?.ticket_number ? <CopyTicketButton ticketNumber={ticket.ticket_number} /> : null}
{periodType === "month" && data?.closed ? <Badge variant="outline">Monat geschlossen</Badge> : null} {periodType === "month" && data?.closed ? <Badge variant="outline">Monat geschlossen</Badge> : null}
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Sessions {sessionsScopeLabel}</p>
Sessions im {periodLabel.toLowerCase()} {period}
</p>
</div> </div>
<Badge variant={data?.openCount === 0 ? "success" : "warning"}> <Badge variant={data?.openCount === 0 ? "success" : "warning"}>
@@ -535,12 +552,66 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</Badge> </Badge>
</div> </div>
{periodType === "month" && data?.closed ? ( {(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? (
<Alert variant="success" className="py-3 text-sm"> <Alert variant="success" className="py-3 text-sm">
Dieser Monat ist abgeschlossen. Bewertungen und Änderungen sind geschützt, bis du den Monat im Monatsabschluss wieder öffnest. Dieser Monat ist abgeschlossen. Bewertungen und Änderungen sind geschützt, bis du den Monat im Monatsabschluss wieder öffnest.
</Alert> </Alert>
) : null} ) : null}
{isGlobalTicket ? (
<Card>
<CardHeader className="p-4">
<CardTitle>Filter</CardTitle>
<CardDescription>Grenzt die Sessionliste dieses Tickets ein. Die Ticketdaten selbst bleiben global.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-3">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-month-filter">Monat</label>
<select
id="ticket-month-filter"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={globalMonthFilter}
onChange={(event) => setGlobalMonthFilter(event.currentTarget.value)}
>
<option value="all">Alle Monate</option>
{(data?.availableMonths ?? []).map((month) => (
<option key={month.month} value={month.month}>
{month.month} ({month.session_count})
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-status-filter">Bewertung</label>
<select
id="ticket-status-filter"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={globalStatusFilter}
onChange={(event) => setGlobalStatusFilter(event.currentTarget.value as typeof globalStatusFilter)}
>
<option value="all">Alle Bewertungen</option>
<option value="open">Offen</option>
<option value="billed">Abgerechnet</option>
<option value="non_billable">Nicht abrechenbar</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-source-filter">Quelle</label>
<select
id="ticket-source-filter"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={globalSourceFilter}
onChange={(event) => setGlobalSourceFilter(event.currentTarget.value as typeof globalSourceFilter)}
>
<option value="all">Alle Quellen</option>
<option value="manual">Manuell</option>
<option value="recurring">Fixe Abrechnung</option>
</select>
</div>
</CardContent>
</Card>
) : null}
<Card> <Card>
<CardHeader className="p-4"> <CardHeader className="p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -600,11 +671,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm"> <div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm">
<span className="text-muted-foreground">{periodLabel} abgerechnet</span> <span className="text-muted-foreground">Abgerechnet</span>
<span className="font-semibold">{formatMinutes(periodTotalMinutes)}</span> <span className="font-semibold">{formatMinutes(periodTotalMinutes)}</span>
</div> </div>
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm"> <div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm">
<span className="text-muted-foreground">{periodType === "month" ? "Teamspace gesamt" : "Teamspace Tag"}</span> <span className="text-muted-foreground">{teamspaceLabel}</span>
<span className="font-semibold">{formatMinutes(periodTeamspaceMinutes)}</span> <span className="font-semibold">{formatMinutes(periodTeamspaceMinutes)}</span>
</div> </div>
<HelpLink anchor="ticketdetail" label="Hilfe zur Ticketdetailansicht" /> <HelpLink anchor="ticketdetail" label="Hilfe zur Ticketdetailansicht" />
@@ -709,7 +780,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</div> </div>
{data?.sessions.length === 0 ? ( {data?.sessions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Keine Sessions für dieses Ticket im gewählten Zeitraum.</p> <p className="py-8 text-center text-sm text-muted-foreground">Keine Sessions für dieses Ticket im gewählten Filter.</p>
) : null} ) : null}
</CardContent> </CardContent>
</Card> </Card>