From d5952d82bbdca12f69e06eca84dc9fe4f4cc5186 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 30 Sep 2026 16:59:14 +0200 Subject: [PATCH] Update --- backend/src/auth.ts | 19 +- backend/src/index.ts | 576 ++++++++++++++++++++++-- backend/src/migrations.ts | 62 +++ frontend/src/App.tsx | 94 ++-- frontend/src/api.ts | 10 + frontend/src/types.ts | 34 ++ frontend/src/views/ProfilePage.tsx | 114 ++++- frontend/src/views/StatisticsPage.tsx | 164 ++++++- frontend/src/views/TicketDetailPage.tsx | 30 +- frontend/src/views/TimerPage.tsx | 6 + frontend/src/views/TodosPage.tsx | 91 ++-- 11 files changed, 1081 insertions(+), 119 deletions(-) diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 7d92b1a..1be1cb3 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -24,6 +24,11 @@ export type AuthUser = { zammad_time_unit_rounding_minutes: number; zammad_atoss_email_sync_enabled: boolean; zammad_article_type_id: number; + project_followup_enabled: boolean; + project_followup_weeks: number; + project_followup_second_enabled: boolean; + project_followup_second_weeks: number; + project_followup_complete_on_session: boolean; }; declare global { @@ -91,7 +96,12 @@ function publicUser(user: AuthUser) { zammad_time_unit_rounding_enabled: Boolean(user.zammad_time_unit_rounding_enabled), zammad_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15), zammad_atoss_email_sync_enabled: Boolean(user.zammad_atoss_email_sync_enabled ?? true), - zammad_article_type_id: Number(user.zammad_article_type_id ?? 6) + zammad_article_type_id: Number(user.zammad_article_type_id ?? 6), + project_followup_enabled: Boolean(user.project_followup_enabled ?? true), + project_followup_weeks: Number(user.project_followup_weeks ?? 4), + project_followup_second_enabled: Boolean(user.project_followup_second_enabled ?? true), + project_followup_second_weeks: Number(user.project_followup_second_weeks ?? 2), + project_followup_complete_on_session: Boolean(user.project_followup_complete_on_session ?? true) }; } @@ -155,7 +165,12 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio u.zammad_time_unit_rounding_enabled, u.zammad_time_unit_rounding_minutes, u.zammad_atoss_email_sync_enabled, - COALESCE(u.zammad_article_type_id, 6) AS zammad_article_type_id + COALESCE(u.zammad_article_type_id, 6) AS zammad_article_type_id, + u.project_followup_enabled, + u.project_followup_weeks, + u.project_followup_second_enabled, + u.project_followup_second_weeks, + u.project_followup_complete_on_session FROM auth_sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1 diff --git a/backend/src/index.ts b/backend/src/index.ts index f7c1a74..624ccc3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -457,6 +457,20 @@ function parseZammadArticleTypeId(value: unknown, fallback = 6) { return typeId; } +function parseProjectFollowupWeeks(value: unknown, fallback = 4) { + if (value === undefined || value === null || value === "") { + return fallback; + } + + const weeks = Number(value); + + if (!Number.isInteger(weeks) || weeks < 1 || weeks > 52) { + throw badRequest("projectFollowupWeeks must be an integer between 1 and 52"); + } + + return weeks; +} + function optionalTicketSessionMonth(value: unknown) { if (value === undefined || value === null || value === "" || value === "all") { return null; @@ -1653,7 +1667,7 @@ async function fetchZammadMergedTargetTicket(baseUrl: string, apiToken: string, throw new Error(`Zammad-Merge-Ziel-ID für Ticket-ID ${sourceTicketId} wurde in ticket_history nicht gefunden (${zammadHistoryDebugSummary(history, sourceTicketId)})`); } - const targetTicket = await fetchZammadTicketDetail(baseUrl, apiToken, targetTicketId, false); + const targetTicket = await fetchZammadTicketDetail(baseUrl, apiToken, targetTicketId); const sourceOrganizationId = numberOrNull(rawTicket.organization_id); const normalizedTargetTicket = targetTicket && !numberOrNull(targetTicket.organization_id) && sourceOrganizationId ? { ...targetTicket, organization_id: sourceOrganizationId } @@ -1855,6 +1869,12 @@ async function createSessionForUser(input: SessionCreationInput) { throw Object.assign(new Error("Ticket wurde in Zammad nicht gefunden"), { status: 404 }); } + const effectiveTicketNumber = zammadTicket?.ticketNumber ?? ticketNumber; + + if (effectiveTicketNumber !== ticketNumber && await ticketHasRecurringBilling({ query }, effectiveTicketNumber, userId)) { + throw Object.assign(new Error("Für fixe Abrechnungen dürfen keine manuellen Sessions erstellt werden. Bitte die fixe Abrechnung bearbeiten."), { status: 409 }); + } + if (sourceEvent && (sourceEvent.zammadArticleId || sourceEvent.deliveryId)) { await query( ` @@ -1928,7 +1948,7 @@ async function createSessionForUser(input: SessionCreationInput) { LIMIT 1 FOR UPDATE OF t; `, - [ticketNumber, userId] + [effectiveTicketNumber, userId] ); const existingTicket = existingTicketResult.rows[0] ?? null; const effectiveOrganizationId = existingTicket?.organization_id ? Number(existingTicket.organization_id) : requestedOrganizationId; @@ -1960,7 +1980,7 @@ async function createSessionForUser(input: SessionCreationInput) { END RETURNING id, ticket_number, organization_id, customer_name, work_type, zammad_ticket_id::text AS zammad_ticket_id; `, - [ticketNumber, organization.id, organization.name, existingTicket?.project_id ? "consulting" : requestedWorkType, userId] + [effectiveTicketNumber, organization.id, organization.name, existingTicket?.project_id ? "consulting" : requestedWorkType, userId] ); const ticket = ticketResult.rows[0]; @@ -2077,7 +2097,7 @@ async function createSessionForUser(input: SessionCreationInput) { userId, sessionId: session.id, ticketId: ticket.id, - ticketNumber, + ticketNumber: effectiveTicketNumber, zammadTicketId: zammadArticleTicketId, activity, articleBody: zammadArticleBody, @@ -2092,6 +2112,7 @@ async function createSessionForUser(input: SessionCreationInput) { if (workType === "consulting") { await reopenProjectsForTicket(client, ticket.id); await showProjectsForUserTicket(client, ticket.id, userId); + await completeAutomaticProjectFollowupsForSession(client, userId, ticket.id, startedAt); } if (eventId) { @@ -2592,7 +2613,7 @@ async function fetchZammadConsultingTicketsForOwner(userId: string): Promise(); for (const [id, candidate] of candidates.entries()) { const rawTicket = await fetchZammadTicketDetail(baseUrl, apiToken, id) ?? candidate; @@ -2600,11 +2621,15 @@ async function fetchZammadConsultingTicketsForOwner(userId: string): Promise left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true })); } async function projectOwnerUserIdForZammadOwner(client: QueryClient, zammadOwnerId: number | null, fallbackUserId: string) { @@ -2663,11 +2688,20 @@ async function importZammadTicketForUser(client: QueryClient, zammadTicket: Zamm zammad_priority_id, zammad_state_id, zammad_owner_id, + zammad_merged_from_ticket_id, + zammad_merged_from_ticket_number, zammad_preferences ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb) ON CONFLICT (ticket_number) DO UPDATE SET - ticket_number = tickets.ticket_number + zammad_ticket_id = COALESCE(tickets.zammad_ticket_id, EXCLUDED.zammad_ticket_id), + zammad_group_id = EXCLUDED.zammad_group_id, + zammad_priority_id = EXCLUDED.zammad_priority_id, + zammad_state_id = EXCLUDED.zammad_state_id, + zammad_owner_id = EXCLUDED.zammad_owner_id, + zammad_merged_from_ticket_id = COALESCE(EXCLUDED.zammad_merged_from_ticket_id, tickets.zammad_merged_from_ticket_id), + zammad_merged_from_ticket_number = COALESCE(EXCLUDED.zammad_merged_from_ticket_number, tickets.zammad_merged_from_ticket_number), + zammad_preferences = EXCLUDED.zammad_preferences RETURNING id::text; `, [ @@ -2681,6 +2715,8 @@ async function importZammadTicketForUser(client: QueryClient, zammadTicket: Zamm zammadTicket.zammadPriorityId, zammadTicket.zammadStateId, zammadTicket.zammadOwnerId, + zammadTicket.mergedFromTicketId, + zammadTicket.mergedFromTicketNumber, JSON.stringify(zammadTicket.preferences) ] ); @@ -4225,6 +4261,36 @@ function isBlankTeamspaceProject(value: string | null | undefined) { return !value?.trim(); } +type ProjectTeamspaceEntrySyncRow = { + id: string; + teamspace_project: string | null; + teamspace_description: string | null; + is_default: boolean; + display_order: number; + budget_sync_disabled: boolean; +}; + +async function loadProjectTeamspaceEntriesForUpdate(client: QueryClient, projectId: string) { + const result = await client.query( + ` + SELECT + id::text AS id, + teamspace_project, + teamspace_description, + is_default, + display_order, + budget_sync_disabled + FROM project_teamspace_entries + WHERE project_id = $1 + ORDER BY is_default DESC, display_order ASC, id ASC + FOR UPDATE; + `, + [projectId] + ); + + return result.rows; +} + function teamspaceProjectDiff(localProjects: Array, zammadProjects: Array) { const local = normalizedTeamspaceProjectList(localProjects); const zammad = normalizedTeamspaceProjectList(zammadProjects); @@ -4426,7 +4492,8 @@ async function mergeTeamspaceEntriesIntoTarget(client: QueryClient, sourceEntryI SELECT COALESCE(SUM(manual_consumption_minutes), 0)::int AS manual_consumption_minutes, COALESCE(SUM(hour_budget_minutes) FILTER (WHERE hour_budget_minutes IS NOT NULL), NULL)::int AS hour_budget_minutes, - BOOL_OR(budget_sync_disabled) AS budget_sync_disabled + BOOL_OR(budget_sync_disabled) AS budget_sync_disabled, + NULLIF(string_agg(DISTINCT NULLIF(teamspace_description, ''), E'\n\n'), '') AS teamspace_description FROM project_teamspace_entries WHERE id = ANY($1::bigint[]) ) @@ -4434,6 +4501,10 @@ async function mergeTeamspaceEntriesIntoTarget(client: QueryClient, sourceEntryI SET hour_budget_minutes = COALESCE(target.hour_budget_minutes, source_totals.hour_budget_minutes), budget_sync_disabled = target.budget_sync_disabled OR COALESCE(source_totals.budget_sync_disabled, false), manual_consumption_minutes = target.manual_consumption_minutes + source_totals.manual_consumption_minutes, + teamspace_description = CASE + WHEN NULLIF(target.teamspace_description, '') IS NULL THEN COALESCE(source_totals.teamspace_description, target.teamspace_description) + ELSE target.teamspace_description + END, updated_at = now() FROM source_totals WHERE target.id = $2::bigint; @@ -4481,6 +4552,37 @@ async function mergeTeamspaceEntriesIntoTarget(client: QueryClient, sourceEntryI ); } +async function dedupeProjectTeamspaceEntries(client: QueryClient, projectId: string) { + const entries = await loadProjectTeamspaceEntriesForUpdate(client, projectId); + const groups = new Map(); + + for (const entry of entries) { + const teamspaceProject = entry.teamspace_project?.trim(); + + if (!teamspaceProject) { + continue; + } + + groups.set(teamspaceProject, [...(groups.get(teamspaceProject) ?? []), entry]); + } + + for (const groupEntries of groups.values()) { + if (groupEntries.length < 2) { + continue; + } + + const [targetEntry, ...sourceEntries] = [...groupEntries].sort((left, right) => + Number(right.is_default) - Number(left.is_default) || + Number(Boolean(right.teamspace_description?.trim())) - Number(Boolean(left.teamspace_description?.trim())) || + Number(right.budget_sync_disabled) - Number(left.budget_sync_disabled) || + Number(left.display_order ?? 0) - Number(right.display_order ?? 0) || + Number(left.id) - Number(right.id) + ); + + await mergeTeamspaceEntriesIntoTarget(client, sourceEntries.map((entry) => entry.id), targetEntry.id); + } +} + async function assignUnassignedProjectSessionsToTeamspaceEntry(client: QueryClient, projectId: string, targetEntryId: string) { await client.query( ` @@ -4541,18 +4643,9 @@ async function syncProjectTeamspaceEntriesFromZammad( return; } - const existingResult = await client.query<{ id: string; teamspace_project: string | null; is_default: boolean; display_order: number }>( - ` - SELECT id::text AS id, teamspace_project, is_default, display_order - FROM project_teamspace_entries - WHERE project_id = $1 - ORDER BY is_default DESC, display_order ASC, id ASC - FOR UPDATE; - `, - [projectId] - ); + let existingEntries = await loadProjectTeamspaceEntriesForUpdate(client, projectId); - if (existingResult.rows.length === 0) { + if (existingEntries.length === 0) { for (const [index, teamspaceProject] of zammadProjects.entries()) { await client.query( ` @@ -4576,7 +4669,6 @@ async function syncProjectTeamspaceEntriesFromZammad( return; } - let existingEntries = existingResult.rows; let defaultEntry = existingEntries.find((entry) => entry.is_default) ?? existingEntries[0]; if (!defaultEntry.is_default) { @@ -4624,6 +4716,10 @@ async function syncProjectTeamspaceEntriesFromZammad( } } + await dedupeProjectTeamspaceEntries(client, projectId); + existingEntries = await loadProjectTeamspaceEntriesForUpdate(client, projectId); + defaultEntry = existingEntries.find((entry) => entry.is_default) ?? existingEntries[0]; + const existingTeamspaceProjects = new Set(normalizedTeamspaceProjectList(existingEntries.map((entry) => entry.teamspace_project))); let nextDisplayOrder = Math.max(...existingEntries.map((entry) => Number(entry.display_order ?? 0)), 0) + 1024; @@ -5813,6 +5909,7 @@ async function mergeProjectRowsIntoTicket(client: QueryClient, sourceTicketId: s ); await client.query("DELETE FROM projects WHERE id = $1;", [sourceProject.id]); + await dedupeProjectTeamspaceEntries(client, targetProject.id); } await client.query( @@ -6852,6 +6949,354 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio }; } +async function getProjectFollowups(userId: string) { + return withTransaction(async (client) => { + const userResult = await client.query<{ + project_followup_enabled: boolean; + project_followup_weeks: number; + project_followup_second_enabled: boolean; + project_followup_second_weeks: number; + }>("SELECT project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks FROM users WHERE id = $1;", [userId]); + const enabled = Boolean(userResult.rows[0]?.project_followup_enabled ?? true); + const weeks = Number(userResult.rows[0]?.project_followup_weeks ?? 4); + const secondEnabled = Boolean(userResult.rows[0]?.project_followup_second_enabled ?? true); + const secondWeeks = Number(userResult.rows[0]?.project_followup_second_weeks ?? 2); + + if (!enabled) { + return { + enabled, + weeks, + secondEnabled, + secondWeeks, + createdTodos: 0, + updatedTodos: 0, + projects: [] + }; + } + + async function loadRows() { + return client.query<{ + project_id: string; + ticket_id: string; + ticket_number: string; + title: string | null; + organization_name: string | null; + teamspace_project: string | null; + hour_budget_minutes: number | null; + consumption_minutes: number; + remaining_minutes: number | null; + latest_tt_update: string; + last_session_at: string | null; + project_updated_at: string; + open_followup_todo_id: string | null; + existing_followup_todo_id: string | null; + existing_followup_todo_status: "open" | "done" | null; + reminder_id: string | null; + followup_state: "open" | "done" | "dismissed" | null; + followup_stage: number; + followup_title: string; + open_todos: number; + overdue_todos: number; + followup_due_on: string; + }>( + ` + WITH followup_projects AS ( + SELECT + p.id, + p.ticket_id, + p.updated_at AS project_updated_at, + GREATEST( + p.updated_at, + COALESCE(entry_updates.latest_entry_updated_at, '-infinity'::timestamptz), + COALESCE(session_updates.latest_session_created_at, '-infinity'::timestamptz), + COALESCE(todo_updates.latest_todo_updated_at, '-infinity'::timestamptz), + COALESCE(day_billing_updates.latest_day_billing_updated_at, '-infinity'::timestamptz) + ) AS latest_tt_update, + session_updates.latest_session_at, + session_updates.last_session_at, + entry_totals.total_hour_budget_minutes, + (COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + COALESCE(entry_totals.total_manual_consumption_minutes, 0))::int AS consumption_minutes + FROM projects p + JOIN tickets ft ON ft.id = p.ticket_id + LEFT JOIN LATERAL ( + SELECT + COALESCE(SUM(pte.hour_budget_minutes) FILTER (WHERE pte.hour_budget_minutes IS NOT NULL), NULL)::int AS total_hour_budget_minutes, + COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes + FROM project_teamspace_entries pte + WHERE pte.project_id = p.id + ) entry_totals ON true + LEFT JOIN LATERAL ( + SELECT MAX(pte.updated_at) AS latest_entry_updated_at + FROM project_teamspace_entries pte + WHERE pte.project_id = p.id + ) entry_updates ON true + LEFT JOIN LATERAL ( + SELECT + MAX(s.created_at) AS latest_session_created_at, + MAX(s.started_at) AS latest_session_at, + MAX(s.started_at)::text AS last_session_at + FROM sessions s + WHERE s.ticket_id = p.ticket_id + AND s.user_id = $1 + ) session_updates ON true + LEFT JOIN LATERAL ( + SELECT MAX(tt.updated_at) AS latest_todo_updated_at + FROM ticket_todos tt + WHERE tt.ticket_id = p.ticket_id + AND tt.user_id = $1 + AND tt.title <> 'Wiedervorlage ' || ft.ticket_number + AND NOT EXISTS ( + SELECT 1 + FROM project_followup_reminders pfr + WHERE pfr.todo_id = tt.id + AND pfr.user_id = $1 + ) + ) todo_updates ON true + LEFT JOIN LATERAL ( + SELECT MAX(tdb.updated_at) AS latest_day_billing_updated_at + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND tdb.user_id = $1 + ) day_billing_updates 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 + ) teamspace_totals ON true + WHERE p.closed_at IS NULL + AND COALESCE(ft.work_type, 'consulting') = 'consulting' + AND ( + p.user_id = $1 + OR EXISTS ( + SELECT 1 + FROM sessions viewer_sessions + WHERE viewer_sessions.ticket_id = p.ticket_id + AND viewer_sessions.user_id = $1 + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM hidden_user_projects hup + WHERE hup.user_id = $1 + AND hup.project_id = p.id + ) + ) + SELECT + fp.id::text AS project_id, + t.id::text AS ticket_id, + t.ticket_number, + t.title, + COALESCE(o.name, t.customer_name) AS organization_name, + p.teamspace_project, + fp.total_hour_budget_minutes AS hour_budget_minutes, + fp.consumption_minutes, + CASE + WHEN fp.total_hour_budget_minutes IS NULL THEN NULL + ELSE (fp.total_hour_budget_minutes - fp.consumption_minutes)::int + END AS remaining_minutes, + fp.latest_tt_update::text AS latest_tt_update, + fp.last_session_at, + fp.project_updated_at::text AS project_updated_at, + CASE WHEN stage1_reminder.id IS NULL THEN 1 ELSE 2 END AS followup_stage, + CASE + WHEN stage1_reminder.id IS NULL THEN 'Wiedervorlage ' || t.ticket_number + ELSE '2. Wiedervorlage ' || t.ticket_number + END AS followup_title, + existing_followup_todo.id::text AS existing_followup_todo_id, + existing_followup_todo.status AS existing_followup_todo_status, + CASE WHEN existing_followup_todo.status = 'open' THEN existing_followup_todo.id::text ELSE NULL END AS open_followup_todo_id, + CASE WHEN stage1_reminder.id IS NULL THEN NULL ELSE stage2_reminder.id::text END AS reminder_id, + CASE + WHEN stage1_reminder.id IS NULL THEN NULL + WHEN stage2_reminder.id IS NULL THEN NULL + WHEN stage2_reminder.todo_id IS NULL THEN 'dismissed' + WHEN stage2_todo.status = 'done' THEN 'done' + ELSE 'open' + END AS followup_state, + COALESCE(todo_totals.open_todos, 0)::int AS open_todos, + COALESCE(todo_totals.overdue_todos, 0)::int AS overdue_todos, + CASE + WHEN stage1_reminder.id IS NULL THEN ((fp.latest_tt_update AT TIME ZONE 'Europe/Berlin') + ($2::int * interval '1 week'))::date::text + ELSE (stage1_reminder.due_on + ($3::int * interval '1 week'))::date::text + END AS followup_due_on + FROM followup_projects fp + JOIN projects p ON p.id = fp.id + JOIN tickets t ON t.id = fp.ticket_id + LEFT JOIN organizations o ON o.zammad_id = t.organization_id + LEFT JOIN project_followup_reminders stage1_reminder + ON stage1_reminder.user_id = $1 + AND stage1_reminder.project_id = fp.id + AND stage1_reminder.latest_tt_update_at = fp.latest_tt_update + AND stage1_reminder.stage = 1 + LEFT JOIN ticket_todos stage1_todo ON stage1_todo.id = stage1_reminder.todo_id + LEFT JOIN project_followup_reminders stage2_reminder + ON stage2_reminder.user_id = $1 + AND stage2_reminder.project_id = fp.id + AND stage2_reminder.latest_tt_update_at = fp.latest_tt_update + AND stage2_reminder.stage = 2 + LEFT JOIN ticket_todos stage2_todo ON stage2_todo.id = stage2_reminder.todo_id + LEFT JOIN LATERAL ( + SELECT id, status + FROM ticket_todos tt + WHERE tt.ticket_id = t.id + AND tt.user_id = $1 + AND tt.title = CASE + WHEN stage1_reminder.id IS NULL THEN 'Wiedervorlage ' || t.ticket_number + ELSE '2. Wiedervorlage ' || t.ticket_number + END + ORDER BY CASE tt.status WHEN 'open' THEN 0 ELSE 1 END, tt.id ASC + LIMIT 1 + ) existing_followup_todo ON true + LEFT JOIN LATERAL ( + 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 + FROM ticket_todos tt + WHERE tt.ticket_id = t.id + AND tt.user_id = $1 + ) todo_totals ON true + WHERE ( + stage1_reminder.id IS NULL + AND ((fp.latest_tt_update AT TIME ZONE 'Europe/Berlin') + ($2::int * interval '1 week'))::date <= (now() AT TIME ZONE 'Europe/Berlin')::date + ) + OR ( + $4::boolean + AND + stage1_reminder.id IS NOT NULL + AND stage2_reminder.id IS NULL + AND (stage1_reminder.todo_id IS NULL OR stage1_todo.status = 'done') + AND NOT EXISTS ( + SELECT 1 + FROM sessions reset_session + WHERE reset_session.ticket_id = fp.ticket_id + AND reset_session.user_id = $1 + AND (reset_session.started_at AT TIME ZONE 'Europe/Berlin')::date >= stage1_reminder.due_on + ) + AND (stage1_reminder.due_on + ($3::int * interval '1 week'))::date <= (now() AT TIME ZONE 'Europe/Berlin')::date + ) + ORDER BY fp.latest_tt_update ASC, t.ticket_number ASC; + `, + [userId, weeks, secondWeeks, secondEnabled] + ); + } + + const firstResult = await loadRows(); + let createdTodos = 0; + let registeredTodos = 0; + + for (const row of firstResult.rows) { + const periodDescription = row.followup_stage === 1 + ? `seit ${weeks} Woche(n) keine TT-Aktualisierung` + : `zweite Wiedervorlage nach weiteren ${secondWeeks} Woche(n) ohne neue Session`; + const description = `Automatisch erstellt: Consulting-Projekt hatte ${periodDescription} (${row.hour_budget_minutes === null ? "ohne hinterlegtes Budget" : `mit ${Math.ceil(Number(row.remaining_minutes ?? 0) / 60)} h Budget-Rest`}).`; + + if (row.reminder_id) { + continue; + } + + if (row.existing_followup_todo_id) { + const reminderResult = await client.query( + ` + INSERT INTO project_followup_reminders (user_id, project_id, ticket_id, todo_id, stage, latest_tt_update_at, due_on) + VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::date) + ON CONFLICT (user_id, project_id, latest_tt_update_at, stage) DO NOTHING; + `, + [userId, row.project_id, row.ticket_id, row.existing_followup_todo_id, row.followup_stage, row.latest_tt_update, row.followup_due_on] + ); + registeredTodos += reminderResult.rowCount ?? 0; + continue; + } + + const insertResult = await client.query<{ id: string }>( + ` + INSERT INTO ticket_todos (ticket_id, user_id, title, description, priority, starts_on, ends_on, sort_order) + SELECT + $1, + $2, + $3, + $4, + 'medium', + $5::date, + $5::date, + (SELECT COALESCE(MAX(sort_order), 0) + 1024 FROM ticket_todos WHERE user_id = $2) + WHERE NOT EXISTS ( + SELECT 1 + FROM ticket_todos + WHERE ticket_id = $1 + AND user_id = $2 + AND status = 'open' + AND title = $3 + ) + RETURNING id::text; + `, + [ + row.ticket_id, + userId, + row.followup_title, + description, + row.followup_due_on + ] + ); + + createdTodos += insertResult.rowCount ?? 0; + + const todoId = insertResult.rows[0]?.id ?? null; + + await client.query( + ` + INSERT INTO project_followup_reminders (user_id, project_id, ticket_id, todo_id, stage, latest_tt_update_at, due_on) + VALUES ($1, $2, $3, $4::bigint, $5, $6::timestamptz, $7::date) + ON CONFLICT (user_id, project_id, latest_tt_update_at, stage) DO UPDATE + SET todo_id = COALESCE(project_followup_reminders.todo_id, EXCLUDED.todo_id), + due_on = EXCLUDED.due_on, + updated_at = now(); + `, + [userId, row.project_id, row.ticket_id, todoId, row.followup_stage, row.latest_tt_update, row.followup_due_on] + ); + } + + const finalResult = createdTodos > 0 || registeredTodos > 0 ? await loadRows() : firstResult; + + return { + enabled, + weeks, + secondEnabled, + secondWeeks, + createdTodos, + updatedTodos: 0, + projects: finalResult.rows + }; + }); +} + +async function completeAutomaticProjectFollowupsForSession(client: QueryClient, userId: string, ticketId: string, completedAt: Date) { + const userResult = await client.query<{ project_followup_enabled: boolean; project_followup_complete_on_session: boolean }>( + "SELECT project_followup_enabled, project_followup_complete_on_session FROM users WHERE id = $1;", + [userId] + ); + + if (!Boolean(userResult.rows[0]?.project_followup_enabled ?? true) || !Boolean(userResult.rows[0]?.project_followup_complete_on_session ?? true)) { + return; + } + + await client.query( + ` + UPDATE ticket_todos tt + SET status = 'done', + completed_at = COALESCE(tt.completed_at, $3::timestamptz), + updated_at = now() + FROM project_followup_reminders pfr + WHERE pfr.todo_id = tt.id + AND pfr.user_id = $1 + AND pfr.ticket_id = $2 + AND tt.user_id = $1 + AND tt.ticket_id = $2 + AND tt.status = 'open'; + `, + [userId, ticketId, completedAt.toISOString()] + ); +} + async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticketId: number, userId: string) { await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso); await autoAssignSingleProjectTeamspaceEntry({ query }, String(ticketId), userId); @@ -7415,11 +7860,16 @@ app.post("/api/setup/admin", async (req, res) => { zammad_time_unit_rounding_minutes: number; zammad_atoss_email_sync_enabled: boolean; zammad_article_type_id: number; + project_followup_enabled: boolean; + project_followup_weeks: number; + project_followup_second_enabled: boolean; + project_followup_second_weeks: number; + project_followup_complete_on_session: boolean; }>( ` INSERT INTO users (username, display_name, password_hash, role, active) VALUES ($1, $2, $3, 'admin', true) - RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id; + RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session; `, [username, displayName, await hashPassword(password)] ); @@ -7482,6 +7932,11 @@ app.post("/api/auth/login", async (req, res) => { zammad_time_unit_rounding_minutes: number; zammad_atoss_email_sync_enabled: boolean; zammad_article_type_id: number; + project_followup_enabled: boolean; + project_followup_weeks: number; + project_followup_second_enabled: boolean; + project_followup_second_weeks: number; + project_followup_complete_on_session: boolean; }>( ` SELECT @@ -7499,7 +7954,12 @@ app.post("/api/auth/login", async (req, res) => { zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, - COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id + COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, + project_followup_enabled, + project_followup_weeks, + project_followup_second_enabled, + project_followup_second_weeks, + project_followup_complete_on_session FROM users WHERE lower(username) = lower($1); `, @@ -7551,7 +8011,12 @@ app.post("/api/auth/login", async (req, res) => { zammad_time_unit_rounding_enabled: user.zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes: user.zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled: user.zammad_atoss_email_sync_enabled, - zammad_article_type_id: Number(user.zammad_article_type_id ?? 6) + zammad_article_type_id: Number(user.zammad_article_type_id ?? 6), + project_followup_enabled: Boolean(user.project_followup_enabled ?? true), + project_followup_weeks: Number(user.project_followup_weeks ?? 4), + project_followup_second_enabled: Boolean(user.project_followup_second_enabled ?? true), + project_followup_second_weeks: Number(user.project_followup_second_weeks ?? 2), + project_followup_complete_on_session: Boolean(user.project_followup_complete_on_session ?? true) } }); }); @@ -7775,6 +8240,21 @@ app.patch("/api/auth/me", async (req, res) => { const zammadArticleTypeId = user.role === "user" ? parseZammadArticleTypeId(req.body.zammadArticleTypeId, user.zammad_article_type_id) : 6; + const projectFollowupEnabled = user.role === "user" + ? optionalBoolean(req.body.projectFollowupEnabled, user.project_followup_enabled) + : true; + const projectFollowupWeeks = user.role === "user" + ? parseProjectFollowupWeeks(req.body.projectFollowupWeeks, user.project_followup_weeks) + : 4; + const projectFollowupSecondEnabled = user.role === "user" + ? optionalBoolean(req.body.projectFollowupSecondEnabled, user.project_followup_second_enabled) + : true; + const projectFollowupSecondWeeks = user.role === "user" + ? parseProjectFollowupWeeks(req.body.projectFollowupSecondWeeks, user.project_followup_second_weeks) + : 2; + const projectFollowupCompleteOnSession = user.role === "user" + ? optionalBoolean(req.body.projectFollowupCompleteOnSession, user.project_followup_complete_on_session) + : true; if (newPassword && newPassword.length < 6) { throw badRequest("newPassword must be at least 6 characters"); @@ -7809,9 +8289,14 @@ app.patch("/api/auth/me", async (req, res) => { zammad_time_unit_rounding_minutes = CASE WHEN $8::boolean THEN $13 ELSE zammad_time_unit_rounding_minutes END, zammad_atoss_email_sync_enabled = CASE WHEN $8::boolean THEN $14 ELSE zammad_atoss_email_sync_enabled END, zammad_article_type_id = CASE WHEN $8::boolean THEN $15 ELSE zammad_article_type_id END, + project_followup_enabled = CASE WHEN $8::boolean THEN $16 ELSE project_followup_enabled END, + project_followup_weeks = CASE WHEN $8::boolean THEN $17 ELSE project_followup_weeks END, + project_followup_second_enabled = CASE WHEN $8::boolean THEN $18 ELSE project_followup_second_enabled END, + project_followup_second_weeks = CASE WHEN $8::boolean THEN $19 ELSE project_followup_second_weeks END, + project_followup_complete_on_session = CASE WHEN $8::boolean THEN $20 ELSE project_followup_complete_on_session END, updated_at = now() WHERE id = $4 - RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id; + RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session; `, [ username, @@ -7828,7 +8313,12 @@ app.patch("/api/auth/me", async (req, res) => { zammadTimeUnitRoundingEnabled, zammadTimeUnitRoundingMinutes, zammadAtossEmailSyncEnabled, - zammadArticleTypeId + zammadArticleTypeId, + projectFollowupEnabled, + projectFollowupWeeks, + projectFollowupSecondEnabled, + projectFollowupSecondWeeks, + projectFollowupCompleteOnSession ] ); @@ -7856,7 +8346,7 @@ app.patch("/api/auth/me", async (req, res) => { app.get("/api/admin/users", requireAdmin, async (_req, res) => { const result = await query( ` - SELECT id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_time_unit_rounding_minutes, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, created_at, updated_at + SELECT id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_time_unit_rounding_minutes, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session, created_at, updated_at FROM users ORDER BY username ASC; ` @@ -7880,7 +8370,7 @@ app.post("/api/admin/users", requireAdmin, async (req, res) => { ` INSERT INTO users (username, display_name, password_hash, role, active) VALUES ($1, $2, $3, $4, $5) - RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, created_at, updated_at; + RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session, created_at, updated_at; `, [username, displayName, await hashPassword(password), "user", active] ); @@ -7941,7 +8431,7 @@ app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => { password_hash = COALESCE($4, password_hash), updated_at = now() WHERE id = $5 - RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, created_at, updated_at; + RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session, created_at, updated_at; `, [username, displayName, active, password ? await hashPassword(password) : null, userId] ); @@ -9251,6 +9741,10 @@ app.get("/api/statistics/weeks/:week", requireUser, async (req, res) => { res.json(await getStatisticsOverview(week, currentUser(req).id, "week")); }); +app.get("/api/statistics/project-followups", requireUser, async (req, res) => { + res.json(await getProjectFollowups(currentUser(req).id)); +}); + app.get("/api/tickets/lookup", requireUser, async (req, res) => { const ticketNumber = parseTicketNumber(req.query.ticketNumber); const userId = currentUser(req).id; @@ -9317,7 +9811,10 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => { FROM project_teamspace_entries pte WHERE pte.project_id = p.id ) project_entries ON true - WHERE t.ticket_number = $1; + WHERE t.ticket_number = $1 + OR t.zammad_merged_from_ticket_number = $1 + ORDER BY CASE WHEN t.ticket_number = $1 THEN 0 ELSE 1 END, t.id ASC + LIMIT 1; `; let result = await query(lookupSql, [ticketNumber, userId]); @@ -9325,6 +9822,15 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => { const zammadTicket = await fetchZammadTicketByNumber(userId, ticketNumber); if (zammadTicket) { + result = await query(lookupSql, [zammadTicket.ticketNumber, userId]); + + if ((result.rowCount ?? 0) > 0) { + res.json({ + ticket: result.rows[0] + }); + return; + } + const organization = await getOrganizationByZammadId({ query }, zammadTicket.organizationId); if (!organization) { @@ -10618,6 +11124,8 @@ app.post("/api/projects", requireUser, async (req, res) => { return; } + const effectiveTicketNumber = zammadTicket?.ticketNumber ?? ticketNumber; + if (zammadTicket) { const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket); const currentDefaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; @@ -10671,7 +11179,7 @@ app.post("/api/projects", requireUser, async (req, res) => { WHERE ticket_number = $1 FOR UPDATE; `, - [ticketNumber] + [effectiveTicketNumber] ); const organizationId = existingTicket.rows[0]?.organization_id ? Number(existingTicket.rows[0].organization_id) : requestOrganizationId; @@ -10698,7 +11206,7 @@ app.post("/api/projects", requireUser, async (req, res) => { work_type = 'consulting' RETURNING id; `, - [ticketNumber, title, organization.id, organization.name] + [effectiveTicketNumber, title, organization.id, organization.name] ); const ticketId = ticketResult.rows[0].id; @@ -10796,7 +11304,7 @@ app.post("/api/projects", requireUser, async (req, res) => { endsOn, defaultTeamspaceEntry.hourBudgetMinutes, defaultTeamspaceEntry.manualConsumptionMinutes, - ticketNumber, + effectiveTicketNumber, organization.id, organization.name, title, diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index 836e75f..a89c7b1 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -93,6 +93,11 @@ export async function migrate() { await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_minutes INTEGER NOT NULL DEFAULT 15;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_atoss_email_sync_enabled BOOLEAN NOT NULL DEFAULT true;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_article_type_id INTEGER DEFAULT 6;"); + await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_enabled BOOLEAN NOT NULL DEFAULT true;"); + await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_weeks INTEGER NOT NULL DEFAULT 4;"); + await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_second_enabled BOOLEAN NOT NULL DEFAULT true;"); + await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_second_weeks INTEGER NOT NULL DEFAULT 2;"); + await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_complete_on_session BOOLEAN NOT NULL DEFAULT true;"); await query("UPDATE users SET zammad_article_type_id = 6 WHERE zammad_article_type_id IS NULL;"); await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET DEFAULT 6;"); await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET NOT NULL;"); @@ -106,6 +111,16 @@ export async function migrate() { DROP CONSTRAINT IF EXISTS users_zammad_article_type_id_valid, ADD CONSTRAINT users_zammad_article_type_id_valid CHECK (zammad_article_type_id IN (1, 2, 3, 4, 5, 6)); `); + await query(` + ALTER TABLE users + DROP CONSTRAINT IF EXISTS users_project_followup_weeks_valid, + ADD CONSTRAINT users_project_followup_weeks_valid CHECK (project_followup_weeks BETWEEN 1 AND 52); + `); + await query(` + ALTER TABLE users + DROP CONSTRAINT IF EXISTS users_project_followup_second_weeks_valid, + ADD CONSTRAINT users_project_followup_second_weeks_valid CHECK (project_followup_second_weeks BETWEEN 1 AND 52); + `); await query(` CREATE TABLE IF NOT EXISTS auth_sessions ( @@ -920,6 +935,53 @@ export async function migrate() { 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 TABLE IF NOT EXISTS project_followup_reminders ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + todo_id BIGINT REFERENCES ticket_todos(id) ON DELETE SET NULL, + stage INTEGER NOT NULL DEFAULT 1, + latest_tt_update_at TIMESTAMPTZ NOT NULL, + due_on DATE NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT project_followup_reminders_stage_valid CHECK (stage IN (1, 2)) + ); + `); + await query("ALTER TABLE project_followup_reminders ADD COLUMN IF NOT EXISTS stage INTEGER NOT NULL DEFAULT 1;"); + await query(` + ALTER TABLE project_followup_reminders + DROP CONSTRAINT IF EXISTS project_followup_reminders_stage_valid, + ADD CONSTRAINT project_followup_reminders_stage_valid CHECK (stage IN (1, 2)); + `); + await query(` + DO $$ + DECLARE + constraint_name text; + BEGIN + SELECT c.conname + INTO constraint_name + FROM pg_constraint c + WHERE c.conrelid = 'project_followup_reminders'::regclass + AND c.contype = 'u' + AND c.conkey = ARRAY[ + (SELECT attnum FROM pg_attribute WHERE attrelid = 'project_followup_reminders'::regclass AND attname = 'user_id'), + (SELECT attnum FROM pg_attribute WHERE attrelid = 'project_followup_reminders'::regclass AND attname = 'project_id'), + (SELECT attnum FROM pg_attribute WHERE attrelid = 'project_followup_reminders'::regclass AND attname = 'latest_tt_update_at') + ]::smallint[]; + + IF constraint_name IS NOT NULL THEN + EXECUTE format('ALTER TABLE project_followup_reminders DROP CONSTRAINT %I', constraint_name); + END IF; + END $$; + `); + await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_followup_reminders_unique_stage ON project_followup_reminders(user_id, project_id, latest_tt_update_at, stage);"); + await query("CREATE INDEX IF NOT EXISTS idx_project_followup_reminders_user_project ON project_followup_reminders(user_id, project_id, latest_tt_update_at DESC);"); + await query("CREATE INDEX IF NOT EXISTS idx_project_followup_reminders_todo ON project_followup_reminders(todo_id) WHERE todo_id IS NOT NULL;"); + 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/frontend/src/App.tsx b/frontend/src/App.tsx index 7097583..f21111d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,4 @@ import { - AlertCircle, CalendarDays, CheckCircle2, ClipboardCheck, @@ -591,20 +590,57 @@ const sidebarTodoCalendarClasses: Record = { }; const sidebarOverdueTodoClass = "border-red-500/60 bg-red-500/15 text-red-100"; +const sidebarTodoPriorityOrder: Record = { + urgent: 0, + high: 1, + medium: 2, + low: 3 +}; + +function sortSidebarCurrentTodos(todos: TicketTodo[], today: string) { + return [...todos].sort((left, right) => { + const leftOverdue = isTodoOverdue(left, today); + const rightOverdue = isTodoOverdue(right, today); + + if (leftOverdue !== rightOverdue) { + return leftOverdue ? -1 : 1; + } + + return sidebarTodoPriorityOrder[left.priority] - sidebarTodoPriorityOrder[right.priority] || left.sort_order - right.sort_order || left.title.localeCompare(right.title, "de-DE", { sensitivity: "base" }); + }); +} function notifyTodosChanged() { window.dispatchEvent(new Event("tickettracker:todos-changed")); } function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) { - const [runningTodosOpen, setRunningTodosOpen] = useState(false); + const [todosModalOpen, setTodosModalOpen] = 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 currentTodos = sortSidebarCurrentTodos( + [...overdueTodos, ...fixedTodos, ...summarizedTodos].filter((todo, index, list) => list.findIndex((item) => item.id === todo.id) === index), + today + ); + const visibleCurrentTodos = currentTodos.slice(0, 6); + const additionalTodosCount = Math.max(0, currentTodos.length - visibleCurrentTodos.length); const hasTodos = fixedTodos.length > 0 || summarizedTodos.length > 0 || overdueTodos.length > 0; + function todoStateLabel(todo: TicketTodo) { + if (isTodoOverdue(todo, today)) { + return "Überfällig"; + } + + if (isTodoFixedOnDay(todo, today)) { + return "Fester Termin"; + } + + return "Laufend"; + } + async function toggleTodo(todo: TicketTodo) { setModalBusy(true); @@ -666,42 +702,30 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) { {hasTodos ? (
- {overdueTodos.slice(0, 3).map((todo) => ( + {visibleCurrentTodos.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 ? ( + {additionalTodosCount > 0 ? ( ) : null}
@@ -712,22 +736,24 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) { - + - ToDos am Tag - Heute · {summarizedTodos.length} laufende ToDo(s) + Aktuelle ToDos + + {overdueTodos.length} überfällig · {fixedTodos.length} feste Termine · {summarizedTodos.length} laufende ToDo(s) +
- {summarizedTodos.map((todo) => ( + {currentTodos.map((todo) => (
{todo.title} + {todoStateLabel(todo)} {sidebarTodoPriorityLabels[todo.priority]} - {isTodoOverdue(todo, today) ? überfällig : null}

{todo.organization_name ?? "Keine Organisation"} @@ -749,7 +775,7 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) { size="sm" variant="ghost" onClick={() => { - setRunningTodosOpen(false); + setTodosModalOpen(false); onNavigate(`/tickets/${todo.ticket_id}`); }} > @@ -764,8 +790,8 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {

))} - {summarizedTodos.length === 0 ? ( -

Keine laufenden ToDos an diesem Tag.

+ {currentTodos.length === 0 ? ( +

Keine aktuellen ToDos.

) : null}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 7c76007..b190c63 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -10,6 +10,7 @@ import type { ProjectSummary, RecurringBilling, StatisticsOverview, + StatisticsProjectFollowupOverview, TicketMeta, TicketPeriod, TicketTodo, @@ -254,6 +255,11 @@ export function updateCurrentUser(payload: { zammadTimeUnitRoundingMinutes?: number; zammadAtossEmailSyncEnabled?: boolean; zammadArticleTypeId?: number; + projectFollowupEnabled?: boolean; + projectFollowupWeeks?: number; + projectFollowupSecondEnabled?: boolean; + projectFollowupSecondWeeks?: number; + projectFollowupCompleteOnSession?: boolean; }) { return request<{ user: AuthUser }>("/api/auth/me", { method: "PATCH", @@ -889,6 +895,10 @@ export function getStatisticsOverview(period: string, type: "month" | "week" = " return request(`/api/statistics/${type === "week" ? "weeks" : "months"}/${period}`); } +export function getProjectFollowups() { + return request("/api/statistics/project-followups"); +} + export function getTicketPeriod(type: PeriodType, period: string, ticketId: string) { return request(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}`); } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 245d894..b91e4de 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -19,6 +19,11 @@ export type AuthUser = { zammad_time_unit_rounding_minutes: number; zammad_atoss_email_sync_enabled: boolean; zammad_article_type_id: number; + project_followup_enabled: boolean; + project_followup_weeks: number; + project_followup_second_enabled: boolean; + project_followup_second_weeks: number; + project_followup_complete_on_session: boolean; }; export type DashboardWidgetType = "dayControl" | "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes"; @@ -479,6 +484,35 @@ export type StatisticsTeamspaceDay = { project_teamspace_project: string | null; }; +export type StatisticsProjectFollowup = { + project_id: string; + ticket_id: string; + ticket_number: string; + title: string | null; + organization_name: string | null; + teamspace_project: string | null; + hour_budget_minutes: number | null; + consumption_minutes: number; + remaining_minutes: number | null; + latest_tt_update: string; + last_session_at: string | null; + project_updated_at: string; + open_followup_todo_id: string | null; + followup_state: "open" | "done" | "dismissed" | null; + open_todos: number; + overdue_todos: number; +}; + +export type StatisticsProjectFollowupOverview = { + enabled: boolean; + weeks: number; + secondEnabled: boolean; + secondWeeks: number; + createdTodos: number; + updatedTodos: number; + projects: StatisticsProjectFollowup[]; +}; + export type StatisticsOverview = { periodType: "month" | "week"; period: string; diff --git a/frontend/src/views/ProfilePage.tsx b/frontend/src/views/ProfilePage.tsx index a053db2..3a3a54e 100644 --- a/frontend/src/views/ProfilePage.tsx +++ b/frontend/src/views/ProfilePage.tsx @@ -31,6 +31,11 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled); const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes); const [zammadArticleTypeId, setZammadArticleTypeId] = useState(currentUser.zammad_article_type_id ?? 6); + const [projectFollowupEnabled, setProjectFollowupEnabled] = useState(currentUser.project_followup_enabled ?? true); + const [projectFollowupWeeks, setProjectFollowupWeeks] = useState(currentUser.project_followup_weeks ?? 4); + const [projectFollowupSecondEnabled, setProjectFollowupSecondEnabled] = useState(currentUser.project_followup_second_enabled ?? true); + const [projectFollowupSecondWeeks, setProjectFollowupSecondWeeks] = useState(currentUser.project_followup_second_weeks ?? 2); + const [projectFollowupCompleteOnSession, setProjectFollowupCompleteOnSession] = useState(currentUser.project_followup_complete_on_session ?? true); const [zammadQueue, setZammadQueue] = useState([]); const [loadingZammadQueue, setLoadingZammadQueue] = useState(false); const [retryingZammadQueue, setRetryingZammadQueue] = useState(false); @@ -48,6 +53,11 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled); setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes); setZammadArticleTypeId(currentUser.zammad_article_type_id ?? 6); + setProjectFollowupEnabled(currentUser.project_followup_enabled ?? true); + setProjectFollowupWeeks(currentUser.project_followup_weeks ?? 4); + setProjectFollowupSecondEnabled(currentUser.project_followup_second_enabled ?? true); + setProjectFollowupSecondWeeks(currentUser.project_followup_second_weeks ?? 2); + setProjectFollowupCompleteOnSession(currentUser.project_followup_complete_on_session ?? true); }, [currentUser]); async function loadZammadQueue() { @@ -126,14 +136,19 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { ...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {}), ...(currentUser.role === "user" ? { - zammadEmailSessionWebhookEnabled, - zammadAtossEmailSyncEnabled, - sessionBillingPromptEnabled, - zammadSessionNoteDefaultEnabled, - zammadTimeUnitRoundingEnabled, - zammadTimeUnitRoundingMinutes, - zammadArticleTypeId - } + zammadEmailSessionWebhookEnabled, + zammadAtossEmailSyncEnabled, + sessionBillingPromptEnabled, + zammadSessionNoteDefaultEnabled, + zammadTimeUnitRoundingEnabled, + zammadTimeUnitRoundingMinutes, + zammadArticleTypeId, + projectFollowupEnabled, + projectFollowupWeeks, + projectFollowupSecondEnabled, + projectFollowupSecondWeeks, + projectFollowupCompleteOnSession + } : {}) }; @@ -334,6 +349,89 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { onCheckedChange={setSessionBillingPromptEnabled} />
+
+
+
+ +

Consulting-Projekte automatisch auf Wiedervorlage setzen.

+
+ +
+ + {projectFollowupEnabled ? ( +
+
+

1. Wiedervorlage aktiv

+

Wenn automatische Wiedervorlagen aktiv sind, wird die erste WVL immer geprüft.

+
+ +
+ + setProjectFollowupWeeks(Math.max(1, Math.min(52, Number(event.currentTarget.value) || 1)))} + /> +
+ + + +
+
+ +

Nur anlegen, wenn die erste WVL erledigt oder gelöscht wurde und keine Session entstanden ist.

+
+ +
+ + {projectFollowupSecondEnabled ? ( +
+ + setProjectFollowupSecondWeeks(Math.max(1, Math.min(52, Number(event.currentTarget.value) || 1)))} + /> +
+ ) : null} +
+ ) : null} +
+
+ +

Gilt für beide Zeiträume. Betrifft nur automatisch erzeugte Wiedervorlagen, niemals manuelle ToDos.

+
+ +
+
diff --git a/frontend/src/views/StatisticsPage.tsx b/frontend/src/views/StatisticsPage.tsx index 3a6a0d3..496b698 100644 --- a/frontend/src/views/StatisticsPage.tsx +++ b/frontend/src/views/StatisticsPage.tsx @@ -1,9 +1,11 @@ import { ChevronLeft, ChevronRight, + BellPlus, CircleAlert, Clock3, ExternalLink, + ListChecks, RotateCcw, Ticket, TrendingUp, @@ -20,11 +22,12 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Input } from "@/components/ui/input"; import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "@/components/ui/pagination"; import { Progress } from "@/components/ui/progress"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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"; +import { getProjectFollowups, getStatisticsOverview } from "../api"; +import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format"; +import type { StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsProjectFollowup, StatisticsProjectFollowupOverview, StatisticsTicket, WorkType } from "../types"; type StatisticsPageProps = { onNavigate: (to: string) => void; @@ -361,12 +364,72 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket ); } +function daysSince(value: string) { + const then = new Date(value).getTime(); + + if (!Number.isFinite(then)) { + return null; + } + + return Math.max(0, Math.floor((Date.now() - then) / 86_400_000)); +} + +function ProjectFollowupRow({ project, onNavigate }: { project: StatisticsProjectFollowup; onNavigate: (to: string) => void }) { + const inactiveDays = daysSince(project.latest_tt_update); + const hasBudget = project.hour_budget_minutes !== null; + + return ( +
+
+
+ {project.ticket_number} + {hasBudget ? ( + 0 ? "warning" : "secondary"}> + {project.remaining_minutes !== null && project.remaining_minutes > 0 ? `${formatMinutes(project.remaining_minutes)} offen` : "Budget verbraucht"} + + ) : ( + ohne Budget + )} + {project.open_followup_todo_id ? ToDo erstellt : null} + {project.followup_state === "done" ? erledigt : null} + {project.followup_state === "dismissed" ? gelöscht : null} + {project.overdue_todos > 0 ? {project.overdue_todos} überfällig : null} +
+
+

{project.title ?? project.teamspace_project ?? "Projekt ohne Titel"}

+

{project.organization_name ?? "Keine Organisation"}

+
+
+ Letztes TT-Update {formatDateTime(project.latest_tt_update)} + {inactiveDays !== null ? {inactiveDays} Tag(e) inaktiv : null} + Budget {hasBudget ? formatMinutes(project.hour_budget_minutes!) : "nicht gesetzt"} + Verbraucht {formatMinutes(project.consumption_minutes)} + {project.open_todos} offene ToDo(s) +
+
+
+ + +
+
+ ); +} + export function StatisticsPage({ onNavigate }: StatisticsPageProps) { const [month, setMonth] = useState(currentMonth()); const [stats, setStats] = useState(null); + const [followups, setFollowups] = useState(null); const [loading, setLoading] = useState(false); + const [loadingFollowups, setLoadingFollowups] = useState(false); const [organizationPage, setOrganizationPage] = useState(1); const loadRequestId = useRef(0); + const followupRequestId = useRef(0); async function load() { const requestId = loadRequestId.current + 1; @@ -396,6 +459,44 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) { void load(); }, [month]); + async function loadFollowups() { + const requestId = followupRequestId.current + 1; + followupRequestId.current = requestId; + setLoadingFollowups(true); + + try { + const result = await getProjectFollowups(); + + if (requestId === followupRequestId.current) { + setFollowups(result); + + if (result.createdTodos > 0 || result.updatedTodos > 0) { + window.dispatchEvent(new Event("tickettracker:todos-changed")); + } + + if (result.createdTodos > 0) { + toast.success("Wiedervorlagen erstellt", { + description: `${result.createdTodos} ToDo(s) automatisch angelegt.` + }); + } + } + } catch (error) { + if (requestId === followupRequestId.current) { + toast.error("Wiedervorlagen konnten nicht geladen werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } + } finally { + if (requestId === followupRequestId.current) { + setLoadingFollowups(false); + } + } + } + + useEffect(() => { + void loadFollowups(); + }, []); + useEffect(() => { function refreshVisible() { if (document.visibilityState === "visible") { @@ -476,6 +577,15 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
+ + + Überblick + Kunden & Typen + Tickets + Wiedervorlagen + + +
+ + +
@@ -731,6 +844,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
+
+ +
@@ -768,6 +884,48 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) { ) : null} + + + + + +
+ Automatische Wiedervorlagen + + {followups?.enabled === false + ? "Automatische Wiedervorlagen sind im Profil deaktiviert." + : followups?.secondEnabled === false + ? `Erste Wiedervorlage nach ${followups?.weeks ?? "-"} Woche(n).` + : `Erste Wiedervorlage nach ${followups?.weeks ?? "-"} Woche(n), zweite nach weiteren ${followups?.secondWeeks ?? "-"} Woche(n) ohne Session.`} + +
+
+ {followups?.projects.length ?? 0} Projekt(e) + {loadingFollowups ? lädt... : null} + +
+
+ + {(followups?.projects ?? []).map((project) => ( + + ))} + {!loadingFollowups && followups && followups.projects.length === 0 ? ( +
+ +
+

Keine Wiedervorlagen nötig

+

Alle offenen Projektbudgets wurden innerhalb des eingestellten Zeitraums aktualisiert.

+
+
+ ) : null} + {loadingFollowups && !followups ?

Wiedervorlagen werden geprüft...

: null} +
+
+
+
); } diff --git a/frontend/src/views/TicketDetailPage.tsx b/frontend/src/views/TicketDetailPage.tsx index 1c1f477..f29b051 100644 --- a/frontend/src/views/TicketDetailPage.tsx +++ b/frontend/src/views/TicketDetailPage.tsx @@ -129,6 +129,10 @@ function notifyZammadArticleResult(result: CreateSessionResult) { } } +function notifyTodosChanged() { + window.dispatchEvent(new Event("tickettracker:todos-changed")); +} + function zammadSyncStorageKey(userId: string) { return `tickettracker.zammad-sync.${userId}`; } @@ -259,6 +263,18 @@ function teamspaceBillingKey(day: string, projectTeamspaceEntryId: string | null return `${projectTeamspaceEntryId ?? "ticket"}::${day}`; } +function dayBillingProjectEntryIdForView( + projectTeamspaceEntryId: string | null | undefined, + hasMultipleProjectTeamspaceEntries: boolean, + defaultProjectEntryId: string | null +) { + if (!hasMultipleProjectTeamspaceEntries) { + return null; + } + + return projectTeamspaceEntryId ?? defaultProjectEntryId; +} + function projectEditForm(project: ProjectSummary): ProjectEditFormState { const entries = projectTeamspaceEntries(project).map((entry, index) => ({ clientId: entry.id || projectEntryId(), @@ -824,10 +840,12 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS }, [periodType, period, ticketId]); useEffect(() => { - const defaultEntryId = data?.project ? projectTeamspaceEntries(data.project).find((entry) => entry.is_default)?.id ?? null : null; + const loadedProjectEntries = data?.project ? projectTeamspaceEntries(data.project) : []; + const hasMultipleEntries = loadedProjectEntries.length > 1; + const defaultEntryId = loadedProjectEntries.find((entry) => entry.is_default)?.id ?? null; const nextInputs = Object.fromEntries( (data?.dayBillings ?? []).map((billing) => [ - teamspaceBillingKey(billing.day, billing.project_teamspace_entry_id ?? defaultEntryId), + teamspaceBillingKey(billing.day, dayBillingProjectEntryIdForView(billing.project_teamspace_entry_id, hasMultipleEntries, defaultEntryId)), formatHoursInput(billing.billed_minutes) ]) ); @@ -920,11 +938,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS () => new Map( (data?.dayBillings ?? []).map((billing) => [ - teamspaceBillingKey(billing.day, billing.project_teamspace_entry_id ?? defaultProjectEntryId), + teamspaceBillingKey(billing.day, dayBillingProjectEntryIdForView(billing.project_teamspace_entry_id, requiresProjectTeamspaceEntry, defaultProjectEntryId)), billing.billed_minutes ]) ), - [data?.dayBillings, defaultProjectEntryId] + [data?.dayBillings, defaultProjectEntryId, requiresProjectTeamspaceEntry] ); const savedDayBillingInput = (key: string) => formatHoursInput(dayBillingMap.get(key)); const isDayBillingDirty = (key: string) => (dayBillingInputs[key] ?? "") !== savedDayBillingInput(key); @@ -1186,6 +1204,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS : `${result.created} angelegt, ${result.skipped} ohne Transfer.`; const notify = result.created === 0 && result.errors.length > 0 ? toast.error : toast.success; notify("Mail-Sessions importiert", { description }); + if (result.created > 0) { + notifyTodosChanged(); + } await load(); } catch (error) { toast.error("Mail-Sessions konnten nicht importiert werden", { @@ -1514,6 +1535,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet." }); notifyZammadArticleResult(result); + notifyTodosChanged(); setTicketSession({ ...emptyTicketSessionForm(), organizationId: ticket.organization_id ?? "", diff --git a/frontend/src/views/TimerPage.tsx b/frontend/src/views/TimerPage.tsx index 5613828..548980a 100644 --- a/frontend/src/views/TimerPage.tsx +++ b/frontend/src/views/TimerPage.tsx @@ -141,6 +141,10 @@ function notifyZammadArticleResult(result: CreateSessionResult) { } } +function notifyTodosChanged() { + window.dispatchEvent(new Event("tickettracker:todos-changed")); +} + function escapeHtml(value: string) { return value .replace(/&/g, "&") @@ -773,6 +777,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer description: `${finished.ticketNumber} wurde mit ${finished.roundedMinutes} Minute(n) erfasst.` }); notifyZammadArticleResult(result); + notifyTodosChanged(); try { const timerResult = await deleteActiveTimer(finished.timerId); removeTimerActivityDraft(currentUser.id, finished.timerId); @@ -868,6 +873,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen." }); notifyZammadArticleResult(result); + notifyTodosChanged(); setManualSession({ ...emptyManualSession(), day: manualSession.day, diff --git a/frontend/src/views/TodosPage.tsx b/frontend/src/views/TodosPage.tsx index 87e0749..1ba731b 100644 --- a/frontend/src/views/TodosPage.tsx +++ b/frontend/src/views/TodosPage.tsx @@ -21,6 +21,7 @@ type TodosPageProps = { type TodoSortMode = "custom" | "priority" | "title" | "ticket" | "organization" | "date" | "updated"; type TodoGroupMode = "ticket" | "priority" | "organization" | "status"; +type CalendarDayMode = "current"; type TodoPreferences = { status: TodoStatus | "all"; @@ -277,6 +278,23 @@ function sortTodos(todos: TicketTodo[], sortMode: TodoSortMode) { return [...todos].sort((left, right) => compareTodos(left, right, sortMode)); } +function uniqueTodos(todos: TicketTodo[]) { + return todos.filter((todo, index, list) => list.findIndex((item) => item.id === todo.id) === index); +} + +function sortCurrentTodos(todos: TicketTodo[], today: string) { + return [...todos].sort((left, right) => { + const leftOverdue = isTodoOverdue(left, today); + const rightOverdue = isTodoOverdue(right, today); + + if (leftOverdue !== rightOverdue) { + return leftOverdue ? -1 : 1; + } + + return priorityOrder[left.priority] - priorityOrder[right.priority] || left.sort_order - right.sort_order || compareText(left.title, right.title); + }); +} + function matchesTicketSearch(todo: TicketTodo, search: string) { const normalizedSearch = normalizeSearch(search); @@ -400,9 +418,9 @@ export function TodosPage({ onNavigate }: TodosPageProps) { const [modalEndsOn, setModalEndsOn] = useState(""); const [modalBusy, setModalBusy] = useState(false); const [selectedCalendarDay, setSelectedCalendarDay] = useState(null); - const [selectedCalendarDayMode, setSelectedCalendarDayMode] = useState<"summarized" | "overdue">("summarized"); + const [selectedCalendarDayMode, setSelectedCalendarDayMode] = useState("current"); const [returnCalendarDay, setReturnCalendarDay] = useState(null); - const [returnCalendarDayMode, setReturnCalendarDayMode] = useState<"summarized" | "overdue">("summarized"); + const [returnCalendarDayMode, setReturnCalendarDayMode] = useState("current"); async function load() { setLoading(true); @@ -422,6 +440,13 @@ export function TodosPage({ onNavigate }: TodosPageProps) { void load(); }, []); + useEffect(() => { + const onTodosChanged = () => void load(); + + window.addEventListener("tickettracker:todos-changed", onTodosChanged); + return () => window.removeEventListener("tickettracker:todos-changed", onTodosChanged); + }, []); + useEffect(() => { window.localStorage.setItem(todoPreferencesKey, JSON.stringify(preferences)); }, [preferences]); @@ -432,7 +457,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) { if (selectedTodoId && !selectedTodo) { setSelectedTodoId(null); setReturnCalendarDay(null); - setReturnCalendarDayMode("summarized"); + setReturnCalendarDayMode("current"); } }, [selectedTodo, selectedTodoId]); @@ -473,7 +498,19 @@ export function TodosPage({ onNavigate }: TodosPageProps) { return entries; }, [calendarDays, todos]); - const selectedCalendarDayTodos = selectedCalendarDay ? calendarTodosByDay.get(selectedCalendarDay)?.[selectedCalendarDayMode] ?? [] : []; + const selectedCalendarDayTodos = useMemo(() => { + if (!selectedCalendarDay) { + return []; + } + + const dayTodos = calendarTodosByDay.get(selectedCalendarDay); + + if (!dayTodos) { + return []; + } + + return sortCurrentTodos(uniqueTodos([...dayTodos.overdue, ...dayTodos.single, ...dayTodos.summarized]), currentDay()); + }, [calendarTodosByDay, selectedCalendarDay]); const counts = useMemo( () => ({ open: todos.filter((todo) => todo.status === "open").length, @@ -531,7 +568,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) { }); } - function openTodoModal(todo: TicketTodo, returnToDay: string | null = null, returnToDayMode: "summarized" | "overdue" = "summarized") { + function openTodoModal(todo: TicketTodo, returnToDay: string | null = null, returnToDayMode: CalendarDayMode = "current") { setReturnCalendarDay(returnToDay); setReturnCalendarDayMode(returnToDayMode); setSelectedTodoId(todo.id); @@ -545,7 +582,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) { function closeTodoModal() { setSelectedTodoId(null); setReturnCalendarDay(null); - setReturnCalendarDayMode("summarized"); + setReturnCalendarDayMode("current"); } function backToCalendarDay() { @@ -557,7 +594,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) { const mode = returnCalendarDayMode; setSelectedTodoId(null); setReturnCalendarDay(null); - setReturnCalendarDayMode("summarized"); + setReturnCalendarDayMode("current"); setSelectedCalendarDayMode(mode); setSelectedCalendarDay(day); } @@ -904,6 +941,9 @@ export function TodosPage({ onNavigate }: TodosPageProps) { const summarizedTodos = dayTodos.summarized; const overdueTodos = dayTodos.overdue; const totalTodos = singleTodos.length + summarizedTodos.length + overdueTodos.length; + const currentTodos = sortCurrentTodos(uniqueTodos([...overdueTodos, ...singleTodos, ...summarizedTodos]), currentDay()); + const visibleCalendarTodos = currentTodos.slice(0, 2); + const additionalTodosCount = Math.max(0, currentTodos.length - visibleCalendarTodos.length); return (
{!day.workingDay ?
{day.holidayName ?? "Wochenende"}
: null}
- {singleTodos.slice(0, 3).map((todo) => ( + {visibleCalendarTodos.map((todo) => ( ))} - {singleTodos.length > 3 ? +{singleTodos.length - 3} weitere fixe : null}
-
- {summarizedTodos.length > 0 ? ( +
+ {additionalTodosCount > 0 ? ( - ) : null} - {overdueTodos.length > 0 ? ( - ) : null}
@@ -1073,13 +1100,11 @@ export function TodosPage({ onNavigate }: TodosPageProps) { !open && setSelectedCalendarDay(null)}> - {selectedCalendarDayMode === "overdue" ? "Überfällige ToDos" : "ToDos am Tag"} + Aktuelle ToDos {selectedCalendarDay - ? `${formatTodoDate(selectedCalendarDay)} · ${selectedCalendarDayTodos.length} ${selectedCalendarDayMode === "overdue" ? "überfällige" : "laufende"} ToDo(s)` - : selectedCalendarDayMode === "overdue" - ? "Überfällige ToDos dieses Tages" - : "Laufende ToDos dieses Tages"} + ? `${formatTodoDate(selectedCalendarDay)} · ${selectedCalendarDayTodos.length} aktuelle ToDo(s)` + : "Aktuelle ToDos dieses Tages"}
@@ -1142,9 +1167,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
))} {selectedCalendarDayTodos.length === 0 ? ( -

- {selectedCalendarDayMode === "overdue" ? "Keine überfälligen ToDos an diesem Tag." : "Keine laufenden ToDos an diesem Tag."} -

+

Keine aktuellen ToDos an diesem Tag.

) : null}