From 239be9f2183797e1a1625c2fd67b522ed796188c Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Wed, 2 Sep 2026 11:23:34 +0200 Subject: [PATCH] Refine project and todo workflow --- backend/src/index.ts | 70 ++++-- backend/src/migrations.ts | 2 + frontend/src/App.tsx | 8 +- frontend/src/api.ts | 3 + frontend/src/components/TodoPanel.tsx | 96 +++++--- frontend/src/types.ts | 5 + frontend/src/views/AnalysisPage.tsx | 7 +- frontend/src/views/ProjectsPage.tsx | 296 +++++++++++++---------- frontend/src/views/TicketDetailPage.tsx | 301 ++++++++++++++---------- 9 files changed, 487 insertions(+), 301 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 2e77de7..2edefef 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -58,6 +58,19 @@ function optionalString(value: unknown) { return trimmed.length > 0 ? trimmed : null; } +function optionalNullableString(value: unknown) { + if (value === null || value === undefined) { + return null; + } + + if (typeof value !== "string") { + throw badRequest("value must be a string or null"); + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + function optionalWorkType(value: unknown) { if (value === undefined || value === null || value === "") { return null; @@ -148,6 +161,7 @@ type ProjectRow = { id: string; ticket_id: string; ticket_number: string; + title: string | null; organization_id: string | null; organization_name: string | null; customer_name: string | null; @@ -637,6 +651,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use SELECT t.id, t.ticket_number, + t.title, t.organization_id, o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, @@ -662,6 +677,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use GROUP BY t.id, t.ticket_number, + t.title, t.organization_id, o.name, t.customer_name, @@ -1153,8 +1169,9 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke ` SELECT t.id, - t.ticket_number, - t.organization_id, + t.ticket_number, + t.title, + t.organization_id, o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, @@ -1275,6 +1292,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon SELECT t.id, t.ticket_number, + t.title, t.organization_id, o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, @@ -1402,6 +1420,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon p.id::text AS id, p.ticket_id::text AS ticket_id, t.ticket_number, + t.title, t.organization_id::text AS organization_id, o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, @@ -3044,6 +3063,7 @@ app.get("/api/tickets/search", requireUser, async (req, res) => { SELECT t.id AS ticket_id, t.ticket_number, + t.title, t.organization_id, COALESCE(o.name, t.customer_name, '') AS organization_name, COALESCE(o.name, t.customer_name, '') AS customer_name, @@ -3063,6 +3083,7 @@ app.get("/api/tickets/search", requireUser, async (req, res) => { AND ( t.ticket_number ILIKE $2 OR replace(t.ticket_number, 'Ticket#', '') ILIKE $3 + OR COALESCE(t.title, '') ILIKE $2 OR COALESCE(o.name, '') ILIKE $2 OR COALESCE(t.customer_name, '') ILIKE $2 OR (s.user_id = $1 AND COALESCE(s.customer_name, '') ILIKE $2) @@ -3071,7 +3092,7 @@ app.get("/api/tickets/search", requireUser, async (req, res) => { 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, p.id, p.teamspace_project + GROUP BY t.id, t.ticket_number, t.title, 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 @@ -3097,6 +3118,7 @@ app.get("/api/projects", requireUser, async (req, res) => { p.id::text AS id, p.ticket_id::text AS ticket_id, t.ticket_number, + t.title, t.organization_id::text AS organization_id, o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, @@ -3150,6 +3172,7 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { p.id::text AS id, p.ticket_id::text AS ticket_id, t.ticket_number, + t.title, t.organization_id::text AS organization_id, o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, @@ -3182,6 +3205,7 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { app.post("/api/projects", requireUser, async (req, res) => { const userId = currentUser(req).id; const ticketNumber = parseTicketNumber(req.body.ticketNumber); + const title = optionalNullableString(req.body.title); 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) ?? ""; @@ -3198,11 +3222,12 @@ app.post("/api/projects", requireUser, async (req, res) => { const created = await withTransaction(async (client) => { const existingTicket = await client.query<{ id: string; + title: string | null; organization_id: string | null; customer_name: string | null; }>( ` - SELECT id, organization_id::text AS organization_id, customer_name + SELECT id, title, organization_id::text AS organization_id, customer_name FROM tickets WHERE ticket_number = $1 FOR UPDATE; @@ -3224,16 +3249,17 @@ app.post("/api/projects", requireUser, async (req, res) => { const ticketResult = await client.query<{ id: string }>( ` - INSERT INTO tickets (ticket_number, organization_id, customer_name, work_type) - VALUES ($1, $2, $3, 'consulting') + INSERT INTO tickets (ticket_number, title, organization_id, customer_name, work_type) + VALUES ($1, $2, $3, $4, 'consulting') ON CONFLICT (ticket_number) DO UPDATE SET + title = COALESCE(EXCLUDED.title, tickets.title), organization_id = EXCLUDED.organization_id, customer_name = EXCLUDED.customer_name, work_type = 'consulting' RETURNING id; `, - [ticketNumber, organization.id, organization.name] + [ticketNumber, title, organization.id, organization.name] ); const ticketId = ticketResult.rows[0].id; @@ -3268,6 +3294,7 @@ app.post("/api/projects", requireUser, async (req, res) => { id::text, ticket_id::text, $9::text AS ticket_number, + COALESCE($12::text, $13::text) AS title, $10::text AS organization_id, $11::text AS organization_name, $11::text AS customer_name, @@ -3281,7 +3308,7 @@ app.post("/api/projects", requireUser, async (req, res) => { created_at, updated_at; `, - [userId, ticketId, teamspaceProject, description, goals, startsOn, endsOn, hourBudgetMinutes, ticketNumber, organization.id, organization.name] + [userId, ticketId, teamspaceProject, description, goals, startsOn, endsOn, hourBudgetMinutes, ticketNumber, organization.id, organization.name, title, existingTicket.rows[0]?.title ?? null] ); return projectResult.rows[0]; @@ -3312,6 +3339,7 @@ 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 title = optionalNullableString(req.body.title); const organizationId = parseOrganizationId(req.body.organizationId); const teamspaceProject = optionalString(req.body.teamspaceProject); const description = optionalString(req.body.description) ?? ""; @@ -3351,12 +3379,13 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { ` UPDATE tickets SET ticket_number = $1, - organization_id = $2, - customer_name = $3, + title = $2, + organization_id = $3, + customer_name = $4, work_type = 'consulting' - WHERE id = $4; + WHERE id = $5; `, - [ticketNumber, organization.id, organization.name, projectResult.rows[0].ticket_id] + [ticketNumber, title, organization.id, organization.name, projectResult.rows[0].ticket_id] ); await client.query( @@ -3387,6 +3416,7 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { id::text, ticket_id::text, $9::text AS ticket_number, + $12::text AS title, $10::text AS organization_id, $11::text AS organization_name, $11::text AS customer_name, @@ -3400,7 +3430,7 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { created_at, updated_at; `, - [teamspaceProject, description, goals, startsOn, endsOn, hourBudgetMinutes, projectId, userId, ticketNumber, organization.id, organization.name] + [teamspaceProject, description, goals, startsOn, endsOn, hourBudgetMinutes, projectId, userId, ticketNumber, organization.id, organization.name, title] ); return result.rows[0]; @@ -3586,6 +3616,7 @@ app.get("/api/tickets/:ticketId/details", requireUser, async (req, res) => { app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber); + const title = optionalNullableString(req.body.title); const organizationId = parseOrganizationId(req.body.organizationId); const workType = parseWorkType(req.body.workType); @@ -3611,13 +3642,14 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { ` UPDATE tickets SET ticket_number = $1, - organization_id = $2, - customer_name = $3, - work_type = $4 - WHERE id = $5 - RETURNING id, ticket_number, organization_id, customer_name, work_type; + title = $2, + organization_id = $3, + customer_name = $4, + work_type = $5 + WHERE id = $6 + RETURNING id, ticket_number, title, organization_id, customer_name, work_type; `, - [ticketNumber, organization.id, organization.name, effectiveWorkType, ticketId] + [ticketNumber, title, organization.id, organization.name, effectiveWorkType, ticketId] ); if (ticketResult.rowCount === 0) { diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index 573d73f..946e897 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -113,6 +113,7 @@ export async function migrate() { CREATE TABLE IF NOT EXISTS tickets ( id BIGSERIAL PRIMARY KEY, ticket_number TEXT NOT NULL UNIQUE, + title TEXT, organization_id BIGINT REFERENCES organizations(zammad_id), customer_name TEXT, work_type TEXT, @@ -123,6 +124,7 @@ export async function migrate() { `); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS customer_name TEXT;"); + await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS title TEXT;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(zammad_id);"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS work_type TEXT;"); await query(` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4bf3cfb..2142868 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -400,12 +400,13 @@ function HeaderTicketSearch({ className, onNavigate }: HeaderTicketSearchProps) > - {ticket.ticket_number} + {ticket.title || ticket.ticket_number} {ticket.is_project ? Projekt : null} {formatMinutes(ticket.total_minutes)} + {ticket.title ? `${ticket.ticket_number} · ` : ""} {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)}` : ""} @@ -892,11 +893,12 @@ export function App() { { type: "item" as const, href: "/timer", label: "Session starten", icon: Timer, active: path.startsWith("/timer") || path === "/" }, { type: "item" as const, href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") }, { type: "separator" as const, key: "after-analysis" }, + { type: "item" as const, href: "/projects", label: "Projekte", icon: FolderKanban, active: path.startsWith("/projects") }, + { type: "item" as const, href: "/todos", label: "ToDos", icon: ListTodo, active: path.startsWith("/todos") }, + { type: "separator" as const, key: "after-work-items" }, { 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") }, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 165db56..b2c291d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -304,6 +304,7 @@ export function getProject(projectId: string) { export function createProject(payload: { ticketNumber: string; + title?: string | null; organizationId?: string | null; teamspaceProject?: string | null; description: string; @@ -322,6 +323,7 @@ export function updateProject( projectId: string, payload: { ticketNumber: string; + title?: string | null; organizationId: string; teamspaceProject?: string | null; description: string; @@ -401,6 +403,7 @@ export function updateTicket( ticketId: string, payload: { ticketNumber: string; + title?: string | null; organizationId: string; workType: WorkType; } diff --git a/frontend/src/components/TodoPanel.tsx b/frontend/src/components/TodoPanel.tsx index 4f1c47e..e732ca8 100644 --- a/frontend/src/components/TodoPanel.tsx +++ b/frontend/src/components/TodoPanel.tsx @@ -4,6 +4,7 @@ import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; @@ -38,6 +39,7 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT const [description, setDescription] = useState(""); const [priority, setPriority] = useState("medium"); const [saving, setSaving] = useState(false); + const [createOpen, setCreateOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [editTitle, setEditTitle] = useState(""); const [editDescription, setEditDescription] = useState(""); @@ -57,6 +59,7 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT setTitle(""); setDescription(""); setPriority("medium"); + setCreateOpen(false); toast.success("ToDo angelegt"); await onChanged(); } catch (error) { @@ -119,41 +122,64 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT return (
{showCreate && ticketId ? ( -
-
- - setTitle(event.currentTarget.value)} required /> -
-
- - -
- -