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;
|
||||
}
|
||||
|
||||
function optionalTicketSessionMonth(value: unknown) {
|
||||
if (value === undefined || value === null || value === "" || value === "all") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseMonth(String(value));
|
||||
}
|
||||
|
||||
function optionalTicketSessionStatus(value: unknown) {
|
||||
if (value === undefined || value === null || value === "" || value === "all") {
|
||||
return "all" as const;
|
||||
}
|
||||
|
||||
if (value === "open" || value === "billed" || value === "non_billable") {
|
||||
return value;
|
||||
}
|
||||
|
||||
throw badRequest("status must be all, open, billed or non_billable");
|
||||
}
|
||||
|
||||
function optionalTicketSessionSource(value: unknown) {
|
||||
if (value === undefined || value === null || value === "" || value === "all") {
|
||||
return "all" as const;
|
||||
}
|
||||
|
||||
if (value === "manual" || value === "recurring") {
|
||||
return value;
|
||||
}
|
||||
|
||||
throw badRequest("source must be all, manual or recurring");
|
||||
}
|
||||
|
||||
async function hasAdminUser() {
|
||||
const result = await query<{ exists: boolean }>("SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin') AS exists;");
|
||||
return Boolean(result.rows[0]?.exists);
|
||||
@@ -1167,6 +1199,171 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke
|
||||
};
|
||||
}
|
||||
|
||||
async function getTicketDetails(ticketId: number, userId: string, filters: { month: ParsedPeriod | null; status: "all" | "open" | "billed" | "non_billable"; source: "all" | "manual" | "recurring" }) {
|
||||
if (filters.month) {
|
||||
await ensureRecurringSessionsForUserPeriod(userId, filters.month.startIso, filters.month.endIso);
|
||||
}
|
||||
|
||||
const ticketResult = await query(
|
||||
`
|
||||
SELECT
|
||||
t.id,
|
||||
t.ticket_number,
|
||||
t.organization_id,
|
||||
o.name AS organization_name,
|
||||
COALESCE(o.name, t.customer_name) AS customer_name,
|
||||
t.work_type,
|
||||
NULL::timestamptz AS closed_at,
|
||||
NULL::timestamptz AS period_closed_at,
|
||||
COALESCE(all_sessions.session_count, 0)::int AS ticket_session_count,
|
||||
COALESCE(all_sessions.open_count, 0)::int AS ticket_open_count,
|
||||
COALESCE(all_sessions.manual_session_count, 0)::int AS ticket_manual_session_count
|
||||
FROM tickets t
|
||||
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(*)::int AS session_count,
|
||||
COUNT(*) FILTER (WHERE sm.billing_status IS NULL)::int AS open_count,
|
||||
COUNT(*) FILTER (WHERE sm.recurring_billing_id IS NULL)::int AS manual_session_count
|
||||
FROM sessions sm
|
||||
WHERE sm.ticket_id = t.id
|
||||
AND sm.user_id = $2
|
||||
) all_sessions ON true
|
||||
WHERE t.id = $1;
|
||||
`,
|
||||
[ticketId, userId]
|
||||
);
|
||||
|
||||
if (ticketResult.rowCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params: unknown[] = [ticketId, userId];
|
||||
const where = ["s.ticket_id = $1", "s.user_id = $2"];
|
||||
|
||||
if (filters.month) {
|
||||
params.push(filters.month.startIso, filters.month.endIso);
|
||||
where.push(`s.started_at >= $${params.length - 1}::timestamptz`);
|
||||
where.push(`s.started_at < $${params.length}::timestamptz`);
|
||||
}
|
||||
|
||||
if (filters.status === "open") {
|
||||
where.push("s.billing_status IS NULL");
|
||||
} else if (filters.status !== "all") {
|
||||
params.push(filters.status);
|
||||
where.push(`s.billing_status = $${params.length}`);
|
||||
}
|
||||
|
||||
if (filters.source === "manual") {
|
||||
where.push("s.recurring_billing_id IS NULL");
|
||||
} else if (filters.source === "recurring") {
|
||||
where.push("s.recurring_billing_id IS NOT NULL");
|
||||
}
|
||||
|
||||
const sessionWhere = where.join(" AND ");
|
||||
const [sessionsResult, dayBillingResult, monthsResult, closureResult] = await Promise.all([
|
||||
query(
|
||||
`
|
||||
SELECT
|
||||
s.id,
|
||||
s.organization_id,
|
||||
(
|
||||
SELECT name
|
||||
FROM organizations
|
||||
WHERE zammad_id = s.organization_id
|
||||
) AS organization_name,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT name
|
||||
FROM organizations
|
||||
WHERE zammad_id = s.organization_id
|
||||
),
|
||||
s.customer_name
|
||||
) AS customer_name,
|
||||
s.activity,
|
||||
s.work_type,
|
||||
s.started_at,
|
||||
s.ended_at,
|
||||
s.duration_seconds,
|
||||
s.rounded_minutes,
|
||||
s.billing_status,
|
||||
s.billing_updated_at,
|
||||
s.created_at,
|
||||
s.recurring_billing_id,
|
||||
s.recurring_billing_slot_id,
|
||||
s.recurring_occurrence_date
|
||||
FROM sessions s
|
||||
WHERE ${sessionWhere}
|
||||
ORDER BY s.started_at ASC;
|
||||
`,
|
||||
params
|
||||
),
|
||||
query(
|
||||
`
|
||||
WITH filtered_days AS (
|
||||
SELECT DISTINCT (s.started_at AT TIME ZONE 'Europe/Berlin')::date AS day
|
||||
FROM sessions s
|
||||
WHERE ${sessionWhere}
|
||||
)
|
||||
SELECT tdb.day::text AS day, tdb.billed_minutes
|
||||
FROM ticket_day_billings tdb
|
||||
JOIN filtered_days fd ON fd.day = tdb.day
|
||||
WHERE tdb.ticket_id = $1
|
||||
AND tdb.user_id = $2
|
||||
ORDER BY tdb.day ASC;
|
||||
`,
|
||||
params
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT
|
||||
to_char(s.started_at AT TIME ZONE 'Europe/Berlin', 'YYYY-MM') AS month,
|
||||
COUNT(*)::int AS session_count
|
||||
FROM sessions s
|
||||
WHERE s.ticket_id = $1
|
||||
AND s.user_id = $2
|
||||
GROUP BY month
|
||||
ORDER BY month DESC;
|
||||
`,
|
||||
[ticketId, userId]
|
||||
),
|
||||
filters.month
|
||||
? query<{ closed_at: string }>("SELECT closed_at FROM month_closures WHERE month = $1::date AND user_id = $2;", [filters.month.start, userId])
|
||||
: Promise.resolve({ rows: [] as { closed_at: string }[], rowCount: 0 })
|
||||
]);
|
||||
|
||||
const sessions = sessionsResult.rows;
|
||||
const openCount = sessions.filter((session: any) => session.billing_status === null).length;
|
||||
const manualSessionCount = sessions.filter((session: any) => session.recurring_billing_id === null).length;
|
||||
const recurringSessionCount = sessions.length - manualSessionCount;
|
||||
const ticket = ticketResult.rows[0] as any;
|
||||
const closed = Boolean(filters.month && closureResult.rows.length > 0);
|
||||
|
||||
return {
|
||||
periodType: "all",
|
||||
period: filters.month?.label ?? "all",
|
||||
closed,
|
||||
closedAt: closed ? closureResult.rows[0]?.closed_at ?? null : null,
|
||||
filters: {
|
||||
month: filters.month?.label ?? "all",
|
||||
status: filters.status,
|
||||
source: filters.source
|
||||
},
|
||||
availableMonths: monthsResult.rows,
|
||||
ticket: {
|
||||
...ticket,
|
||||
requires_closure: false,
|
||||
recurring_session_count: recurringSessionCount,
|
||||
manual_session_count: manualSessionCount
|
||||
},
|
||||
sessions,
|
||||
dayBillings: dayBillingResult.rows,
|
||||
canClose: false,
|
||||
openCount,
|
||||
ticketOpenCount: Number(ticket.ticket_open_count ?? 0)
|
||||
};
|
||||
}
|
||||
|
||||
app.get("/api/setup/status", async (_req, res) => {
|
||||
res.json({ required: !(await hasAdminUser()) });
|
||||
});
|
||||
@@ -2737,6 +2934,22 @@ app.get("/api/tickets/search", requireUser, async (req, res) => {
|
||||
res.json({ tickets: result.rows });
|
||||
});
|
||||
|
||||
app.get("/api/tickets/:ticketId/details", requireUser, async (req, res) => {
|
||||
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
|
||||
const result = await getTicketDetails(ticketId, currentUser(req).id, {
|
||||
month: optionalTicketSessionMonth(req.query.month),
|
||||
status: optionalTicketSessionStatus(req.query.status),
|
||||
source: optionalTicketSessionSource(req.query.source)
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
res.status(404).json({ error: "Ticket not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => {
|
||||
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
|
||||
const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber);
|
||||
@@ -3164,12 +3377,11 @@ app.patch("/api/sessions/:sessionId/billing", requireUser, async (req, res) => {
|
||||
s.ticket_id,
|
||||
date_trunc('month', s.started_at)::date AS month,
|
||||
date_trunc('day', s.started_at)::date AS day,
|
||||
tm.closed_at AS ticket_month_closed_at
|
||||
mc.closed_at AS month_closed_at
|
||||
FROM sessions s
|
||||
LEFT JOIN ticket_month_closures tm
|
||||
ON tm.ticket_id = s.ticket_id
|
||||
AND tm.month = date_trunc('month', s.started_at)::date
|
||||
AND tm.user_id = s.user_id
|
||||
LEFT JOIN month_closures mc
|
||||
ON mc.month = date_trunc('month', s.started_at AT TIME ZONE 'Europe/Berlin')::date
|
||||
AND mc.user_id = s.user_id
|
||||
WHERE s.id = $1
|
||||
AND s.user_id = $2;
|
||||
`,
|
||||
@@ -3181,8 +3393,8 @@ app.patch("/api/sessions/:sessionId/billing", requireUser, async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (billingStatus !== null && lockResult.rows[0].ticket_month_closed_at) {
|
||||
res.status(409).json({ error: "Ticket is closed for this period" });
|
||||
if (lockResult.rows[0].month_closed_at) {
|
||||
res.status(409).json({ error: "Monat ist abgeschlossen" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3245,6 +3457,7 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => {
|
||||
work_type: "support" | "consulting";
|
||||
recurring_billing_id: string | null;
|
||||
recurring_work_type: "support" | "consulting" | null;
|
||||
month_closed_at: string | null;
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
@@ -3253,9 +3466,13 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => {
|
||||
s.started_at,
|
||||
s.work_type,
|
||||
s.recurring_billing_id,
|
||||
rb.work_type AS recurring_work_type
|
||||
rb.work_type AS recurring_work_type,
|
||||
mc.closed_at AS month_closed_at
|
||||
FROM sessions s
|
||||
LEFT JOIN recurring_billings rb ON rb.id = s.recurring_billing_id
|
||||
LEFT JOIN month_closures mc
|
||||
ON mc.month = date_trunc('month', s.started_at AT TIME ZONE 'Europe/Berlin')::date
|
||||
AND mc.user_id = s.user_id
|
||||
WHERE s.id = $1
|
||||
AND s.user_id = $2
|
||||
FOR UPDATE OF s;
|
||||
@@ -3268,6 +3485,20 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => {
|
||||
}
|
||||
|
||||
const current = currentResult.rows[0];
|
||||
const newMonthClosureResult = await client.query<{ closed_at: string }>(
|
||||
`
|
||||
SELECT closed_at
|
||||
FROM month_closures
|
||||
WHERE month = date_trunc('month', $1::timestamptz AT TIME ZONE 'Europe/Berlin')::date
|
||||
AND user_id = $2;
|
||||
`,
|
||||
[startedAt.toISOString(), userId]
|
||||
);
|
||||
|
||||
if (current.month_closed_at || newMonthClosureResult.rows.length > 0) {
|
||||
return "month-closed" as const;
|
||||
}
|
||||
|
||||
const effectiveWorkType = current.recurring_billing_id ? current.recurring_work_type ?? current.work_type : workType;
|
||||
await reopenPeriodsForSession(client, current.ticket_id, new Date(current.started_at), userId);
|
||||
await reopenPeriodsForSession(client, current.ticket_id, startedAt, userId);
|
||||
@@ -3300,6 +3531,11 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (updated === "month-closed") {
|
||||
res.status(409).json({ error: "Monat ist abgeschlossen" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: "Session not found" });
|
||||
return;
|
||||
@@ -3313,28 +3549,46 @@ app.delete("/api/sessions/:sessionId", requireUser, async (req, res) => {
|
||||
const userId = currentUser(req).id;
|
||||
|
||||
const deleted = await withTransaction(async (client) => {
|
||||
const sessionResult = await client.query<{
|
||||
const currentResult = await client.query<{
|
||||
id: string;
|
||||
ticket_id: string;
|
||||
started_at: string;
|
||||
recurring_billing_id: string | null;
|
||||
recurring_billing_slot_id: string | null;
|
||||
recurring_occurrence_date: string | null;
|
||||
month_closed_at: string | null;
|
||||
}>(
|
||||
`
|
||||
DELETE FROM sessions
|
||||
WHERE id = $1
|
||||
AND user_id = $2
|
||||
RETURNING id, ticket_id, started_at, recurring_billing_id, recurring_billing_slot_id, recurring_occurrence_date;
|
||||
SELECT
|
||||
s.id,
|
||||
s.ticket_id,
|
||||
s.started_at,
|
||||
s.recurring_billing_id,
|
||||
s.recurring_billing_slot_id,
|
||||
s.recurring_occurrence_date,
|
||||
mc.closed_at AS month_closed_at
|
||||
FROM sessions s
|
||||
LEFT JOIN month_closures mc
|
||||
ON mc.month = date_trunc('month', s.started_at AT TIME ZONE 'Europe/Berlin')::date
|
||||
AND mc.user_id = s.user_id
|
||||
WHERE s.id = $1
|
||||
AND s.user_id = $2
|
||||
FOR UPDATE OF s;
|
||||
`,
|
||||
[sessionId, userId]
|
||||
);
|
||||
|
||||
if (sessionResult.rows.length === 0) {
|
||||
if (currentResult.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = sessionResult.rows[0];
|
||||
const session = currentResult.rows[0];
|
||||
|
||||
if (session.month_closed_at) {
|
||||
return "month-closed" as const;
|
||||
}
|
||||
|
||||
await client.query("DELETE FROM sessions WHERE id = $1 AND user_id = $2;", [sessionId, userId]);
|
||||
|
||||
if (session.recurring_billing_id && session.recurring_billing_slot_id && session.recurring_occurrence_date) {
|
||||
await client.query(
|
||||
@@ -3388,6 +3642,11 @@ app.delete("/api/sessions/:sessionId", requireUser, async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleted === "month-closed") {
|
||||
res.status(409).json({ error: "Monat ist abgeschlossen" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ deleted });
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user