Add global ticket history view
This commit is contained in:
1 parent
19cc37d42a
commit
b67dbad0b1
5 files changed
+421
-39
No files matched your search
+22
-7
@@ -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<OpenTicketNavItem>[];
|
||||
|
||||
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() {
|
||||
<a
|
||||
href={ticket.path}
|
||||
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)}
|
||||
>
|
||||
<span className={cn("size-2 shrink-0 rounded-full", ticket.dirty ? "bg-amber-400" : isActive ? "bg-primary-foreground" : "bg-muted-foreground/70")} />
|
||||
|
||||
@@ -373,6 +373,31 @@ export function getTicketPeriod(type: PeriodType, period: string, ticketId: stri
|
||||
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) {
|
||||
return request(`/api/sessions/${sessionId}/billing`, {
|
||||
method: "PATCH",
|
||||
|
||||
+13
-1
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user