Add monthly statistics dashboard

This commit is contained in:
2026-08-05 14:25:08 +02:00
parent cfc4ef45a8
commit eafd718e64
5 changed files with 1091 additions and 0 deletions
+329
View File
@@ -659,6 +659,330 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use
};
}
async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso);
const baseCte = `
WITH session_base AS (
SELECT
s.id,
s.ticket_id,
t.ticket_number,
COALESCE(s.organization_id, t.organization_id) AS organization_id,
COALESCE(so.name, ot.name, s.customer_name, t.customer_name, 'Keine Organisation') AS organization_name,
s.activity,
s.work_type,
s.started_at,
(s.started_at AT TIME ZONE 'Europe/Berlin')::date AS day,
s.rounded_minutes,
s.billing_status,
s.recurring_billing_id
FROM sessions s
JOIN tickets t ON t.id = s.ticket_id
LEFT JOIN organizations so ON so.id = s.organization_id
LEFT JOIN organizations ot ON ot.id = t.organization_id
WHERE s.started_at >= $1::timestamptz
AND s.started_at < $2::timestamptz
AND s.user_id = $3
),
ticket_day AS (
SELECT
ticket_id,
ticket_number,
day,
SUM(rounded_minutes)::int AS tracked_minutes
FROM session_base
GROUP BY ticket_id, ticket_number, day
),
ticket_day_billing AS (
SELECT
td.ticket_id,
td.ticket_number,
td.day,
td.tracked_minutes,
COALESCE(tdb.billed_minutes, 0)::int AS crm_billed_minutes,
tdb.billed_minutes IS NOT NULL AS has_crm_value
FROM ticket_day td
LEFT JOIN ticket_day_billings tdb
ON tdb.ticket_id = td.ticket_id
AND tdb.user_id = $3
AND tdb.day = td.day
),
session_group AS (
SELECT
sb.organization_id,
sb.organization_name,
sb.work_type,
sb.ticket_id,
sb.ticket_number,
sb.day,
COUNT(*)::int AS session_count,
SUM(sb.rounded_minutes)::int AS total_minutes,
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billed')::int AS billed_minutes,
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_minutes,
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status IS NULL)::int AS open_minutes,
COUNT(*) FILTER (WHERE sb.billing_status IS NULL)::int AS open_count,
COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NOT NULL)::int AS recurring_session_count,
COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NULL)::int AS manual_session_count
FROM session_base sb
GROUP BY sb.organization_id, sb.organization_name, sb.work_type, sb.ticket_id, sb.ticket_number, sb.day
),
session_group_with_crm AS (
SELECT
sg.*,
ROUND(COALESCE(tdb.crm_billed_minutes, 0) * sg.total_minutes::numeric / NULLIF(tdb.tracked_minutes, 0))::int AS crm_billed_minutes
FROM session_group sg
LEFT JOIN ticket_day_billing tdb
ON tdb.ticket_id = sg.ticket_id
AND tdb.day = sg.day
)
`;
const [
totalsResult,
dailyResult,
organizationResult,
workTypeResult,
ticketResult,
openResult,
missingCrmResult,
mismatchResult,
closureResult
] = await Promise.all([
query(
`
${baseCte}
SELECT
COUNT(DISTINCT ticket_id)::int AS tickets,
COUNT(*)::int AS sessions,
COALESCE(SUM(rounded_minutes), 0)::int AS minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes,
COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions,
COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NOT NULL), 0)::int AS recurring_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NULL), 0)::int AS manual_minutes,
COALESCE(ROUND(AVG(rounded_minutes)), 0)::int AS average_session_minutes,
COUNT(DISTINCT day)::int AS active_days,
COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing), 0)::int AS crm_billed_minutes
FROM session_base;
`,
[period.startIso, period.endIso, userId]
),
query(
`
${baseCte}
SELECT
td.day::text AS day,
COALESCE(day_sessions.session_count, 0)::int AS sessions,
td.tracked_minutes::int AS total_minutes,
COALESCE(day_sessions.billed_minutes, 0)::int AS billed_minutes,
COALESCE(day_sessions.non_billable_minutes, 0)::int AS non_billable_minutes,
COALESCE(day_sessions.open_minutes, 0)::int AS open_minutes,
COALESCE(SUM(tdb.crm_billed_minutes), 0)::int AS crm_billed_minutes
FROM (
SELECT day, SUM(tracked_minutes)::int AS tracked_minutes
FROM ticket_day
GROUP BY day
) td
LEFT JOIN (
SELECT
day,
SUM(session_count)::int AS session_count,
SUM(COALESCE(billed_minutes, 0))::int AS billed_minutes,
SUM(COALESCE(non_billable_minutes, 0))::int AS non_billable_minutes,
SUM(COALESCE(open_minutes, 0))::int AS open_minutes
FROM session_group
GROUP BY day
) day_sessions ON day_sessions.day = td.day
LEFT JOIN ticket_day_billing tdb ON tdb.day = td.day
GROUP BY td.day, td.tracked_minutes, day_sessions.session_count, day_sessions.billed_minutes, day_sessions.non_billable_minutes, day_sessions.open_minutes
ORDER BY td.day ASC;
`,
[period.startIso, period.endIso, userId]
),
query(
`
${baseCte}
SELECT
organization_id::text AS organization_id,
organization_name,
COUNT(DISTINCT ticket_id)::int AS tickets,
SUM(session_count)::int AS sessions,
COALESCE(SUM(total_minutes), 0)::int AS total_minutes,
COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes,
COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes,
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes
FROM session_group_with_crm
GROUP BY organization_id, organization_name
ORDER BY total_minutes DESC, organization_name ASC;
`,
[period.startIso, period.endIso, userId]
),
query(
`
${baseCte}
SELECT
work_type,
COUNT(DISTINCT ticket_id)::int AS tickets,
SUM(session_count)::int AS sessions,
COALESCE(SUM(total_minutes), 0)::int AS total_minutes,
COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes,
COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes,
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes
FROM session_group_with_crm
GROUP BY work_type
ORDER BY total_minutes DESC, work_type ASC;
`,
[period.startIso, period.endIso, userId]
),
query(
`
${baseCte}
SELECT
ticket_id::text AS ticket_id,
ticket_number,
MIN(organization_id)::text AS organization_id,
MIN(organization_name) AS organization_name,
COUNT(DISTINCT day)::int AS active_days,
COUNT(*)::int AS sessions,
COALESCE(SUM(rounded_minutes), 0)::int AS total_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes,
COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions,
COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing tdb WHERE tdb.ticket_id = session_base.ticket_id), 0)::int AS crm_billed_minutes
FROM session_base
GROUP BY ticket_id, ticket_number
ORDER BY total_minutes DESC, ticket_number ASC
LIMIT 12;
`,
[period.startIso, period.endIso, userId]
),
query(
`
SELECT
s.id::text AS id,
s.ticket_id::text AS ticket_id,
t.ticket_number,
COALESCE(so.name, o.name, s.customer_name, t.customer_name, 'Keine Organisation') AS organization_name,
s.activity,
s.started_at,
s.rounded_minutes
FROM sessions s
JOIN tickets t ON t.id = s.ticket_id
LEFT JOIN organizations so ON so.id = s.organization_id
LEFT JOIN organizations o ON o.id = t.organization_id
WHERE s.started_at >= $1::timestamptz
AND s.started_at < $2::timestamptz
AND s.user_id = $3
AND s.billing_status IS NULL
ORDER BY s.started_at ASC
LIMIT 12;
`,
[period.startIso, period.endIso, userId]
),
query(
`
${baseCte}
SELECT
tdb.ticket_id::text AS ticket_id,
tdb.ticket_number,
MIN(sb.organization_name) AS organization_name,
tdb.day::text AS day,
tdb.tracked_minutes,
tdb.crm_billed_minutes
FROM ticket_day_billing tdb
JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day
WHERE NOT tdb.has_crm_value
GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.crm_billed_minutes
ORDER BY tdb.day ASC, tdb.ticket_number ASC
LIMIT 12;
`,
[period.startIso, period.endIso, userId]
),
query(
`
${baseCte}
SELECT
tdb.ticket_id::text AS ticket_id,
tdb.ticket_number,
MIN(sb.organization_name) AS organization_name,
tdb.day::text AS day,
tdb.tracked_minutes,
tdb.crm_billed_minutes,
(tdb.tracked_minutes - tdb.crm_billed_minutes)::int AS delta_minutes
FROM ticket_day_billing tdb
JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day
WHERE tdb.has_crm_value
AND tdb.tracked_minutes <> tdb.crm_billed_minutes
GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.crm_billed_minutes
ORDER BY ABS(tdb.tracked_minutes - tdb.crm_billed_minutes) DESC, tdb.day ASC
LIMIT 12;
`,
[period.startIso, period.endIso, userId]
),
query<{ closed_at: string }>("SELECT closed_at FROM month_closures WHERE month = $1::date AND user_id = $2;", [period.start, userId])
]);
const totals = totalsResult.rows[0] ?? {
tickets: 0,
sessions: 0,
minutes: 0,
billed_minutes: 0,
non_billable_minutes: 0,
open_minutes: 0,
open_sessions: 0,
recurring_minutes: 0,
manual_minutes: 0,
average_session_minutes: 0,
active_days: 0,
crm_billed_minutes: 0
};
return {
periodType: "month",
period: period.label,
closed: closureResult.rows.length > 0,
closedAt: closureResult.rows[0]?.closed_at ?? null,
totals: {
tickets: totals.tickets,
sessions: totals.sessions,
minutes: totals.minutes,
billedMinutes: totals.billed_minutes,
nonBillableMinutes: totals.non_billable_minutes,
openMinutes: totals.open_minutes,
openSessions: totals.open_sessions,
recurringMinutes: totals.recurring_minutes,
manualMinutes: totals.manual_minutes,
averageSessionMinutes: totals.average_session_minutes,
activeDays: totals.active_days,
crmBilledMinutes: totals.crm_billed_minutes,
crmDeltaMinutes: totals.minutes - totals.crm_billed_minutes
},
dailySeries: dailyResult.rows,
organizations: organizationResult.rows.map((row: any) => ({
...row,
crm_delta_minutes: row.total_minutes - row.crm_billed_minutes
})),
workTypes: workTypeResult.rows.map((row: any) => ({
...row,
crm_delta_minutes: row.total_minutes - row.crm_billed_minutes
})),
tickets: ticketResult.rows.map((row: any) => ({
...row,
crm_delta_minutes: row.total_minutes - row.crm_billed_minutes
})),
attention: {
openSessions: openResult.rows,
missingCrmDays: missingCrmResult.rows,
crmMismatches: mismatchResult.rows
}
};
}
async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticketId: number, userId: string) {
await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso);
@@ -2055,6 +2379,11 @@ app.get("/api/periods/:periodType/:period/overview", async (req, res) => {
res.json(await getPeriodOverview(config, period, currentUser(req).id));
});
app.get("/api/statistics/months/:month", async (req, res) => {
const month = parseMonth(req.params.month);
res.json(await getStatisticsOverview(month, currentUser(req).id));
});
app.get("/api/tickets/lookup", async (req, res) => {
const ticketNumber = parseTicketNumber(req.query.ticketNumber);
const result = await query(