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
+274
-15
@@ -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
@@ -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")} />
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user