Compare commits
12
Commits
0325631d02
...
737c868376
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
737c868376 | ||
|
|
b01fc1655b | ||
|
|
f8574e5f39 | ||
|
|
3b6f35e31a | ||
|
|
33f0170927 | ||
|
|
8e4f07dcf8 | ||
|
|
84057771fe | ||
|
|
20bc04778d | ||
|
|
ca5e015065 | ||
|
|
4b35dfe55e | ||
|
|
eafd718e64 | ||
|
|
cfc4ef45a8 |
+449
-1
@@ -659,13 +659,394 @@ 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,
|
||||||
|
ack.acknowledged_at IS NOT NULL AS missing_crm_acknowledged,
|
||||||
|
ack.acknowledged_at AS missing_crm_acknowledged_at
|
||||||
|
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
|
||||||
|
LEFT JOIN ticket_day_billing_acknowledgements ack
|
||||||
|
ON ack.ticket_id = td.ticket_id
|
||||||
|
AND ack.user_id = $3
|
||||||
|
AND ack.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 = 'billed')::int AS billed_sessions,
|
||||||
|
COUNT(*) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_sessions,
|
||||||
|
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,
|
||||||
|
acknowledgedMissingCrmResult,
|
||||||
|
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 = 'billed')::int AS billed_sessions,
|
||||||
|
COUNT(*) FILTER (WHERE billing_status = 'non_billable')::int AS non_billable_sessions,
|
||||||
|
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,
|
||||||
|
COUNT(*) FILTER (WHERE recurring_billing_id IS NOT NULL)::int AS recurring_sessions,
|
||||||
|
COUNT(*) FILTER (WHERE recurring_billing_id IS NULL)::int AS manual_sessions,
|
||||||
|
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(day_sessions.billed_sessions, 0)::int AS billed_sessions,
|
||||||
|
COALESCE(day_sessions.non_billable_sessions, 0)::int AS non_billable_sessions,
|
||||||
|
COALESCE(day_sessions.open_sessions, 0)::int AS open_sessions,
|
||||||
|
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,
|
||||||
|
SUM(billed_sessions)::int AS billed_sessions,
|
||||||
|
SUM(non_billable_sessions)::int AS non_billable_sessions,
|
||||||
|
SUM(open_count)::int AS open_sessions
|
||||||
|
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, day_sessions.billed_sessions, day_sessions.non_billable_sessions, day_sessions.open_sessions
|
||||||
|
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(billed_sessions), 0)::int AS billed_sessions,
|
||||||
|
COALESCE(SUM(non_billable_sessions), 0)::int AS non_billable_sessions,
|
||||||
|
COALESCE(SUM(open_count), 0)::int AS open_sessions,
|
||||||
|
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(billed_sessions), 0)::int AS billed_sessions,
|
||||||
|
COALESCE(SUM(non_billable_sessions), 0)::int AS non_billable_sessions,
|
||||||
|
COALESCE(SUM(open_count), 0)::int AS open_sessions,
|
||||||
|
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 = 'billed')::int AS billed_sessions,
|
||||||
|
COUNT(*) FILTER (WHERE billing_status = 'non_billable')::int AS non_billable_sessions,
|
||||||
|
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;
|
||||||
|
`,
|
||||||
|
[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,
|
||||||
|
false AS acknowledged,
|
||||||
|
NULL::text AS acknowledged_at
|
||||||
|
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
|
||||||
|
AND NOT tdb.missing_crm_acknowledged
|
||||||
|
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;
|
||||||
|
`,
|
||||||
|
[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,
|
||||||
|
true AS acknowledged,
|
||||||
|
MAX(tdb.missing_crm_acknowledged_at)::text AS acknowledged_at
|
||||||
|
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
|
||||||
|
AND tdb.missing_crm_acknowledged
|
||||||
|
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;
|
||||||
|
`,
|
||||||
|
[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.crm_billed_minutes - tdb.tracked_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;
|
||||||
|
`,
|
||||||
|
[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,
|
||||||
|
billed_sessions: 0,
|
||||||
|
non_billable_sessions: 0,
|
||||||
|
open_sessions: 0,
|
||||||
|
recurring_minutes: 0,
|
||||||
|
manual_minutes: 0,
|
||||||
|
recurring_sessions: 0,
|
||||||
|
manual_sessions: 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,
|
||||||
|
billedSessions: totals.billed_sessions,
|
||||||
|
nonBillableSessions: totals.non_billable_sessions,
|
||||||
|
openSessions: totals.open_sessions,
|
||||||
|
recurringMinutes: totals.recurring_minutes,
|
||||||
|
manualMinutes: totals.manual_minutes,
|
||||||
|
recurringSessions: totals.recurring_sessions,
|
||||||
|
manualSessions: totals.manual_sessions,
|
||||||
|
averageSessionMinutes: totals.average_session_minutes,
|
||||||
|
activeDays: totals.active_days,
|
||||||
|
crmBilledMinutes: totals.crm_billed_minutes,
|
||||||
|
crmDeltaMinutes: totals.crm_billed_minutes - totals.minutes
|
||||||
|
},
|
||||||
|
dailySeries: dailyResult.rows,
|
||||||
|
organizations: organizationResult.rows.map((row: any) => ({
|
||||||
|
...row,
|
||||||
|
crm_delta_minutes: row.crm_billed_minutes - row.total_minutes
|
||||||
|
})),
|
||||||
|
workTypes: workTypeResult.rows.map((row: any) => ({
|
||||||
|
...row,
|
||||||
|
crm_delta_minutes: row.crm_billed_minutes - row.total_minutes
|
||||||
|
})),
|
||||||
|
tickets: ticketResult.rows.map((row: any) => ({
|
||||||
|
...row,
|
||||||
|
crm_delta_minutes: row.crm_billed_minutes - row.total_minutes
|
||||||
|
})),
|
||||||
|
attention: {
|
||||||
|
openSessions: openResult.rows,
|
||||||
|
missingCrmDays: missingCrmResult.rows,
|
||||||
|
acknowledgedMissingCrmDays: acknowledgedMissingCrmResult.rows,
|
||||||
|
crmMismatches: mismatchResult.rows
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticketId: number, userId: string) {
|
async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticketId: number, userId: string) {
|
||||||
await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso);
|
await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso);
|
||||||
|
|
||||||
const periodClosureJoin =
|
const periodClosureJoin =
|
||||||
config.type === "month"
|
config.type === "month"
|
||||||
? "LEFT JOIN month_closures pc ON pc.month = $3::date AND pc.user_id = $2"
|
? "LEFT JOIN month_closures pc ON pc.month = $3::date AND pc.user_id = $2"
|
||||||
: "LEFT JOIN (SELECT NULL::timestamptz AS closed_at) pc ON true";
|
: "LEFT JOIN (SELECT $3::date AS period_start, NULL::timestamptz AS closed_at) pc ON true";
|
||||||
const ticketResult = await query(
|
const ticketResult = await query(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -2055,6 +2436,11 @@ app.get("/api/periods/:periodType/:period/overview", async (req, res) => {
|
|||||||
res.json(await getPeriodOverview(config, period, currentUser(req).id));
|
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) => {
|
app.get("/api/tickets/lookup", async (req, res) => {
|
||||||
const ticketNumber = parseTicketNumber(req.query.ticketNumber);
|
const ticketNumber = parseTicketNumber(req.query.ticketNumber);
|
||||||
const result = await query(
|
const result = await query(
|
||||||
@@ -2206,9 +2592,71 @@ app.patch("/api/tickets/:ticketId/day-billings/:day", async (req, res) => {
|
|||||||
[ticketId, userId, day.start, billedMinutes]
|
[ticketId, userId, day.start, billedMinutes]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`
|
||||||
|
DELETE FROM ticket_day_billing_acknowledgements
|
||||||
|
WHERE ticket_id = $1
|
||||||
|
AND user_id = $2
|
||||||
|
AND day = $3::date;
|
||||||
|
`,
|
||||||
|
[ticketId, userId, day.start]
|
||||||
|
);
|
||||||
|
|
||||||
res.json({ dayBilling: result.rows[0] });
|
res.json({ dayBilling: result.rows[0] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.patch("/api/tickets/:ticketId/day-billings/:day/acknowledgement", async (req, res) => {
|
||||||
|
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
|
||||||
|
const day = parseDay(req.params.day);
|
||||||
|
const acknowledged = req.body.acknowledged !== false;
|
||||||
|
const userId = currentUser(req).id;
|
||||||
|
|
||||||
|
const sessionResult = await query<{ id: string }>(
|
||||||
|
`
|
||||||
|
SELECT id
|
||||||
|
FROM sessions
|
||||||
|
WHERE ticket_id = $1
|
||||||
|
AND user_id = $2
|
||||||
|
AND (started_at AT TIME ZONE 'Europe/Berlin')::date = $3::date
|
||||||
|
LIMIT 1;
|
||||||
|
`,
|
||||||
|
[ticketId, userId, day.start]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sessionResult.rowCount === 0) {
|
||||||
|
res.status(404).json({ error: "No sessions found for this ticket and day" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!acknowledged) {
|
||||||
|
await query(
|
||||||
|
`
|
||||||
|
DELETE FROM ticket_day_billing_acknowledgements
|
||||||
|
WHERE ticket_id = $1
|
||||||
|
AND user_id = $2
|
||||||
|
AND day = $3::date;
|
||||||
|
`,
|
||||||
|
[ticketId, userId, day.start]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ acknowledged: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`
|
||||||
|
INSERT INTO ticket_day_billing_acknowledgements (ticket_id, user_id, day)
|
||||||
|
VALUES ($1, $2, $3::date)
|
||||||
|
ON CONFLICT (ticket_id, user_id, day)
|
||||||
|
DO UPDATE SET acknowledged_at = now()
|
||||||
|
RETURNING day::text AS day, acknowledged_at;
|
||||||
|
`,
|
||||||
|
[ticketId, userId, day.start]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ acknowledged: true, acknowledgement: result.rows[0] });
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/api/periods/:periodType/:period/tickets/:ticketId/close", async (req, res) => {
|
app.post("/api/periods/:periodType/:period/tickets/:ticketId/close", async (req, res) => {
|
||||||
parsePeriod(req.params.periodType, req.params.period);
|
parsePeriod(req.params.periodType, req.params.period);
|
||||||
parsePositiveInteger(req.params.ticketId, "ticketId");
|
parsePositiveInteger(req.params.ticketId, "ticketId");
|
||||||
|
|||||||
@@ -295,6 +295,16 @@ export async function migrate() {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_day_billing_acknowledgements (
|
||||||
|
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
day DATE NOT NULL,
|
||||||
|
acknowledged_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (ticket_id, user_id, day)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
await ensureClosureOwnership("ticket_month_closures", "month", ["ticket_id", "month"], adminId);
|
await ensureClosureOwnership("ticket_month_closures", "month", ["ticket_id", "month"], adminId);
|
||||||
await ensureClosureOwnership("month_closures", "month", ["month"], adminId);
|
await ensureClosureOwnership("month_closures", "month", ["month"], adminId);
|
||||||
await ensureClosureOwnership("ticket_day_closures", "day", ["ticket_id", "day"], adminId);
|
await ensureClosureOwnership("ticket_day_closures", "day", ["ticket_id", "day"], adminId);
|
||||||
@@ -309,6 +319,7 @@ export async function migrate() {
|
|||||||
await query("CREATE INDEX IF NOT EXISTS idx_sessions_billing_status ON sessions(billing_status);");
|
await query("CREATE INDEX IF NOT EXISTS idx_sessions_billing_status ON sessions(billing_status);");
|
||||||
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_closures_day ON ticket_day_closures(day);");
|
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_closures_day ON ticket_day_closures(day);");
|
||||||
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_user_day ON ticket_day_billings(user_id, day);");
|
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_user_day ON ticket_day_billings(user_id, day);");
|
||||||
|
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billing_ack_user_day ON ticket_day_billing_acknowledgements(user_id, day);");
|
||||||
await query("CREATE INDEX IF NOT EXISTS idx_recurring_billings_user_active ON recurring_billings(user_id, active);");
|
await query("CREATE INDEX IF NOT EXISTS idx_recurring_billings_user_active ON recurring_billings(user_id, active);");
|
||||||
await query("UPDATE recurring_billings SET active = true WHERE active = false;");
|
await query("UPDATE recurring_billings SET active = true WHERE active = false;");
|
||||||
await query(`
|
await query(`
|
||||||
|
|||||||
+67
-18
@@ -1,7 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ClipboardCheck,
|
||||||
Command,
|
Command,
|
||||||
|
ChartNoAxesCombined,
|
||||||
|
CircleHelp,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
LogOut,
|
LogOut,
|
||||||
Moon,
|
Moon,
|
||||||
@@ -31,6 +34,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { HelpLink } from "@/components/HelpLink";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -54,14 +58,30 @@ import { formatTimer } from "./format";
|
|||||||
import { activeElapsedMs, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
|
import { activeElapsedMs, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
|
||||||
import { AdminUsersPage } from "./views/AdminUsersPage";
|
import { AdminUsersPage } from "./views/AdminUsersPage";
|
||||||
import { AnalysisPage } from "./views/AnalysisPage";
|
import { AnalysisPage } from "./views/AnalysisPage";
|
||||||
|
import { FaqPage } from "./views/FaqPage";
|
||||||
import { LoginPage } from "./views/LoginPage";
|
import { LoginPage } from "./views/LoginPage";
|
||||||
|
import { MonthlyClosePage } from "./views/MonthlyClosePage";
|
||||||
import { ProfilePage } from "./views/ProfilePage";
|
import { ProfilePage } from "./views/ProfilePage";
|
||||||
import { RecurringBillingsPage } from "./views/RecurringBillingsPage";
|
import { RecurringBillingsPage } from "./views/RecurringBillingsPage";
|
||||||
|
import { StatisticsPage } from "./views/StatisticsPage";
|
||||||
import { TicketDetailPage } from "./views/TicketDetailPage";
|
import { TicketDetailPage } from "./views/TicketDetailPage";
|
||||||
import { TimerPage } from "./views/TimerPage";
|
import { TimerPage } from "./views/TimerPage";
|
||||||
import type { AuthUser, PeriodType } from "./types";
|
import type { AuthUser, PeriodType } from "./types";
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
|
type NavMenuEntry =
|
||||||
|
| {
|
||||||
|
type: "item";
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon: typeof Timer;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "separator";
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
|
||||||
function routeFromPath(pathname: string) {
|
function routeFromPath(pathname: string) {
|
||||||
const periodTicketMatch = pathname.match(/^\/analysis\/(month|day)\/([^/]+)\/tickets\/(\d+)$/);
|
const periodTicketMatch = pathname.match(/^\/analysis\/(month|day)\/([^/]+)\/tickets\/(\d+)$/);
|
||||||
|
|
||||||
@@ -89,6 +109,18 @@ function routeFromPath(pathname: string) {
|
|||||||
return { page: "analysis" as const };
|
return { page: "analysis" as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/statistics")) {
|
||||||
|
return { page: "statistics" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/faq")) {
|
||||||
|
return { page: "faq" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/monthly-close")) {
|
||||||
|
return { page: "monthly-close" as const };
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname.startsWith("/recurring")) {
|
if (pathname.startsWith("/recurring")) {
|
||||||
return { page: "recurring" as const };
|
return { page: "recurring" as const };
|
||||||
}
|
}
|
||||||
@@ -190,6 +222,7 @@ function QuickTimerStarter({ className, inputId, onStartTimer }: QuickTimerStart
|
|||||||
<span className="hidden sm:inline">Timer starten</span>
|
<span className="hidden sm:inline">Timer starten</span>
|
||||||
<span className="sm:hidden">Start</span>
|
<span className="sm:hidden">Start</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
<HelpLink anchor="timer" label="Hilfe zum schnellen Timerstart" className="hidden md:inline-flex" />
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -498,14 +531,21 @@ export function App() {
|
|||||||
return <LoginPage onLogin={setCurrentUser} />;
|
return <LoginPage onLogin={setCurrentUser} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems = [
|
const navItems: NavMenuEntry[] = [
|
||||||
{ href: "/timer", label: "Timer", icon: Timer, active: path.startsWith("/timer") || path === "/" },
|
{ type: "item", href: "/timer", label: "Timer", icon: Timer, active: path.startsWith("/timer") || path === "/" },
|
||||||
{ href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") },
|
{ type: "item", href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") },
|
||||||
{ href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
|
{ type: "separator", key: "after-analysis" },
|
||||||
{ href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
|
{ type: "item", href: "/monthly-close", label: "Monatsabschluss", icon: ClipboardCheck, active: path.startsWith("/monthly-close") },
|
||||||
|
{ type: "separator", key: "after-monthly-close" },
|
||||||
|
{ type: "item", href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") },
|
||||||
|
{ type: "item", href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
|
||||||
|
{ type: "separator", key: "before-profile" },
|
||||||
|
{ type: "item", href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
|
||||||
...(currentUser.role === "admin"
|
...(currentUser.role === "admin"
|
||||||
? [{ href: "/admin/users", label: "Benutzer", icon: Shield, active: path.startsWith("/admin/users") }]
|
? [{ type: "item" as const, href: "/admin/users", label: "Benutzer", icon: Shield, active: path.startsWith("/admin/users") }]
|
||||||
: []),
|
: []),
|
||||||
|
{ type: "separator", key: "before-faq" },
|
||||||
|
{ type: "item", href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") },
|
||||||
];
|
];
|
||||||
const runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
|
const runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
|
||||||
const runningElapsedSeconds = runningTimer ? Math.floor(activeElapsedMs(runningTimer, tick) / 1000) : 0;
|
const runningElapsedSeconds = runningTimer ? Math.floor(activeElapsedMs(runningTimer, tick) / 1000) : 0;
|
||||||
@@ -566,16 +606,22 @@ export function App() {
|
|||||||
<SidebarGroupLabel>Workflows</SidebarGroupLabel>
|
<SidebarGroupLabel>Workflows</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) =>
|
||||||
<SidebarMenuItem key={item.href}>
|
item.type === "separator" ? (
|
||||||
<SidebarMenuButton asChild tooltip={item.label} isActive={item.active}>
|
<SidebarMenuItem key={item.key} className="py-1 group-data-[collapsible=icon]:hidden">
|
||||||
<a href={item.href} onClick={navHandler(item.href)}>
|
<Separator />
|
||||||
<item.icon />
|
</SidebarMenuItem>
|
||||||
<span>{item.label}</span>
|
) : (
|
||||||
</a>
|
<SidebarMenuItem key={item.href}>
|
||||||
</SidebarMenuButton>
|
<SidebarMenuButton asChild tooltip={item.label} isActive={item.active}>
|
||||||
</SidebarMenuItem>
|
<a href={item.href} onClick={navHandler(item.href)}>
|
||||||
))}
|
<item.icon />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
@@ -588,7 +634,7 @@ export function App() {
|
|||||||
<CheckCircle2 className="size-4 text-muted-foreground" />
|
<CheckCircle2 className="size-4 text-muted-foreground" />
|
||||||
Abschluss
|
Abschluss
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">Offene Sessions findest du in Tages- und Monatsansicht.</p>
|
<p className="text-muted-foreground">Offene Bewertungen findest du im Monatsabschluss.</p>
|
||||||
</div>
|
</div>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
@@ -674,7 +720,7 @@ export function App() {
|
|||||||
<QuickTimerStarter inputId="quick-ticket-number-mobile" onStartTimer={startQuickTimer} />
|
<QuickTimerStarter inputId="quick-ticket-number-mobile" onStartTimer={startQuickTimer} />
|
||||||
</div>
|
</div>
|
||||||
<main className="min-h-0 min-w-0 flex-1 overflow-x-hidden p-3 sm:p-4 md:p-5">
|
<main className="min-h-0 min-w-0 flex-1 overflow-x-hidden p-3 sm:p-4 md:p-5">
|
||||||
<div className="mx-auto w-full max-w-[1180px]">
|
<div className="mx-auto w-full max-w-screen-2xl">
|
||||||
{route.page === "timer" ? (
|
{route.page === "timer" ? (
|
||||||
<TimerPage
|
<TimerPage
|
||||||
timers={timers}
|
timers={timers}
|
||||||
@@ -685,7 +731,10 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
|
{route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
|
||||||
|
{route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null}
|
||||||
|
{route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null}
|
||||||
{route.page === "recurring" ? <RecurringBillingsPage /> : null}
|
{route.page === "recurring" ? <RecurringBillingsPage /> : null}
|
||||||
|
{route.page === "faq" ? <FaqPage /> : null}
|
||||||
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
|
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
|
||||||
{route.page === "admin-users" && currentUser.role === "admin" ? <AdminUsersPage currentUser={currentUser} /> : null}
|
{route.page === "admin-users" && currentUser.role === "admin" ? <AdminUsersPage currentUser={currentUser} /> : null}
|
||||||
{route.page === "admin-users" && currentUser.role !== "admin" ? (
|
{route.page === "admin-users" && currentUser.role !== "admin" ? (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
PeriodOverview,
|
PeriodOverview,
|
||||||
PeriodType,
|
PeriodType,
|
||||||
RecurringBilling,
|
RecurringBilling,
|
||||||
|
StatisticsOverview,
|
||||||
TicketMeta,
|
TicketMeta,
|
||||||
TicketPeriod,
|
TicketPeriod,
|
||||||
UserRole,
|
UserRole,
|
||||||
@@ -283,6 +284,13 @@ export function updateTicketDayBilling(ticketId: string, day: string, billedMinu
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function acknowledgeMissingTicketDayBilling(ticketId: string, day: string, acknowledged = true) {
|
||||||
|
return request(`/api/tickets/${ticketId}/day-billings/${day}/acknowledgement`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ acknowledged })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getMonthOverview(month: string) {
|
export function getMonthOverview(month: string) {
|
||||||
return getPeriodOverview("month", month);
|
return getPeriodOverview("month", month);
|
||||||
}
|
}
|
||||||
@@ -299,6 +307,10 @@ export function getPeriodOverview(type: PeriodType, period: string) {
|
|||||||
return request<PeriodOverview>(`/api/periods/${periodPath(type)}/${period}/overview`);
|
return request<PeriodOverview>(`/api/periods/${periodPath(type)}/${period}/overview`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStatisticsOverview(month: string) {
|
||||||
|
return request<StatisticsOverview>(`/api/statistics/months/${month}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function getTicketPeriod(type: PeriodType, period: string, ticketId: string) {
|
export function getTicketPeriod(type: PeriodType, period: string, ticketId: string) {
|
||||||
return request<TicketPeriod>(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}`);
|
return request<TicketPeriod>(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { CircleHelp } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { getHelpSection } from "../help-content";
|
||||||
|
|
||||||
|
type HelpLinkProps = {
|
||||||
|
anchor: string;
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HelpLink({ anchor, label = "Hilfe öffnen", className }: HelpLinkProps) {
|
||||||
|
const section = getHelpSection(anchor);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button type="button" variant="ghost" size="icon-sm" className={className} title={label} aria-label={label}>
|
||||||
|
<CircleHelp className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{section.title}</DialogTitle>
|
||||||
|
<DialogDescription>{section.description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||||
|
{section.quickItems.map((item) => (
|
||||||
|
<li key={item} className="flex gap-2">
|
||||||
|
<span className="mt-2 size-1.5 shrink-0 rounded-full bg-primary" />
|
||||||
|
<span>{item}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button asChild variant="secondary">
|
||||||
|
<a href={`/faq#${section.id}`}>Ausführliche FAQ öffnen</a>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
export type HelpSection = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
quickItems: string[];
|
||||||
|
details: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const helpSections: HelpSection[] = [
|
||||||
|
{
|
||||||
|
id: "grundprinzip",
|
||||||
|
title: "Grundprinzip",
|
||||||
|
description: "TicketTracker ist die Arbeitszeiterfassung neben Ticketsystem und CRM. Die App misst Arbeit, bewertet Sessions und macht Monatsabschlüsse kontrollierbar.",
|
||||||
|
quickItems: [
|
||||||
|
"Sessions sind die getrackten oder nachgetragenen Arbeitszeiten.",
|
||||||
|
"Teamspace-Werte sind Tageswerte pro Ticket, nicht einzelne Sessions.",
|
||||||
|
"Abgeschlossen wird nur der Monat."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"TicketTracker ersetzt nicht das Ticketsystem. Ticketnummern dienen als Bezug zur Arbeit, die eigentliche Dokumentation bleibt im vorhandenen Ticketsystem.",
|
||||||
|
"TicketTracker ersetzt auch nicht das CRM. Die CRM- beziehungsweise Teamspace-Zeit wird als Kontrollwert gepflegt, damit getrackte Zeiten und eingetragene Abrechnung gegenübergestellt werden können.",
|
||||||
|
"Eine Session ist immer einem Benutzer zugeordnet. In den normalen Auswertungen sieht jeder Benutzer nur seine eigenen Sessions.",
|
||||||
|
"Ticketdaten wie Organisation und Art können aus bestehenden Tickets übernommen werden, auch wenn ein anderer Benutzer das Ticket schon bearbeitet hat.",
|
||||||
|
"Tickets werden nicht abgeschlossen. Sie gruppieren Sessions. Relevant für den Abschluss ist nur, ob alle Sessions eines Monats bewertet sind.",
|
||||||
|
"Der Monatsabschluss sperrt den Monat gegen versehentliche Änderungen. Muss doch noch etwas korrigiert werden, kann der Monat wieder geöffnet werden."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "timer",
|
||||||
|
title: "Timer",
|
||||||
|
description: "Timer erfassen laufende Arbeit an Tickets. Es können mehrere Timer vorbereitet sein, aber immer nur einer läuft aktiv.",
|
||||||
|
quickItems: [
|
||||||
|
"Ticketnummer eingeben und Timer starten.",
|
||||||
|
"Aktivieren eines Timers pausiert alle anderen Timer automatisch.",
|
||||||
|
"Beim Beenden stoppt die Zeit sofort; Details werden danach gespeichert."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"Neue Timer werden mit einer Ticketnummer im Format Ticket#XXXXXX gestartet.",
|
||||||
|
"Alle laufenden und pausierten Timer bleiben in der Seitenleiste sichtbar, damit du auf jeder Unterseite sehen kannst, was vorbereitet oder aktiv ist.",
|
||||||
|
"Wenn ein anderer Timer aktiviert wird, pausiert TicketTracker die übrigen Timer automatisch. Dadurch kann nie versehentlich auf zwei Tickets gleichzeitig Zeit laufen.",
|
||||||
|
"Beim Klick auf Beenden wird die Stoppuhr sofort angehalten. Das Speichern der Tätigkeit findet danach statt und verlängert die gemessene Zeit nicht.",
|
||||||
|
"Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Art. Dann muss beim Beenden nur noch die Tätigkeit eingetragen werden.",
|
||||||
|
"Existiert das Ticket noch nicht, müssen Organisation, Tätigkeit und Art angegeben werden.",
|
||||||
|
"Pausen werden von der Gesamtdauer abgezogen. Die gespeicherte Zeit wird auf volle Minuten gerundet.",
|
||||||
|
"Der Schnellstart im Kopfbereich funktioniert auf allen Unterseiten und nutzt dieselbe Timerlogik."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "session-nachtragen",
|
||||||
|
title: "Session Nachtragen",
|
||||||
|
description: "Nachtragen ist für vergessene Zeiten gedacht, wenn kein Timer gelaufen ist.",
|
||||||
|
quickItems: [
|
||||||
|
"Datum, Von, Bis und Tätigkeit eintragen.",
|
||||||
|
"Benutzer tragen eigene Sessions nach; Admins können Benutzer auswählen.",
|
||||||
|
"Tätigkeiten sind mehrzeilig."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"Beim Nachtragen wird aus Datum, Startzeit und Endzeit automatisch die Dauer berechnet.",
|
||||||
|
"Das Ende muss nach dem Beginn liegen. Übernacht-Sessions sind aktuell nicht vorgesehen und sollten als getrennte Einträge gepflegt werden.",
|
||||||
|
"Die Organisation muss aus der synchronisierten Organisationsliste gewählt werden. Freitext ist absichtlich gesperrt.",
|
||||||
|
"Die Tätigkeit ist eine Textarea. Zeilenumbrüche bleiben erhalten und werden in der Ticketdetailansicht wieder ausgegeben.",
|
||||||
|
"Nachgetragene Sessions starten immer offen. Sie müssen später als Abgerechnet oder Nicht abrechenbar bewertet werden.",
|
||||||
|
"Admins können im Adminbereich zusätzlich bestimmen, welchem Benutzer die nachgetragene Session gehört.",
|
||||||
|
"In der Ticketdetailansicht kann eine Session direkt zum geöffneten Ticket nachgetragen werden. Ticketnummer und Stammdaten sind dort schon vorbelegt beziehungsweise aus dem Ticket abgeleitet."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ticketdaten",
|
||||||
|
title: "Ticketdaten",
|
||||||
|
description: "Ticketdaten sind Stammdaten für ein Ticket und können nachträglich korrigiert werden.",
|
||||||
|
quickItems: [
|
||||||
|
"Ticketnummer, Organisation und Art sind bearbeitbar.",
|
||||||
|
"Organisationen kommen aus der lokalen Zammad-Synchronisation.",
|
||||||
|
"Die Art wiederkehrender Sessions kommt aus der Regel."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"Ticketnummern können korrigiert werden, falls beim Starten oder Nachtragen ein falscher Bezug gewählt wurde.",
|
||||||
|
"Die Organisation wird immer über die Suchliste gewählt. Dadurch sind spätere Auswertungen pro Organisation zuverlässig.",
|
||||||
|
"Änderungen an Organisation und Art werden auf vorhandene manuelle Sessions dieses Tickets übernommen.",
|
||||||
|
"Bei wiederkehrenden Sessions ist die Art nicht direkt an der Session änderbar. Sie wird ausschließlich über die fixe Abrechnungsregel gesteuert.",
|
||||||
|
"Wenn ein Ticket bereits durch einen anderen Benutzer bekannt ist, können dessen Ticketdaten beim Beenden eines Timers übernommen werden. Die Session selbst bleibt trotzdem deinem Benutzer zugeordnet."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "organisationen",
|
||||||
|
title: "Organisationen",
|
||||||
|
description: "Organisationen kommen aus Zammad und bilden die Grundlage für saubere Kunden- und Monatsauswertungen.",
|
||||||
|
quickItems: [
|
||||||
|
"Nur Admins synchronisieren Organisationen.",
|
||||||
|
"Freitext ist danach nicht mehr möglich.",
|
||||||
|
"Gespeichert werden Zammad-ID, Name und Zeitstempel."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"Im Adminbereich werden Zammad-URL und API-Key gespeichert. Der API-Key wird danach nicht mehr im Klartext angezeigt.",
|
||||||
|
"Beim Sync fragt TicketTracker die Organisationen aus Zammad ab und schreibt sie in die lokale Datenbank.",
|
||||||
|
"Neue Organisationen werden angelegt, geänderte Namen werden aktualisiert.",
|
||||||
|
"Organisationen, die in Zammad nicht mehr vorhanden sind, werden aus der lokalen Liste entfernt beziehungsweise von vorhandenen Tickets und Sessions gelöst.",
|
||||||
|
"Alte Sessions oder Tickets mit Freitext-Organisationen müssen manuell korrigiert werden, indem eine Organisation aus der Liste gewählt wird.",
|
||||||
|
"Die bewusst schlanke Organisationstabelle enthält interne ID, Zammad-ID, Name sowie Erstell- und Änderungszeitpunkte."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "auswertung",
|
||||||
|
title: "Auswertung",
|
||||||
|
description: "Die Auswertung ist die Arbeitsliste für Monat und Tag: Tickets öffnen, Sessions prüfen, sortieren und bewerten.",
|
||||||
|
quickItems: [
|
||||||
|
"Monat oder Tag wählen; die Ansicht aktualisiert automatisch.",
|
||||||
|
"Tickets können gefiltert, gruppiert und sortiert werden.",
|
||||||
|
"Die Einstellungen bleiben nach Refresh und Browserneustart erhalten."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"Die Monatsansicht zeigt alle Tickets, die im gewählten Monat mindestens eine Session haben.",
|
||||||
|
"Die Tagesansicht zeigt dieselben Datenlogiken auf einen einzelnen Tag gefiltert. Es gibt keinen Tagesabschluss.",
|
||||||
|
"Die Pfeile neben der Zeitraum-Eingabe springen jeweils einen Monat oder einen Tag vor beziehungsweise zurück.",
|
||||||
|
"Der Gesamtaufwand-Graph zeigt den getrackten Aufwand im Zeitraum. In der Monatsansicht pro Tag, in der Tagesansicht pro Stunde.",
|
||||||
|
"Die Ticketliste kann nach Typ, Organisation oder Status gruppiert werden. Gruppenüberschriften zeigen Anzahl, Zeit und offene Bewertungen.",
|
||||||
|
"Filter und Sortierungen bleiben im Browser gespeichert und können über Zurücksetzen wieder auf Standard gestellt werden.",
|
||||||
|
"Das Copy-Icon neben der Ticketnummer kopiert die Nummer direkt in die Zwischenablage."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ticketdetail",
|
||||||
|
title: "Ticketdetailansicht",
|
||||||
|
description: "Die Ticketdetailansicht ist die Bearbeitungsfläche für Sessions, Bewertungen und Teamspace-Tageswerte eines Tickets.",
|
||||||
|
quickItems: [
|
||||||
|
"Sessions sind nach Tag gruppiert.",
|
||||||
|
"Teamspace wird pro Ticket und Tag gespeichert.",
|
||||||
|
"Monats- und Tagesansicht verwenden denselben Teamspace-Wert."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"Oben steht die Ticketnummer mit Copy-Icon, damit sie schnell in andere Systeme übernommen werden kann.",
|
||||||
|
"Die Session-Einträge sind nach Kalendertagen gruppiert. Pro Tagesgruppe steht die Summe der getrackten Zeit.",
|
||||||
|
"Das Teamspace-Feld wird als Stundenwert gepflegt, zum Beispiel 1, 1,5 oder 2.25.",
|
||||||
|
"Während der Eingabe wird der Teamspace-Wert nicht sofort gespeichert. Sobald eine Änderung vorliegt, erscheint ein Speichern-Button.",
|
||||||
|
"Ein leeres Teamspace-Feld entfernt den gespeicherten Teamspace-Wert für diesen Ticket-Tag.",
|
||||||
|
"Der Teamspace-Wert hängt am Ticket, Benutzer und Tag. Deshalb ist er in Monats- und Tagesansicht identisch.",
|
||||||
|
"Sessions können bewertet, bearbeitet oder gelöscht werden. Gelöschte letzte Sessions können dazu führen, dass ein leeres Ticket entfernt wird."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sessions-bewerten",
|
||||||
|
title: "Sessions Bewerten",
|
||||||
|
description: "Bewertungen bestimmen, ob eine Session abgerechnet wurde oder bewusst nicht abrechenbar ist.",
|
||||||
|
quickItems: [
|
||||||
|
"Jede Session braucht genau eine Bewertung.",
|
||||||
|
"Abgerechnet und Nicht abrechenbar können durch erneuten Klick zurückgenommen werden.",
|
||||||
|
"Offene Bewertungen blockieren den Monatsabschluss."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"Abgerechnet bedeutet: Die Session wurde für die Abrechnung beziehungsweise im CRM berücksichtigt.",
|
||||||
|
"Nicht abrechenbar bedeutet: Die Session gehört zum Arbeitsnachweis, wird aber nicht abgerechnet.",
|
||||||
|
"Ein erneuter Klick auf den aktiven Bewertungsbutton entfernt die Bewertung und macht die Session wieder offen.",
|
||||||
|
"Nicht abrechenbare Sessions zählen als bewertet. Sie blockieren den Monatsabschluss nicht.",
|
||||||
|
"Wenn ein Monat abgeschlossen ist, sind Bewertungen gesperrt. Öffne den Monat zuerst wieder, wenn du etwas korrigieren musst.",
|
||||||
|
"Die offenen Bewertungen findest du gebündelt im Monatsabschluss."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sessions-bearbeiten",
|
||||||
|
title: "Sessions Bearbeiten und Löschen",
|
||||||
|
description: "Sessions können korrigiert, gelöscht und bei wiederkehrenden Einträgen als Ausnahme behandelt werden.",
|
||||||
|
quickItems: [
|
||||||
|
"Bearbeiten läuft über das Stift-Icon.",
|
||||||
|
"Löschen läuft über das Papierkorb-Icon.",
|
||||||
|
"Wiederkehrende gelöschte Sessions werden nicht neu erzeugt."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"Beim Bearbeiten sind Datum, Von-Uhrzeit, Bis-Uhrzeit, Organisation und Tätigkeit änderbar.",
|
||||||
|
"Die Art ist bei manuellen Sessions änderbar. Bei wiederkehrenden Sessions ist sie gesperrt und kommt aus der Regel.",
|
||||||
|
"Die Dauer wird aus Von und Bis neu berechnet und wieder auf Minuten gerundet.",
|
||||||
|
"Beim Löschen wird geprüft, ob dadurch ein Ticket für dich oder komplett leer wird. Leere Tickets werden entfernt.",
|
||||||
|
"Wenn die letzte Session eines Tickets gelöscht wird, springt die Ansicht zurück zur Auswertung.",
|
||||||
|
"Wiederkehrend erzeugte Sessions werden beim Löschen als Ausnahme gespeichert. Dadurch erscheinen sie beim nächsten Öffnen des Monats nicht wieder automatisch.",
|
||||||
|
"Wenn eine wiederkehrende Leistung nur an einem anderen Tag erledigt wurde, wird die erzeugte Session bearbeitet beziehungsweise verschoben."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "monatsabschluss",
|
||||||
|
title: "Monatsabschluss",
|
||||||
|
description: "Der Monatsabschluss ist die Kontrollseite für offene Bewertungen und Teamspace-Punkte vor dem Abschluss.",
|
||||||
|
quickItems: [
|
||||||
|
"Bewertungen sind Pflicht für den Abschluss.",
|
||||||
|
"Tage ohne Teamspace-Wert können zur Kenntnis genommen werden.",
|
||||||
|
"Der Monat kann wieder geöffnet werden."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"Ein Monat kann abgeschlossen werden, sobald alle Sessions dieses Monats bewertet sind.",
|
||||||
|
"Tage ohne Teamspace-Wert blockieren den Abschluss nicht automatisch. Sie sind eine Prüfliste für CRM-Abgleich und Nacharbeit.",
|
||||||
|
"Wenn eine Session bewusst nicht in Teamspace eingetragen wird, kann der fehlende Tageswert zur Kenntnis genommen werden.",
|
||||||
|
"Zur Kenntnis genommene Einträge können über die Checkbox Zur Kenntnis anzeigen wieder eingeblendet werden.",
|
||||||
|
"Die Kenntnisnahme kann pro Eintrag über Zurücknehmen rückgängig gemacht werden.",
|
||||||
|
"Die Einstellung, ob zur Kenntnis genommene Einträge angezeigt werden, bleibt im Browser gespeichert.",
|
||||||
|
"Offene Bewertungen werden nach Ticket gruppiert. Ein Klick auf Ticket öffnen führt zur Bewertung im Ticket.",
|
||||||
|
"Es gibt keinen Tagesabschluss. Tagesansichten dienen nur der Kontrolle."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fixe-abrechnungen",
|
||||||
|
title: "Fixe Abrechnungen",
|
||||||
|
description: "Fixe Abrechnungen erzeugen wiederkehrende Sessions für planbare regelmäßige Leistungen.",
|
||||||
|
quickItems: [
|
||||||
|
"Jeder Benutzer verwaltet eigene fixe Abrechnungen.",
|
||||||
|
"Ticket ist optional; ohne Ticket entsteht Fix#ID.",
|
||||||
|
"Feiertage in Bayern werden übersprungen."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"Fixe Abrechnungen sind für regelmäßige Leistungen gedacht, zum Beispiel jeden Donnerstag vier Stunden oder mehrere Wochentage mit unterschiedlichen Dauern.",
|
||||||
|
"Das Ticketfeld darf leer bleiben. Dann gruppiert TicketTracker die erzeugten Sessions automatisch unter Fix#ID.",
|
||||||
|
"Die Organisation, Art und Tätigkeit kommen aus der Regel und werden auf erzeugte Sessions angewendet.",
|
||||||
|
"Beim Muster Wochentage werden einzelne Slots mit Wochentag, Startzeit und Dauer gepflegt.",
|
||||||
|
"Beim Muster Alle X Wochen zählt das Gültig-von-Datum als Startpunkt. Das Intervall läuft wochenweise.",
|
||||||
|
"Gültig bis ist optional. Wird es später gesetzt oder geändert, werden erzeugte Sessions außerhalb der Gültigkeit entfernt.",
|
||||||
|
"Bayerische Feiertage werden bei der automatischen Anlage ausgelassen.",
|
||||||
|
"Einzelne erzeugte Sessions können wegen Urlaub, Krankheit oder Feiertagsverschiebung gelöscht oder verschoben werden."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "statistiken",
|
||||||
|
title: "Statistiken",
|
||||||
|
description: "Statistiken vergleichen getrackte Arbeit, bewertete Sessions und Teamspace-Werte.",
|
||||||
|
quickItems: [
|
||||||
|
"Sessions und Teamspace haben getrennte Farben.",
|
||||||
|
"Positive oder gleiche Differenz ist grün.",
|
||||||
|
"Negative Differenz ist rot."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"Die Statistikseite betrachtet immer einen Monat.",
|
||||||
|
"Getrackt zeigt die Summe aller Session-Zeiten und die Anzahl der Sessions und Tickets.",
|
||||||
|
"Teamspace zeigt, wie viel Zeit für dieselben Ticket-Tage im CRM eingetragen wurde.",
|
||||||
|
"Eine positive Differenz bedeutet: In Teamspace steht mehr Zeit als in den Sessions getrackt wurde.",
|
||||||
|
"Eine negative Differenz bedeutet: In Teamspace steht weniger Zeit als getrackt wurde.",
|
||||||
|
"Bewertet umfasst sowohl Abgerechnet als auch Nicht abrechenbar. Offen sind nur Sessions ohne Bewertung.",
|
||||||
|
"Der Monatsverlauf zeigt getrackte Session-Zeit als Balken und Teamspace als Linie.",
|
||||||
|
"Organisationen, Typen und Ticket-Hotspots helfen dabei, Auffälligkeiten im Monat schnell zu finden."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "statistik-getrackt",
|
||||||
|
title: "Statistik: Getrackt",
|
||||||
|
description: "Diese Kennzahl zeigt die gesamte erfasste Session-Zeit im gewählten Monat.",
|
||||||
|
quickItems: [
|
||||||
|
"Summe aller getrackten und nachgetragenen Sessions.",
|
||||||
|
"Zeigt zusätzlich Session- und Ticketanzahl.",
|
||||||
|
"Basiswert für den Vergleich mit Teamspace."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"Getrackt umfasst alle Sessions des angemeldeten Benutzers im gewählten Monat.",
|
||||||
|
"Dabei ist egal, ob die Session bereits bewertet wurde oder noch offen ist.",
|
||||||
|
"Auch automatisch erzeugte Sessions aus fixen Abrechnungen zählen hier mit.",
|
||||||
|
"Die Ticketanzahl zeigt, auf wie viele unterschiedliche Tickets sich die Sessions verteilen.",
|
||||||
|
"Dieser Wert ist die Ausgangsbasis für Teamspace-Differenzen: Teamspace minus getrackte Session-Zeit ergibt die Differenz."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "statistik-teamspace",
|
||||||
|
title: "Statistik: Teamspace",
|
||||||
|
description: "Diese Kennzahl zeigt, wie viele Stunden für die Ticket-Tage im CRM beziehungsweise Teamspace eingetragen wurden.",
|
||||||
|
quickItems: [
|
||||||
|
"Summe aller Teamspace-Tageswerte im Monat.",
|
||||||
|
"Positive oder gleiche Differenz ist grün.",
|
||||||
|
"Negative Differenz ist rot."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"Teamspace-Werte werden pro Ticket und Tag gepflegt, nicht pro einzelner Session.",
|
||||||
|
"Die Statistik summiert alle Teamspace-Werte des Monats für den angemeldeten Benutzer.",
|
||||||
|
"Eine positive Differenz bedeutet, dass in Teamspace mehr Zeit steht als in den Sessions getrackt wurde.",
|
||||||
|
"Eine negative Differenz bedeutet, dass in Teamspace weniger Zeit steht als getrackt wurde.",
|
||||||
|
"Fehlende Teamspace-Werte können im Monatsabschluss geprüft und bei Bedarf zur Kenntnis genommen werden."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "statistik-bewertet",
|
||||||
|
title: "Statistik: Bewertet",
|
||||||
|
description: "Diese Kennzahl zeigt, wie viel der erfassten Arbeit bereits fachlich eingeordnet wurde.",
|
||||||
|
quickItems: [
|
||||||
|
"Abgerechnet und Nicht abrechenbar zählen beide als bewertet.",
|
||||||
|
"Der Prozentwert bezieht sich auf die Sessionanzahl.",
|
||||||
|
"Bewertete Sessions blockieren den Monatsabschluss nicht."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"Bewertet ist die Summe aus Abgerechnet und Nicht abrechenbar.",
|
||||||
|
"Nicht abrechenbare Sessions fallen also nicht aus der Qualität heraus, sondern gelten als bewusst entschieden.",
|
||||||
|
"Die Zeit zeigt die Summe der bewerteten Session-Minuten.",
|
||||||
|
"Die Prozentwerte werden zusammen mit den offenen Sessions gerundet, sodass bewertet und offen zusammen 100 Prozent ergeben.",
|
||||||
|
"Wenn eine Bewertung zurückgenommen wird, wandert die Session wieder in Offen."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "statistik-offen",
|
||||||
|
title: "Statistik: Offen",
|
||||||
|
description: "Diese Kennzahl zeigt Sessions, die noch keine Bewertung haben.",
|
||||||
|
quickItems: [
|
||||||
|
"Offen bedeutet: weder Abgerechnet noch Nicht abrechenbar.",
|
||||||
|
"Offene Sessions blockieren den Monatsabschluss.",
|
||||||
|
"Die Klärung erfolgt in der Ticketdetailansicht."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"Offen umfasst alle Sessions des Monats ohne Bewertung.",
|
||||||
|
"Diese Sessions müssen vor dem Monatsabschluss entweder als Abgerechnet oder Nicht abrechenbar markiert werden.",
|
||||||
|
"Die offene Zeit zeigt, wie viele Minuten noch bewertet werden müssen.",
|
||||||
|
"Der Monatsabschluss listet offene Bewertungen gruppiert nach Ticket, damit du direkt in die passende Ticketdetailansicht springen kannst.",
|
||||||
|
"Wird eine Bewertung zurückgenommen, steigt die offene Anzahl wieder."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "statistik-verlauf",
|
||||||
|
title: "Statistik: Monatsverlauf und Qualität",
|
||||||
|
description: "Diese Card verbindet Tagesverlauf, Teamspace-Linie, Bewertungsstand und Qualitätswerte.",
|
||||||
|
quickItems: [
|
||||||
|
"Balken zeigen getrackte Sessions.",
|
||||||
|
"Die Linie zeigt Teamspace-Werte.",
|
||||||
|
"Der rechte Bereich zeigt Bewertung und Teamspace-Abgleich."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"Der Monatsverlauf zeigt jeden Tag des Monats. Tage ohne Sessions bleiben leer, damit die Monatsstruktur sichtbar bleibt.",
|
||||||
|
"Die blauen Balken stehen für getrackte Session-Zeit.",
|
||||||
|
"Die grüne Linie steht für Teamspace-Zeit, sofern für den Tag Werte gepflegt wurden.",
|
||||||
|
"Der Bewertungsstand vergleicht bewertete und offene Sessions.",
|
||||||
|
"Der Teamspace-Abgleich zeigt die Gesamtdifferenz zwischen CRM-Werten und getrackter Session-Zeit.",
|
||||||
|
"Manuell und Fix trennen selbst erfasste Sessions von automatisch erzeugten Sessions aus fixen Abrechnungen."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "statistik-organisationen",
|
||||||
|
title: "Statistik pro Organisation",
|
||||||
|
description: "Diese Card zeigt Aufwand und Teamspace-Abgleich je Kunde beziehungsweise Organisation.",
|
||||||
|
quickItems: [
|
||||||
|
"Gruppiert nach Organisation.",
|
||||||
|
"Zeigt Sessions, Zeiten und Differenz.",
|
||||||
|
"Die Liste ist paginiert."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"Die Organisationsstatistik fasst alle Tickets und Sessions einer Organisation im gewählten Monat zusammen.",
|
||||||
|
"Sessions zeigt die Anzahl der einzelnen Arbeitseinträge.",
|
||||||
|
"Zeiten vergleicht getrackte Session-Zeit mit Teamspace-Zeit.",
|
||||||
|
"Die Differenz wird als Teamspace minus Sessions berechnet.",
|
||||||
|
"Der Anteil am Monat zeigt, wie groß der getrackte Aufwand dieser Organisation im Verhältnis zur größten Organisation im Monat ist.",
|
||||||
|
"Bei vielen Organisationen wird die Liste paginiert, damit die Seite kompakt bleibt."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "statistik-typen",
|
||||||
|
title: "Statistik: Typen",
|
||||||
|
description: "Diese Card trennt den Monatsaufwand nach Support und Consulting.",
|
||||||
|
quickItems: [
|
||||||
|
"Vergleicht Support und Consulting.",
|
||||||
|
"Zeigt Sessions, Zeiten und Teamspace-Differenz.",
|
||||||
|
"Zeigt zusätzlich Bewertungsstatus je Typ."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"Die Typenstatistik gruppiert Sessions nach Art.",
|
||||||
|
"Support und Consulting werden getrennt summiert.",
|
||||||
|
"Die Zeitwerte zeigen getrackte Sessions und Teamspace für den jeweiligen Typ.",
|
||||||
|
"Die Differenz folgt derselben Logik wie überall: Teamspace minus getrackte Sessions.",
|
||||||
|
"Die Zeile Abr., Nicht und Offen zeigt, wie viele Sessions dieses Typs bereits bewertet oder noch offen sind."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "statistik-hotspots",
|
||||||
|
title: "Ticket-Hotspots",
|
||||||
|
description: "Diese Card zeigt die größten Zeitblöcke des Monats nach Ticket.",
|
||||||
|
quickItems: [
|
||||||
|
"Sortiert nach größtem Zeitaufwand.",
|
||||||
|
"Zeigt Sessions, Teamspace und Differenz.",
|
||||||
|
"Öffnen führt direkt zur Ticketdetailansicht."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"Ticket-Hotspots helfen, die größten Aufwandstreiber im Monat schnell zu erkennen.",
|
||||||
|
"Die Liste zeigt die wichtigsten Tickets nach getrackter Zeit.",
|
||||||
|
"Pro Ticket siehst du Organisation, Sessionanzahl, aktive Tage, getrackte Zeit, Teamspace-Zeit und Differenz.",
|
||||||
|
"Offene Tickets werden mit einem Warnstatus angezeigt, damit Bewertungen nachgezogen werden können.",
|
||||||
|
"Über Öffnen gelangst du direkt in die Ticketdetailansicht des Monats."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "profil",
|
||||||
|
title: "Profil",
|
||||||
|
description: "Im Profil pflegt jeder Benutzer die eigenen Zugangsdaten.",
|
||||||
|
quickItems: [
|
||||||
|
"Benutzername und voller Name sind änderbar.",
|
||||||
|
"Passwortänderung benötigt das aktuelle Passwort.",
|
||||||
|
"Der volle Name wird in der Oberfläche angezeigt."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"Der Benutzername wird beim Login verwendet und muss eindeutig sein.",
|
||||||
|
"Der volle Name erscheint in der App, zum Beispiel im Kopfbereich oder im Adminbereich.",
|
||||||
|
"Wenn du dein Passwort ändern willst, musst du das aktuelle Passwort sowie das neue Passwort zweimal eingeben.",
|
||||||
|
"Bleiben die Passwortfelder leer, werden nur Benutzername und voller Name gespeichert.",
|
||||||
|
"Admins können Benutzer ebenfalls verwalten, aber jeder Benutzer kann die eigenen Daten jederzeit selbst pflegen."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "admin",
|
||||||
|
title: "Adminbereich",
|
||||||
|
description: "Der Adminbereich verwaltet Benutzer, Session-Zuordnung, manuelle Admin-Nachträge und Zammad-Sync.",
|
||||||
|
quickItems: [
|
||||||
|
"Admins legen Benutzer an und bearbeiten Rollen.",
|
||||||
|
"Sessions können anderen Benutzern zugewiesen werden.",
|
||||||
|
"Zammad-Organisationen werden über Data-Sync aktualisiert."
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
"Neue Benutzer bekommen Benutzername, vollen Namen, Passwort, Rolle und Aktivstatus.",
|
||||||
|
"Admins können bestehende Benutzer bearbeiten, Passwörter setzen und Benutzer deaktivieren.",
|
||||||
|
"Der eigene Admin-Zugang kann nicht versehentlich deaktiviert oder zur normalen User-Rolle herabgestuft werden.",
|
||||||
|
"In der Session-Zuordnung sehen Admins die letzten Sessions inklusive Besitzer und können einzelne Sessions umverteilen.",
|
||||||
|
"Beim Umverteilen werden die betroffenen Monatsabschlüsse für alten und neuen Besitzer wieder geöffnet, damit Bewertungen und Auswertungen konsistent bleiben.",
|
||||||
|
"Admins können Sessions für andere Benutzer nachtragen, wenn Zeiten vergessen wurden.",
|
||||||
|
"Der Data-Sync speichert Zammad-Zugangsdaten und synchronisiert Organisationen in die lokale Datenbank."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getHelpSection(anchor: string) {
|
||||||
|
return helpSections.find((section) => section.id === anchor) ?? helpSections[0];
|
||||||
|
}
|
||||||
@@ -191,3 +191,109 @@ export type TicketPeriod = {
|
|||||||
|
|
||||||
export type MonthOverview = PeriodOverview;
|
export type MonthOverview = PeriodOverview;
|
||||||
export type TicketMonth = TicketPeriod;
|
export type TicketMonth = TicketPeriod;
|
||||||
|
|
||||||
|
export type StatisticsDailyBucket = {
|
||||||
|
day: string;
|
||||||
|
sessions: number;
|
||||||
|
total_minutes: number;
|
||||||
|
billed_minutes: number;
|
||||||
|
non_billable_minutes: number;
|
||||||
|
open_minutes: number;
|
||||||
|
billed_sessions: number;
|
||||||
|
non_billable_sessions: number;
|
||||||
|
open_sessions: number;
|
||||||
|
crm_billed_minutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatisticsGroup = {
|
||||||
|
organization_id?: string | null;
|
||||||
|
organization_name?: string;
|
||||||
|
work_type?: WorkType;
|
||||||
|
tickets: number;
|
||||||
|
sessions: number;
|
||||||
|
total_minutes: number;
|
||||||
|
billed_minutes: number;
|
||||||
|
non_billable_minutes: number;
|
||||||
|
open_minutes: number;
|
||||||
|
billed_sessions: number;
|
||||||
|
non_billable_sessions: number;
|
||||||
|
open_sessions: number;
|
||||||
|
crm_billed_minutes: number;
|
||||||
|
crm_delta_minutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatisticsTicket = {
|
||||||
|
ticket_id: string;
|
||||||
|
ticket_number: string;
|
||||||
|
organization_id: string | null;
|
||||||
|
organization_name: string;
|
||||||
|
active_days: number;
|
||||||
|
sessions: number;
|
||||||
|
total_minutes: number;
|
||||||
|
billed_minutes: number;
|
||||||
|
non_billable_minutes: number;
|
||||||
|
open_minutes: number;
|
||||||
|
billed_sessions: number;
|
||||||
|
non_billable_sessions: number;
|
||||||
|
open_sessions: number;
|
||||||
|
crm_billed_minutes: number;
|
||||||
|
crm_delta_minutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatisticsOpenSession = {
|
||||||
|
id: string;
|
||||||
|
ticket_id: string;
|
||||||
|
ticket_number: string;
|
||||||
|
organization_name: string;
|
||||||
|
activity: string;
|
||||||
|
started_at: string;
|
||||||
|
rounded_minutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatisticsCrmDay = {
|
||||||
|
ticket_id: string;
|
||||||
|
ticket_number: string;
|
||||||
|
organization_name: string;
|
||||||
|
day: string;
|
||||||
|
tracked_minutes: number;
|
||||||
|
crm_billed_minutes: number;
|
||||||
|
delta_minutes?: number;
|
||||||
|
acknowledged?: boolean;
|
||||||
|
acknowledged_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatisticsOverview = {
|
||||||
|
periodType: "month";
|
||||||
|
period: string;
|
||||||
|
closed: boolean;
|
||||||
|
closedAt: string | null;
|
||||||
|
totals: {
|
||||||
|
tickets: number;
|
||||||
|
sessions: number;
|
||||||
|
minutes: number;
|
||||||
|
billedMinutes: number;
|
||||||
|
nonBillableMinutes: number;
|
||||||
|
openMinutes: number;
|
||||||
|
billedSessions: number;
|
||||||
|
nonBillableSessions: number;
|
||||||
|
openSessions: number;
|
||||||
|
recurringMinutes: number;
|
||||||
|
manualMinutes: number;
|
||||||
|
recurringSessions: number;
|
||||||
|
manualSessions: number;
|
||||||
|
averageSessionMinutes: number;
|
||||||
|
activeDays: number;
|
||||||
|
crmBilledMinutes: number;
|
||||||
|
crmDeltaMinutes: number;
|
||||||
|
};
|
||||||
|
dailySeries: StatisticsDailyBucket[];
|
||||||
|
organizations: StatisticsGroup[];
|
||||||
|
workTypes: StatisticsGroup[];
|
||||||
|
tickets: StatisticsTicket[];
|
||||||
|
attention: {
|
||||||
|
openSessions: StatisticsOpenSession[];
|
||||||
|
missingCrmDays: StatisticsCrmDay[];
|
||||||
|
acknowledgedMissingCrmDays: StatisticsCrmDay[];
|
||||||
|
crmMismatches: StatisticsCrmDay[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { HelpLink } from "@/components/HelpLink";
|
||||||
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
||||||
import {
|
import {
|
||||||
createAdminSession,
|
createAdminSession,
|
||||||
@@ -335,9 +336,12 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<DatabaseZap className="size-5 text-muted-foreground" />
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle>Data-Sync</CardTitle>
|
<DatabaseZap className="size-5 text-muted-foreground" />
|
||||||
|
<CardTitle>Data-Sync</CardTitle>
|
||||||
|
</div>
|
||||||
|
<HelpLink anchor="organisationen" label="Hilfe zum Zammad-Sync" />
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>Zammad-Zugang speichern und Organisationen in die lokale TicketTracker-Datenbank übernehmen.</CardDescription>
|
<CardDescription>Zammad-Zugang speichern und Organisationen in die lokale TicketTracker-Datenbank übernehmen.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -378,8 +382,13 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4">
|
||||||
<CardTitle>Neuer Benutzer</CardTitle>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<CardDescription>Neue Benutzer sehen später nur ihre eigenen Sessions und Abschlüsse.</CardDescription>
|
<div>
|
||||||
|
<CardTitle>Neuer Benutzer</CardTitle>
|
||||||
|
<CardDescription>Neue Benutzer sehen später nur ihre eigenen Sessions und Abschlüsse.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<HelpLink anchor="admin" label="Hilfe zur Benutzerverwaltung" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4">
|
<CardContent className="px-4 pb-4">
|
||||||
<form className="grid gap-3 lg:grid-cols-[160px_minmax(180px,1fr)_160px_130px_auto_auto] lg:items-end" onSubmit={createUser}>
|
<form className="grid gap-3 lg:grid-cols-[160px_minmax(180px,1fr)_160px_130px_auto_auto] lg:items-end" onSubmit={createUser}>
|
||||||
@@ -421,9 +430,12 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<CalendarPlus className="size-5 text-muted-foreground" />
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle>Session nachtragen</CardTitle>
|
<CalendarPlus className="size-5 text-muted-foreground" />
|
||||||
|
<CardTitle>Session nachtragen</CardTitle>
|
||||||
|
</div>
|
||||||
|
<HelpLink anchor="session-nachtragen" label="Hilfe zum Nachtragen für Benutzer" />
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>Vergessene Zeiten manuell erfassen und direkt einem Benutzer zuweisen.</CardDescription>
|
<CardDescription>Vergessene Zeiten manuell erfassen und direkt einem Benutzer zuweisen.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -541,7 +553,10 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
<CardTitle>Vorhandene Benutzer</CardTitle>
|
<CardTitle>Vorhandene Benutzer</CardTitle>
|
||||||
<CardDescription>{loading ? "Lädt..." : `${users.length} Account(s)`}</CardDescription>
|
<CardDescription>{loading ? "Lädt..." : `${users.length} Account(s)`}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{currentUser.display_name}</Badge>
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline">{currentUser.display_name}</Badge>
|
||||||
|
<HelpLink anchor="admin" label="Hilfe zu vorhandenen Benutzern" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4">
|
<CardContent className="px-4 pb-4">
|
||||||
<div className="hidden xl:block">
|
<div className="hidden xl:block">
|
||||||
@@ -658,6 +673,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
<CardTitle>Session-Zuordnung</CardTitle>
|
<CardTitle>Session-Zuordnung</CardTitle>
|
||||||
<CardDescription>{loading ? "Lädt..." : `${sessions.length} letzte Session(s), inklusive Besitzer.`}</CardDescription>
|
<CardDescription>{loading ? "Lädt..." : `${sessions.length} letzte Session(s), inklusive Besitzer.`}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
<HelpLink anchor="admin" label="Hilfe zur Session-Zuordnung" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4">
|
<CardContent className="px-4 pb-4">
|
||||||
<div className="hidden xl:block">
|
<div className="hidden xl:block">
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { CheckCircle2, ChevronLeft, ChevronRight, CircleAlert, Clock3, ExternalLink, LockOpen, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Clock3, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Alert } from "@/components/ui/alert";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -10,8 +9,9 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
||||||
import { closePeriod, getPeriodOverview, reopenPeriod } from "../api";
|
import { HelpLink } from "@/components/HelpLink";
|
||||||
import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format";
|
import { getPeriodOverview } from "../api";
|
||||||
|
import { currentDay, currentMonth, formatMinutes } from "../format";
|
||||||
import type { PeriodOverview, PeriodType, TicketSummary } from "../types";
|
import type { PeriodOverview, PeriodType, TicketSummary } from "../types";
|
||||||
|
|
||||||
type AnalysisPageProps = {
|
type AnalysisPageProps = {
|
||||||
@@ -304,9 +304,6 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
const [period, setPeriod] = useState(currentMonth());
|
const [period, setPeriod] = useState(currentMonth());
|
||||||
const [overview, setOverview] = useState<PeriodOverview | null>(null);
|
const [overview, setOverview] = useState<PeriodOverview | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [closing, setClosing] = useState(false);
|
|
||||||
const [reopening, setReopening] = useState(false);
|
|
||||||
const [showOpen, setShowOpen] = useState(false);
|
|
||||||
const [ticketViewSettings, setTicketViewSettings] = useState<AnalysisTicketViewSettings>(() => readStoredTicketViewSettings());
|
const [ticketViewSettings, setTicketViewSettings] = useState<AnalysisTicketViewSettings>(() => readStoredTicketViewSettings());
|
||||||
const loadRequestId = useRef(0);
|
const loadRequestId = useRef(0);
|
||||||
|
|
||||||
@@ -362,7 +359,6 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
function switchPeriodType(nextType: PeriodType) {
|
function switchPeriodType(nextType: PeriodType) {
|
||||||
setPeriodType(nextType);
|
setPeriodType(nextType);
|
||||||
setPeriod(nextType === "month" ? currentMonth() : currentDay());
|
setPeriod(nextType === "month" ? currentMonth() : currentDay());
|
||||||
setShowOpen(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function changePeriodBy(delta: number) {
|
function changePeriodBy(delta: number) {
|
||||||
@@ -381,48 +377,10 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
setPeriod(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`);
|
setPeriod(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function finishPeriod() {
|
|
||||||
setClosing(true);
|
|
||||||
try {
|
|
||||||
await closePeriod(periodType, period);
|
|
||||||
toast.success(`${periodType === "month" ? "Monat" : "Tag"} abgeschlossen`, {
|
|
||||||
description: `${period} ist abgeschlossen.`
|
|
||||||
});
|
|
||||||
await load();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(`${periodType === "month" ? "Monat" : "Tag"} noch nicht abschließbar`, {
|
|
||||||
description: error instanceof Error ? error.message : "Bitte offene Sessions prüfen."
|
|
||||||
});
|
|
||||||
setShowOpen(true);
|
|
||||||
await load();
|
|
||||||
} finally {
|
|
||||||
setClosing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openPeriodAgain() {
|
|
||||||
setReopening(true);
|
|
||||||
try {
|
|
||||||
await reopenPeriod(periodType, period);
|
|
||||||
toast.success(`${periodType === "month" ? "Monat" : "Tag"} wieder geöffnet`, {
|
|
||||||
description: `${period} kann wieder bearbeitet werden.`
|
|
||||||
});
|
|
||||||
await load();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(`${periodType === "month" ? "Monat" : "Tag"} konnte nicht geöffnet werden`, {
|
|
||||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setReopening(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const totals = overview?.totals;
|
const totals = overview?.totals;
|
||||||
const periodLabel = periodType === "month" ? "Monat" : "Tag";
|
const periodLabel = periodType === "month" ? "Monat" : "Tag";
|
||||||
const supportsPeriodClosure = periodType === "month";
|
|
||||||
const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [
|
const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [
|
||||||
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
|
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
|
||||||
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
|
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
|
||||||
@@ -499,7 +457,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold tracking-normal">Auswertung</h2>
|
<h2 className="text-2xl font-semibold tracking-normal">Auswertung</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{supportsPeriodClosure ? "Tickets prüfen, Sessions markieren und Monat abschließen." : "Sessions des Tages prüfen und bewerten."}
|
{periodType === "month" ? "Tickets prüfen, Sessions bewerten und Zeiten kontrollieren." : "Sessions des Tages prüfen und bewerten."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 sm:grid-cols-[auto_minmax(220px,300px)] sm:items-end">
|
<div className="grid gap-2 sm:grid-cols-[auto_minmax(220px,300px)] sm:items-end">
|
||||||
@@ -527,10 +485,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
className="h-9"
|
className="h-9"
|
||||||
type={periodType === "month" ? "month" : "date"}
|
type={periodType === "month" ? "month" : "date"}
|
||||||
value={period}
|
value={period}
|
||||||
onChange={(event) => {
|
onChange={(event) => setPeriod(event.currentTarget.value)}
|
||||||
setPeriod(event.currentTarget.value);
|
|
||||||
setShowOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changePeriodBy(1)} aria-label={`${periodLabel} vor`}>
|
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changePeriodBy(1)} aria-label={`${periodLabel} vor`}>
|
||||||
<ChevronRight className="size-4" />
|
<ChevronRight className="size-4" />
|
||||||
@@ -565,7 +520,10 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
{periodType === "month" ? "Aufwand pro Tag im ausgewählten Monat." : "Aufwand pro Stunde am ausgewählten Tag."}
|
{periodType === "month" ? "Aufwand pro Tag im ausgewählten Monat." : "Aufwand pro Stunde am ausgewählten Tag."}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{formatMinutes(totals?.minutes ?? 0)}</Badge>
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline">{formatMinutes(totals?.minutes ?? 0)}</Badge>
|
||||||
|
<HelpLink anchor="auswertung" label="Hilfe zur Auswertung" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4">
|
<CardContent className="px-4 pb-4">
|
||||||
<div className="h-44 w-full overflow-hidden rounded-md border bg-background">
|
<div className="h-44 w-full overflow-hidden rounded-md border bg-background">
|
||||||
@@ -643,73 +601,15 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{supportsPeriodClosure && overview?.closed ? (
|
|
||||||
<Alert variant="success" className="flex items-center gap-2 py-3">
|
|
||||||
<CheckCircle2 className="size-4 shrink-0" />
|
|
||||||
<span>Dieser Monat wurde am {formatDateTime(overview.closedAt!)} abgeschlossen.</span>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{overview && overview.openSessions.length > 0 ? (
|
|
||||||
<Alert variant="warning" className="flex flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CircleAlert className="size-4 shrink-0" />
|
|
||||||
<span>Es gibt noch {overview.openSessions.length} Session(s) ohne Auswahl.</span>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setShowOpen((value) => !value)}>
|
|
||||||
Offene Sessions prüfen
|
|
||||||
</Button>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{showOpen && overview?.openSessions.length ? (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="p-4">
|
|
||||||
<CardTitle>Nicht bearbeitete Sessions</CardTitle>
|
|
||||||
<CardDescription>Diese Einträge blockieren den Monatsabschluss.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2 px-4 pb-4">
|
|
||||||
{overview.openSessions.map((session) => (
|
|
||||||
<div key={session.id} className="grid gap-2 rounded-md border p-3 sm:grid-cols-[1fr_auto] sm:items-center">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="font-medium">{session.ticket_number}</span>
|
|
||||||
<Badge variant="outline">{formatMinutes(session.rounded_minutes)}</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="truncate text-sm text-muted-foreground">{session.customer_name}</p>
|
|
||||||
<p className="text-sm">{session.activity}</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${session.ticket_id}`)}>
|
|
||||||
Öffnen
|
|
||||||
<ExternalLink className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Tickets im Zeitraum</CardTitle>
|
<CardTitle>Tickets im Zeitraum</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{supportsPeriodClosure ? "Sessions prüfen und danach den Monat abschließen." : "Tagesansicht ohne eigenen Abschluss."}
|
{periodType === "month" ? "Sessions prüfen, bewerten und bei Bedarf Tickets öffnen." : "Tagesansicht ohne eigenen Abschluss."}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
{supportsPeriodClosure ? (
|
<HelpLink anchor="auswertung" label="Hilfe zu Tickets im Zeitraum" />
|
||||||
overview?.closed ? (
|
|
||||||
<Button size="sm" variant="secondary" disabled={reopening} onClick={openPeriodAgain}>
|
|
||||||
<LockOpen className="size-4" />
|
|
||||||
{reopening ? "Öffnet..." : "Monat wieder öffnen"}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button size="sm" variant={overview?.canClose ? "default" : "secondary"} disabled={!overview?.canClose || closing} onClick={finishPeriod}>
|
|
||||||
<CheckCircle2 className="size-4" />
|
|
||||||
{closing ? "Schließt..." : overview?.canClose ? "Monat abschließen" : "Noch nicht abschließbar"}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4">
|
<CardContent className="px-4 pb-4">
|
||||||
<div className="mb-4 rounded-md border bg-muted/20 p-3">
|
<div className="mb-4 rounded-md border bg-muted/20 p-3">
|
||||||
@@ -723,6 +623,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{activeFilterCount > 0 ? <Badge variant="outline">{activeFilterCount} aktiv</Badge> : null}
|
{activeFilterCount > 0 ? <Badge variant="outline">{activeFilterCount} aktiv</Badge> : null}
|
||||||
|
<HelpLink anchor="auswertung" label="Hilfe zu Filtern und Sortierung" />
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { ArrowUp, BookOpenCheck } from "lucide-react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { helpSections } from "../help-content";
|
||||||
|
|
||||||
|
export function FaqPage() {
|
||||||
|
return (
|
||||||
|
<div id="top" className="space-y-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold tracking-normal">FAQ und Anleitung</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Ausführliche Anleitung für Timer, Auswertung, Teamspace, fixe Abrechnungen, Monatsabschluss und Adminfunktionen.</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{helpSections.length} Abschnitt(e)</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpenCheck className="size-5 text-muted-foreground" />
|
||||||
|
<CardTitle>Schnellnavigation</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Die Fragezeichen in der App öffnen eine Kurzfassung. Von dort gelangst du direkt zum passenden ausführlichen Abschnitt.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap gap-2 px-4 pb-4">
|
||||||
|
{helpSections.map((section) => (
|
||||||
|
<Button key={section.id} asChild variant="secondary" size="sm">
|
||||||
|
<a href={`#${section.id}`}>{section.title}</a>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
|
{helpSections.map((section) => (
|
||||||
|
<Card key={section.id} id={section.id} className="scroll-mt-16">
|
||||||
|
<CardHeader className="p-4">
|
||||||
|
<CardTitle>{section.title}</CardTitle>
|
||||||
|
<CardDescription>{section.description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 px-4 pb-4">
|
||||||
|
<div className="rounded-md border bg-muted/20 p-3">
|
||||||
|
<p className="mb-2 text-sm font-medium">Kurzfassung</p>
|
||||||
|
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||||
|
{section.quickItems.map((item) => (
|
||||||
|
<li key={item} className="flex gap-2">
|
||||||
|
<span className="mt-2 size-1.5 shrink-0 rounded-full bg-primary" />
|
||||||
|
<span>{item}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium">Details</p>
|
||||||
|
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||||
|
{section.details.map((item) => (
|
||||||
|
<li key={item} className="flex gap-2">
|
||||||
|
<span className="mt-2 size-1.5 shrink-0 rounded-full bg-primary/70" />
|
||||||
|
<span>{item}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button asChild variant="ghost" size="sm">
|
||||||
|
<a href="#top">
|
||||||
|
<ArrowUp className="size-4" />
|
||||||
|
Nach oben
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Clock3,
|
||||||
|
ExternalLink,
|
||||||
|
FileWarning,
|
||||||
|
Lock,
|
||||||
|
LockOpen,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Alert } from "@/components/ui/alert";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
||||||
|
import { HelpLink } from "@/components/HelpLink";
|
||||||
|
import { acknowledgeMissingTicketDayBilling, closePeriod, getStatisticsOverview, reopenPeriod } from "../api";
|
||||||
|
import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format";
|
||||||
|
import type { StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview } from "../types";
|
||||||
|
|
||||||
|
type MonthlyClosePageProps = {
|
||||||
|
onNavigate: (to: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackedTextClass = "text-sky-700 dark:text-sky-300";
|
||||||
|
const teamspaceTextClass = "text-emerald-700 dark:text-emerald-300";
|
||||||
|
const showAcknowledgedMissingCrmStorageKey = "tickettracker.monthlyClose.showAcknowledgedMissingCrm";
|
||||||
|
|
||||||
|
function formatSignedMinutes(minutes: number) {
|
||||||
|
if (minutes === 0) {
|
||||||
|
return "ausgeglichen";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTeamspaceDelta(minutes: number) {
|
||||||
|
return formatSignedMinutes(minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusCard({ label, value, detail, tone }: { label: string; value: string | number; detail: string; tone?: "ok" | "warn" | "neutral" }) {
|
||||||
|
const valueClassName = tone === "ok" ? "text-emerald-700 dark:text-emerald-300" : tone === "warn" ? "text-amber-700 dark:text-amber-300" : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||||
|
<p className={`text-xl font-semibold tracking-normal ${valueClassName}`}>{value}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{detail}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenTicketGroup = {
|
||||||
|
ticketId: string;
|
||||||
|
ticketNumber: string;
|
||||||
|
organizationName: string;
|
||||||
|
openCount: number;
|
||||||
|
totalMinutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function groupOpenSessions(sessions: StatisticsOpenSession[]) {
|
||||||
|
const groups = new Map<string, OpenTicketGroup>();
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
const existing = groups.get(session.ticket_id);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.openCount += 1;
|
||||||
|
existing.totalMinutes += session.rounded_minutes;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.set(session.ticket_id, {
|
||||||
|
ticketId: session.ticket_id,
|
||||||
|
ticketNumber: session.ticket_number,
|
||||||
|
organizationName: session.organization_name,
|
||||||
|
openCount: 1,
|
||||||
|
totalMinutes: session.rounded_minutes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(groups.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup; month: string; onNavigate: (to: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-36 flex-col justify-between gap-3 rounded-md border bg-background p-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">{group.ticketNumber}</span>
|
||||||
|
<CopyTicketButton ticketNumber={group.ticketNumber} />
|
||||||
|
<Badge variant="warning">{group.openCount} offen</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-sm text-muted-foreground">{group.organizationName}</p>
|
||||||
|
<p className={`text-sm font-medium ${trackedTextClass}`}>{formatMinutes(group.totalMinutes)} offen zu bewerten</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${group.ticketId}`)}>
|
||||||
|
Ticket öffnen
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CrmDayItem({
|
||||||
|
item,
|
||||||
|
busy,
|
||||||
|
onSetAcknowledgement,
|
||||||
|
onNavigate
|
||||||
|
}: {
|
||||||
|
item: StatisticsCrmDay;
|
||||||
|
busy: boolean;
|
||||||
|
onSetAcknowledgement: (item: StatisticsCrmDay, acknowledged: boolean) => void;
|
||||||
|
onNavigate: (to: string) => void;
|
||||||
|
}) {
|
||||||
|
const isAcknowledged = Boolean(item.acknowledged);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex min-h-36 flex-col justify-between gap-3 rounded-md border bg-background p-3 ${isAcknowledged ? "opacity-75" : ""}`}>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">{item.ticket_number}</span>
|
||||||
|
<CopyTicketButton ticketNumber={item.ticket_number} />
|
||||||
|
<Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</Badge>
|
||||||
|
<Badge variant={isAcknowledged ? "success" : "warning"}>{isAcknowledged ? "zur Kenntnis" : "offen"}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-sm text-muted-foreground">{item.organization_name}</p>
|
||||||
|
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||||
|
<span className={trackedTextClass}>Sessions {formatMinutes(item.tracked_minutes)}</span>
|
||||||
|
<span className={teamspaceTextClass}>Teamspace {formatMinutes(item.crm_billed_minutes)}</span>
|
||||||
|
{typeof item.delta_minutes === "number" ? <span className="text-muted-foreground">Differenz {formatTeamspaceDelta(item.delta_minutes)}</span> : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<Button variant={isAcknowledged ? "secondary" : "ghost"} size="sm" disabled={busy} onClick={() => onSetAcknowledgement(item, !isAcknowledged)}>
|
||||||
|
{isAcknowledged ? "Zurücknehmen" : "Zur Kenntnis"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/day/${item.day}/tickets/${item.ticket_id}`)}>
|
||||||
|
Tag öffnen
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||||
|
const [month, setMonth] = useState(currentMonth());
|
||||||
|
const [stats, setStats] = useState<StatisticsOverview | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [closing, setClosing] = useState(false);
|
||||||
|
const [reopening, setReopening] = useState(false);
|
||||||
|
const [acknowledgingKeys, setAcknowledgingKeys] = useState<Set<string>>(() => new Set());
|
||||||
|
const [showAcknowledgedMissingCrm, setShowAcknowledgedMissingCrm] = useState(false);
|
||||||
|
const [showAcknowledgedMissingCrmLoaded, setShowAcknowledgedMissingCrmLoaded] = useState(false);
|
||||||
|
const loadRequestId = useRef(0);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const requestId = loadRequestId.current + 1;
|
||||||
|
loadRequestId.current = requestId;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getStatisticsOverview(month);
|
||||||
|
|
||||||
|
if (requestId === loadRequestId.current) {
|
||||||
|
setStats(result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId === loadRequestId.current) {
|
||||||
|
toast.error("Monatsabschluss konnte nicht geladen werden", {
|
||||||
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === loadRequestId.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [month]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShowAcknowledgedMissingCrm(window.localStorage.getItem(showAcknowledgedMissingCrmStorageKey) === "true");
|
||||||
|
setShowAcknowledgedMissingCrmLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showAcknowledgedMissingCrmLoaded) {
|
||||||
|
window.localStorage.setItem(showAcknowledgedMissingCrmStorageKey, String(showAcknowledgedMissingCrm));
|
||||||
|
}
|
||||||
|
}, [showAcknowledgedMissingCrm, showAcknowledgedMissingCrmLoaded]);
|
||||||
|
|
||||||
|
function changeMonthBy(delta: number) {
|
||||||
|
const [year, monthNumber] = month.split("-").map(Number);
|
||||||
|
const date = Number.isFinite(year) && Number.isFinite(monthNumber) ? new Date(year, monthNumber - 1, 1) : new Date();
|
||||||
|
date.setMonth(date.getMonth() + delta);
|
||||||
|
setMonth(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishMonth() {
|
||||||
|
setClosing(true);
|
||||||
|
try {
|
||||||
|
await closePeriod("month", month);
|
||||||
|
toast.success("Monat abgeschlossen", {
|
||||||
|
description: `${month} ist abgeschlossen.`
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Monat noch nicht abschließbar", {
|
||||||
|
description: error instanceof Error ? error.message : "Bitte offene Bewertungen prüfen."
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
} finally {
|
||||||
|
setClosing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reopenMonth() {
|
||||||
|
setReopening(true);
|
||||||
|
try {
|
||||||
|
await reopenPeriod("month", month);
|
||||||
|
toast.success("Monat wieder geöffnet", {
|
||||||
|
description: `${month} kann wieder bearbeitet werden.`
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Monat konnte nicht geöffnet werden", {
|
||||||
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setReopening(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setMissingCrmAcknowledgement(item: StatisticsCrmDay, acknowledged: boolean) {
|
||||||
|
const key = `${item.ticket_id}:${item.day}`;
|
||||||
|
setAcknowledgingKeys((current) => new Set(current).add(key));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await acknowledgeMissingTicketDayBilling(item.ticket_id, item.day, acknowledged);
|
||||||
|
toast.success(acknowledged ? "Teamspace-Prüfung abgehakt" : "Kenntnisnahme zurückgenommen", {
|
||||||
|
description: `${item.ticket_number} am ${formatDate(`${item.day}T00:00:00`)}`
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(acknowledged ? "Konnte nicht abgehakt werden" : "Konnte nicht zurückgenommen werden", {
|
||||||
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setAcknowledgingKeys((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
next.delete(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSessions = stats?.attention.openSessions ?? [];
|
||||||
|
const missingCrmDays = stats?.attention.missingCrmDays ?? [];
|
||||||
|
const acknowledgedMissingCrmDays = stats?.attention.acknowledgedMissingCrmDays ?? [];
|
||||||
|
const openTicketGroups = useMemo(() => groupOpenSessions(openSessions), [openSessions]);
|
||||||
|
const visibleMissingCrmDays = useMemo(() => {
|
||||||
|
const days = showAcknowledgedMissingCrm ? [...missingCrmDays, ...acknowledgedMissingCrmDays] : [...missingCrmDays];
|
||||||
|
return days.sort((left, right) => left.day.localeCompare(right.day) || left.ticket_number.localeCompare(right.ticket_number, "de", { numeric: true, sensitivity: "base" }));
|
||||||
|
}, [acknowledgedMissingCrmDays, missingCrmDays, showAcknowledgedMissingCrm]);
|
||||||
|
const canClose = Boolean(stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
|
||||||
|
const checklist = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: "Offene Bewertungen", count: openSessions.length, detail: `${openTicketGroups.length} Ticket(s)`, blocker: true },
|
||||||
|
{ label: "Tage ohne Teamspace-Wert", count: missingCrmDays.length, detail: `${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`, blocker: false }
|
||||||
|
],
|
||||||
|
[acknowledgedMissingCrmDays.length, openSessions.length, openTicketGroups.length, missingCrmDays.length]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold tracking-normal">Monatsabschluss</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Offene Bewertungen, Teamspace-Abgleich und der eigentliche Monatsabschluss.</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-[minmax(220px,300px)_auto] sm:items-end">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground" htmlFor="close-month">Monat</label>
|
||||||
|
{loading ? <span className="text-xs text-muted-foreground">lädt...</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[2.25rem_minmax(0,1fr)_2.25rem] gap-1">
|
||||||
|
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(-1)} aria-label="Monat zurück">
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Input id="close-month" className="h-9" type="month" value={month} onChange={(event) => setMonth(event.currentTarget.value)} />
|
||||||
|
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(1)} aria-label="Monat vor">
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{stats?.closed ? (
|
||||||
|
<Button size="sm" variant="secondary" disabled={reopening} onClick={reopenMonth}>
|
||||||
|
<LockOpen className="size-4" />
|
||||||
|
{reopening ? "Öffnet..." : "Monat öffnen"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" disabled={!canClose || closing} onClick={finishMonth}>
|
||||||
|
<Lock className="size-4" />
|
||||||
|
{closing ? "Schließt..." : "Monat abschließen"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats?.closed ? (
|
||||||
|
<Alert variant="success" className="flex items-center gap-2 py-3">
|
||||||
|
<CheckCircle2 className="size-4 shrink-0" />
|
||||||
|
<span>Dieser Monat wurde am {formatDateTime(stats.closedAt!)} abgeschlossen.</span>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<StatusCard label="Sessions" value={stats?.totals.sessions ?? 0} detail={`${formatMinutes(stats?.totals.minutes ?? 0)} getrackt`} />
|
||||||
|
<StatusCard label="Teamspace" value={formatMinutes(stats?.totals.crmBilledMinutes ?? 0)} detail={`Differenz ${formatTeamspaceDelta(stats?.totals.crmDeltaMinutes ?? 0)}`} />
|
||||||
|
<StatusCard label="Offene Bewertungen" value={openSessions.length} detail={`${openTicketGroups.length} Ticket(s)`} tone={openSessions.length > 0 ? "warn" : "ok"} />
|
||||||
|
<StatusCard label="Ohne Teamspace" value={missingCrmDays.length} detail={`${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`} tone={missingCrmDays.length > 0 ? "warn" : "ok"} />
|
||||||
|
<StatusCard label="Status" value={stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen"} detail={stats?.closed ? "Monat ist gesperrt" : canClose ? "kann abgeschlossen werden" : "Prüfpunkte bearbeiten"} tone={stats?.closed || canClose ? "ok" : "warn"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Checkliste</CardTitle>
|
||||||
|
<CardDescription>Bewertungen sind Pflicht. Teamspace-Punkte helfen beim sauberen CRM-Abgleich.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<HelpLink anchor="monatsabschluss" label="Hilfe zum Monatsabschluss" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-2 px-4 pb-4 md:grid-cols-2">
|
||||||
|
{checklist.map((item) => (
|
||||||
|
<div key={item.label} className="rounded-md border bg-background p-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium">{item.label}</span>
|
||||||
|
<Badge variant={item.count > 0 ? "warning" : "success"}>{item.count}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{item.blocker ? "Muss erledigt sein." : item.detail}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="p-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Aufräumen</CardTitle>
|
||||||
|
<CardDescription>Alles, was für diesen Monat noch Aufmerksamkeit braucht.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="inline-flex h-8 items-center gap-2 rounded-md border bg-background px-3 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={showAcknowledgedMissingCrm}
|
||||||
|
aria-label="Zur Kenntnis genommene Teamspace-Tage anzeigen"
|
||||||
|
onCheckedChange={(checked) => setShowAcknowledgedMissingCrm(checked === true)}
|
||||||
|
/>
|
||||||
|
<span>Zur Kenntnis anzeigen ({acknowledgedMissingCrmDays.length})</span>
|
||||||
|
</div>
|
||||||
|
<HelpLink anchor="monatsabschluss" label="Hilfe zu offenen Bewertungen und Teamspace-Prüfpunkten" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 px-4 pb-4 xl:grid-cols-2">
|
||||||
|
<section className="space-y-2">
|
||||||
|
<div className="flex min-h-8 items-center gap-2 text-sm font-medium">
|
||||||
|
<FileWarning className="size-4 text-muted-foreground" />
|
||||||
|
Offene Bewertungen
|
||||||
|
</div>
|
||||||
|
{openTicketGroups.map((group) => (
|
||||||
|
<OpenTicketItem key={group.ticketId} group={group} month={month} onNavigate={onNavigate} />
|
||||||
|
))}
|
||||||
|
{openTicketGroups.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine offenen Bewertungen.</p> : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<div className="flex min-h-8 items-center gap-2 text-sm font-medium">
|
||||||
|
<Sparkles className="size-4 text-muted-foreground" />
|
||||||
|
Tage ohne Teamspace-Wert
|
||||||
|
</div>
|
||||||
|
{visibleMissingCrmDays.map((item) => (
|
||||||
|
<CrmDayItem
|
||||||
|
key={`${item.ticket_id}-${item.day}-missing`}
|
||||||
|
item={item}
|
||||||
|
busy={acknowledgingKeys.has(`${item.ticket_id}:${item.day}`)}
|
||||||
|
onSetAcknowledgement={setMissingCrmAcknowledgement}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{visibleMissingCrmDays.length === 0 ? (
|
||||||
|
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">
|
||||||
|
{acknowledgedMissingCrmDays.length > 0 ? "Alle offenen Punkte sind zur Kenntnis genommen." : "Alle getrackten Tage haben einen Teamspace-Wert."}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{stats && stats.totals.sessions === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="grid place-items-center gap-2 p-8 text-center">
|
||||||
|
<Clock3 className="size-8 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Keine Sessions in diesem Monat</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Ein Monatsabschluss ist erst sinnvoll, wenn Sessions vorhanden sind.</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { toast } from "sonner";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { HelpLink } from "@/components/HelpLink";
|
||||||
import { updateCurrentUser } from "../api";
|
import { updateCurrentUser } from "../api";
|
||||||
import type { AuthUser } from "../types";
|
import type { AuthUser } from "../types";
|
||||||
|
|
||||||
@@ -74,9 +75,12 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<UserCircle className="size-5 text-muted-foreground" />
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle>Accountdaten</CardTitle>
|
<UserCircle className="size-5 text-muted-foreground" />
|
||||||
|
<CardTitle>Accountdaten</CardTitle>
|
||||||
|
</div>
|
||||||
|
<HelpLink anchor="profil" label="Hilfe zum Profil" />
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>Der Benutzername wird beim Login verwendet. Dein voller Name wird in der App angezeigt.</CardDescription>
|
<CardDescription>Der Benutzername wird beim Login verwendet. Dein voller Name wird in der App angezeigt.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { HelpLink } from "@/components/HelpLink";
|
||||||
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
||||||
import { createRecurringBilling, deleteRecurringBilling, getRecurringBillings, updateRecurringBilling } from "../api";
|
import { createRecurringBilling, deleteRecurringBilling, getRecurringBillings, updateRecurringBilling } from "../api";
|
||||||
import { currentDay, formatMinutes } from "../format";
|
import { currentDay, formatMinutes } from "../format";
|
||||||
@@ -248,9 +249,12 @@ export function RecurringBillingsPage() {
|
|||||||
|
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<CardHeader className="border-b bg-muted/30 p-4">
|
<CardHeader className="border-b bg-muted/30 p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<Repeat className="size-5 text-muted-foreground" />
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle>{editingBillingId ? "Regel bearbeiten" : "Neue Regel"}</CardTitle>
|
<Repeat className="size-5 text-muted-foreground" />
|
||||||
|
<CardTitle>{editingBillingId ? "Regel bearbeiten" : "Neue Regel"}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<HelpLink anchor="fixe-abrechnungen" label="Hilfe zu fixen Abrechnungen" />
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>Ticket ist optional. Leer bedeutet automatische Gruppierung als Fix#ID.</CardDescription>
|
<CardDescription>Ticket ist optional. Leer bedeutet automatische Gruppierung als Fix#ID.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -447,8 +451,13 @@ export function RecurringBillingsPage() {
|
|||||||
|
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<CardHeader className="border-b bg-muted/30 p-4">
|
<CardHeader className="border-b bg-muted/30 p-4">
|
||||||
<CardTitle>Regeln</CardTitle>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<CardDescription>Erzeugte Einträge können in der Auswertung verschoben oder gelöscht werden.</CardDescription>
|
<div>
|
||||||
|
<CardTitle>Regeln</CardTitle>
|
||||||
|
<CardDescription>Erzeugte Einträge können in der Auswertung verschoben oder gelöscht werden.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<HelpLink anchor="fixe-abrechnungen" label="Hilfe zu erzeugten fixen Sessions" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 p-4">
|
<CardContent className="space-y-2 p-4">
|
||||||
{recurringBillings.map((billing) => (
|
{recurringBillings.map((billing) => (
|
||||||
|
|||||||
@@ -0,0 +1,799 @@
|
|||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
CircleAlert,
|
||||||
|
Clock3,
|
||||||
|
ExternalLink,
|
||||||
|
ListChecks,
|
||||||
|
RotateCcw,
|
||||||
|
Ticket,
|
||||||
|
TrendingUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { HelpLink } from "@/components/HelpLink";
|
||||||
|
import { getStatisticsOverview } from "../api";
|
||||||
|
import { currentMonth, formatDate, formatMinutes } from "../format";
|
||||||
|
import type { StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsTicket, WorkType } from "../types";
|
||||||
|
|
||||||
|
type StatisticsPageProps = {
|
||||||
|
onNavigate: (to: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackedTextClass = "text-sky-700 dark:text-sky-300";
|
||||||
|
const teamspaceTextClass = "text-emerald-700 dark:text-emerald-300";
|
||||||
|
const trackedFillClass = "fill-sky-500/70 dark:fill-sky-400/65";
|
||||||
|
const teamspaceStrokeClass = "text-emerald-600 dark:text-emerald-300";
|
||||||
|
const trackedDotClass = "bg-sky-500 dark:bg-sky-400";
|
||||||
|
const teamspaceDotClass = "bg-emerald-500 dark:bg-emerald-400";
|
||||||
|
const evaluatedTextClass = "text-violet-700 dark:text-violet-300";
|
||||||
|
const openTextClass = "text-amber-700 dark:text-amber-300";
|
||||||
|
const organizationPageSize = 8;
|
||||||
|
|
||||||
|
function workTypeLabel(workType: WorkType | undefined) {
|
||||||
|
if (workType === "support") {
|
||||||
|
return "Support";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workType === "consulting") {
|
||||||
|
return "Consulting";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Ohne Typ";
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentage(value: number, total: number) {
|
||||||
|
if (total <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, Math.min(100, Math.round((value / total) * 100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundedPercentages(values: number[]) {
|
||||||
|
const total = values.reduce((sum, value) => sum + value, 0);
|
||||||
|
|
||||||
|
if (total <= 0) {
|
||||||
|
return values.map(() => 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = values.map((value) => (value / total) * 100);
|
||||||
|
const floors = raw.map(Math.floor);
|
||||||
|
let remainder = 100 - floors.reduce((sum, value) => sum + value, 0);
|
||||||
|
const order = raw
|
||||||
|
.map((value, index) => ({ index, fraction: value - Math.floor(value) }))
|
||||||
|
.sort((left, right) => right.fraction - left.fraction);
|
||||||
|
|
||||||
|
for (const item of order) {
|
||||||
|
if (remainder <= 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
floors[item.index] += 1;
|
||||||
|
remainder -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return floors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSignedMinutes(minutes: number) {
|
||||||
|
if (minutes === 0) {
|
||||||
|
return "ausgeglichen";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTeamspaceDelta(minutes: number) {
|
||||||
|
return formatSignedMinutes(minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deltaTextClass(minutes: number) {
|
||||||
|
return minutes < 0 ? "text-red-700 dark:text-red-300" : "text-emerald-700 dark:text-emerald-300";
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeltaBadge({ minutes }: { minutes: number }) {
|
||||||
|
return (
|
||||||
|
<Badge variant={minutes < 0 ? "destructive" : "success"}>
|
||||||
|
{formatTeamspaceDelta(minutes)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deltaDescription(minutes: number) {
|
||||||
|
if (minutes > 0) {
|
||||||
|
return "Teamspace liegt über den Sessions";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minutes < 0) {
|
||||||
|
return "Teamspace liegt unter den Sessions";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Teamspace und Sessions sind ausgeglichen";
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinutes: number; crmMinutes: number; sessions?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5 text-sm">
|
||||||
|
<div className={`font-semibold ${trackedTextClass}`}>Sessions {formatMinutes(trackedMinutes)}</div>
|
||||||
|
<div className={`font-semibold ${teamspaceTextClass}`}>Teamspace {formatMinutes(crmMinutes)}</div>
|
||||||
|
{typeof sessions === "number" ? <div className="text-xs text-muted-foreground">{sessions} Session(s)</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorLegend() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-2 rounded-md border bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className={`size-2 rounded-sm ${trackedDotClass}`} />
|
||||||
|
<span className={trackedTextClass}>Sessions</span>
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
|
||||||
|
<span className={teamspaceTextClass}>Teamspace</span>
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="size-2 rounded-full bg-emerald-500 dark:bg-emerald-400" />
|
||||||
|
<span className="text-emerald-700 dark:text-emerald-300">Differenz plus oder ausgeglichen</span>
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="size-2 rounded-full bg-red-500 dark:bg-red-400" />
|
||||||
|
<span className="text-red-700 dark:text-red-300">Differenz minus</span>
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="size-2 rounded-full bg-violet-500 dark:bg-violet-400" />
|
||||||
|
<span className={evaluatedTextClass}>bewertet</span>
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="size-2 rounded-full bg-amber-500 dark:bg-amber-400" />
|
||||||
|
<span className={openTextClass}>offen</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAxisMinutes(value: number) {
|
||||||
|
const minutes = Math.round(value);
|
||||||
|
|
||||||
|
if (minutes <= 0) {
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minutes >= 60) {
|
||||||
|
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: minutes < 600 ? 1 : 0 }).format(minutes / 60)} h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${minutes} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function niceCeilMinutes(value: number) {
|
||||||
|
if (value <= 30) {
|
||||||
|
return Math.max(5, Math.ceil(value / 5) * 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value <= 120) {
|
||||||
|
return Math.ceil(value / 15) * 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value <= 480) {
|
||||||
|
return Math.ceil(value / 30) * 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.ceil(value / 60) * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChartPath(points: Array<{ x: number; y: number }>) {
|
||||||
|
if (points.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (points.length === 1) {
|
||||||
|
return `M ${points[0].x - 18} ${points[0].y} L ${points[0].x + 18} ${points[0].y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMonthSeries(month: string, buckets: StatisticsDailyBucket[]) {
|
||||||
|
const series = new Map(buckets.map((bucket) => [bucket.day, bucket]));
|
||||||
|
const [year, monthNumber] = month.split("-").map(Number);
|
||||||
|
const daysInMonth = Number.isFinite(year) && Number.isFinite(monthNumber) ? new Date(year, monthNumber, 0).getDate() : 31;
|
||||||
|
|
||||||
|
return Array.from({ length: daysInMonth }).map((_, index) => {
|
||||||
|
const day = String(index + 1).padStart(2, "0");
|
||||||
|
const key = `${month}-${day}`;
|
||||||
|
const bucket = series.get(key);
|
||||||
|
|
||||||
|
return {
|
||||||
|
day: key,
|
||||||
|
label: day,
|
||||||
|
sessions: bucket?.sessions ?? 0,
|
||||||
|
totalMinutes: bucket?.total_minutes ?? 0,
|
||||||
|
billedMinutes: bucket?.billed_minutes ?? 0,
|
||||||
|
nonBillableMinutes: bucket?.non_billable_minutes ?? 0,
|
||||||
|
openMinutes: bucket?.open_minutes ?? 0,
|
||||||
|
billedSessions: bucket?.billed_sessions ?? 0,
|
||||||
|
nonBillableSessions: bucket?.non_billable_sessions ?? 0,
|
||||||
|
openSessions: bucket?.open_sessions ?? 0,
|
||||||
|
crmBilledMinutes: bucket?.crm_billed_minutes ?? 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function DailyEffortChart({ month, buckets }: { month: string; buckets: StatisticsDailyBucket[] }) {
|
||||||
|
const series = buildMonthSeries(month, buckets);
|
||||||
|
const width = 900;
|
||||||
|
const height = 220;
|
||||||
|
const padding = { top: 18, right: 20, bottom: 36, left: 64 };
|
||||||
|
const innerWidth = width - padding.left - padding.right;
|
||||||
|
const innerHeight = height - padding.top - padding.bottom;
|
||||||
|
const maxMinutes = niceCeilMinutes(Math.max(...series.map((bucket) => Math.max(bucket.totalMinutes, bucket.crmBilledMinutes)), 1));
|
||||||
|
const ticks = Array.from({ length: 5 }).map((_, index) => maxMinutes - (index * maxMinutes) / 4);
|
||||||
|
const labelStep = Math.max(1, Math.ceil(series.length / 9));
|
||||||
|
const barWidth = Math.max(5, Math.min(16, innerWidth / Math.max(series.length, 1) / 2));
|
||||||
|
const points = series.map((bucket, index) => {
|
||||||
|
const x = series.length === 1 ? padding.left + innerWidth / 2 : padding.left + (index * innerWidth) / (series.length - 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bucket,
|
||||||
|
x,
|
||||||
|
totalBarHeight: bucket.totalMinutes > 0 ? Math.max(2, (bucket.totalMinutes / maxMinutes) * innerHeight) : 0,
|
||||||
|
crmY: padding.top + (1 - bucket.crmBilledMinutes / maxMinutes) * innerHeight
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const crmPath = buildChartPath(points.filter((point) => point.bucket.crmBilledMinutes > 0).map((point) => ({ x: point.x, y: point.crmY })));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-56 w-full overflow-hidden rounded-md border bg-background">
|
||||||
|
{series.some((bucket) => bucket.totalMinutes > 0 || bucket.crmBilledMinutes > 0) ? (
|
||||||
|
<svg viewBox={`0 0 ${width} ${height}`} className="h-full w-full">
|
||||||
|
{ticks.map((tick) => {
|
||||||
|
const y = padding.top + (1 - tick / maxMinutes) * innerHeight;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={tick}>
|
||||||
|
<line x1={padding.left} x2={width - padding.right} y1={y} y2={y} stroke="currentColor" className="text-muted/70" />
|
||||||
|
<text x={padding.left - 9} y={y + 4} textAnchor="end" className="fill-muted-foreground text-[11px]">
|
||||||
|
{formatAxisMinutes(tick)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<line x1={padding.left} x2={padding.left} y1={padding.top} y2={padding.top + innerHeight} stroke="currentColor" className="text-muted-foreground/70" />
|
||||||
|
{points.map((point) => {
|
||||||
|
const y = padding.top + innerHeight - point.totalBarHeight;
|
||||||
|
|
||||||
|
return point.totalBarHeight > 0 ? (
|
||||||
|
<rect key={point.bucket.day} x={point.x - barWidth / 2} y={y} width={barWidth} height={point.totalBarHeight} rx="4" className={trackedFillClass} />
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
{crmPath ? <path d={crmPath} fill="none" stroke="currentColor" strokeWidth="2.5" className={teamspaceStrokeClass} /> : null}
|
||||||
|
{points
|
||||||
|
.filter((point) => point.bucket.crmBilledMinutes > 0)
|
||||||
|
.map((point) => (
|
||||||
|
<circle key={`${point.bucket.day}-crm`} cx={point.x} cy={point.crmY} r="3" className={`fill-background ${teamspaceStrokeClass}`} stroke="currentColor" strokeWidth="2" />
|
||||||
|
))}
|
||||||
|
{points.map((point, index) =>
|
||||||
|
index % labelStep === 0 || index === points.length - 1 ? (
|
||||||
|
<text key={`${point.bucket.day}-label`} x={point.x} y={height - 11} textAnchor="middle" className="fill-muted-foreground text-[11px]">
|
||||||
|
{point.bucket.label}
|
||||||
|
</text>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<div className="grid h-full place-items-center text-sm text-muted-foreground">Keine Statistikdaten für diesen Monat.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
detail,
|
||||||
|
icon: Icon,
|
||||||
|
valueClassName = "",
|
||||||
|
helpAnchor,
|
||||||
|
helpLabel
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
detail?: ReactNode;
|
||||||
|
icon: LucideIcon;
|
||||||
|
valueClassName?: string;
|
||||||
|
helpAnchor?: string;
|
||||||
|
helpLabel?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="relative">
|
||||||
|
{helpAnchor ? <HelpLink anchor={helpAnchor} label={helpLabel ?? `Hilfe zu ${label}`} className="absolute right-2 top-2" /> : null}
|
||||||
|
<CardContent className="flex items-center gap-3 p-3 pr-10">
|
||||||
|
<div className="grid size-8 shrink-0 place-items-center rounded-lg border bg-muted/40 text-muted-foreground">
|
||||||
|
<Icon className="size-3.5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||||
|
<p className={`truncate text-xl font-semibold tracking-normal ${valueClassName}`}>{value}</p>
|
||||||
|
{detail ? <div className="text-xs text-muted-foreground">{detail}</div> : null}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organization: StatisticsGroup; maxMinutes: number; totalMinutes: number }) {
|
||||||
|
const monthlyShare = percentage(organization.total_minutes, totalMinutes);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="max-w-[320px] whitespace-normal">
|
||||||
|
<div className="font-medium">{organization.organization_name ?? "Keine Organisation"}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{organization.tickets} Ticket(s) · {organization.sessions} Session(s)
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{organization.sessions}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TimeComparison trackedMinutes={organization.total_minutes} crmMinutes={organization.crm_billed_minutes} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DeltaBadge minutes={organization.crm_delta_minutes} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="min-w-[150px]">
|
||||||
|
<Progress value={percentage(organization.total_minutes, maxMinutes)} />
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">{monthlyShare}% vom Monat</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket; month: string; onNavigate: (to: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2 rounded-md border bg-background p-3 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-semibold">{ticket.ticket_number}</span>
|
||||||
|
{ticket.open_sessions > 0 ? <Badge variant="warning">{ticket.open_sessions} offen</Badge> : <Badge variant="success">bewertet</Badge>}
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-sm text-muted-foreground">{ticket.organization_name ?? "Keine Organisation"}</p>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||||
|
<span className={trackedTextClass}>Sessions {formatMinutes(ticket.total_minutes)}</span>
|
||||||
|
<span className={teamspaceTextClass}>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
|
||||||
|
<span className="text-muted-foreground">{ticket.sessions} Session(s)</span>
|
||||||
|
<span>{ticket.active_days} Tag(e)</span>
|
||||||
|
<span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${ticket.ticket_id}`)}>
|
||||||
|
Öffnen
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||||
|
const [month, setMonth] = useState(currentMonth());
|
||||||
|
const [stats, setStats] = useState<StatisticsOverview | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [organizationPage, setOrganizationPage] = useState(1);
|
||||||
|
const loadRequestId = useRef(0);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const requestId = loadRequestId.current + 1;
|
||||||
|
loadRequestId.current = requestId;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getStatisticsOverview(month);
|
||||||
|
|
||||||
|
if (requestId === loadRequestId.current) {
|
||||||
|
setStats(result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId === loadRequestId.current) {
|
||||||
|
toast.error("Statistiken konnten nicht geladen werden", {
|
||||||
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === loadRequestId.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [month]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function refreshVisible() {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = window.setInterval(refreshVisible, 60_000);
|
||||||
|
window.addEventListener("focus", refreshVisible);
|
||||||
|
document.addEventListener("visibilitychange", refreshVisible);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(interval);
|
||||||
|
window.removeEventListener("focus", refreshVisible);
|
||||||
|
document.removeEventListener("visibilitychange", refreshVisible);
|
||||||
|
};
|
||||||
|
}, [month]);
|
||||||
|
|
||||||
|
function changeMonthBy(delta: number) {
|
||||||
|
const [year, monthNumber] = month.split("-").map(Number);
|
||||||
|
const date = Number.isFinite(year) && Number.isFinite(monthNumber) ? new Date(year, monthNumber - 1, 1) : new Date();
|
||||||
|
date.setMonth(date.getMonth() + delta);
|
||||||
|
setMonth(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totals = stats?.totals;
|
||||||
|
const evaluatedSessions = (totals?.billedSessions ?? 0) + (totals?.nonBillableSessions ?? 0);
|
||||||
|
const evaluatedMinutes = (totals?.billedMinutes ?? 0) + (totals?.nonBillableMinutes ?? 0);
|
||||||
|
const [evaluatedSessionShare, openSessionShare] = roundedPercentages([evaluatedSessions, totals?.openSessions ?? 0]);
|
||||||
|
const [evaluatedTimeShare, openTimeShare] = roundedPercentages([evaluatedMinutes, totals?.openMinutes ?? 0]);
|
||||||
|
const crmCoverage = percentage(totals?.crmBilledMinutes ?? 0, totals?.minutes ?? 0);
|
||||||
|
const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1);
|
||||||
|
const topOrganization = stats?.organizations[0] ?? null;
|
||||||
|
const strongestDay = useMemo(() => {
|
||||||
|
return [...(stats?.dailySeries ?? [])].sort((left, right) => right.total_minutes - left.total_minutes)[0] ?? null;
|
||||||
|
}, [stats?.dailySeries]);
|
||||||
|
const organizationPageCount = Math.max(1, Math.ceil((stats?.organizations.length ?? 0) / organizationPageSize));
|
||||||
|
const safeOrganizationPage = Math.min(organizationPage, organizationPageCount);
|
||||||
|
const organizationStart = (safeOrganizationPage - 1) * organizationPageSize;
|
||||||
|
const visibleOrganizations = (stats?.organizations ?? []).slice(organizationStart, organizationStart + organizationPageSize);
|
||||||
|
const organizationRangeStart = stats?.organizations.length ? organizationStart + 1 : 0;
|
||||||
|
const organizationRangeEnd = Math.min(organizationStart + organizationPageSize, stats?.organizations.length ?? 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOrganizationPage(1);
|
||||||
|
}, [month]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOrganizationPage((current) => Math.min(current, organizationPageCount));
|
||||||
|
}, [organizationPageCount]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold tracking-normal">Statistiken</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Monatswerte, Teamspace-Abgleich, Organisationen und offene Nacharbeit.</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1 sm:w-[300px]">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground" htmlFor="statistics-month">Monat</label>
|
||||||
|
{loading ? <span className="text-xs text-muted-foreground">lädt...</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[2.25rem_minmax(0,1fr)_2.25rem] gap-1">
|
||||||
|
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(-1)} aria-label="Monat zurück">
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Input id="statistics-month" className="h-9" type="month" value={month} onChange={(event) => setMonth(event.currentTarget.value)} />
|
||||||
|
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(1)} aria-label="Monat vor">
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<MetricCard
|
||||||
|
label="Getrackt"
|
||||||
|
value={formatMinutes(totals?.minutes ?? 0)}
|
||||||
|
detail={`${totals?.sessions ?? 0} Session(s), ${totals?.tickets ?? 0} Ticket(s)`}
|
||||||
|
icon={Clock3}
|
||||||
|
valueClassName={trackedTextClass}
|
||||||
|
helpAnchor="statistik-getrackt"
|
||||||
|
helpLabel="Hilfe zur Kennzahl Getrackt"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Teamspace"
|
||||||
|
value={formatMinutes(totals?.crmBilledMinutes ?? 0)}
|
||||||
|
detail={<span className={deltaTextClass(totals?.crmDeltaMinutes ?? 0)}>Differenz {formatTeamspaceDelta(totals?.crmDeltaMinutes ?? 0)}</span>}
|
||||||
|
icon={TrendingUp}
|
||||||
|
valueClassName={teamspaceTextClass}
|
||||||
|
helpAnchor="statistik-teamspace"
|
||||||
|
helpLabel="Hilfe zur Kennzahl Teamspace"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Bewertet"
|
||||||
|
value={formatMinutes(evaluatedMinutes)}
|
||||||
|
detail={
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div>{evaluatedSessions} von {totals?.sessions ?? 0} Session(s), {evaluatedSessionShare}%</div>
|
||||||
|
<div>{evaluatedTimeShare}% der Zeit · Abr. {totals?.billedSessions ?? 0} · Nicht {totals?.nonBillableSessions ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
icon={ListChecks}
|
||||||
|
valueClassName={evaluatedTextClass}
|
||||||
|
helpAnchor="statistik-bewertet"
|
||||||
|
helpLabel="Hilfe zur Kennzahl Bewertet"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Offen"
|
||||||
|
value={formatMinutes(totals?.openMinutes ?? 0)}
|
||||||
|
detail={
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div>{totals?.openSessions ?? 0} von {totals?.sessions ?? 0} Session(s), {openSessionShare}%</div>
|
||||||
|
<div>{openTimeShare}% der Zeit</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
icon={CircleAlert}
|
||||||
|
valueClassName={openTextClass}
|
||||||
|
helpAnchor="statistik-offen"
|
||||||
|
helpLabel="Hilfe zur Kennzahl Offen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Monatsverlauf und Abrechnungsqualität</CardTitle>
|
||||||
|
<CardDescription>Getrackter Aufwand pro Tag, Teamspace-Linie und Bewertungsstand in einem Überblick.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline">{crmCoverage}% Teamspace-Abdeckung</Badge>
|
||||||
|
<HelpLink anchor="statistik-verlauf" label="Hilfe zu Monatsverlauf und Abrechnungsqualität" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 px-4 pb-4 xl:grid-cols-[1.55fr_1fr]">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} />
|
||||||
|
<ColorLegend />
|
||||||
|
{strongestDay ? <p className="text-xs text-muted-foreground">Stärkster Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span className="text-muted-foreground">Bewertungsstand</span>
|
||||||
|
<span className="font-medium">{evaluatedSessionShare}% / {openSessionShare}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className="bg-violet-500 dark:bg-violet-400" style={{ width: `${evaluatedSessionShare}%` }} />
|
||||||
|
<div className="bg-amber-500 dark:bg-amber-400" style={{ width: `${openSessionShare}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 text-xs sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
|
||||||
|
<div className={evaluatedTextClass}>Bewertet: {evaluatedSessions} Session(s), {formatMinutes(evaluatedMinutes)}</div>
|
||||||
|
<div className={openTextClass}>Offen: {totals?.openSessions ?? 0} Session(s), {formatMinutes(totals?.openMinutes ?? 0)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Teamspace-Abgleich</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{deltaDescription(totals?.crmDeltaMinutes ?? 0)}</p>
|
||||||
|
</div>
|
||||||
|
<DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} />
|
||||||
|
</div>
|
||||||
|
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.sessions ?? 0} />
|
||||||
|
<div className="text-xs text-muted-foreground">Teamspace-Abdeckung: {crmCoverage}% der getrackten Session-Zeit</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div className="rounded-md border bg-muted/20 p-3">
|
||||||
|
<p className="text-muted-foreground">Manuell</p>
|
||||||
|
<p className={`font-semibold ${trackedTextClass}`}>{formatMinutes(totals?.manualMinutes ?? 0)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{totals?.manualSessions ?? 0} Session(s)</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-muted/20 p-3">
|
||||||
|
<p className="text-muted-foreground">Fix</p>
|
||||||
|
<p className={`font-semibold ${trackedTextClass}`}>{formatMinutes(totals?.recurringMinutes ?? 0)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{totals?.recurringSessions ?? 0} Session(s)</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-muted/20 p-3">
|
||||||
|
<p className="text-muted-foreground">Aktive Tage</p>
|
||||||
|
<p className="font-semibold">{totals?.activeDays ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-muted/20 p-3">
|
||||||
|
<p className="text-muted-foreground">Schnitt</p>
|
||||||
|
<p className="font-semibold">{formatMinutes(totals?.averageSessionMinutes ?? 0)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{topOrganization ? (
|
||||||
|
<div className="rounded-md border bg-muted/20 p-3 text-sm">
|
||||||
|
<p className="text-muted-foreground">Top Organisation</p>
|
||||||
|
<p className="truncate font-semibold">{topOrganization.organization_name}</p>
|
||||||
|
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||||
|
<span className={trackedTextClass}>Sessions {formatMinutes(topOrganization.total_minutes)}</span>
|
||||||
|
<span className={teamspaceTextClass}>Teamspace {formatMinutes(topOrganization.crm_billed_minutes)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-3 xl:grid-cols-[1.45fr_0.8fr]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Statistik pro Organisation</CardTitle>
|
||||||
|
<CardDescription>Kundenaufwand inklusive Teamspace-Differenz und Anteil am getrackten Monatsaufwand.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline">
|
||||||
|
{organizationRangeStart}-{organizationRangeEnd} von {stats?.organizations.length ?? 0}
|
||||||
|
</Badge>
|
||||||
|
<HelpLink anchor="statistik-organisationen" label="Hilfe zur Organisationsstatistik" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4">
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Organisation</TableHead>
|
||||||
|
<TableHead>Sessions</TableHead>
|
||||||
|
<TableHead>Zeiten</TableHead>
|
||||||
|
<TableHead>Differenz</TableHead>
|
||||||
|
<TableHead>Anteil am Monat</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{visibleOrganizations.map((organization) => (
|
||||||
|
<OrganizationRow
|
||||||
|
key={organization.organization_id ?? organization.organization_name}
|
||||||
|
organization={organization}
|
||||||
|
maxMinutes={maxOrganizationMinutes}
|
||||||
|
totalMinutes={totals?.minutes ?? 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 md:hidden">
|
||||||
|
{visibleOrganizations.map((organization) => (
|
||||||
|
<div key={organization.organization_id ?? organization.organization_name} className="space-y-2 rounded-md border bg-background p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{organization.organization_name ?? "Keine Organisation"}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.sessions} Session(s)</p>
|
||||||
|
</div>
|
||||||
|
<DeltaBadge minutes={organization.crm_delta_minutes} />
|
||||||
|
</div>
|
||||||
|
<Progress value={percentage(organization.total_minutes, maxOrganizationMinutes)} />
|
||||||
|
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||||
|
<span className={trackedTextClass}>Sessions {formatMinutes(organization.total_minutes)}</span>
|
||||||
|
<span className={teamspaceTextClass}>Teamspace {formatMinutes(organization.crm_billed_minutes)}</span>
|
||||||
|
<span className="text-muted-foreground">{percentage(organization.total_minutes, totals?.minutes ?? 0)}% vom Monat</span>
|
||||||
|
<span className={deltaTextClass(organization.crm_delta_minutes)}>{formatTeamspaceDelta(organization.crm_delta_minutes)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{(stats?.organizations.length ?? 0) > organizationPageSize ? (
|
||||||
|
<Pagination className="mt-4 justify-end">
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
href="#"
|
||||||
|
text="Zurück"
|
||||||
|
aria-disabled={safeOrganizationPage <= 1}
|
||||||
|
className={safeOrganizationPage <= 1 ? "pointer-events-none opacity-50" : undefined}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setOrganizationPage((current) => Math.max(1, current - 1));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<span className="flex h-9 items-center px-3 text-sm text-muted-foreground">
|
||||||
|
Seite {safeOrganizationPage} von {organizationPageCount}
|
||||||
|
</span>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
href="#"
|
||||||
|
text="Weiter"
|
||||||
|
aria-disabled={safeOrganizationPage >= organizationPageCount}
|
||||||
|
className={safeOrganizationPage >= organizationPageCount ? "pointer-events-none opacity-50" : undefined}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setOrganizationPage((current) => Math.min(organizationPageCount, current + 1));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
) : null}
|
||||||
|
{stats && stats.organizations.length === 0 ? <p className="py-8 text-center text-sm text-muted-foreground">Keine Organisationen im gewählten Monat.</p> : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Typen</CardTitle>
|
||||||
|
<CardDescription>Support und Consulting im direkten Vergleich.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<HelpLink anchor="statistik-typen" label="Hilfe zu Typenstatistiken" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 px-4 pb-4">
|
||||||
|
{(stats?.workTypes ?? []).map((group) => (
|
||||||
|
<div key={group.work_type ?? "none"} className="space-y-2 rounded-md border bg-background p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{workTypeLabel(group.work_type)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.sessions} Session(s)</p>
|
||||||
|
</div>
|
||||||
|
<DeltaBadge minutes={group.crm_delta_minutes} />
|
||||||
|
</div>
|
||||||
|
<Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} />
|
||||||
|
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||||
|
<span className={trackedTextClass}>Sessions {formatMinutes(group.total_minutes)}</span>
|
||||||
|
<span className={teamspaceTextClass}>Teamspace {formatMinutes(group.crm_billed_minutes)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>Abr. {group.billed_sessions} / {formatMinutes(group.billed_minutes)}</span>
|
||||||
|
<span>Nicht {group.non_billable_sessions} / {formatMinutes(group.non_billable_minutes)}</span>
|
||||||
|
<span>Offen {group.open_sessions} / {formatMinutes(group.open_minutes)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{stats && stats.workTypes.length === 0 ? <p className="py-8 text-center text-sm text-muted-foreground">Noch keine Typdaten vorhanden.</p> : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Ticket-Hotspots</CardTitle>
|
||||||
|
<CardDescription>Die größten Zeitblöcke im Monat.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => onNavigate("/analysis")}>
|
||||||
|
Auswertung öffnen
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<HelpLink anchor="statistik-hotspots" label="Hilfe zu Ticket-Hotspots" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 px-4 pb-4">
|
||||||
|
{(stats?.tickets ?? []).map((ticket) => (
|
||||||
|
<TicketHotspot key={ticket.ticket_id} ticket={ticket} month={month} onNavigate={onNavigate} />
|
||||||
|
))}
|
||||||
|
{stats && stats.tickets.length === 0 ? <p className="py-8 text-center text-sm text-muted-foreground">Keine Tickets im gewählten Monat.</p> : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{stats && stats.totals.sessions === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="grid place-items-center gap-2 p-8 text-center">
|
||||||
|
<Ticket className="size-8 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Keine Sessions in diesem Monat</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Sobald Sessions vorhanden sind, füllt sich die Statistik automatisch.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setMonth(currentMonth())}>
|
||||||
|
<RotateCcw className="size-4" />
|
||||||
|
Aktuellen Monat zeigen
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
||||||
|
import { HelpLink } from "@/components/HelpLink";
|
||||||
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
||||||
import {
|
import {
|
||||||
createSession,
|
createSession,
|
||||||
@@ -228,6 +229,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
|
|||||||
return Array.from(groups.values());
|
return Array.from(groups.values());
|
||||||
}, [data?.sessions]);
|
}, [data?.sessions]);
|
||||||
const periodTotalMinutes = sessionGroups.reduce((sum, group) => sum + group.totalMinutes, 0);
|
const periodTotalMinutes = sessionGroups.reduce((sum, group) => sum + group.totalMinutes, 0);
|
||||||
|
const periodTeamspaceMinutes = Array.from(dayBillingMap.values()).reduce((sum, minutes) => sum + Number(minutes ?? 0), 0);
|
||||||
|
|
||||||
async function setBilling(sessionId: string, billingStatus: BillingStatus) {
|
async function setBilling(sessionId: string, billingStatus: BillingStatus) {
|
||||||
if (isPeriodClosed && billingStatus !== null) {
|
if (isPeriodClosed && billingStatus !== null) {
|
||||||
@@ -492,8 +494,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4">
|
||||||
<CardTitle>Ticketdaten</CardTitle>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<CardDescription>Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen.</CardDescription>
|
<div>
|
||||||
|
<CardTitle>Ticketdaten</CardTitle>
|
||||||
|
<CardDescription>Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<HelpLink anchor="ticketdaten" label="Hilfe zu Ticketdaten" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4">
|
<CardContent className="px-4 pb-4">
|
||||||
<form className="grid gap-3 lg:grid-cols-[180px_minmax(220px,1fr)_180px_auto] lg:items-end" onSubmit={saveTicketData}>
|
<form className="grid gap-3 lg:grid-cols-[180px_minmax(220px,1fr)_180px_auto] lg:items-end" onSubmit={saveTicketData}>
|
||||||
@@ -542,9 +549,16 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
|
|||||||
<CardTitle>Session-Einträge</CardTitle>
|
<CardTitle>Session-Einträge</CardTitle>
|
||||||
<CardDescription>Wähle für jede Session genau eine Bewertung aus oder lösche falsche Einträge.</CardDescription>
|
<CardDescription>Wähle für jede Session genau eine Bewertung aus oder lösche falsche Einträge.</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-muted-foreground">{periodLabel} gesamt</span>
|
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm">
|
||||||
<span className="font-semibold">{formatMinutes(periodTotalMinutes)}</span>
|
<span className="text-muted-foreground">{periodLabel} gesamt</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="font-semibold">{formatMinutes(periodTeamspaceMinutes)}</span>
|
||||||
|
</div>
|
||||||
|
<HelpLink anchor="ticketdetail" label="Hilfe zur Ticketdetailansicht" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4">
|
<CardContent className="px-4 pb-4">
|
||||||
@@ -650,8 +664,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4">
|
||||||
<CardTitle>Session nachtragen</CardTitle>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<CardDescription>Neue Zeit direkt diesem Ticket zuordnen.</CardDescription>
|
<div>
|
||||||
|
<CardTitle>Session nachtragen</CardTitle>
|
||||||
|
<CardDescription>Neue Zeit direkt diesem Ticket zuordnen.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<HelpLink anchor="session-nachtragen" label="Hilfe zum Nachtragen von Sessions" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4">
|
<CardContent className="px-4 pb-4">
|
||||||
<form className="space-y-4" onSubmit={submitTicketSession}>
|
<form className="space-y-4" onSubmit={submitTicketSession}>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { HelpLink } from "@/components/HelpLink";
|
||||||
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
||||||
import { createSession, lookupTicket } from "../api";
|
import { createSession, lookupTicket } from "../api";
|
||||||
import { formatTimer } from "../format";
|
import { formatTimer } from "../format";
|
||||||
@@ -300,8 +301,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
|
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<CardHeader className="border-b bg-muted/30 p-4">
|
<CardHeader className="border-b bg-muted/30 p-4">
|
||||||
<CardTitle>Neuen Timer starten</CardTitle>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<CardDescription>Ein neuer Timer startet sofort und pausiert alle anderen Timer automatisch.</CardDescription>
|
<div>
|
||||||
|
<CardTitle>Neuen Timer starten</CardTitle>
|
||||||
|
<CardDescription>Ein neuer Timer startet sofort und pausiert alle anderen Timer automatisch.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<HelpLink anchor="timer" label="Hilfe zu Timern" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3 p-4 sm:grid-cols-[minmax(220px,320px)_auto] sm:items-end">
|
<CardContent className="grid gap-3 p-4 sm:grid-cols-[minmax(220px,320px)_auto] sm:items-end">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -330,14 +336,19 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
|
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<CardHeader className="border-b bg-muted/30 p-4">
|
<CardHeader className="border-b bg-muted/30 p-4">
|
||||||
<CardTitle>{selectedTimer ? selectedTimer.ticketNumber : "Keine Session ausgewählt"}</CardTitle>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<CardDescription>
|
<div>
|
||||||
{selectedTimer
|
<CardTitle>{selectedTimer ? selectedTimer.ticketNumber : "Keine Session ausgewählt"}</CardTitle>
|
||||||
? selectedTimer.organizationName
|
<CardDescription>
|
||||||
? `${selectedTimer.organizationName}${selectedTimer.workType ? ` · ${selectedTimer.workType === "support" ? "Support" : "Consulting"}` : ""}`
|
{selectedTimer
|
||||||
: "Beim Umschalten wird dieser Timer aktiviert und alle anderen werden pausiert."
|
? selectedTimer.organizationName
|
||||||
: "Starte einen Timer, um eine Session zu erfassen."}
|
? `${selectedTimer.organizationName}${selectedTimer.workType ? ` · ${selectedTimer.workType === "support" ? "Support" : "Consulting"}` : ""}`
|
||||||
</CardDescription>
|
: "Beim Umschalten wird dieser Timer aktiviert und alle anderen werden pausiert."
|
||||||
|
: "Starte einen Timer, um eine Session zu erfassen."}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<HelpLink anchor="timer" label="Hilfe zum Timerstatus" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 p-4">
|
<CardContent className="space-y-4 p-4">
|
||||||
<div className="rounded-lg border bg-background p-4 sm:p-5">
|
<div className="rounded-lg border bg-background p-4 sm:p-5">
|
||||||
@@ -374,8 +385,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
|
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<CardHeader className="border-b bg-muted/30 p-4">
|
<CardHeader className="border-b bg-muted/30 p-4">
|
||||||
<CardTitle>Session nachtragen</CardTitle>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<CardDescription>Vergessene Zeiten für deinen eigenen Account manuell erfassen.</CardDescription>
|
<div>
|
||||||
|
<CardTitle>Session nachtragen</CardTitle>
|
||||||
|
<CardDescription>Vergessene Zeiten für deinen eigenen Account manuell erfassen.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<HelpLink anchor="session-nachtragen" label="Hilfe zum Nachtragen von Sessions" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<form className="space-y-4" onSubmit={submitManualSession}>
|
<form className="space-y-4" onSubmit={submitManualSession}>
|
||||||
|
|||||||
Reference in New Issue
Block a user