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

+87 -16
View File
@@ -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<string | null>(null);
const [ticketSession, setTicketSession] = useState<TicketSessionFormState>(() => 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 ? <CopyTicketButton ticketNumber={ticket.ticket_number} /> : null}
{periodType === "month" && data?.closed ? <Badge variant="outline">Monat geschlossen</Badge> : null}
</div>
<p className="text-sm text-muted-foreground">
Sessions im {periodLabel.toLowerCase()} {period}
</p>
<p className="text-sm text-muted-foreground">Sessions {sessionsScopeLabel}</p>
</div>
<Badge variant={data?.openCount === 0 ? "success" : "warning"}>
@@ -535,12 +552,66 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</Badge>
</div>
{periodType === "month" && data?.closed ? (
{(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? (
<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.
</Alert>
) : 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>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
@@ -600,11 +671,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</div>
<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">
<span className="text-muted-foreground">{periodLabel} abgerechnet</span>
<span className="text-muted-foreground">Abgerechnet</span>
<span className="font-semibold">{formatMinutes(periodTotalMinutes)}</span>
</div>
<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>
</div>
<HelpLink anchor="ticketdetail" label="Hilfe zur Ticketdetailansicht" />
@@ -709,7 +780,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</div>
{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}
</CardContent>
</Card>