Compare commits
12
Commits
| 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) {
|
||||
await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso);
|
||||
|
||||
const periodClosureJoin =
|
||||
config.type === "month"
|
||||
? "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(
|
||||
`
|
||||
SELECT
|
||||
@@ -2055,6 +2436,11 @@ app.get("/api/periods/:periodType/:period/overview", async (req, res) => {
|
||||
res.json(await getPeriodOverview(config, period, currentUser(req).id));
|
||||
});
|
||||
|
||||
app.get("/api/statistics/months/:month", async (req, res) => {
|
||||
const month = parseMonth(req.params.month);
|
||||
res.json(await getStatisticsOverview(month, currentUser(req).id));
|
||||
});
|
||||
|
||||
app.get("/api/tickets/lookup", async (req, res) => {
|
||||
const ticketNumber = parseTicketNumber(req.query.ticketNumber);
|
||||
const result = await query(
|
||||
@@ -2206,9 +2592,71 @@ app.patch("/api/tickets/:ticketId/day-billings/:day", async (req, res) => {
|
||||
[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] });
|
||||
});
|
||||
|
||||
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) => {
|
||||
parsePeriod(req.params.periodType, req.params.period);
|
||||
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("month_closures", "month", ["month"], 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_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_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("UPDATE recurring_billings SET active = true WHERE active = false;");
|
||||
await query(`
|
||||
|
||||
+67
-18
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
BarChart3,
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
Command,
|
||||
ChartNoAxesCombined,
|
||||
CircleHelp,
|
||||
LayoutGrid,
|
||||
LogOut,
|
||||
Moon,
|
||||
@@ -31,6 +34,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { HelpLink } from "@/components/HelpLink";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -54,14 +58,30 @@ import { formatTimer } from "./format";
|
||||
import { activeElapsedMs, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
|
||||
import { AdminUsersPage } from "./views/AdminUsersPage";
|
||||
import { AnalysisPage } from "./views/AnalysisPage";
|
||||
import { FaqPage } from "./views/FaqPage";
|
||||
import { LoginPage } from "./views/LoginPage";
|
||||
import { MonthlyClosePage } from "./views/MonthlyClosePage";
|
||||
import { ProfilePage } from "./views/ProfilePage";
|
||||
import { RecurringBillingsPage } from "./views/RecurringBillingsPage";
|
||||
import { StatisticsPage } from "./views/StatisticsPage";
|
||||
import { TicketDetailPage } from "./views/TicketDetailPage";
|
||||
import { TimerPage } from "./views/TimerPage";
|
||||
import type { AuthUser, PeriodType } from "./types";
|
||||
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) {
|
||||
const periodTicketMatch = pathname.match(/^\/analysis\/(month|day)\/([^/]+)\/tickets\/(\d+)$/);
|
||||
|
||||
@@ -89,6 +109,18 @@ function routeFromPath(pathname: string) {
|
||||
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")) {
|
||||
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="sm:hidden">Start</span>
|
||||
</Button>
|
||||
<HelpLink anchor="timer" label="Hilfe zum schnellen Timerstart" className="hidden md:inline-flex" />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -498,14 +531,21 @@ export function App() {
|
||||
return <LoginPage onLogin={setCurrentUser} />;
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ href: "/timer", label: "Timer", icon: Timer, active: path.startsWith("/timer") || path === "/" },
|
||||
{ href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") },
|
||||
{ href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
|
||||
{ href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
|
||||
const navItems: NavMenuEntry[] = [
|
||||
{ type: "item", href: "/timer", label: "Timer", icon: Timer, active: path.startsWith("/timer") || path === "/" },
|
||||
{ type: "item", href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") },
|
||||
{ type: "separator", key: "after-analysis" },
|
||||
{ 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"
|
||||
? [{ 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 runningElapsedSeconds = runningTimer ? Math.floor(activeElapsedMs(runningTimer, tick) / 1000) : 0;
|
||||
@@ -566,16 +606,22 @@ export function App() {
|
||||
<SidebarGroupLabel>Workflows</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{navItems.map((item) => (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton asChild tooltip={item.label} isActive={item.active}>
|
||||
<a href={item.href} onClick={navHandler(item.href)}>
|
||||
<item.icon />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{navItems.map((item) =>
|
||||
item.type === "separator" ? (
|
||||
<SidebarMenuItem key={item.key} className="py-1 group-data-[collapsible=icon]:hidden">
|
||||
<Separator />
|
||||
</SidebarMenuItem>
|
||||
) : (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton asChild tooltip={item.label} isActive={item.active}>
|
||||
<a href={item.href} onClick={navHandler(item.href)}>
|
||||
<item.icon />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
@@ -588,7 +634,7 @@ export function App() {
|
||||
<CheckCircle2 className="size-4 text-muted-foreground" />
|
||||
Abschluss
|
||||
</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>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
@@ -674,7 +720,7 @@ export function App() {
|
||||
<QuickTimerStarter inputId="quick-ticket-number-mobile" onStartTimer={startQuickTimer} />
|
||||
</div>
|
||||
<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" ? (
|
||||
<TimerPage
|
||||
timers={timers}
|
||||
@@ -685,7 +731,10 @@ export function App() {
|
||||
/>
|
||||
) : 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 === "faq" ? <FaqPage /> : 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" ? (
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
PeriodOverview,
|
||||
PeriodType,
|
||||
RecurringBilling,
|
||||
StatisticsOverview,
|
||||
TicketMeta,
|
||||
TicketPeriod,
|
||||
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) {
|
||||
return getPeriodOverview("month", month);
|
||||
}
|
||||
@@ -299,6 +307,10 @@ export function getPeriodOverview(type: PeriodType, period: string) {
|
||||
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) {
|
||||
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 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { HelpLink } from "@/components/HelpLink";
|
||||
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
||||
import {
|
||||
createAdminSession,
|
||||
@@ -335,9 +336,12 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<DatabaseZap className="size-5 text-muted-foreground" />
|
||||
<CardTitle>Data-Sync</CardTitle>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<DatabaseZap className="size-5 text-muted-foreground" />
|
||||
<CardTitle>Data-Sync</CardTitle>
|
||||
</div>
|
||||
<HelpLink anchor="organisationen" label="Hilfe zum Zammad-Sync" />
|
||||
</div>
|
||||
<CardDescription>Zammad-Zugang speichern und Organisationen in die lokale TicketTracker-Datenbank übernehmen.</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -378,8 +382,13 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle>Neuer Benutzer</CardTitle>
|
||||
<CardDescription>Neue Benutzer sehen später nur ihre eigenen Sessions und Abschlüsse.</CardDescription>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<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>
|
||||
<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}>
|
||||
@@ -421,9 +430,12 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarPlus className="size-5 text-muted-foreground" />
|
||||
<CardTitle>Session nachtragen</CardTitle>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarPlus className="size-5 text-muted-foreground" />
|
||||
<CardTitle>Session nachtragen</CardTitle>
|
||||
</div>
|
||||
<HelpLink anchor="session-nachtragen" label="Hilfe zum Nachtragen für Benutzer" />
|
||||
</div>
|
||||
<CardDescription>Vergessene Zeiten manuell erfassen und direkt einem Benutzer zuweisen.</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -541,7 +553,10 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
<CardTitle>Vorhandene Benutzer</CardTitle>
|
||||
<CardDescription>{loading ? "Lädt..." : `${users.length} Account(s)`}</CardDescription>
|
||||
</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>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<div className="hidden xl:block">
|
||||
@@ -658,6 +673,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
<CardTitle>Session-Zuordnung</CardTitle>
|
||||
<CardDescription>{loading ? "Lädt..." : `${sessions.length} letzte Session(s), inklusive Besitzer.`}</CardDescription>
|
||||
</div>
|
||||
<HelpLink anchor="admin" label="Hilfe zur Session-Zuordnung" />
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<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 { Fragment, 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";
|
||||
@@ -10,8 +9,9 @@ import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
||||
import { closePeriod, getPeriodOverview, reopenPeriod } from "../api";
|
||||
import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format";
|
||||
import { HelpLink } from "@/components/HelpLink";
|
||||
import { getPeriodOverview } from "../api";
|
||||
import { currentDay, currentMonth, formatMinutes } from "../format";
|
||||
import type { PeriodOverview, PeriodType, TicketSummary } from "../types";
|
||||
|
||||
type AnalysisPageProps = {
|
||||
@@ -304,9 +304,6 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
const [period, setPeriod] = useState(currentMonth());
|
||||
const [overview, setOverview] = useState<PeriodOverview | null>(null);
|
||||
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 loadRequestId = useRef(0);
|
||||
|
||||
@@ -362,7 +359,6 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
function switchPeriodType(nextType: PeriodType) {
|
||||
setPeriodType(nextType);
|
||||
setPeriod(nextType === "month" ? currentMonth() : currentDay());
|
||||
setShowOpen(false);
|
||||
}
|
||||
|
||||
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")}`);
|
||||
}
|
||||
|
||||
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 periodLabel = periodType === "month" ? "Monat" : "Tag";
|
||||
const supportsPeriodClosure = periodType === "month";
|
||||
const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [
|
||||
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
|
||||
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
|
||||
@@ -499,7 +457,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-normal">Auswertung</h2>
|
||||
<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>
|
||||
</div>
|
||||
<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"
|
||||
type={periodType === "month" ? "month" : "date"}
|
||||
value={period}
|
||||
onChange={(event) => {
|
||||
setPeriod(event.currentTarget.value);
|
||||
setShowOpen(false);
|
||||
}}
|
||||
onChange={(event) => setPeriod(event.currentTarget.value)}
|
||||
/>
|
||||
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changePeriodBy(1)} aria-label={`${periodLabel} vor`}>
|
||||
<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."}
|
||||
</CardDescription>
|
||||
</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>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<div className="h-44 w-full overflow-hidden rounded-md border bg-background">
|
||||
@@ -643,73 +601,15 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
</CardContent>
|
||||
</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>
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<CardTitle>Tickets im Zeitraum</CardTitle>
|
||||
<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>
|
||||
</div>
|
||||
{supportsPeriodClosure ? (
|
||||
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}
|
||||
<HelpLink anchor="auswertung" label="Hilfe zu Tickets im Zeitraum" />
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<div className="mb-4 rounded-md border bg-muted/20 p-3">
|
||||
@@ -723,6 +623,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
</p>
|
||||
</div>
|
||||
{activeFilterCount > 0 ? <Badge variant="outline">{activeFilterCount} aktiv</Badge> : null}
|
||||
<HelpLink anchor="auswertung" label="Hilfe zu Filtern und Sortierung" />
|
||||
</div>
|
||||
<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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { HelpLink } from "@/components/HelpLink";
|
||||
import { updateCurrentUser } from "../api";
|
||||
import type { AuthUser } from "../types";
|
||||
|
||||
@@ -74,9 +75,12 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCircle className="size-5 text-muted-foreground" />
|
||||
<CardTitle>Accountdaten</CardTitle>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCircle className="size-5 text-muted-foreground" />
|
||||
<CardTitle>Accountdaten</CardTitle>
|
||||
</div>
|
||||
<HelpLink anchor="profil" label="Hilfe zum Profil" />
|
||||
</div>
|
||||
<CardDescription>Der Benutzername wird beim Login verwendet. Dein voller Name wird in der App angezeigt.</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { HelpLink } from "@/components/HelpLink";
|
||||
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
||||
import { createRecurringBilling, deleteRecurringBilling, getRecurringBillings, updateRecurringBilling } from "../api";
|
||||
import { currentDay, formatMinutes } from "../format";
|
||||
@@ -248,9 +249,12 @@ export function RecurringBillingsPage() {
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="border-b bg-muted/30 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Repeat className="size-5 text-muted-foreground" />
|
||||
<CardTitle>{editingBillingId ? "Regel bearbeiten" : "Neue Regel"}</CardTitle>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<CardDescription>Ticket ist optional. Leer bedeutet automatische Gruppierung als Fix#ID.</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -447,8 +451,13 @@ export function RecurringBillingsPage() {
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="border-b bg-muted/30 p-4">
|
||||
<CardTitle>Regeln</CardTitle>
|
||||
<CardDescription>Erzeugte Einträge können in der Auswertung verschoben oder gelöscht werden.</CardDescription>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<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>
|
||||
<CardContent className="space-y-2 p-4">
|
||||
{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 { Textarea } from "@/components/ui/textarea";
|
||||
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
||||
import { HelpLink } from "@/components/HelpLink";
|
||||
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
||||
import {
|
||||
createSession,
|
||||
@@ -228,6 +229,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
|
||||
return Array.from(groups.values());
|
||||
}, [data?.sessions]);
|
||||
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) {
|
||||
if (isPeriodClosed && billingStatus !== null) {
|
||||
@@ -492,8 +494,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
|
||||
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle>Ticketdaten</CardTitle>
|
||||
<CardDescription>Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen.</CardDescription>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<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>
|
||||
<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}>
|
||||
@@ -542,9 +549,16 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
|
||||
<CardTitle>Session-Einträge</CardTitle>
|
||||
<CardDescription>Wähle für jede Session genau eine Bewertung aus oder lösche falsche Einträge.</CardDescription>
|
||||
</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">{periodLabel} gesamt</span>
|
||||
<span className="font-semibold">{formatMinutes(periodTotalMinutes)}</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm">
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
@@ -650,8 +664,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle>Session nachtragen</CardTitle>
|
||||
<CardDescription>Neue Zeit direkt diesem Ticket zuordnen.</CardDescription>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<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>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<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 { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { HelpLink } from "@/components/HelpLink";
|
||||
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
||||
import { createSession, lookupTicket } from "../api";
|
||||
import { formatTimer } from "../format";
|
||||
@@ -300,8 +301,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="border-b bg-muted/30 p-4">
|
||||
<CardTitle>Neuen Timer starten</CardTitle>
|
||||
<CardDescription>Ein neuer Timer startet sofort und pausiert alle anderen Timer automatisch.</CardDescription>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<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>
|
||||
<CardContent className="grid gap-3 p-4 sm:grid-cols-[minmax(220px,320px)_auto] sm:items-end">
|
||||
<div className="space-y-2">
|
||||
@@ -330,14 +336,19 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="border-b bg-muted/30 p-4">
|
||||
<CardTitle>{selectedTimer ? selectedTimer.ticketNumber : "Keine Session ausgewählt"}</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedTimer
|
||||
? selectedTimer.organizationName
|
||||
? `${selectedTimer.organizationName}${selectedTimer.workType ? ` · ${selectedTimer.workType === "support" ? "Support" : "Consulting"}` : ""}`
|
||||
: "Beim Umschalten wird dieser Timer aktiviert und alle anderen werden pausiert."
|
||||
: "Starte einen Timer, um eine Session zu erfassen."}
|
||||
</CardDescription>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>{selectedTimer ? selectedTimer.ticketNumber : "Keine Session ausgewählt"}</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedTimer
|
||||
? selectedTimer.organizationName
|
||||
? `${selectedTimer.organizationName}${selectedTimer.workType ? ` · ${selectedTimer.workType === "support" ? "Support" : "Consulting"}` : ""}`
|
||||
: "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>
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<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">
|
||||
<CardHeader className="border-b bg-muted/30 p-4">
|
||||
<CardTitle>Session nachtragen</CardTitle>
|
||||
<CardDescription>Vergessene Zeiten für deinen eigenen Account manuell erfassen.</CardDescription>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<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>
|
||||
<CardContent className="p-4">
|
||||
<form className="space-y-4" onSubmit={submitManualSession}>
|
||||
|
||||
Reference in New Issue
Block a user