Compare commits

...
12 Commits
Author SHA1 Message Date
michael 737c868376 Update 2026-08-05 16:14:43 +02:00
michael b01fc1655b Add detailed statistics help 2026-08-05 16:08:12 +02:00
michael f8574e5f39 Refine help UX and monthly close layout 2026-08-05 15:56:09 +02:00
michael 3b6f35e31a Add FAQ guide and contextual help links 2026-08-05 15:46:06 +02:00
michael 33f0170927 Persist monthly close acknowledgement filter 2026-08-05 15:37:56 +02:00
michael 8e4f07dcf8 Refine statistics Teamspace display 2026-08-05 15:34:47 +02:00
michael 84057771fe Allow undoing Teamspace acknowledgements 2026-08-05 15:27:45 +02:00
michael 20bc04778d Refine monthly close cleanup 2026-08-05 15:21:55 +02:00
michael ca5e015065 Move month close workflow 2026-08-05 15:05:25 +02:00
michael 4b35dfe55e Refine statistics comparisons 2026-08-05 14:44:56 +02:00
michael eafd718e64 Add monthly statistics dashboard 2026-08-05 14:25:08 +02:00
michael cfc4ef45a8 Show ticket Teamspace totals 2026-08-05 14:11:30 +02:00
16 changed files with 2528 additions and 167 deletions
+449 -1
View File
@@ -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");
+11
View File
@@ -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(`
+59 -10
View File
@@ -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,7 +606,12 @@ export function App() {
<SidebarGroupLabel>Workflows</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{navItems.map((item) => (
{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)}>
@@ -575,7 +620,8 @@ export function App() {
</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" ? (
+12
View File
@@ -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}`);
}
+44
View File
@@ -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>
);
}
+415
View File
@@ -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];
}
+106
View File
@@ -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[];
};
};
+16
View File
@@ -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,10 +336,13 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<Card>
<CardHeader className="p-4">
<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>
<CardContent className="px-4 pb-4">
@@ -378,8 +382,13 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<Card>
<CardHeader className="p-4">
<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,10 +430,13 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<Card>
<CardHeader className="p-4">
<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>
<CardContent className="px-4 pb-4">
@@ -541,7 +553,10 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<CardTitle>Vorhandene Benutzer</CardTitle>
<CardDescription>{loading ? "Lädt..." : `${users.length} Account(s)`}</CardDescription>
</div>
<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">
+12 -111
View File
@@ -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>
<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"
+80
View File
@@ -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>
);
}
+432
View File
@@ -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
View File
@@ -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,10 +75,13 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
<Card>
<CardHeader className="p-4">
<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>
<CardContent className="px-4 pb-4">
@@ -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,10 +249,13 @@ export function RecurringBillingsPage() {
<Card className="overflow-hidden">
<CardHeader className="border-b bg-muted/30 p-4">
<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>
<CardContent className="p-4">
@@ -447,8 +451,13 @@ export function RecurringBillingsPage() {
<Card className="overflow-hidden">
<CardHeader className="border-b bg-muted/30 p-4">
<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) => (
+799
View File
@@ -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>
);
}
+19
View File
@@ -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">
<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,10 +549,17 @@ 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 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">
<div className="space-y-3">
@@ -650,8 +664,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
</Card>
<Card>
<CardHeader className="p-4">
<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}>
+16
View File
@@ -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">
<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,6 +336,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<Card className="overflow-hidden">
<CardHeader className="border-b bg-muted/30 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>{selectedTimer ? selectedTimer.ticketNumber : "Keine Session ausgewählt"}</CardTitle>
<CardDescription>
{selectedTimer
@@ -338,6 +346,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
: "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">
<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}>