From d1da86beab5d1de1a3e8a0f13da22f2daada9a1e Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Tue, 8 Sep 2026 16:48:28 +0200 Subject: [PATCH] Update --- backend/src/index.ts | 704 +++++- backend/src/migrations.ts | 91 +- docs/user-guide.md | 112 +- frontend/src/App.tsx | 338 ++- frontend/src/api.ts | 63 +- frontend/src/app/globals.css | 4 + frontend/src/components/TodoPanel.tsx | 172 +- frontend/src/help-content.ts | 39 +- frontend/src/recurring-billings.ts | 39 + frontend/src/todos.ts | 62 + frontend/src/types.ts | 41 + frontend/src/views/AnalysisPage.tsx | 229 +- frontend/src/views/DashboardPage.tsx | 2181 ++++++++++++++++++ frontend/src/views/FaqPage.tsx | 2 +- frontend/src/views/ProjectsPage.tsx | 855 +++++-- frontend/src/views/RecurringBillingsPage.tsx | 4 +- frontend/src/views/StatisticsPage.tsx | 2 +- frontend/src/views/TicketDetailPage.tsx | 923 +++++--- frontend/src/views/TimerPage.tsx | 75 +- frontend/src/views/TodosPage.tsx | 1096 ++++++++- 20 files changed, 6109 insertions(+), 923 deletions(-) create mode 100644 frontend/src/recurring-billings.ts create mode 100644 frontend/src/todos.ts create mode 100644 frontend/src/views/DashboardPage.tsx diff --git a/backend/src/index.ts b/backend/src/index.ts index 2edefef..b227c25 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -172,6 +172,10 @@ type ProjectRow = { starts_on: string | null; ends_on: string | null; hour_budget_minutes: number | null; + manual_consumption_minutes: number; + teamspace_consumption_minutes?: number; + consumption_minutes?: number; + closed_at: string | null; created_at: string; updated_at: string; }; @@ -224,6 +228,71 @@ function optionalBoolean(value: unknown, fallback: boolean) { return value; } +const dashboardPreferenceKey = "dashboard"; + +function parseDashboardPreferenceValue(value: unknown) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw badRequest("dashboard preference must be an object"); + } + + const encoded = JSON.stringify(value); + + if (encoded.length > 100_000) { + throw badRequest("dashboard preference is too large"); + } + + return value as Record; +} + +async function getUserPreference(userId: string, key: string) { + const result = await query<{ value: Record }>( + "SELECT value FROM user_preferences WHERE user_id = $1 AND key = $2;", + [userId, key] + ); + + return result.rows[0]?.value ?? {}; +} + +async function setUserPreference(userId: string, key: string, value: Record) { + const result = await query<{ value: Record }>( + ` + INSERT INTO user_preferences (user_id, key, value, updated_at) + VALUES ($1, $2, $3::jsonb, now()) + ON CONFLICT (user_id, key) + DO UPDATE SET value = EXCLUDED.value, updated_at = now() + RETURNING value; + `, + [userId, key, JSON.stringify(value)] + ); + + return result.rows[0]?.value ?? value; +} + +function readDashboardFavorites(preference: Record) { + const favorites = Array.isArray(preference.favorites) ? preference.favorites : []; + + return favorites + .map((favorite, index) => { + if (!favorite || typeof favorite !== "object" || Array.isArray(favorite)) { + return null; + } + + const entry = favorite as Record; + const ticketNumber = typeof entry.ticketNumber === "string" ? entry.ticketNumber : null; + + if (!ticketNumber) { + return null; + } + + return { + ticketNumber, + addedAt: typeof entry.addedAt === "string" ? entry.addedAt : new Date().toISOString(), + sortOrder: Number.isInteger(entry.sortOrder) ? Number(entry.sortOrder) : index + }; + }) + .filter((favorite): favorite is { ticketNumber: string; addedAt: string; sortOrder: number } => Boolean(favorite)); +} + function parseOptionalDateOnly(value: unknown, field: string) { if (value === undefined || value === null || value === "") { return null; @@ -262,6 +331,14 @@ function parseTodoStatus(value: unknown) { return value; } +function parseTodoOrderIds(value: unknown) { + if (!Array.isArray(value)) { + throw badRequest("todoIds must be an array"); + } + + return value.map((item) => parsePositiveInteger(item, "todoId")); +} + const periodConfigs: Record = { months: { type: "month", @@ -518,12 +595,19 @@ async function ensureRecurringSessionsForUserPeriod(userId: string, startIso: st DO UPDATE SET organization_id = COALESCE(tickets.organization_id, EXCLUDED.organization_id), customer_name = COALESCE(tickets.customer_name, EXCLUDED.customer_name), - work_type = COALESCE(tickets.work_type, EXCLUDED.work_type) + work_type = CASE + WHEN EXCLUDED.work_type = 'consulting' THEN 'consulting' + ELSE COALESCE(tickets.work_type, EXCLUDED.work_type) + END RETURNING id; `, [rule.ticket_number, rule.organization_id, rule.organization_name, rule.work_type] ); + if (rule.work_type === "consulting") { + await ensureProjectForTicket(client, ticketResult.rows[0].id, userId); + } + await client.query( ` INSERT INTO sessions ( @@ -624,6 +708,76 @@ async function cleanupTicketDayBillingIfEmpty( ); } +async function ensureProjectForTicket( + client: { query: (text: string, params?: unknown[]) => Promise }, + ticketId: string, + userId: string +) { + await client.query( + ` + INSERT INTO projects (user_id, ticket_id, teamspace_project, description, goals) + SELECT $2, t.id, COALESCE(t.title, ''), '', '' + FROM tickets t + WHERE t.id = $1 + ON CONFLICT (user_id, ticket_id) DO NOTHING; + `, + [ticketId, userId] + ); +} + +async function deleteProjectDataForTicket( + client: { query: (text: string, params?: unknown[]) => Promise }, + ticketId: string, + userId: string +) { + await client.query( + ` + DELETE FROM ticket_day_billing_acknowledgements + WHERE ticket_id = $1 + AND user_id = $2; + `, + [ticketId, userId] + ); + + await client.query( + ` + DELETE FROM ticket_day_billings + WHERE ticket_id = $1 + AND user_id = $2; + `, + [ticketId, userId] + ); + + await client.query( + ` + DELETE FROM projects + WHERE ticket_id = $1 + AND user_id = $2; + `, + [ticketId, userId] + ); +} + +async function ticketHasRecurringBilling( + client: { query: (text: string, params?: unknown[]) => Promise<{ rows: { exists: boolean }[] }> }, + ticketNumber: string, + userId: string +) { + const result = await client.query( + ` + SELECT EXISTS ( + SELECT 1 + FROM recurring_billings rb + WHERE rb.user_id = $2 + AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = $1 + ) AS exists; + `, + [ticketNumber, userId] + ); + + return Boolean(result.rows[0]?.exists); +} + async function reopenPeriodsForSession( client: { query: (text: string, params?: unknown[]) => Promise }, ticketId: string, @@ -656,6 +810,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, + p.id::text AS project_id, COUNT(s.id)::int AS session_count, COUNT(*) FILTER (WHERE s.recurring_billing_id IS NOT NULL)::int AS recurring_session_count, COUNT(*) FILTER (WHERE s.recurring_billing_id IS NULL)::int AS manual_session_count, @@ -672,6 +827,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use FROM tickets t JOIN sessions s ON s.ticket_id = t.id LEFT JOIN organizations o ON o.zammad_id = t.organization_id + LEFT JOIN projects p ON p.ticket_id = t.id AND p.user_id = $3 WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz AND s.user_id = $3 GROUP BY @@ -681,7 +837,8 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use t.organization_id, o.name, t.customer_name, - t.work_type + t.work_type, + p.id ORDER BY t.ticket_number ASC; `, [period.startIso, period.endIso, userId] @@ -698,7 +855,8 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use s.activity, s.work_type, s.started_at, - s.rounded_minutes + s.rounded_minutes, + s.recurring_billing_id::text AS recurring_billing_id FROM sessions s JOIN tickets t ON t.id = s.ticket_id LEFT JOIN organizations so ON so.zammad_id = s.organization_id @@ -1179,7 +1337,13 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke pc.closed_at AS period_closed_at, COALESCE(period_sessions.session_count, 0)::int AS ticket_session_count, COALESCE(period_sessions.open_count, 0)::int AS ticket_open_count, - COALESCE(period_sessions.manual_session_count, 0)::int AS ticket_manual_session_count + COALESCE(period_sessions.manual_session_count, 0)::int AS ticket_manual_session_count, + EXISTS ( + SELECT 1 + FROM recurring_billings rb + WHERE rb.user_id = $2 + AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number + ) AS has_recurring_billing FROM tickets t LEFT JOIN organizations o ON o.zammad_id = t.organization_id ${periodClosureJoin} @@ -1301,7 +1465,13 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon NULL::timestamptz AS period_closed_at, COALESCE(all_sessions.session_count, 0)::int AS ticket_session_count, COALESCE(all_sessions.open_count, 0)::int AS ticket_open_count, - COALESCE(all_sessions.manual_session_count, 0)::int AS ticket_manual_session_count + COALESCE(all_sessions.manual_session_count, 0)::int AS ticket_manual_session_count, + EXISTS ( + SELECT 1 + FROM recurring_billings rb + WHERE rb.user_id = $2 + AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number + ) AS has_recurring_billing FROM tickets t LEFT JOIN organizations o ON o.zammad_id = t.organization_id LEFT JOIN LATERAL ( @@ -1428,15 +1598,31 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon p.teamspace_project, p.description, p.goals, - p.starts_on::text AS starts_on, - p.ends_on::text AS ends_on, - p.hour_budget_minutes, - p.created_at, - p.updated_at - FROM projects p - JOIN tickets t ON t.id = p.ticket_id - LEFT JOIN organizations o ON o.zammad_id = t.organization_id - WHERE p.ticket_id = $1 + p.starts_on::text AS starts_on, + p.ends_on::text AS ends_on, + p.hour_budget_minutes, + p.manual_consumption_minutes, + EXISTS ( + SELECT 1 + FROM recurring_billings rb + WHERE rb.user_id = p.user_id + AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number + ) AS has_recurring_billing, + COALESCE(teamspace_totals.teamspace_consumption_minutes, 0)::int AS teamspace_consumption_minutes, + (COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + p.manual_consumption_minutes)::int AS consumption_minutes, + p.closed_at, + p.created_at, + p.updated_at + FROM projects p + JOIN tickets t ON t.id = p.ticket_id + LEFT JOIN organizations o ON o.zammad_id = t.organization_id + LEFT JOIN LATERAL ( + SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_consumption_minutes + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND tdb.user_id = p.user_id + ) teamspace_totals ON true + WHERE p.ticket_id = $1 AND p.user_id = $2; `, [ticketId, userId] @@ -1454,6 +1640,9 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon tt.description, tt.priority, tt.status, + tt.sort_order, + tt.starts_on::text AS starts_on, + tt.ends_on::text AS ends_on, tt.completed_at, tt.created_at, tt.updated_at @@ -1465,8 +1654,8 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon AND tt.user_id = $2 ORDER BY CASE tt.status WHEN 'open' THEN 0 ELSE 1 END, - CASE tt.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, - tt.updated_at DESC; + tt.sort_order ASC, + tt.id ASC; `, [ticketId, userId] ) @@ -1618,6 +1807,55 @@ app.get("/api/auth/me", (req, res) => { res.json({ user: currentUser(req) }); }); +app.get("/api/dashboard/preferences", requireUser, async (req, res) => { + const preference = await getUserPreference(currentUser(req).id, dashboardPreferenceKey); + res.json({ preference }); +}); + +app.put("/api/dashboard/preferences", requireUser, async (req, res) => { + const preference = parseDashboardPreferenceValue(req.body.preference); + const saved = await setUserPreference(currentUser(req).id, dashboardPreferenceKey, preference); + res.json({ preference: saved }); +}); + +app.post("/api/dashboard/favorites", requireUser, async (req, res) => { + const userId = currentUser(req).id; + const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber); + const preference = await getUserPreference(userId, dashboardPreferenceKey); + const favorites = readDashboardFavorites(preference); + const existing = favorites.find((favorite) => favorite.ticketNumber === ticketNumber); + + if (!existing) { + favorites.push({ + ticketNumber, + addedAt: new Date().toISOString(), + sortOrder: favorites.length + }); + } + + const saved = await setUserPreference(userId, dashboardPreferenceKey, { + ...preference, + favorites + }); + + res.status(existing ? 200 : 201).json({ preference: saved }); +}); + +app.delete("/api/dashboard/favorites/:ticketNumber", requireUser, async (req, res) => { + const userId = currentUser(req).id; + const ticketNumber = parseTrackableTicketNumber(req.params.ticketNumber); + const preference = await getUserPreference(userId, dashboardPreferenceKey); + const favorites = readDashboardFavorites(preference) + .filter((favorite) => favorite.ticketNumber !== ticketNumber) + .map((favorite, index) => ({ ...favorite, sortOrder: index })); + const saved = await setUserPreference(userId, dashboardPreferenceKey, { + ...preference, + favorites + }); + + res.json({ preference: saved }); +}); + app.post("/api/auth/logout", async (req, res) => { await clearAuthSession(req, res); res.json({ ok: true }); @@ -2006,6 +2244,7 @@ const databaseExportTables = [ "users", "auth_sessions", "app_settings", + "user_preferences", "organizations", "tickets", "projects", @@ -2763,12 +3002,19 @@ app.patch("/api/recurring-billings/:billingId", requireUser, async (req, res) => DO UPDATE SET organization_id = COALESCE(tickets.organization_id, EXCLUDED.organization_id), customer_name = COALESCE(tickets.customer_name, EXCLUDED.customer_name), - work_type = COALESCE(tickets.work_type, EXCLUDED.work_type) + work_type = CASE + WHEN EXCLUDED.work_type = 'consulting' THEN 'consulting' + ELSE COALESCE(tickets.work_type, EXCLUDED.work_type) + END RETURNING id; `, [targetTicketNumber, organization.id, organization.name, payload.workType] ); + if (payload.workType === "consulting") { + await ensureProjectForTicket(client, targetTicketResult.rows[0].id, userId); + } + const updatedSessions = await client.query<{ ticket_id: string; started_at: string }>( ` UPDATE sessions s @@ -2902,6 +3148,11 @@ app.post("/api/sessions", requireUser, async (req, res) => { throw badRequest("endedAt must be after startedAt"); } + if (ticketNumber.startsWith("Fix#") || (await ticketHasRecurringBilling({ query }, ticketNumber, user.id))) { + res.status(409).json({ error: "Für fixe Abrechnungen dürfen keine manuellen Sessions erstellt werden. Bitte die fixe Abrechnung bearbeiten." }); + return; + } + const monthClosureResult = await query<{ closed_at: string }>( ` SELECT closed_at @@ -2950,9 +3201,13 @@ app.post("/api/sessions", requireUser, async (req, res) => { DO UPDATE SET organization_id = COALESCE(tickets.organization_id, EXCLUDED.organization_id), customer_name = COALESCE(tickets.customer_name, EXCLUDED.customer_name), - work_type = CASE WHEN EXISTS ( - SELECT 1 FROM projects p WHERE p.ticket_id = tickets.id AND p.user_id = $5 - ) THEN 'consulting' ELSE COALESCE(tickets.work_type, EXCLUDED.work_type) END + work_type = CASE + WHEN EXCLUDED.work_type = 'consulting' THEN 'consulting' + WHEN EXISTS ( + SELECT 1 FROM projects p WHERE p.ticket_id = tickets.id AND p.user_id = $5 + ) THEN 'consulting' + ELSE COALESCE(tickets.work_type, EXCLUDED.work_type) + END RETURNING id, ticket_number, organization_id, customer_name, work_type; `, [ticketNumber, organization.id, organization.name, existingTicket?.project_id ? "consulting" : requestedWorkType, user.id] @@ -2996,6 +3251,10 @@ app.post("/api/sessions", requireUser, async (req, res) => { ] ); + if (workType === "consulting") { + await ensureProjectForTicket(client, ticket.id, user.id); + } + await reopenPeriodsForSession(client, ticket.id, startedAt, user.id); return { @@ -3025,13 +3284,20 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => { SELECT t.id, t.ticket_number, + t.title, t.organization_id, o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, p.id::text AS project_id, (p.id IS NOT NULL) AS is_project, - p.teamspace_project AS project_name + p.teamspace_project AS project_name, + EXISTS ( + SELECT 1 + FROM recurring_billings rb + WHERE rb.user_id = $2 + AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number + ) AS has_recurring_billing FROM tickets t LEFT JOIN organizations o ON o.zammad_id = t.organization_id LEFT JOIN projects p ON p.ticket_id = t.id AND p.user_id = $2 @@ -3112,6 +3378,7 @@ app.get("/api/tickets/search", requireUser, async (req, res) => { app.get("/api/projects", requireUser, async (req, res) => { const userId = currentUser(req).id; + const includeClosed = req.query.includeClosed === "true"; const result = await query( ` SELECT @@ -3126,38 +3393,62 @@ app.get("/api/projects", requireUser, async (req, res) => { p.teamspace_project, p.description, p.goals, - p.starts_on::text AS starts_on, - p.ends_on::text AS ends_on, - p.hour_budget_minutes, - p.created_at, - p.updated_at, - COALESCE(session_totals.session_count, 0)::int AS session_count, - COALESCE(session_totals.billed_minutes, 0)::int AS billed_minutes, - COALESCE(todo_totals.open_todos, 0)::int AS open_todos, - COALESCE(todo_totals.done_todos, 0)::int AS done_todos + p.starts_on::text AS starts_on, + p.ends_on::text AS ends_on, + p.hour_budget_minutes, + p.manual_consumption_minutes, + p.closed_at, + p.created_at, + p.updated_at, + COALESCE(session_totals.session_count, 0)::int AS session_count, + COALESCE(session_totals.recurring_session_count, 0)::int AS recurring_session_count, + COALESCE(session_totals.manual_session_count, 0)::int AS manual_session_count, + EXISTS ( + SELECT 1 + FROM recurring_billings rb + WHERE rb.user_id = p.user_id + AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number + ) AS has_recurring_billing, + COALESCE(teamspace_totals.teamspace_consumption_minutes, 0)::int AS teamspace_consumption_minutes, + (COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + p.manual_consumption_minutes)::int AS consumption_minutes, + COALESCE(todo_totals.open_todos, 0)::int AS open_todos, + COALESCE(todo_totals.overdue_todos, 0)::int AS overdue_todos, + COALESCE(todo_totals.done_todos, 0)::int AS done_todos FROM projects p JOIN tickets t ON t.id = p.ticket_id LEFT JOIN organizations o ON o.zammad_id = t.organization_id + LEFT JOIN LATERAL ( + SELECT + COUNT(*)::int AS session_count, + COUNT(*) FILTER (WHERE s.recurring_billing_id IS NOT NULL)::int AS recurring_session_count, + COUNT(*) FILTER (WHERE s.recurring_billing_id IS NULL)::int AS manual_session_count + FROM sessions s + WHERE s.ticket_id = p.ticket_id + AND s.user_id = p.user_id + ) session_totals ON true + LEFT JOIN LATERAL ( + SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_consumption_minutes + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND tdb.user_id = p.user_id + ) teamspace_totals ON true LEFT JOIN LATERAL ( - SELECT - COUNT(*)::int AS session_count, - COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes - FROM sessions s - WHERE s.ticket_id = p.ticket_id - AND s.user_id = p.user_id - ) session_totals ON true - LEFT JOIN LATERAL ( - SELECT - COUNT(*) FILTER (WHERE status = 'open')::int AS open_todos, - COUNT(*) FILTER (WHERE status = 'done')::int AS done_todos + SELECT + COUNT(*) FILTER (WHERE status = 'open')::int AS open_todos, + COUNT(*) FILTER (WHERE status = 'open' AND ends_on IS NOT NULL AND ends_on < (now() AT TIME ZONE 'Europe/Berlin')::date)::int AS overdue_todos, + COUNT(*) FILTER (WHERE status = 'done')::int AS done_todos FROM ticket_todos tt WHERE tt.ticket_id = p.ticket_id AND tt.user_id = p.user_id ) todo_totals ON true WHERE p.user_id = $1 - ORDER BY p.updated_at DESC, t.ticket_number ASC; + AND ($2::boolean OR p.closed_at IS NULL) + ORDER BY + CASE WHEN p.closed_at IS NULL THEN 0 ELSE 1 END, + p.updated_at DESC, + t.ticket_number ASC; `, - [userId] + [userId, includeClosed] ); res.json({ projects: result.rows }); @@ -3180,15 +3471,31 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { p.teamspace_project, p.description, p.goals, - p.starts_on::text AS starts_on, - p.ends_on::text AS ends_on, - p.hour_budget_minutes, - p.created_at, - p.updated_at - FROM projects p - JOIN tickets t ON t.id = p.ticket_id - LEFT JOIN organizations o ON o.zammad_id = t.organization_id - WHERE p.id = $1 + p.starts_on::text AS starts_on, + p.ends_on::text AS ends_on, + p.hour_budget_minutes, + p.manual_consumption_minutes, + EXISTS ( + SELECT 1 + FROM recurring_billings rb + WHERE rb.user_id = p.user_id + AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number + ) AS has_recurring_billing, + COALESCE(teamspace_totals.teamspace_consumption_minutes, 0)::int AS teamspace_consumption_minutes, + (COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + p.manual_consumption_minutes)::int AS consumption_minutes, + p.closed_at, + p.created_at, + p.updated_at + FROM projects p + JOIN tickets t ON t.id = p.ticket_id + LEFT JOIN organizations o ON o.zammad_id = t.organization_id + LEFT JOIN LATERAL ( + SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_consumption_minutes + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND tdb.user_id = p.user_id + ) teamspace_totals ON true + WHERE p.id = $1 AND p.user_id = $2; `, [projectId, userId] @@ -3213,6 +3520,7 @@ app.post("/api/projects", requireUser, async (req, res) => { const startsOn = parseOptionalDateOnly(req.body.startsOn, "startsOn"); const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn"); const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes"); + const manualConsumptionMinutes = parseOptionalNonNegativeInteger(req.body.manualConsumptionMinutes, "manualConsumptionMinutes") ?? 0; if (startsOn && endsOn && endsOn < startsOn) { throw badRequest("endsOn must be after startsOn"); @@ -3287,17 +3595,18 @@ app.post("/api/projects", requireUser, async (req, res) => { goals, starts_on, ends_on, - hour_budget_minutes + hour_budget_minutes, + manual_consumption_minutes ) - VALUES ($1, $2, $3, $4, $5, $6::date, $7::date, $8) + VALUES ($1, $2, $3, $4, $5, $6::date, $7::date, $8, $9) RETURNING id::text, ticket_id::text, - $9::text AS ticket_number, - COALESCE($12::text, $13::text) AS title, - $10::text AS organization_id, - $11::text AS organization_name, - $11::text AS customer_name, + $10::text AS ticket_number, + COALESCE($13::text, $14::text) AS title, + $11::text AS organization_id, + $12::text AS organization_name, + $12::text AS customer_name, 'consulting'::text AS work_type, teamspace_project, description, @@ -3305,10 +3614,29 @@ app.post("/api/projects", requireUser, async (req, res) => { starts_on::text, ends_on::text, hour_budget_minutes, + manual_consumption_minutes, + 0::int AS teamspace_consumption_minutes, + manual_consumption_minutes AS consumption_minutes, + NULL::timestamptz AS closed_at, created_at, updated_at; `, - [userId, ticketId, teamspaceProject, description, goals, startsOn, endsOn, hourBudgetMinutes, ticketNumber, organization.id, organization.name, title, existingTicket.rows[0]?.title ?? null] + [ + userId, + ticketId, + teamspaceProject, + description, + goals, + startsOn, + endsOn, + hourBudgetMinutes, + manualConsumptionMinutes, + ticketNumber, + organization.id, + organization.name, + title, + existingTicket.rows[0]?.title ?? null + ] ); return projectResult.rows[0]; @@ -3347,6 +3675,7 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { const startsOn = parseOptionalDateOnly(req.body.startsOn, "startsOn"); const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn"); const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes"); + const manualConsumptionMinutes = parseOptionalNonNegativeInteger(req.body.manualConsumptionMinutes, "manualConsumptionMinutes") ?? 0; if (startsOn && endsOn && endsOn < startsOn) { throw badRequest("endsOn must be after startsOn"); @@ -3409,17 +3738,18 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { starts_on = $4::date, ends_on = $5::date, hour_budget_minutes = $6, + manual_consumption_minutes = $7, updated_at = now() - WHERE id = $7 - AND user_id = $8 + WHERE id = $8 + AND user_id = $9 RETURNING id::text, ticket_id::text, - $9::text AS ticket_number, - $12::text AS title, - $10::text AS organization_id, - $11::text AS organization_name, - $11::text AS customer_name, + $10::text AS ticket_number, + $13::text AS title, + $11::text AS organization_id, + $12::text AS organization_name, + $12::text AS customer_name, 'consulting'::text AS work_type, teamspace_project, description, @@ -3427,10 +3757,24 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { starts_on::text, ends_on::text, hour_budget_minutes, + manual_consumption_minutes, + ( + SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = projects.ticket_id + AND tdb.user_id = projects.user_id + ) AS teamspace_consumption_minutes, + (( + SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = projects.ticket_id + AND tdb.user_id = projects.user_id + ) + manual_consumption_minutes)::int AS consumption_minutes, + closed_at, created_at, updated_at; `, - [teamspaceProject, description, goals, startsOn, endsOn, hourBudgetMinutes, projectId, userId, ticketNumber, organization.id, organization.name, title] + [teamspaceProject, description, goals, startsOn, endsOn, hourBudgetMinutes, manualConsumptionMinutes, projectId, userId, ticketNumber, organization.id, organization.name, title] ); return result.rows[0]; @@ -3457,6 +3801,64 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { } }); +app.patch("/api/projects/:projectId/status", requireUser, async (req, res) => { + const projectId = parsePositiveInteger(req.params.projectId, "projectId"); + const userId = currentUser(req).id; + const closed = optionalBoolean(req.body.closed, false); + + const result = await query<{ id: string; ticket_id: string; closed_at: string | null }>( + ` + UPDATE projects + SET closed_at = CASE WHEN $3::boolean THEN now() ELSE NULL END, + updated_at = now() + WHERE id = $1 + AND user_id = $2 + RETURNING id::text, ticket_id::text, closed_at; + `, + [projectId, userId, closed] + ); + + if (result.rowCount === 0) { + res.status(404).json({ error: "Projekt nicht gefunden" }); + return; + } + + res.json({ project: result.rows[0] }); +}); + +app.delete("/api/projects/:projectId", requireUser, async (req, res) => { + const projectId = parsePositiveInteger(req.params.projectId, "projectId"); + const userId = currentUser(req).id; + + const result = await withTransaction(async (client) => { + const projectResult = await client.query<{ id: string; ticket_id: string }>( + ` + SELECT id::text, ticket_id::text + FROM projects + WHERE id = $1 + AND user_id = $2 + FOR UPDATE; + `, + [projectId, userId] + ); + + if (projectResult.rowCount === 0) { + return null; + } + + await deleteProjectDataForTicket(client, projectResult.rows[0].ticket_id, userId); + + return projectResult.rows[0]; + }); + + if (!result) { + res.status(404).json({ error: "Projekt nicht gefunden" }); + return; + } + + res.json({ deleted: result }); +}); + app.get("/api/todos", requireUser, async (req, res) => { const userId = currentUser(req).id; const status = req.query.status === "done" || req.query.status === "open" ? req.query.status : "all"; @@ -3481,6 +3883,9 @@ app.get("/api/todos", requireUser, async (req, res) => { tt.description, tt.priority, tt.status, + tt.sort_order, + tt.starts_on::text AS starts_on, + tt.ends_on::text AS ends_on, tt.completed_at, tt.created_at, tt.updated_at @@ -3491,8 +3896,8 @@ app.get("/api/todos", requireUser, async (req, res) => { WHERE ${where.join(" AND ")} ORDER BY CASE tt.status WHEN 'open' THEN 0 ELSE 1 END, - CASE tt.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, - tt.updated_at DESC; + tt.sort_order ASC, + tt.id ASC; `, params ); @@ -3506,6 +3911,12 @@ app.post("/api/tickets/:ticketId/todos", requireUser, async (req, res) => { const title = requireString(req.body.title, "title"); const description = optionalString(req.body.description) ?? ""; const priority = parseTodoPriority(req.body.priority ?? "medium"); + const startsOn = parseOptionalDateOnly(req.body.startsOn, "startsOn"); + const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn"); + + if (startsOn && endsOn && endsOn < startsOn) { + throw badRequest("endsOn must be after startsOn"); + } const ticketResult = await query<{ id: string }>("SELECT id FROM tickets WHERE id = $1;", [ticketId]); @@ -3516,8 +3927,8 @@ app.post("/api/tickets/:ticketId/todos", requireUser, async (req, res) => { const result = await query( ` - INSERT INTO ticket_todos (ticket_id, user_id, title, description, priority) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO ticket_todos (ticket_id, user_id, title, description, priority, starts_on, ends_on, sort_order) + VALUES ($1, $2, $3, $4, $5, $6::date, $7::date, (SELECT COALESCE(MAX(sort_order), 0) + 1024 FROM ticket_todos WHERE user_id = $2)) RETURNING id::text, ticket_id::text, @@ -3525,11 +3936,14 @@ app.post("/api/tickets/:ticketId/todos", requireUser, async (req, res) => { description, priority, status, + sort_order, + starts_on::text AS starts_on, + ends_on::text AS ends_on, completed_at, created_at, updated_at; `, - [ticketId, userId, title, description, priority] + [ticketId, userId, title, description, priority, startsOn, endsOn] ); res.status(201).json({ todo: result.rows[0] }); @@ -3542,6 +3956,12 @@ app.patch("/api/todos/:todoId", requireUser, async (req, res) => { const description = optionalString(req.body.description) ?? ""; const priority = parseTodoPriority(req.body.priority); const status = parseTodoStatus(req.body.status); + const startsOn = parseOptionalDateOnly(req.body.startsOn, "startsOn"); + const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn"); + + if (startsOn && endsOn && endsOn < startsOn) { + throw badRequest("endsOn must be after startsOn"); + } const result = await query( ` @@ -3550,10 +3970,12 @@ app.patch("/api/todos/:todoId", requireUser, async (req, res) => { description = $2, priority = $3, status = $4, + starts_on = $5::date, + ends_on = $6::date, completed_at = CASE WHEN $4 = 'done' THEN COALESCE(completed_at, now()) ELSE NULL END, updated_at = now() - WHERE id = $5 - AND user_id = $6 + WHERE id = $7 + AND user_id = $8 RETURNING id::text, ticket_id::text, @@ -3561,11 +3983,14 @@ app.patch("/api/todos/:todoId", requireUser, async (req, res) => { description, priority, status, + sort_order, + starts_on::text AS starts_on, + ends_on::text AS ends_on, completed_at, created_at, updated_at; `, - [title, description, priority, status, todoId, userId] + [title, description, priority, status, startsOn, endsOn, todoId, userId] ); if (result.rowCount === 0) { @@ -3576,6 +4001,51 @@ app.patch("/api/todos/:todoId", requireUser, async (req, res) => { res.json({ todo: result.rows[0] }); }); +app.put("/api/todos/order", requireUser, async (req, res) => { + const userId = currentUser(req).id; + const todoIds = parseTodoOrderIds(req.body.todoIds); + + if (todoIds.length === 0) { + res.json({ ok: true }); + return; + } + + await withTransaction(async (client) => { + const ownershipResult = await client.query<{ id: string }>( + ` + SELECT id::text + FROM ticket_todos + WHERE user_id = $1 + AND id = ANY($2::bigint[]); + `, + [userId, todoIds] + ); + const ownedIds = new Set(ownershipResult.rows.map((row) => row.id)); + const unauthorizedId = todoIds.find((todoId) => !ownedIds.has(String(todoId))); + + if (unauthorizedId) { + throw badRequest(`todo ${unauthorizedId} does not belong to current user`); + } + + await Promise.all( + todoIds.map((todoId, index) => + client.query( + ` + UPDATE ticket_todos + SET sort_order = $1, + updated_at = updated_at + WHERE id = $2 + AND user_id = $3; + `, + [(index + 1) * 1024, todoId, userId] + ) + ) + ); + }); + + res.json({ ok: true }); +}); + app.delete("/api/todos/:todoId", requireUser, async (req, res) => { const todoId = parsePositiveInteger(req.params.todoId, "todoId"); const userId = currentUser(req).id; @@ -3619,6 +4089,8 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { const title = optionalNullableString(req.body.title); const organizationId = parseOrganizationId(req.body.organizationId); const workType = parseWorkType(req.body.workType); + const removeProjectData = optionalBoolean(req.body.removeProjectData, false); + const userId = currentUser(req).id; try { const result = await withTransaction(async (client) => { @@ -3633,11 +4105,16 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { SELECT id FROM projects WHERE ticket_id = $1 - AND user_id = $2; + AND user_id = $2 + FOR UPDATE; `, - [ticketId, currentUser(req).id] + [ticketId, userId] ); - const effectiveWorkType = projectResult.rowCount && projectResult.rowCount > 0 ? "consulting" : workType; + + if (projectResult.rowCount && projectResult.rowCount > 0 && workType === "support" && !removeProjectData) { + return "project-removal-required" as const; + } + const ticketResult = await client.query( ` UPDATE tickets @@ -3649,13 +4126,21 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { WHERE id = $6 RETURNING id, ticket_number, title, organization_id, customer_name, work_type; `, - [ticketNumber, title, organization.id, organization.name, effectiveWorkType, ticketId] + [ticketNumber, title, organization.id, organization.name, workType, ticketId] ); if (ticketResult.rowCount === 0) { return null; } + if (workType === "support" && projectResult.rowCount && projectResult.rowCount > 0) { + await deleteProjectDataForTicket(client, String(ticketId), userId); + } + + if (workType === "consulting") { + await ensureProjectForTicket(client, String(ticketId), userId); + } + await client.query( ` UPDATE sessions @@ -3664,7 +4149,7 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { work_type = CASE WHEN recurring_billing_id IS NULL THEN $3 ELSE work_type END WHERE ticket_id = $4; `, - [organization.id, organization.name, effectiveWorkType, ticketId] + [organization.id, organization.name, workType, ticketId] ); return ticketResult.rows[0]; @@ -3675,6 +4160,11 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { return; } + if (result === "project-removal-required") { + res.status(409).json({ error: "Projektdaten müssen bestätigt entfernt werden" }); + return; + } + if (!result) { res.status(404).json({ error: "Ticket not found" }); return; @@ -4123,6 +4613,7 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { const organizationId = parseOrganizationId(req.body.organizationId); const activity = requireString(req.body.activity, "activity"); const workType = parseWorkType(req.body.workType); + const removeProjectData = optionalBoolean(req.body.removeProjectData, false); const startedAt = parseIsoDate(req.body.startedAt, "startedAt"); const endedAt = parseIsoDate(req.body.endedAt, "endedAt"); @@ -4192,7 +4683,12 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { return "month-closed" as const; } - const effectiveWorkType = current.project_id ? "consulting" : current.recurring_billing_id ? current.recurring_work_type ?? current.work_type : workType; + const effectiveWorkType = current.recurring_billing_id ? current.recurring_work_type ?? current.work_type : workType; + + if (current.project_id && current.work_type === "consulting" && effectiveWorkType === "support" && !removeProjectData) { + return "project-removal-required" as const; + } + await reopenPeriodsForSession(client, current.ticket_id, new Date(current.started_at), userId); await reopenPeriodsForSession(client, current.ticket_id, startedAt, userId); @@ -4214,6 +4710,45 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { [organization.id, organization.name, activity, effectiveWorkType, startedAt.toISOString(), endedAt.toISOString(), durationSeconds, roundedMinutes, sessionId, userId] ); + if (effectiveWorkType === "consulting") { + await client.query( + ` + UPDATE tickets + SET work_type = 'consulting', + organization_id = COALESCE(organization_id, $2), + customer_name = COALESCE(customer_name, $3) + WHERE id = $1; + `, + [current.ticket_id, organization.id, organization.name] + ); + await ensureProjectForTicket(client, current.ticket_id, userId); + } + + if (current.project_id && effectiveWorkType === "support" && removeProjectData) { + const remainingConsultingResult = await client.query<{ count: string }>( + ` + SELECT COUNT(*) AS count + FROM sessions + WHERE ticket_id = $1 + AND user_id = $2 + AND work_type = 'consulting'; + `, + [current.ticket_id, userId] + ); + + if (Number(remainingConsultingResult.rows[0]?.count ?? 0) === 0) { + await deleteProjectDataForTicket(client, current.ticket_id, userId); + await client.query( + ` + UPDATE tickets + SET work_type = 'support' + WHERE id = $1; + `, + [current.ticket_id] + ); + } + } + await cleanupTicketDayBillingIfEmpty(client, current.ticket_id, userId, new Date(current.started_at)); return result.rows[0]; @@ -4229,6 +4764,11 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { return; } + if (updated === "project-removal-required") { + res.status(409).json({ error: "Projektdaten müssen bestätigt entfernt werden" }); + return; + } + if (!updated) { res.status(404).json({ error: "Session not found" }); return; diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index 946e897..b6f7ba4 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -89,6 +89,16 @@ export async function migrate() { ); `); + await query(` + CREATE TABLE IF NOT EXISTS user_preferences ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value JSONB NOT NULL DEFAULT '{}'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, key) + ); + `); + await query(` CREATE TABLE IF NOT EXISTS organizations ( id BIGSERIAL PRIMARY KEY, @@ -448,14 +458,54 @@ export async function migrate() { starts_on DATE, ends_on DATE, hour_budget_minutes INTEGER, + manual_consumption_minutes INTEGER NOT NULL DEFAULT 0, + closed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (user_id, ticket_id), CONSTRAINT projects_budget_non_negative CHECK (hour_budget_minutes IS NULL OR hour_budget_minutes >= 0), + CONSTRAINT projects_manual_consumption_non_negative CHECK (manual_consumption_minutes >= 0), CONSTRAINT projects_valid_range CHECK (starts_on IS NULL OR ends_on IS NULL OR ends_on >= starts_on) ); `); + await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;"); + await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS manual_consumption_minutes INTEGER NOT NULL DEFAULT 0;"); + await query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'projects_manual_consumption_non_negative' + ) THEN + ALTER TABLE projects + ADD CONSTRAINT projects_manual_consumption_non_negative CHECK (manual_consumption_minutes >= 0); + END IF; + END $$; + `); + + await query(` + UPDATE tickets t + SET work_type = 'consulting' + WHERE EXISTS ( + SELECT 1 + FROM sessions s + WHERE s.ticket_id = t.id + AND s.work_type = 'consulting' + ); + `); + + await query(` + INSERT INTO projects (user_id, ticket_id, teamspace_project, description, goals) + SELECT DISTINCT s.user_id, s.ticket_id, COALESCE(t.title, ''), '', '' + FROM sessions s + JOIN tickets t ON t.id = s.ticket_id + WHERE s.work_type = 'consulting' + AND s.user_id IS NOT NULL + ON CONFLICT (user_id, ticket_id) DO NOTHING; + `); + await query(` CREATE TABLE IF NOT EXISTS ticket_todos ( id BIGSERIAL PRIMARY KEY, @@ -465,14 +515,50 @@ export async function migrate() { description TEXT NOT NULL DEFAULT '', priority TEXT NOT NULL DEFAULT 'medium', status TEXT NOT NULL DEFAULT 'open', + sort_order INTEGER NOT NULL DEFAULT 0, + starts_on DATE, + ends_on DATE, completed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT ticket_todos_priority_valid CHECK (priority IN ('low', 'medium', 'high', 'urgent')), - CONSTRAINT ticket_todos_status_valid CHECK (status IN ('open', 'done')) + CONSTRAINT ticket_todos_status_valid CHECK (status IN ('open', 'done')), + CONSTRAINT ticket_todos_valid_period CHECK (starts_on IS NULL OR ends_on IS NULL OR ends_on >= starts_on) ); `); + await query("ALTER TABLE ticket_todos ADD COLUMN IF NOT EXISTS starts_on DATE;"); + await query("ALTER TABLE ticket_todos ADD COLUMN IF NOT EXISTS ends_on DATE;"); + await query("ALTER TABLE ticket_todos ADD COLUMN IF NOT EXISTS sort_order INTEGER;"); + await query(` + WITH ordered_todos AS ( + SELECT + id, + ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS row_number + FROM ticket_todos + WHERE sort_order IS NULL + ) + UPDATE ticket_todos tt + SET sort_order = ordered_todos.row_number * 1024 + FROM ordered_todos + WHERE tt.id = ordered_todos.id; + `); + await query("ALTER TABLE ticket_todos ALTER COLUMN sort_order SET DEFAULT 0;"); + await query("ALTER TABLE ticket_todos ALTER COLUMN sort_order SET NOT NULL;"); + await query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'ticket_todos_valid_period' + ) THEN + ALTER TABLE ticket_todos + ADD CONSTRAINT ticket_todos_valid_period CHECK (starts_on IS NULL OR ends_on IS NULL OR ends_on >= starts_on); + END IF; + END $$; + `); + 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); @@ -490,8 +576,11 @@ export async function migrate() { 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_projects_user_ticket ON projects(user_id, ticket_id);"); await query("CREATE INDEX IF NOT EXISTS idx_projects_user_updated ON projects(user_id, updated_at DESC);"); + await query("CREATE INDEX IF NOT EXISTS idx_projects_user_closed_updated ON projects(user_id, closed_at, updated_at DESC);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_status_priority ON ticket_todos(user_id, status, priority);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_ticket_user ON ticket_todos(ticket_id, user_id);"); + await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_period ON ticket_todos(user_id, starts_on, ends_on);"); + await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_sort_order ON ticket_todos(user_id, sort_order, id);"); 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(` diff --git a/docs/user-guide.md b/docs/user-guide.md index 54ff9f2..2f92bac 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -27,7 +27,7 @@ Wichtig: Tickets werden nicht geschlossen. Tickets dienen nur als Gruppierung fu ## Hilfe und FAQ -Die App hat eine eigene Unterseite `FAQ`. Dort steht die ausfuehrliche Bedienungsanleitung mit Abschnitten zu Timer, Auswertung, Teamspace, Monatsabschluss, Statistiken, fixen Abrechnungen, Profil und Adminbereich. +Die App hat eine eigene Unterseite `FAQ`. Dort steht die ausfuehrliche Bedienungsanleitung mit Abschnitten zu Timer, Tickets, Teamspace, Monatsabschluss, Statistiken, fixen Abrechnungen, Profil und Adminbereich. An vielen Stellen in der Oberflaeche gibt es ein Fragezeichen-Icon. Ein Klick darauf oeffnet ein kurzes Hilfe-Modal zur jeweiligen Card oder Funktion. Im Modal gibt es einen Link zum passenden ausfuehrlichen FAQ-Abschnitt. @@ -42,7 +42,7 @@ Normale Benutzer sehen ihre eigenen Zeiten, Auswertungen, fixen Abrechnungen und Die Navigation ist in Workflows gegliedert: - Session starten -- Auswertung +- Tickets - Monatsabschluss - Statistiken - Fixe Abrechnung @@ -50,7 +50,7 @@ Die Navigation ist in Workflows gegliedert: - Administration, nur fuer Admin - FAQ -Nach `Auswertung`, nach `Monatsabschluss` und vor `FAQ` trennt eine Linie die Bereiche optisch. +Nach `Tickets`, nach `Monatsabschluss` und vor `FAQ` trennt eine Linie die Bereiche optisch. ## Globaler Timer-Schnellstart @@ -131,9 +131,9 @@ Der Grund: Nur mit eindeutigen Organisationen sind saubere Auswertungen pro Kund Bereits vorhandene alte Freitexte muessen bei Bedarf manuell korrigiert werden, indem das Ticket oder die Session bearbeitet und eine Organisation aus der Liste ausgewaehlt wird. -## Auswertung +## Tickets -Die Seite `Auswertung` hat zwei Ansichten: +Die Seite `Tickets` hat zwei Ansichten: - Monat - Tag @@ -148,10 +148,7 @@ Die oberen Karten zeigen: - Nicht abrechenbar: Summe der Sessions mit Bewertung `Nicht abrechenbar` - Offen: Anzahl unbewerteter Sessions -Der Graph zeigt abgerechneten Aufwand im Zeitraum: - -- In der Monatsansicht pro Tag -- In der Tagesansicht pro Stunde +Der Verlauf der abgerechneten Zeit liegt auf der Seite `Statistiken`. Dort wird der Monatsverlauf gemeinsam mit Teamspace und Bewertungsstand betrachtet. In der Monatsansicht kann ueber `Monat exportieren` eine CSV-Datei fuer den eigenen Benutzer heruntergeladen werden. Der Export enthaelt alle eigenen Sessions des Monats mit Ticket, Organisation, Art, Datum, Von/Bis, Bewertung, Session-Minuten, Teamspace-Minuten und Taetigkeit. Er ist fuer Kontrolle, Ablage oder Weiterverarbeitung gedacht. @@ -205,19 +202,106 @@ Die Einstellungen bleiben im Browser gespeichert, auch nach Aktualisierung oder ## Ticketdetailansicht -Ein Ticket wird aus der Auswertung heraus geoeffnet. +Ein Ticket wird aus der Ticketliste heraus geoeffnet. In der Ticketdetailansicht sieht man: - Ticketnummer mit Copy-Icon - Status des Monats - Ticketdaten +- Projektinformationen, falls das Ticket ein Projekt ist +- ToDos zu diesem Ticket oder Projekt - Session-Eintraege nach Tagen gruppiert - Teamspace-Wert pro Ticket und Tag - Moeglichkeit, direkt eine Session zu diesem Ticket nachzutragen Geoeffnete Tickets erscheinen in der Seitenleiste unter `Geoeffnete Tickets`. Die Liste bleibt nach Aktualisierung und Browserneustart erhalten. Mit einem Klick auf die Ticketnummer gelangt man direkt zur Ticketdetailansicht zurueck. Die Reihenfolge bleibt beim Anklicken unveraendert und kann nur per Drag and Drop angepasst werden. Der Schliessen-Button entfernt das Ticket aus der Seitenleiste. Wenn im aktuell geoeffneten Ticket noch ungespeicherte Werte vorhanden sind, muss zuerst gespeichert werden. +## Projekte und ToDos + +Projekte erweitern ein normales Ticket um Projektinformationen. Sie werden ueber den Navipunkt `Projekte` oder direkt aus einem Ticket heraus angelegt. + +Ein Projekt enthaelt: + +- Ticketnummer +- Optionalen Titel +- Organisation +- Teamspace-Projekt +- Beschreibung/Ziele +- Ungefaehren Zeitraum +- Optionales Stundenkontingent +- Optionalen manuellen Verbrauch in Stunden + +Projekt-Tickets sind immer Consulting. Wird ein Projekt geloescht, bleiben das Ticket, die Sessions und die ToDos erhalten. Geloescht werden nur die Projektinformationen. + +Projekte koennen offen oder geschlossen sein. Auf der Projektseite werden standardmaessig nur offene Projekte angezeigt. Mit `Geschlossene anzeigen` werden geschlossene Projekte zusaetzlich eingeblendet. Offene Projekte stehen immer vor geschlossenen Projekten. + +Der Projektstatus betrifft nur die Projektverwaltung. Die Sessions eines Tickets werden dadurch nicht ausgeblendet oder veraendert. + +Der Verbrauch eines Projekts ist abrechnungstechnisch von den getrackten Sessions getrennt. Fuer den Projektverbrauch zaehlen nur gepflegte Teamspace-Tageswerte dieses Projekt-Tickets und der optionale manuelle Verbrauch am Projekt. Getrackte Sessionzeiten werden im Projekt zwar weiterhin angezeigt und bleiben normal bewertbar, sie erhoehen den Projektverbrauch aber nicht direkt. + +Der manuelle Verbrauch ist fuer Altbestaende oder einmalige Buchungen gedacht, die vor der Nutzung von TicketTracker bereits im Projekt angefallen sind. Dieser Wert wirkt ausschliesslich auf den Projektverbrauch und wird nicht in Statistiken, Monatsabschluss, Auswertung oder Exporte als Sessionzeit eingerechnet. + +Die Projektseite enthaelt Filter und Sortierungen. Projekte koennen nach Freitext, Status, Organisation, Kontingentstatus und ToDos gefiltert werden. Sortiert werden kann unter anderem nach Ticket, Titel, Organisation, Teamspace-Projekt, Verbrauch, Reststunden, Kontingent und offenen ToDos. Die Einstellungen bleiben im Browser gespeichert und koennen ueber `Zuruecksetzen` wieder auf Standard gestellt werden. + +Reststunden werden aus `Stundenkontingent minus Projektverbrauch` berechnet. Wenn kein Stundenkontingent gepflegt ist, kann kein offener Rest berechnet werden. Ist der Verbrauch hoeher als das Kontingent, zeigt die Projektkarte die Ueberziehung an. + +Projektinformationen und ToDos stehen in der Ticketdetailansicht auf breiten Bildschirmen nebeneinander. Auf kleineren Bildschirmen werden sie untereinander angezeigt. + +ToDos werden direkt am Ticket oder Projekt angelegt. Ein ToDo enthaelt: + +- Aufgabe +- Prioritaet +- Optionalen Umsetzungszeitraum von/bis +- Optionale Details + +Wenn `Umsetzung von` und `Umsetzung bis` gleich sind, ist das ToDo ein festes Datum. + +Die zentrale Seite `ToDos` zeigt oben `Heutige ToDos`. Dort erscheinen nur offene ToDos, bei denen `Umsetzung von` und `Umsetzung bis` beide auf den heutigen Tag gesetzt sind. Ein Zeitraum, der heute nur einschliesst, wird nicht als heutiges ToDo gewertet. + +Darunter steht die Aufgabenliste mit Filter fuer: + +- Offen +- Alle +- Erledigt +- Ticketnummer + +Die Aufgabenliste kann zusaetzlich sortiert werden nach: + +- Benutzerdefiniert +- Prioritaet +- Titel +- Ticketnummer +- Organisation +- Umsetzungsdatum +- Zuletzt geaendert + +Die Ticket-Suche filtert nach Ticketnummer. Dabei reicht auch die Nummer ohne `Ticket#`, zum Beispiel `944079`. + +`Benutzerdefiniert` ist die frei gespeicherte Reihenfolge. Diese Reihenfolge wird per Drag and Drop geaendert: ToDo am Griff ziehen, an der neuen Stelle fallen lassen. Sobald frei sortiert wird, stellt das Dropdown automatisch auf `Benutzerdefiniert`. Die Reihenfolge wird serverseitig pro Benutzer gespeichert. Du kannst also zwischendurch nach Titel oder Prioritaet sortieren und spaeter wieder auf deine eigene Reihenfolge zurueckwechseln. + +Mit den drei Gruppierungsfeldern kann die Aufgabenliste mehrstufig gruppiert werden. Die Reihenfolge der Felder ist die Reihenfolge der Gruppen, zum Beispiel `Ticket` -> `Prioritaet` -> `Kunde`. Moegliche Gruppierungen sind `Ticket`, `Prioritaet`, `Kunde` und `Status`. Statusfilter, Ticket-Suche, Sortierung und Gruppierung bleiben nach Aktualisierung und Browserneustart erhalten. Mit `Zuruecksetzen` werden die Standardeinstellungen wiederhergestellt. + +Unterhalb von `FAQ` zeigt die Seitenleiste die Kachel `Termine`. Dort stehen die festen ToDos des heutigen Tages, eine Summe laufender ToDos fuer heute und ueberfaellige ToDos. Ein Klick auf einen Eintrag oeffnet das zugehoerige Ticket. + +Unter der Aufgabenliste steht der `ToDo-Kalender`. Er zeigt offene ToDos pro Monat; erledigte ToDos werden im Kalender ausgeblendet. Wochenenden und bayerische Feiertage bleiben im Kalender ohne ToDo-Einträge. Der Monat kann mit den Pfeilen gewechselt werden. + +Ein-Tages-ToDos werden einzeln angezeigt. Das sind Eintraege, bei denen `Umsetzung von` und `Umsetzung bis` gleich sind. Die Farbe richtet sich nach der Prioritaet. + +ToDos mit Zeitraum oder nur einem Datum werden nicht einzeln ausgeschrieben, sondern pro Tag als Summe angezeigt: + +- `Umsetzung von` und `Umsetzung bis` unterschiedlich: Das ToDo zaehlt an jedem Tag im Zeitraum. +- Nur `Umsetzung von`: Das ToDo zaehlt ab diesem Datum weiter, bis es erledigt, geloescht oder mit einem Bis-Datum begrenzt wird. +- Nur `Umsetzung bis`: Das ToDo zaehlt ab heute bis zum Bis-Datum. + +Ein offenes ToDo ist ueberfaellig, sobald `Umsetzung bis` vor dem heutigen Tag liegt. Ueberfaellige ToDos werden in Listen, im Kalender und in der Termine-Kachel rot hervorgehoben. Im Kalender bleiben sie ab dem Tag nach der Deadline an jedem Arbeitstag als roter Badge `+X uef` sichtbar, bis sie erledigt oder geloescht werden. + +Ein Klick auf ein einzelnes ToDo oeffnet direkt das Bearbeiten-Modal. Ein Klick auf `+X ToDos` oeffnet ein kompaktes Tagesmodal mit den einzelnen ToDos dieses Tages. Dort koennen ToDos erledigt, geloescht, als einzelnes ToDo geoeffnet oder das zugehoerige Ticket geoeffnet werden. + +Bei den Datumsfeldern gibt es jeweils `Datum entfernen`, um `Umsetzung von` oder `Umsetzung bis` gezielt zu leeren. + +ToDos koennen erledigt, wieder geoeffnet, bearbeitet oder geloescht werden. + Die Sessions sind pro Tag gruppiert. In jeder Tagesgruppe stehen: - Tagesdatum @@ -239,6 +323,8 @@ Ein leeres Teamspace-Feld entfernt den gespeicherten Teamspace-Wert fuer diesen Der Wert ist derselbe in Monats- und Tagesansicht. Wenn er in einer Ansicht gespeichert wird, ist er in der anderen Ansicht identisch gesetzt. +Jede einzelne Session ist innerhalb der Tagesgruppe aufklappbar. In der geschlossenen Zeile stehen nur Uhrzeit, berechnete Zeit und die Buttons fuer Bewertung, Bearbeiten und Loeschen. Der Freitext der Taetigkeit wird erst beim Aufklappen angezeigt. Ueber `Alle aufklappen` koennen alle Sessiondetails gleichzeitig eingeblendet werden. + ## Sessions Bewerten Jede Session kann bewertet werden als: @@ -275,7 +361,7 @@ Von und Bis beziehen sich nur auf die Uhrzeit. Das Datum ist ein eigenes Feld. Eine Session kann ueber das Papierkorb-Icon geloescht werden. -Wird die letzte Session eines Tickets geloescht, wird das Ticket entfernt. Die Ansicht springt danach zurueck zur Auswertung. +Wird die letzte Session eines Tickets geloescht, wird das Ticket entfernt. Die Ansicht springt danach zurueck zur Ticketliste. Bei wiederkehrenden Sessions wird das Loeschen als Ausnahme gespeichert. Dadurch wird die Session fuer diesen Tag nicht automatisch wieder neu erzeugt. @@ -466,7 +552,7 @@ Neue Organisationen werden hinzugefuegt, geaenderte Namen aktualisiert und nicht 1. Im Alltag Sessions per Timer oder Nachtragen erfassen. 2. Optional fixe Abrechnungen pflegen, damit wiederkehrende Sessions automatisch erzeugt werden. -3. Am Monatsende `Auswertung` oeffnen. +3. Am Monatsende `Tickets` oeffnen. 4. Monat auswaehlen. 5. Offene Sessions pruefen. 6. Pro Ticket jede Session als `Abgerechnet` oder `Nicht abrechenbar` markieren. @@ -479,7 +565,7 @@ Neue Organisationen werden hinzugefuegt, geaenderte Namen aktualisiert und nicht ## Hinweise - Ticketnummern koennen ueber das Copy-Icon schnell in die Zwischenablage kopiert werden. -- Die App aktualisiert Auswertungen automatisch beim Wechsel von Monat oder Tag. +- Die App aktualisiert Ticket- und Statistikdaten automatisch beim Wechsel von Monat oder Tag. - Eine zurueckgenommene Bewertung macht eine Session wieder offen und kann den Monatsabschluss wieder verhindern. - Teamspace-Zeiten sind Tageswerte pro Ticket und Benutzer. Sie sind nicht an einzelne Sessions gebunden. - Fragezeichen-Icons oeffnen kurze Hilfen direkt in der aktuellen Ansicht. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2142868..3a6683a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,13 +1,16 @@ import { - BarChart3, + AlertCircle, + CalendarDays, CheckCircle2, ClipboardCheck, Command, ChartNoAxesCombined, CircleHelp, + ExternalLink, FileText, FolderKanban, GripVertical, + LayoutDashboard, LayoutGrid, ListTodo, LogOut, @@ -19,7 +22,9 @@ import { Search, Shield, Sun, + Ticket, Timer, + Trash2, UserCog, UserCircle, X, @@ -28,7 +33,9 @@ import { FormEvent, MouseEvent, useCallback, useEffect, useMemo, useState } from import { useShallow } from "zustand/react/shallow"; import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { DropdownMenu, @@ -58,11 +65,14 @@ import { import { usePreferencesStore } from "@/stores/preferences/preferences-provider"; import { cn } from "./lib/utils"; -import { ApiError, getCurrentUser, getSetupStatus, logout, lookupTicket, searchTickets } from "./api"; -import { formatDateTime, formatMinutes, formatTimer } from "./format"; +import { ApiError, deleteTodo, getCurrentUser, getRecurringBillings, getSetupStatus, getTodos, logout, lookupTicket, searchTickets, updateTodo } from "./api"; +import { currentDay, formatDateTime, formatMinutes, formatTimer } from "./format"; +import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "./recurring-billings"; import { activeElapsedMs, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers"; +import { formatTodoPeriod, isTodoActiveOnDay, isTodoFixedOnDay, isTodoOverdue } from "./todos"; import { AdminUsersPage } from "./views/AdminUsersPage"; import { AnalysisPage } from "./views/AnalysisPage"; +import { DashboardPage } from "./views/DashboardPage"; import { FaqPage } from "./views/FaqPage"; import { LoginPage } from "./views/LoginPage"; import { MonthlyClosePage } from "./views/MonthlyClosePage"; @@ -74,7 +84,7 @@ import { SetupPage } from "./views/SetupPage"; import { TicketDetailPage } from "./views/TicketDetailPage"; import { TimerPage } from "./views/TimerPage"; import { TodosPage } from "./views/TodosPage"; -import type { AuthUser, PeriodType, TicketDetailPeriodType, TicketSearchResult } from "./types"; +import type { AuthUser, PeriodType, TicketDetailPeriodType, TicketSearchResult, TicketTodo } from "./types"; import type { Dispatch, DragEvent, SetStateAction } from "react"; type NavMenuEntry = @@ -97,6 +107,7 @@ type OpenTicketNavItem = { periodType: TicketDetailPeriodType; period: string; path: string; + returnPath?: string; dirty: boolean; }; @@ -127,6 +138,7 @@ function readOpenTickets(userId: string): OpenTicketNavItem[] { periodType: item.periodType === "day" ? "day" : item.periodType === "month" ? "month" : "all", period: item.period ? String(item.period) : "all", path: String(item.path), + returnPath: typeof item.returnPath === "string" ? item.returnPath : undefined, dirty: false })); } catch { @@ -177,6 +189,10 @@ function routeFromPath(pathWithSearch: string) { return { page: "analysis" as const }; } + if (pathname.startsWith("/dashboard")) { + return { page: "dashboard" as const }; + } + if (pathname.startsWith("/statistics")) { return { page: "statistics" as const }; } @@ -535,8 +551,218 @@ function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers, ); } +type SidebarTodayTodosProps = { + todos: TicketTodo[]; + onNavigate: (to: string) => void; +}; + +const sidebarTodoPriorityLabels: Record = { + urgent: "Dringend", + high: "Hoch", + medium: "Normal", + low: "Niedrig" +}; + +const sidebarTodoPriorityClasses: Record = { + low: "border-sky-500/30 bg-sky-500/10 text-sky-300", + medium: "border-muted-foreground/25 bg-muted/50 text-muted-foreground", + high: "border-amber-500/30 bg-amber-500/10 text-amber-300", + urgent: "border-red-500/30 bg-red-500/10 text-red-300" +}; + +const sidebarTodoCalendarClasses: Record = { + low: "border-sky-500/40 bg-sky-500/15 text-sky-200", + medium: "border-zinc-500/40 bg-zinc-500/15 text-zinc-200", + high: "border-amber-500/45 bg-amber-500/15 text-amber-200", + urgent: "border-red-500/45 bg-red-500/15 text-red-200" +}; + +const sidebarOverdueTodoClass = "border-red-500/60 bg-red-500/15 text-red-100"; + +function notifyTodosChanged() { + window.dispatchEvent(new Event("tickettracker:todos-changed")); +} + +function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) { + const [runningTodosOpen, setRunningTodosOpen] = useState(false); + const [modalBusy, setModalBusy] = useState(false); + const today = currentDay(); + const fixedTodos = todos.filter((todo) => isTodoFixedOnDay(todo, today)); + const summarizedTodos = todos.filter((todo) => todo.status === "open" && isTodoActiveOnDay(todo, today) && !isTodoFixedOnDay(todo, today)); + const overdueTodos = todos.filter((todo) => isTodoOverdue(todo, today)); + const hasTodos = fixedTodos.length > 0 || summarizedTodos.length > 0 || overdueTodos.length > 0; + + async function toggleTodo(todo: TicketTodo) { + setModalBusy(true); + + try { + await updateTodo(todo.id, { + title: todo.title, + description: todo.description, + priority: todo.priority, + status: todo.status === "done" ? "open" : "done", + startsOn: todo.starts_on, + endsOn: todo.ends_on + }); + toast.success(todo.status === "done" ? "ToDo wieder geöffnet" : "ToDo erledigt"); + notifyTodosChanged(); + } catch (error) { + toast.error("ToDo konnte nicht aktualisiert werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } finally { + setModalBusy(false); + } + } + + async function removeTodo(todo: TicketTodo) { + const confirmed = window.confirm(`ToDo "${todo.title}" wirklich löschen?`); + + if (!confirmed) { + return; + } + + setModalBusy(true); + + try { + await deleteTodo(todo.id); + toast.success("ToDo gelöscht"); + notifyTodosChanged(); + } catch (error) { + toast.error("ToDo konnte nicht gelöscht werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } finally { + setModalBusy(false); + } + } + + return ( + <> + + Termine + +
+
+ + + Heute + + {overdueTodos.length > 0 ? {overdueTodos.length} überfällig : null} +
+ + {hasTodos ? ( +
+ {overdueTodos.slice(0, 3).map((todo) => ( + + ))} + {overdueTodos.length > 3 ?

+{overdueTodos.length - 3} weitere überfällig

: null} + + {fixedTodos.slice(0, 3).map((todo) => ( + + ))} + {fixedTodos.length > 3 ?

+{fixedTodos.length - 3} weitere feste Termine

: null} + + {summarizedTodos.length > 0 ? ( + + ) : null} +
+ ) : ( +

Heute keine Termine.

+ )} +
+
+
+ + + + + ToDos am Tag + Heute · {summarizedTodos.length} laufende ToDo(s) + +
+ {summarizedTodos.map((todo) => ( +
+
+
+
+
+ {todo.title} + {sidebarTodoPriorityLabels[todo.priority]} + {isTodoOverdue(todo, today) ? überfällig : null} +
+

+ {todo.organization_name ?? "Keine Organisation"} +

+
+ {todo.ticket_number ?? "Ticket"} +
+
+
+ {formatTodoPeriod(todo) ?

{formatTodoPeriod(todo)}

: null} +
+
+ + + +
+
+
+
+ ))} + {summarizedTodos.length === 0 ? ( +

Keine laufenden ToDos an diesem Tag.

+ ) : null} +
+
+
+ + ); +} + export function App() { - const { path, route, navHandler, navigate } = useAppRouter(); + const { path, route, navigate: routerNavigate } = useAppRouter(); const [currentUser, setCurrentUser] = useState(null); const [authLoading, setAuthLoading] = useState(true); const [setupRequired, setSetupRequired] = useState(false); @@ -546,7 +772,37 @@ export function App() { const [selectedTimerId, setSelectedTimerId] = useState(null); const [tick, setTick] = useState(Date.now()); const [openTickets, setOpenTickets] = useState([]); + const [ticketReturnPaths, setTicketReturnPaths] = useState>({}); const [draggedOpenTicketKey, setDraggedOpenTicketKey] = useState(null); + const [sidebarTodos, setSidebarTodos] = useState([]); + + const navigate = useCallback( + (to: string) => { + const targetRoute = routeFromPath(to); + + if (targetRoute.page === "ticket") { + const currentRoute = routeFromPath(path); + + if (!(currentRoute.page === "ticket" && currentRoute.ticketId === targetRoute.ticketId)) { + setTicketReturnPaths((current) => ({ + ...current, + [targetRoute.ticketId]: path + })); + } + } + + routerNavigate(to); + }, + [path, routerNavigate] + ); + + const navHandler = useCallback( + (to: string) => (event: MouseEvent) => { + event.preventDefault(); + navigate(to); + }, + [navigate] + ); useEffect(() => { async function loadUser() { @@ -582,6 +838,7 @@ export function App() { setTimerOwnerId(null); setTimersLoaded(false); setOpenTickets([]); + setSidebarTodos([]); return; } @@ -591,6 +848,7 @@ export function App() { setTimerOwnerId(currentUser.id); setTimersLoaded(true); setOpenTickets([]); + setSidebarTodos([]); return; } @@ -628,6 +886,40 @@ export function App() { } }, [currentUser, timers, selectedTimerId, timersLoaded, timerOwnerId]); + const loadSidebarTodos = useCallback(async () => { + if (!currentUser || currentUser.role === "admin") { + return; + } + + try { + const result = await getTodos("open"); + setSidebarTodos(result.todos); + } catch { + setSidebarTodos([]); + } + }, [currentUser]); + + useEffect(() => { + if (!currentUser || currentUser.role === "admin") { + return; + } + + void loadSidebarTodos(); + + const interval = window.setInterval(() => void loadSidebarTodos(), 60_000); + const onFocus = () => void loadSidebarTodos(); + const onTodosChanged = () => void loadSidebarTodos(); + + window.addEventListener("focus", onFocus); + window.addEventListener("tickettracker:todos-changed", onTodosChanged); + + return () => { + window.clearInterval(interval); + window.removeEventListener("focus", onFocus); + window.removeEventListener("tickettracker:todos-changed", onTodosChanged); + }; + }, [currentUser, loadSidebarTodos]); + useEffect(() => { if (!currentUser || currentUser.role === "admin" || !timersLoaded || timerOwnerId !== currentUser.id) { return; @@ -721,12 +1013,25 @@ export function App() { return false; } - const ticketNumber = rawTicketNumber.trim(); + const ticketNumber = rawTicketNumber.trim(); - if (!ticketPattern.test(ticketNumber)) { + if (!ticketPattern.test(ticketNumber)) { toast.error("Ticketnummer prüfen", { description: "Das Format muss Ticket#XXXXXX sein." }); + return false; + } + + const [ticketResult, recurringResult] = await Promise.all([ + lookupTicket(ticketNumber).catch(() => ({ ticket: null })), + getRecurringBillings().catch(() => ({ recurringBillings: [] })) + ]); + + if (ticketHasRecurringBilling(ticketResult.ticket, recurringResult.recurringBillings) || isRecurringBillingTicketNumber(ticketNumber, recurringResult.recurringBillings)) { + setTimers((current) => current.filter((timer) => timer.ticketNumber !== ticketNumber)); + toast.error("Fixe Abrechnung", { + description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gestartet werden. Bitte die Definition bearbeiten." + }); return false; } @@ -744,8 +1049,6 @@ export function App() { return true; } - const ticketResult = await lookupTicket(ticketNumber).catch(() => ({ ticket: null })); - const newTimer: TimerEntry = { id: crypto.randomUUID(), ticketNumber, @@ -847,7 +1150,8 @@ export function App() { setOpenTickets((items) => items.filter((item) => item.key !== ticket.key)); if (pathWithoutSearch(path) === pathWithoutSearch(ticket.path)) { - navigate("/analysis"); + const returnPath = current.returnPath && pathWithoutSearch(current.returnPath) !== pathWithoutSearch(ticket.path) ? current.returnPath : "/analysis"; + routerNavigate(returnPath); } } @@ -890,8 +1194,10 @@ export function App() { { type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") } ] : [ + { type: "item" as const, href: "/dashboard", label: "Dashboard", icon: LayoutDashboard, active: path.startsWith("/dashboard") }, + { type: "separator" as const, key: "after-dashboard" }, { type: "item" as const, href: "/timer", label: "Session starten", icon: Timer, active: path.startsWith("/timer") || path === "/" }, - { type: "item" as const, href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") }, + { type: "item" as const, href: "/analysis", label: "Ticket-Sessions", icon: Ticket, active: path.startsWith("/analysis") }, { type: "separator" as const, key: "after-analysis" }, { type: "item" as const, href: "/projects", label: "Projekte", icon: FolderKanban, active: path.startsWith("/projects") }, { type: "item" as const, href: "/todos", label: "ToDos", icon: ListTodo, active: path.startsWith("/todos") }, @@ -988,10 +1294,12 @@ export function App() { )} - + - {!isAdmin && openTickets.length > 0 ? ( - + {!isAdmin ? : null} + + {!isAdmin && openTickets.length > 0 ? ( + Geöffnete Tickets @@ -1156,6 +1464,7 @@ export function App() { tick={tick} /> ) : null} + {!isAdmin && route.page === "dashboard" ? : null} {!isAdmin && route.page === "analysis" ? : null} {!isAdmin && route.page === "monthly-close" ? : null} {!isAdmin && route.page === "statistics" ? : null} @@ -1181,6 +1490,7 @@ export function App() { ticketId={route.ticketId} onNavigate={navigate} onOpenTicketChange={updateOpenTicket} + returnPath={ticketReturnPaths[route.ticketId] ?? openTickets.find((ticket) => ticket.ticketId === route.ticketId)?.returnPath ?? "/analysis"} /> ) : null} {isAdmin && route.page !== "admin-users" && route.page !== "profile" && route.page !== "faq" ? : null} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b2c291d..d02a843 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -2,6 +2,7 @@ import type { AdminUser, AuthUser, BillingStatus, + DashboardPreference, Organization, PeriodOverview, PeriodType, @@ -100,6 +101,30 @@ export function updateCurrentUser(payload: { }); } +export function getDashboardPreference() { + return request<{ preference: Partial }>("/api/dashboard/preferences"); +} + +export function saveDashboardPreference(preference: DashboardPreference) { + return request<{ preference: Partial }>("/api/dashboard/preferences", { + method: "PUT", + body: JSON.stringify({ preference }) + }); +} + +export function addFavoriteTicket(ticketNumber: string) { + return request<{ preference: Partial }>("/api/dashboard/favorites", { + method: "POST", + body: JSON.stringify({ ticketNumber }) + }); +} + +export function removeFavoriteTicket(ticketNumber: string) { + return request<{ preference: Partial }>(`/api/dashboard/favorites/${encodeURIComponent(ticketNumber)}`, { + method: "DELETE" + }); +} + export function getSetupStatus() { return request<{ required: boolean }>("/api/setup/status"); } @@ -294,8 +319,14 @@ export function deleteRecurringBilling(billingId: string) { }); } -export function getProjects() { - return request<{ projects: ProjectSummary[] }>("/api/projects"); +export function getProjects(includeClosed = false) { + const params = new URLSearchParams(); + + if (includeClosed) { + params.set("includeClosed", "true"); + } + + return request<{ projects: ProjectSummary[] }>(`/api/projects${params.size ? `?${params}` : ""}`); } export function getProject(projectId: string) { @@ -312,6 +343,7 @@ export function createProject(payload: { startsOn?: string | null; endsOn?: string | null; hourBudgetMinutes?: number | null; + manualConsumptionMinutes?: number | null; }) { return request<{ project: ProjectSummary }>("/api/projects", { method: "POST", @@ -331,6 +363,7 @@ export function updateProject( startsOn?: string | null; endsOn?: string | null; hourBudgetMinutes?: number | null; + manualConsumptionMinutes?: number | null; } ) { return request<{ project: ProjectSummary }>(`/api/projects/${projectId}`, { @@ -339,6 +372,19 @@ export function updateProject( }); } +export function updateProjectStatus(projectId: string, closed: boolean) { + return request<{ project: { id: string; ticket_id: string; closed_at: string | null } }>(`/api/projects/${projectId}/status`, { + method: "PATCH", + body: JSON.stringify({ closed }) + }); +} + +export function deleteProject(projectId: string) { + return request<{ deleted: { id: string; ticket_id: string } }>(`/api/projects/${projectId}`, { + method: "DELETE" + }); +} + export function getTodos(status?: TodoStatus | "all") { const params = new URLSearchParams(); @@ -355,6 +401,8 @@ export function createTicketTodo( title: string; description: string; priority: TodoPriority; + startsOn?: string | null; + endsOn?: string | null; } ) { return request<{ todo: TicketTodo }>(`/api/tickets/${ticketId}/todos`, { @@ -370,6 +418,8 @@ export function updateTodo( description: string; priority: TodoPriority; status: TodoStatus; + startsOn?: string | null; + endsOn?: string | null; } ) { return request<{ todo: TicketTodo }>(`/api/todos/${todoId}`, { @@ -378,6 +428,13 @@ export function updateTodo( }); } +export function updateTodoOrder(todoIds: string[]) { + return request<{ ok: boolean }>("/api/todos/order", { + method: "PUT", + body: JSON.stringify({ todoIds }) + }); +} + export function deleteTodo(todoId: string) { return request<{ deleted: { id: string } }>(`/api/todos/${todoId}`, { method: "DELETE" @@ -406,6 +463,7 @@ export function updateTicket( title?: string | null; organizationId: string; workType: WorkType; + removeProjectData?: boolean; } ) { return request<{ ticket: TicketMeta }>(`/api/tickets/${ticketId}`, { @@ -420,6 +478,7 @@ export function updateSessionDetails( organizationId: string; activity: string; workType: WorkType; + removeProjectData?: boolean; startedAt: string; endedAt: string; } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 299a3d9..74325d4 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -7,6 +7,8 @@ @import "../styles/presets/soft-pop.css"; @import "../styles/presets/tangerine.css"; + + @custom-variant dark (&:is(.dark *)); @theme inline { @@ -50,6 +52,8 @@ --color-sidebar-ring: var(--sidebar-ring); --font-heading: var(--font-sans); --font-sans: var(--font-sans); + --breakpoint-xxl: 108.125rem; + } /* Default theme styles (used when no data-theme-preset is set or when 'default' is selected). diff --git a/frontend/src/components/TodoPanel.tsx b/frontend/src/components/TodoPanel.tsx index e732ca8..78cf9da 100644 --- a/frontend/src/components/TodoPanel.tsx +++ b/frontend/src/components/TodoPanel.tsx @@ -1,4 +1,4 @@ -import { CheckCircle2, Circle, Pencil, Plus, Save, Trash2 } from "lucide-react"; +import { CalendarDays, CheckCircle2, Circle, GripVertical, Pencil, Plus, Save, Trash2, X } from "lucide-react"; import { FormEvent, useState } from "react"; import { toast } from "sonner"; @@ -9,6 +9,8 @@ import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { createTicketTodo, deleteTodo, updateTodo } from "../api"; +import { currentDay } from "../format"; +import { formatTodoPeriod, isTodoOverdue } from "../todos"; import type { TicketTodo, TodoPriority, TodoStatus } from "../types"; const priorityLabels: Record = { @@ -25,6 +27,10 @@ const priorityClasses: Record = { urgent: "border-red-500/30 bg-red-500/10 text-red-300" }; +function notifyTodosChanged() { + window.dispatchEvent(new Event("tickettracker:todos-changed")); +} + type TodoPanelProps = { ticketId?: string; todos: TicketTodo[]; @@ -32,19 +38,26 @@ type TodoPanelProps = { showCreate?: boolean; showTicketMeta?: boolean; onNavigate?: (to: string) => void; + reorderEnabled?: boolean; + onReorder?: (draggedTodoId: string, targetTodoId: string) => void; }; -export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate }: TodoPanelProps) { +export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate, reorderEnabled = false, onReorder }: TodoPanelProps) { const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [priority, setPriority] = useState("medium"); + const [startsOn, setStartsOn] = useState(""); + const [endsOn, setEndsOn] = useState(""); const [saving, setSaving] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [editTitle, setEditTitle] = useState(""); const [editDescription, setEditDescription] = useState(""); const [editPriority, setEditPriority] = useState("medium"); + const [editStartsOn, setEditStartsOn] = useState(""); + const [editEndsOn, setEditEndsOn] = useState(""); const [busyId, setBusyId] = useState(null); + const [draggingId, setDraggingId] = useState(null); async function submitTodo(event: FormEvent) { event.preventDefault(); @@ -53,15 +66,25 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT return; } + if (startsOn && endsOn && endsOn < startsOn) { + toast.error("Zeitraum prüfen", { + description: "Das Bis-Datum darf nicht vor dem Von-Datum liegen." + }); + return; + } + setSaving(true); try { - await createTicketTodo(ticketId, { title, description, priority }); + await createTicketTodo(ticketId, { title, description, priority, startsOn: startsOn || null, endsOn: endsOn || null }); setTitle(""); setDescription(""); setPriority("medium"); + setStartsOn(""); + setEndsOn(""); setCreateOpen(false); toast.success("ToDo angelegt"); await onChanged(); + notifyTodosChanged(); } catch (error) { toast.error("ToDo konnte nicht angelegt werden", { description: error instanceof Error ? error.message : "Unbekannter Fehler" @@ -76,19 +99,31 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT setEditTitle(todo.title); setEditDescription(todo.description); setEditPriority(todo.priority); + setEditStartsOn(todo.starts_on ?? ""); + setEditEndsOn(todo.ends_on ?? ""); } async function saveTodo(todo: TicketTodo, status: TodoStatus = todo.status) { + if (editingId === todo.id && editStartsOn && editEndsOn && editEndsOn < editStartsOn) { + toast.error("Zeitraum prüfen", { + description: "Das Bis-Datum darf nicht vor dem Von-Datum liegen." + }); + return; + } + setBusyId(todo.id); try { await updateTodo(todo.id, { title: editingId === todo.id ? editTitle : todo.title, description: editingId === todo.id ? editDescription : todo.description, priority: editingId === todo.id ? editPriority : todo.priority, - status + status, + startsOn: editingId === todo.id ? editStartsOn || null : todo.starts_on, + endsOn: editingId === todo.id ? editEndsOn || null : todo.ends_on }); setEditingId(null); await onChanged(); + notifyTodosChanged(); } catch (error) { toast.error("ToDo konnte nicht gespeichert werden", { description: error instanceof Error ? error.message : "Unbekannter Fehler" @@ -110,6 +145,7 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT await deleteTodo(todo.id); toast.success("ToDo gelöscht"); await onChanged(); + notifyTodosChanged(); } catch (error) { toast.error("ToDo konnte nicht gelöscht werden", { description: error instanceof Error ? error.message : "Unbekannter Fehler" @@ -159,6 +195,34 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT ))} +
+
+ +
+ setStartsOn(event.currentTarget.value)} /> + {startsOn ? ( + + ) : null} +
+
+
+ +
+ setEndsOn(event.currentTarget.value)} /> + {endsOn ? ( + + ) : null} +
+
+