Add monthly statistics dashboard
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user