From 3da436423fb08e68c4408b54b1c354670481e0f1 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Wed, 2 Sep 2026 11:03:50 +0200 Subject: [PATCH] Add project and todo management --- backend/src/index.ts | 683 +++++++++++++++++++++++- backend/src/migrations.ts | 40 ++ frontend/src/App.tsx | 41 +- frontend/src/api.ts | 92 ++++ frontend/src/components/TodoPanel.tsx | 235 ++++++++ frontend/src/help-content.ts | 37 ++ frontend/src/timers.ts | 2 + frontend/src/types.ts | 51 +- frontend/src/views/ProjectsPage.tsx | 532 ++++++++++++++++++ frontend/src/views/TicketDetailPage.tsx | 77 ++- frontend/src/views/TimerPage.tsx | 16 +- frontend/src/views/TodosPage.tsx | 102 ++++ 12 files changed, 1865 insertions(+), 43 deletions(-) create mode 100644 frontend/src/components/TodoPanel.tsx create mode 100644 frontend/src/views/ProjectsPage.tsx create mode 100644 frontend/src/views/TodosPage.tsx diff --git a/backend/src/index.ts b/backend/src/index.ts index 10d16cd..2e77de7 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -144,6 +144,24 @@ type OrganizationRow = { synced_at: string; }; +type ProjectRow = { + id: string; + ticket_id: string; + ticket_number: string; + organization_id: string | null; + organization_name: string | null; + customer_name: string | null; + work_type: "support" | "consulting" | null; + teamspace_project: string | null; + description: string; + goals: string; + starts_on: string | null; + ends_on: string | null; + hour_budget_minutes: number | null; + created_at: string; + updated_at: string; +}; + const zammadBaseUrlSettingKey = "zammad.base_url"; const zammadApiKeySettingKey = "zammad.api_key"; @@ -192,6 +210,44 @@ function optionalBoolean(value: unknown, fallback: boolean) { return value; } +function parseOptionalDateOnly(value: unknown, field: string) { + if (value === undefined || value === null || value === "") { + return null; + } + + return parseDay(String(value)).start; +} + +function parseOptionalNonNegativeInteger(value: unknown, field: string) { + if (value === undefined || value === null || value === "") { + return null; + } + + const number = Number(value); + + if (!Number.isInteger(number) || number < 0) { + throw badRequest(`${field} must be a non-negative integer`); + } + + return number; +} + +function parseTodoPriority(value: unknown) { + if (value !== "low" && value !== "medium" && value !== "high" && value !== "urgent") { + throw badRequest("priority must be low, medium, high or urgent"); + } + + return value; +} + +function parseTodoStatus(value: unknown) { + if (value !== "open" && value !== "done") { + throw badRequest("status must be open or done"); + } + + return value; +} + const periodConfigs: Record = { months: { type: "month", @@ -514,6 +570,16 @@ async function cleanupEmptyTickets(client: { query: (text: string, params?: unkn SELECT 1 FROM sessions s WHERE s.ticket_id = t.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM projects p + WHERE p.ticket_id = t.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM ticket_todos tt + WHERE tt.ticket_id = t.id ); `, [uniqueTicketIds] @@ -1261,7 +1327,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon } const sessionWhere = where.join(" AND "); - const [sessionsResult, dayBillingResult, monthsResult, closureResult] = await Promise.all([ + const [sessionsResult, dayBillingResult, monthsResult, closureResult, projectResult, todosResult] = await Promise.all([ query( ` SELECT @@ -1329,7 +1395,62 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon ), filters.month ? query<{ closed_at: string }>("SELECT closed_at FROM month_closures WHERE month = $1::date AND user_id = $2;", [filters.month.start, userId]) - : Promise.resolve({ rows: [] as { closed_at: string }[], rowCount: 0 }) + : Promise.resolve({ rows: [] as { closed_at: string }[], rowCount: 0 }), + query( + ` + SELECT + p.id::text AS id, + p.ticket_id::text AS ticket_id, + t.ticket_number, + t.organization_id::text AS organization_id, + o.name AS organization_name, + COALESCE(o.name, t.customer_name) AS customer_name, + t.work_type, + 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 + AND p.user_id = $2; + `, + [ticketId, userId] + ), + query( + ` + SELECT + tt.id::text AS id, + tt.ticket_id::text AS ticket_id, + t.ticket_number, + COALESCE(o.name, t.customer_name) AS organization_name, + p.id::text AS project_id, + p.teamspace_project, + tt.title, + tt.description, + tt.priority, + tt.status, + tt.completed_at, + tt.created_at, + tt.updated_at + FROM ticket_todos tt + JOIN tickets t ON t.id = tt.ticket_id + LEFT JOIN organizations o ON o.zammad_id = t.organization_id + LEFT JOIN projects p ON p.ticket_id = tt.ticket_id AND p.user_id = tt.user_id + WHERE tt.ticket_id = $1 + 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; + `, + [ticketId, userId] + ) ]); const sessions = sessionsResult.rows; @@ -1358,6 +1479,8 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon }, sessions, dayBillings: dayBillingResult.rows, + project: projectResult.rows[0] ?? null, + todos: todosResult.rows, canClose: false, openCount, ticketOpenCount: Number(ticket.ticket_open_count ?? 0) @@ -1866,6 +1989,8 @@ const databaseExportTables = [ "app_settings", "organizations", "tickets", + "projects", + "ticket_todos", "recurring_billings", "recurring_billing_slots", "recurring_billing_exceptions", @@ -2775,14 +2900,15 @@ app.post("/api/sessions", requireUser, async (req, res) => { const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60)); const created = await withTransaction(async (client) => { - const existingTicketResult = await client.query<{ id: string; organization_id: string | null; customer_name: string | null; work_type: string | null }>( + const existingTicketResult = await client.query<{ id: string; organization_id: string | null; customer_name: string | null; work_type: string | null; project_id: string | null }>( ` - SELECT id, organization_id, customer_name, work_type - FROM tickets - WHERE ticket_number = $1 - FOR UPDATE; + SELECT t.id, t.organization_id, t.customer_name, t.work_type, p.id AS project_id + FROM tickets t + LEFT JOIN projects p ON p.ticket_id = t.id AND p.user_id = $2 + WHERE t.ticket_number = $1 + FOR UPDATE OF t; `, - [ticketNumber] + [ticketNumber, user.id] ); const existingTicket = existingTicketResult.rows[0] ?? null; const effectiveOrganizationId = existingTicket?.organization_id ? Number(existingTicket.organization_id) : requestedOrganizationId; @@ -2805,14 +2931,16 @@ 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 = COALESCE(tickets.work_type, EXCLUDED.work_type) + 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 RETURNING id, ticket_number, organization_id, customer_name, work_type; `, - [ticketNumber, organization.id, organization.name, requestedWorkType] + [ticketNumber, organization.id, organization.name, existingTicket?.project_id ? "consulting" : requestedWorkType, user.id] ); const ticket = ticketResult.rows[0]; - const workType = ticket.work_type ?? requestedWorkType; + const workType = existingTicket?.project_id ? "consulting" : ticket.work_type ?? requestedWorkType; if (!workType) { throw badRequest("workType must be support or consulting"); @@ -2872,6 +3000,7 @@ app.get("/api/statistics/months/:month", requireUser, async (req, res) => { app.get("/api/tickets/lookup", requireUser, async (req, res) => { const ticketNumber = parseTicketNumber(req.query.ticketNumber); + const userId = currentUser(req).id; const result = await query( ` SELECT @@ -2880,12 +3009,16 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => { t.organization_id, o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, - t.work_type + t.work_type, + p.id::text AS project_id, + (p.id IS NOT NULL) AS is_project, + p.teamspace_project AS project_name 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 WHERE t.ticket_number = $1; `, - [ticketNumber] + [ticketNumber, userId] ); res.json({ @@ -2915,23 +3048,30 @@ app.get("/api/tickets/search", requireUser, async (req, res) => { COALESCE(o.name, t.customer_name, '') AS organization_name, COALESCE(o.name, t.customer_name, '') AS customer_name, t.work_type, - MAX(s.started_at) AS latest_started_at, - to_char(MAX(s.started_at) AT TIME ZONE 'Europe/Berlin', 'YYYY-MM') AS latest_period, - COUNT(s.id)::int AS session_count, - COALESCE(SUM(s.rounded_minutes), 0)::int AS total_minutes + p.id::text AS project_id, + (p.id IS NOT NULL) AS is_project, + p.teamspace_project AS project_name, + MAX(s.started_at) FILTER (WHERE s.user_id = $1) AS latest_started_at, + COALESCE(to_char(MAX(s.started_at) FILTER (WHERE s.user_id = $1) AT TIME ZONE 'Europe/Berlin', 'YYYY-MM'), '') AS latest_period, + COUNT(s.id) FILTER (WHERE s.user_id = $1)::int AS session_count, + COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.user_id = $1), 0)::int AS total_minutes FROM tickets t - JOIN sessions s ON s.ticket_id = t.id + LEFT JOIN sessions s ON s.ticket_id = t.id LEFT JOIN organizations o ON o.zammad_id = t.organization_id - WHERE s.user_id = $1 + LEFT JOIN projects p ON p.ticket_id = t.id AND p.user_id = $1 + WHERE (s.user_id = $1 OR p.id IS NOT NULL) AND ( t.ticket_number ILIKE $2 OR replace(t.ticket_number, 'Ticket#', '') ILIKE $3 OR COALESCE(o.name, '') ILIKE $2 OR COALESCE(t.customer_name, '') ILIKE $2 - OR COALESCE(s.customer_name, '') ILIKE $2 - OR s.activity ILIKE $2 + OR (s.user_id = $1 AND COALESCE(s.customer_name, '') ILIKE $2) + OR (s.user_id = $1 AND s.activity ILIKE $2) + OR COALESCE(p.teamspace_project, '') ILIKE $2 + OR COALESCE(p.description, '') ILIKE $2 + OR COALESCE(p.goals, '') ILIKE $2 ) - GROUP BY t.id, t.ticket_number, t.organization_id, o.name, t.customer_name, t.work_type + GROUP BY t.id, t.ticket_number, t.organization_id, o.name, t.customer_name, t.work_type, p.id, p.teamspace_project ORDER BY CASE WHEN lower(t.ticket_number) = lower($4) THEN 0 @@ -2939,7 +3079,7 @@ app.get("/api/tickets/search", requireUser, async (req, res) => { WHEN t.ticket_number ILIKE $3 THEN 2 ELSE 3 END, - MAX(s.started_at) DESC, + MAX(s.started_at) FILTER (WHERE s.user_id = $1) DESC NULLS LAST, t.ticket_number ASC LIMIT 12; `, @@ -2949,6 +3089,484 @@ app.get("/api/tickets/search", requireUser, async (req, res) => { res.json({ tickets: result.rows }); }); +app.get("/api/projects", requireUser, async (req, res) => { + const userId = currentUser(req).id; + const result = await query( + ` + SELECT + p.id::text AS id, + p.ticket_id::text AS ticket_id, + t.ticket_number, + t.organization_id::text AS organization_id, + o.name AS organization_name, + COALESCE(o.name, t.customer_name) AS customer_name, + t.work_type, + 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 + 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, + 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 + 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; + `, + [userId] + ); + + res.json({ projects: result.rows }); +}); + +app.get("/api/projects/:projectId", requireUser, async (req, res) => { + const projectId = parsePositiveInteger(req.params.projectId, "projectId"); + const userId = currentUser(req).id; + const result = await query( + ` + SELECT + p.id::text AS id, + p.ticket_id::text AS ticket_id, + t.ticket_number, + t.organization_id::text AS organization_id, + o.name AS organization_name, + COALESCE(o.name, t.customer_name) AS customer_name, + t.work_type, + 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 + AND p.user_id = $2; + `, + [projectId, userId] + ); + + if (result.rowCount === 0) { + res.status(404).json({ error: "Projekt nicht gefunden" }); + return; + } + + res.json({ project: result.rows[0] }); +}); + +app.post("/api/projects", requireUser, async (req, res) => { + const userId = currentUser(req).id; + const ticketNumber = parseTicketNumber(req.body.ticketNumber); + const requestOrganizationId = req.body.organizationId === undefined || req.body.organizationId === "" ? null : parseOrganizationId(req.body.organizationId); + const teamspaceProject = optionalString(req.body.teamspaceProject); + const description = optionalString(req.body.description) ?? ""; + const goals = optionalString(req.body.goals) ?? ""; + const startsOn = parseOptionalDateOnly(req.body.startsOn, "startsOn"); + const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn"); + const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes"); + + if (startsOn && endsOn && endsOn < startsOn) { + throw badRequest("endsOn must be after startsOn"); + } + + try { + const created = await withTransaction(async (client) => { + const existingTicket = await client.query<{ + id: string; + organization_id: string | null; + customer_name: string | null; + }>( + ` + SELECT id, organization_id::text AS organization_id, customer_name + FROM tickets + WHERE ticket_number = $1 + FOR UPDATE; + `, + [ticketNumber] + ); + + const organizationId = existingTicket.rows[0]?.organization_id ? Number(existingTicket.rows[0].organization_id) : requestOrganizationId; + + if (!organizationId) { + return "organization-required" as const; + } + + const organization = await getOrganizationByZammadId(client, organizationId); + + if (!organization) { + return "organization-not-found" as const; + } + + const ticketResult = await client.query<{ id: string }>( + ` + INSERT INTO tickets (ticket_number, organization_id, customer_name, work_type) + VALUES ($1, $2, $3, 'consulting') + ON CONFLICT (ticket_number) + DO UPDATE SET + organization_id = EXCLUDED.organization_id, + customer_name = EXCLUDED.customer_name, + work_type = 'consulting' + RETURNING id; + `, + [ticketNumber, organization.id, organization.name] + ); + + const ticketId = ticketResult.rows[0].id; + + await client.query( + ` + UPDATE sessions + SET organization_id = $1, + customer_name = $2, + work_type = 'consulting' + WHERE ticket_id = $3 + AND user_id = $4 + AND recurring_billing_id IS NULL; + `, + [organization.id, organization.name, ticketId, userId] + ); + + const projectResult = await client.query( + ` + INSERT INTO projects ( + user_id, + ticket_id, + teamspace_project, + description, + goals, + starts_on, + ends_on, + hour_budget_minutes + ) + VALUES ($1, $2, $3, $4, $5, $6::date, $7::date, $8) + RETURNING + id::text, + ticket_id::text, + $9::text AS ticket_number, + $10::text AS organization_id, + $11::text AS organization_name, + $11::text AS customer_name, + 'consulting'::text AS work_type, + teamspace_project, + description, + goals, + starts_on::text, + ends_on::text, + hour_budget_minutes, + created_at, + updated_at; + `, + [userId, ticketId, teamspaceProject, description, goals, startsOn, endsOn, hourBudgetMinutes, ticketNumber, organization.id, organization.name] + ); + + return projectResult.rows[0]; + }); + + if (created === "organization-required") { + res.status(400).json({ error: "Organisation ist erforderlich, wenn das Ticket noch keine Stammdaten hat" }); + return; + } + + if (created === "organization-not-found") { + res.status(404).json({ error: "Organisation nicht gefunden" }); + return; + } + + res.status(201).json({ project: created }); + } catch (error: any) { + if (error?.code === "23505") { + res.status(409).json({ error: "Dieses Ticket ist bereits als Projekt angelegt" }); + return; + } + + throw error; + } +}); + +app.patch("/api/projects/:projectId", requireUser, async (req, res) => { + const projectId = parsePositiveInteger(req.params.projectId, "projectId"); + const userId = currentUser(req).id; + const ticketNumber = parseTicketNumber(req.body.ticketNumber); + const organizationId = parseOrganizationId(req.body.organizationId); + const teamspaceProject = optionalString(req.body.teamspaceProject); + const description = optionalString(req.body.description) ?? ""; + const goals = optionalString(req.body.goals) ?? ""; + const startsOn = parseOptionalDateOnly(req.body.startsOn, "startsOn"); + const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn"); + const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes"); + + if (startsOn && endsOn && endsOn < startsOn) { + throw badRequest("endsOn must be after startsOn"); + } + + try { + const updated = await withTransaction(async (client) => { + const projectResult = await client.query<{ id: string; ticket_id: string }>( + ` + SELECT id, ticket_id + FROM projects + WHERE id = $1 + AND user_id = $2 + FOR UPDATE; + `, + [projectId, userId] + ); + + if (projectResult.rowCount === 0) { + return null; + } + + const organization = await getOrganizationByZammadId(client, organizationId); + + if (!organization) { + return "organization-not-found" as const; + } + + await client.query( + ` + UPDATE tickets + SET ticket_number = $1, + organization_id = $2, + customer_name = $3, + work_type = 'consulting' + WHERE id = $4; + `, + [ticketNumber, organization.id, organization.name, projectResult.rows[0].ticket_id] + ); + + await client.query( + ` + UPDATE sessions + SET organization_id = $1, + customer_name = $2, + work_type = CASE WHEN recurring_billing_id IS NULL THEN 'consulting' ELSE work_type END + WHERE ticket_id = $3 + AND user_id = $4; + `, + [organization.id, organization.name, projectResult.rows[0].ticket_id, userId] + ); + + const result = await client.query( + ` + UPDATE projects + SET teamspace_project = $1, + description = $2, + goals = $3, + starts_on = $4::date, + ends_on = $5::date, + hour_budget_minutes = $6, + updated_at = now() + WHERE id = $7 + AND user_id = $8 + RETURNING + id::text, + ticket_id::text, + $9::text AS ticket_number, + $10::text AS organization_id, + $11::text AS organization_name, + $11::text AS customer_name, + 'consulting'::text AS work_type, + teamspace_project, + description, + goals, + starts_on::text, + ends_on::text, + hour_budget_minutes, + created_at, + updated_at; + `, + [teamspaceProject, description, goals, startsOn, endsOn, hourBudgetMinutes, projectId, userId, ticketNumber, organization.id, organization.name] + ); + + return result.rows[0]; + }); + + if (updated === "organization-not-found") { + res.status(404).json({ error: "Organisation nicht gefunden" }); + return; + } + + if (!updated) { + res.status(404).json({ error: "Projekt nicht gefunden" }); + return; + } + + res.json({ project: updated }); + } catch (error: any) { + if (error?.code === "23505") { + res.status(409).json({ error: "Ticketnummer ist bereits vergeben" }); + return; + } + + throw error; + } +}); + +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"; + const params: unknown[] = [userId]; + const where = ["tt.user_id = $1"]; + + if (status !== "all") { + params.push(status); + where.push(`tt.status = $${params.length}`); + } + + const result = await query( + ` + SELECT + tt.id::text AS id, + tt.ticket_id::text AS ticket_id, + t.ticket_number, + COALESCE(o.name, t.customer_name) AS organization_name, + p.id::text AS project_id, + p.teamspace_project, + tt.title, + tt.description, + tt.priority, + tt.status, + tt.completed_at, + tt.created_at, + tt.updated_at + FROM ticket_todos tt + JOIN tickets t ON t.id = tt.ticket_id + LEFT JOIN organizations o ON o.zammad_id = t.organization_id + LEFT JOIN projects p ON p.ticket_id = tt.ticket_id AND p.user_id = tt.user_id + 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; + `, + params + ); + + res.json({ todos: result.rows }); +}); + +app.post("/api/tickets/:ticketId/todos", requireUser, async (req, res) => { + const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); + const userId = currentUser(req).id; + const title = requireString(req.body.title, "title"); + const description = optionalString(req.body.description) ?? ""; + const priority = parseTodoPriority(req.body.priority ?? "medium"); + + const ticketResult = await query<{ id: string }>("SELECT id FROM tickets WHERE id = $1;", [ticketId]); + + if (ticketResult.rowCount === 0) { + res.status(404).json({ error: "Ticket nicht gefunden" }); + return; + } + + const result = await query( + ` + INSERT INTO ticket_todos (ticket_id, user_id, title, description, priority) + VALUES ($1, $2, $3, $4, $5) + RETURNING + id::text, + ticket_id::text, + title, + description, + priority, + status, + completed_at, + created_at, + updated_at; + `, + [ticketId, userId, title, description, priority] + ); + + res.status(201).json({ todo: result.rows[0] }); +}); + +app.patch("/api/todos/:todoId", requireUser, async (req, res) => { + const todoId = parsePositiveInteger(req.params.todoId, "todoId"); + const userId = currentUser(req).id; + const title = requireString(req.body.title, "title"); + const description = optionalString(req.body.description) ?? ""; + const priority = parseTodoPriority(req.body.priority); + const status = parseTodoStatus(req.body.status); + + const result = await query( + ` + UPDATE ticket_todos + SET title = $1, + description = $2, + priority = $3, + status = $4, + completed_at = CASE WHEN $4 = 'done' THEN COALESCE(completed_at, now()) ELSE NULL END, + updated_at = now() + WHERE id = $5 + AND user_id = $6 + RETURNING + id::text, + ticket_id::text, + title, + description, + priority, + status, + completed_at, + created_at, + updated_at; + `, + [title, description, priority, status, todoId, userId] + ); + + if (result.rowCount === 0) { + res.status(404).json({ error: "ToDo nicht gefunden" }); + return; + } + + res.json({ todo: result.rows[0] }); +}); + +app.delete("/api/todos/:todoId", requireUser, async (req, res) => { + const todoId = parsePositiveInteger(req.params.todoId, "todoId"); + const userId = currentUser(req).id; + const result = await query( + ` + DELETE FROM ticket_todos + WHERE id = $1 + AND user_id = $2 + RETURNING id::text; + `, + [todoId, userId] + ); + + if (result.rowCount === 0) { + res.status(404).json({ error: "ToDo nicht gefunden" }); + return; + } + + res.json({ deleted: result.rows[0] }); +}); + app.get("/api/tickets/:ticketId/details", requireUser, async (req, res) => { const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const result = await getTicketDetails(ticketId, currentUser(req).id, { @@ -2979,6 +3597,16 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { return "organization-not-found" as const; } + const projectResult = await client.query<{ id: string }>( + ` + SELECT id + FROM projects + WHERE ticket_id = $1 + AND user_id = $2; + `, + [ticketId, currentUser(req).id] + ); + const effectiveWorkType = projectResult.rowCount && projectResult.rowCount > 0 ? "consulting" : workType; const ticketResult = await client.query( ` UPDATE tickets @@ -2989,7 +3617,7 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { WHERE id = $5 RETURNING id, ticket_number, organization_id, customer_name, work_type; `, - [ticketNumber, organization.id, organization.name, workType, ticketId] + [ticketNumber, organization.id, organization.name, effectiveWorkType, ticketId] ); if (ticketResult.rowCount === 0) { @@ -3004,7 +3632,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, workType, ticketId] + [organization.id, organization.name, effectiveWorkType, ticketId] ); return ticketResult.rows[0]; @@ -3487,6 +4115,7 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { work_type: "support" | "consulting"; recurring_billing_id: string | null; recurring_work_type: "support" | "consulting" | null; + project_id: string | null; month_closed_at: string | null; }>( ` @@ -3497,9 +4126,11 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { s.work_type, s.recurring_billing_id, rb.work_type AS recurring_work_type, + p.id AS project_id, mc.closed_at AS month_closed_at FROM sessions s LEFT JOIN recurring_billings rb ON rb.id = s.recurring_billing_id + LEFT JOIN projects p ON p.ticket_id = s.ticket_id AND p.user_id = s.user_id LEFT JOIN month_closures mc ON mc.month = date_trunc('month', s.started_at AT TIME ZONE 'Europe/Berlin')::date AND mc.user_id = s.user_id @@ -3529,7 +4160,7 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { return "month-closed" as const; } - const effectiveWorkType = current.recurring_billing_id ? current.recurring_work_type ?? current.work_type : workType; + const effectiveWorkType = current.project_id ? "consulting" : current.recurring_billing_id ? current.recurring_work_type ?? current.work_type : workType; await reopenPeriodsForSession(client, current.ticket_id, new Date(current.started_at), userId); await reopenPeriodsForSession(client, current.ticket_id, startedAt, userId); diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index 8405fa2..573d73f 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -435,6 +435,42 @@ export async function migrate() { ); `); + await query(` + CREATE TABLE IF NOT EXISTS projects ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + teamspace_project TEXT, + description TEXT NOT NULL DEFAULT '', + goals TEXT NOT NULL DEFAULT '', + starts_on DATE, + ends_on DATE, + hour_budget_minutes INTEGER, + 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_valid_range CHECK (starts_on IS NULL OR ends_on IS NULL OR ends_on >= starts_on) + ); + `); + + await query(` + CREATE TABLE IF NOT EXISTS ticket_todos ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + priority TEXT NOT NULL DEFAULT 'medium', + status TEXT NOT NULL DEFAULT 'open', + 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')) + ); + `); + 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); @@ -450,6 +486,10 @@ export async function migrate() { await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_closures_day ON ticket_day_closures(day);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_user_day ON ticket_day_billings(user_id, day);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billing_ack_user_day ON ticket_day_billing_acknowledgements(user_id, day);"); + await query("CREATE INDEX IF NOT EXISTS idx_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_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_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 a8edecb..4bf3cfb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,8 +6,10 @@ import { ChartNoAxesCombined, CircleHelp, FileText, + FolderKanban, GripVertical, LayoutGrid, + ListTodo, LogOut, Moon, Pause, @@ -65,11 +67,13 @@ import { FaqPage } from "./views/FaqPage"; import { LoginPage } from "./views/LoginPage"; import { MonthlyClosePage } from "./views/MonthlyClosePage"; import { ProfilePage } from "./views/ProfilePage"; +import { ProjectsPage } from "./views/ProjectsPage"; import { RecurringBillingsPage } from "./views/RecurringBillingsPage"; import { StatisticsPage } from "./views/StatisticsPage"; 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 { Dispatch, DragEvent, SetStateAction } from "react"; @@ -177,6 +181,14 @@ function routeFromPath(pathWithSearch: string) { return { page: "statistics" as const }; } + if (pathname.startsWith("/projects")) { + return { page: "projects" as const }; + } + + if (pathname.startsWith("/todos")) { + return { page: "todos" as const }; + } + if (pathname.startsWith("/faq")) { return { page: "faq" as const }; } @@ -387,11 +399,15 @@ function HeaderTicketSearch({ className, onNavigate }: HeaderTicketSearchProps) }} > - {ticket.ticket_number} + + {ticket.ticket_number} + {ticket.is_project ? Projekt : null} + {formatMinutes(ticket.total_minutes)} - {ticket.organization_name || ticket.customer_name || "Keine Organisation"} · {ticket.session_count} Session(s) · zuletzt {formatDateTime(ticket.latest_started_at)} + {ticket.project_name || ticket.organization_name || ticket.customer_name || "Keine Organisation"} · {ticket.session_count} Session(s) + {ticket.latest_started_at ? ` · zuletzt ${formatDateTime(ticket.latest_started_at)}` : ""} )) @@ -489,7 +505,8 @@ function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers, {timer.organizationName ? ( - {timer.organizationName} + {timer.isProject ? "Projekt · " : ""} + {timer.projectName || timer.organizationName} ) : null} @@ -631,13 +648,17 @@ export function App() { return { id: timer.id, organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null, - workType: result.ticket?.work_type ?? null + workType: result.ticket?.work_type ?? null, + isProject: Boolean(result.ticket?.is_project), + projectName: result.ticket?.project_name ?? null }; } catch { return { id: timer.id, organizationName: null, - workType: null + workType: null, + isProject: false, + projectName: null }; } }) @@ -659,6 +680,8 @@ export function App() { ...timer, organizationName: result.organizationName, workType: result.workType, + isProject: result.isProject, + projectName: result.projectName, ticketLookupDone: true }; }) @@ -727,6 +750,8 @@ export function App() { ticketNumber, organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null, workType: ticketResult.ticket?.work_type ?? null, + isProject: Boolean(ticketResult.ticket?.is_project), + projectName: ticketResult.ticket?.project_name ?? null, ticketLookupDone: true, startedAt: now, pausedTotalMs: 0, @@ -737,7 +762,7 @@ export function App() { setTimers((current) => [...current.map((timer) => pauseEntry(timer, now)), newTimer]); setSelectedTimerId(newTimer.id); toast.success("Timer gestartet", { - description: ticketNumber + description: ticketResult.ticket?.is_project ? `${ticketNumber} ist ein Projekt` : ticketNumber }); return true; } @@ -870,6 +895,8 @@ export function App() { { type: "item" as const, href: "/monthly-close", label: "Monatsabschluss", icon: ClipboardCheck, active: path.startsWith("/monthly-close") }, { type: "separator" as const, key: "after-monthly-close" }, { type: "item" as const, href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") }, + { 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") }, { type: "item" as const, href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") }, { type: "separator" as const, key: "before-profile" }, { type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") }, @@ -1130,6 +1157,8 @@ export function App() { {!isAdmin && route.page === "analysis" ? : null} {!isAdmin && route.page === "monthly-close" ? : null} {!isAdmin && route.page === "statistics" ? : null} + {!isAdmin && route.page === "projects" ? : null} + {!isAdmin && route.page === "todos" ? : null} {!isAdmin && route.page === "recurring" ? : null} {route.page === "faq" ? : null} {route.page === "profile" ? : null} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 8b5de4d..165db56 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -5,11 +5,15 @@ import type { Organization, PeriodOverview, PeriodType, + ProjectSummary, RecurringBilling, StatisticsOverview, TicketMeta, TicketPeriod, + TicketTodo, TicketSearchResult, + TodoPriority, + TodoStatus, WorkType } from "./types"; @@ -290,6 +294,94 @@ export function deleteRecurringBilling(billingId: string) { }); } +export function getProjects() { + return request<{ projects: ProjectSummary[] }>("/api/projects"); +} + +export function getProject(projectId: string) { + return request<{ project: ProjectSummary }>(`/api/projects/${projectId}`); +} + +export function createProject(payload: { + ticketNumber: string; + organizationId?: string | null; + teamspaceProject?: string | null; + description: string; + goals: string; + startsOn?: string | null; + endsOn?: string | null; + hourBudgetMinutes?: number | null; +}) { + return request<{ project: ProjectSummary }>("/api/projects", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function updateProject( + projectId: string, + payload: { + ticketNumber: string; + organizationId: string; + teamspaceProject?: string | null; + description: string; + goals: string; + startsOn?: string | null; + endsOn?: string | null; + hourBudgetMinutes?: number | null; + } +) { + return request<{ project: ProjectSummary }>(`/api/projects/${projectId}`, { + method: "PATCH", + body: JSON.stringify(payload) + }); +} + +export function getTodos(status?: TodoStatus | "all") { + const params = new URLSearchParams(); + + if (status && status !== "all") { + params.set("status", status); + } + + return request<{ todos: TicketTodo[] }>(`/api/todos${params.size ? `?${params}` : ""}`); +} + +export function createTicketTodo( + ticketId: string, + payload: { + title: string; + description: string; + priority: TodoPriority; + } +) { + return request<{ todo: TicketTodo }>(`/api/tickets/${ticketId}/todos`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function updateTodo( + todoId: string, + payload: { + title: string; + description: string; + priority: TodoPriority; + status: TodoStatus; + } +) { + return request<{ todo: TicketTodo }>(`/api/todos/${todoId}`, { + method: "PATCH", + body: JSON.stringify(payload) + }); +} + +export function deleteTodo(todoId: string) { + return request<{ deleted: { id: string } }>(`/api/todos/${todoId}`, { + method: "DELETE" + }); +} + export function createSession(payload: CreateSessionPayload) { return request("/api/sessions", { method: "POST", diff --git a/frontend/src/components/TodoPanel.tsx b/frontend/src/components/TodoPanel.tsx new file mode 100644 index 0000000..4f1c47e --- /dev/null +++ b/frontend/src/components/TodoPanel.tsx @@ -0,0 +1,235 @@ +import { CheckCircle2, Circle, Pencil, Plus, Save, Trash2 } from "lucide-react"; +import { FormEvent, useState } from "react"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { createTicketTodo, deleteTodo, updateTodo } from "../api"; +import type { TicketTodo, TodoPriority, TodoStatus } from "../types"; + +const priorityLabels: Record = { + low: "Niedrig", + medium: "Normal", + high: "Hoch", + urgent: "Dringend" +}; + +const priorityClasses: 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" +}; + +type TodoPanelProps = { + ticketId?: string; + todos: TicketTodo[]; + onChanged: () => Promise | void; + showCreate?: boolean; + showTicketMeta?: boolean; + onNavigate?: (to: string) => void; +}; + +export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate }: TodoPanelProps) { + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [priority, setPriority] = useState("medium"); + const [saving, setSaving] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editTitle, setEditTitle] = useState(""); + const [editDescription, setEditDescription] = useState(""); + const [editPriority, setEditPriority] = useState("medium"); + const [busyId, setBusyId] = useState(null); + + async function submitTodo(event: FormEvent) { + event.preventDefault(); + + if (!ticketId) { + return; + } + + setSaving(true); + try { + await createTicketTodo(ticketId, { title, description, priority }); + setTitle(""); + setDescription(""); + setPriority("medium"); + toast.success("ToDo angelegt"); + await onChanged(); + } catch (error) { + toast.error("ToDo konnte nicht angelegt werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } finally { + setSaving(false); + } + } + + function startEdit(todo: TicketTodo) { + setEditingId(todo.id); + setEditTitle(todo.title); + setEditDescription(todo.description); + setEditPriority(todo.priority); + } + + async function saveTodo(todo: TicketTodo, status: TodoStatus = todo.status) { + 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 + }); + setEditingId(null); + await onChanged(); + } catch (error) { + toast.error("ToDo konnte nicht gespeichert werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } finally { + setBusyId(null); + } + } + + async function removeTodo(todo: TicketTodo) { + const confirmed = window.confirm(`ToDo "${todo.title}" wirklich löschen?`); + + if (!confirmed) { + return; + } + + setBusyId(todo.id); + try { + await deleteTodo(todo.id); + toast.success("ToDo gelöscht"); + await onChanged(); + } catch (error) { + toast.error("ToDo konnte nicht gelöscht werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } finally { + setBusyId(null); + } + } + + return ( +
+ {showCreate && ticketId ? ( +
+
+ + setTitle(event.currentTarget.value)} required /> +
+
+ + +
+ +