Add project and todo management

This commit is contained in:
michael committed 2026-09-02 11:03:50 +02:00
1 parent 2792051a16
commit 3da436423f
12 files changed
+1863 -41

No files matched your search

+657 -26
View File
@@ -144,6 +144,24 @@ type OrganizationRow = {
synced_at: string; 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 zammadBaseUrlSettingKey = "zammad.base_url";
const zammadApiKeySettingKey = "zammad.api_key"; const zammadApiKeySettingKey = "zammad.api_key";
@@ -192,6 +210,44 @@ function optionalBoolean(value: unknown, fallback: boolean) {
return value; 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<string, PeriodConfig> = { const periodConfigs: Record<string, PeriodConfig> = {
months: { months: {
type: "month", type: "month",
@@ -514,6 +570,16 @@ async function cleanupEmptyTickets(client: { query: (text: string, params?: unkn
SELECT 1 SELECT 1
FROM sessions s FROM sessions s
WHERE s.ticket_id = t.id 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] [uniqueTicketIds]
@@ -1261,7 +1327,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
} }
const sessionWhere = where.join(" AND "); const sessionWhere = where.join(" AND ");
const [sessionsResult, dayBillingResult, monthsResult, closureResult] = await Promise.all([ const [sessionsResult, dayBillingResult, monthsResult, closureResult, projectResult, todosResult] = await Promise.all([
query( query(
` `
SELECT SELECT
@@ -1329,7 +1395,62 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
), ),
filters.month filters.month
? query<{ closed_at: string }>("SELECT closed_at FROM month_closures WHERE month = $1::date AND user_id = $2;", [filters.month.start, userId]) ? 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; const sessions = sessionsResult.rows;
@@ -1358,6 +1479,8 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
}, },
sessions, sessions,
dayBillings: dayBillingResult.rows, dayBillings: dayBillingResult.rows,
project: projectResult.rows[0] ?? null,
todos: todosResult.rows,
canClose: false, canClose: false,
openCount, openCount,
ticketOpenCount: Number(ticket.ticket_open_count ?? 0) ticketOpenCount: Number(ticket.ticket_open_count ?? 0)
@@ -1866,6 +1989,8 @@ const databaseExportTables = [
"app_settings", "app_settings",
"organizations", "organizations",
"tickets", "tickets",
"projects",
"ticket_todos",
"recurring_billings", "recurring_billings",
"recurring_billing_slots", "recurring_billing_slots",
"recurring_billing_exceptions", "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 roundedMinutes = Math.max(1, Math.round(durationSeconds / 60));
const created = await withTransaction(async (client) => { 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 SELECT t.id, t.organization_id, t.customer_name, t.work_type, p.id AS project_id
FROM tickets FROM tickets t
WHERE ticket_number = $1 LEFT JOIN projects p ON p.ticket_id = t.id AND p.user_id = $2
FOR UPDATE; WHERE t.ticket_number = $1
FOR UPDATE OF t;
`, `,
[ticketNumber] [ticketNumber, user.id]
); );
const existingTicket = existingTicketResult.rows[0] ?? null; const existingTicket = existingTicketResult.rows[0] ?? null;
const effectiveOrganizationId = existingTicket?.organization_id ? Number(existingTicket.organization_id) : requestedOrganizationId; 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 DO UPDATE SET
organization_id = COALESCE(tickets.organization_id, EXCLUDED.organization_id), organization_id = COALESCE(tickets.organization_id, EXCLUDED.organization_id),
customer_name = COALESCE(tickets.customer_name, EXCLUDED.customer_name), 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; 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 ticket = ticketResult.rows[0];
const workType = ticket.work_type ?? requestedWorkType; const workType = existingTicket?.project_id ? "consulting" : ticket.work_type ?? requestedWorkType;
if (!workType) { if (!workType) {
throw badRequest("workType must be support or consulting"); 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) => { app.get("/api/tickets/lookup", requireUser, async (req, res) => {
const ticketNumber = parseTicketNumber(req.query.ticketNumber); const ticketNumber = parseTicketNumber(req.query.ticketNumber);
const userId = currentUser(req).id;
const result = await query( const result = await query(
` `
SELECT SELECT
@@ -2880,12 +3009,16 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => {
t.organization_id, t.organization_id,
o.name AS organization_name, o.name AS organization_name,
COALESCE(o.name, t.customer_name) AS customer_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 FROM tickets t
LEFT JOIN organizations o ON o.zammad_id = t.organization_id LEFT JOIN organizations o ON o.zammad_id = t.organization_id
LEFT JOIN projects p ON p.ticket_id = t.id AND p.user_id = $2
WHERE t.ticket_number = $1; WHERE t.ticket_number = $1;
`, `,
[ticketNumber] [ticketNumber, userId]
); );
res.json({ 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 organization_name,
COALESCE(o.name, t.customer_name, '') AS customer_name, COALESCE(o.name, t.customer_name, '') AS customer_name,
t.work_type, t.work_type,
MAX(s.started_at) AS latest_started_at, p.id::text AS project_id,
to_char(MAX(s.started_at) AT TIME ZONE 'Europe/Berlin', 'YYYY-MM') AS latest_period, (p.id IS NOT NULL) AS is_project,
COUNT(s.id)::int AS session_count, p.teamspace_project AS project_name,
COALESCE(SUM(s.rounded_minutes), 0)::int AS total_minutes 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 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 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 ( AND (
t.ticket_number ILIKE $2 t.ticket_number ILIKE $2
OR replace(t.ticket_number, 'Ticket#', '') ILIKE $3 OR replace(t.ticket_number, 'Ticket#', '') ILIKE $3
OR COALESCE(o.name, '') ILIKE $2 OR COALESCE(o.name, '') ILIKE $2
OR COALESCE(t.customer_name, '') ILIKE $2 OR COALESCE(t.customer_name, '') ILIKE $2
OR COALESCE(s.customer_name, '') ILIKE $2 OR (s.user_id = $1 AND COALESCE(s.customer_name, '') ILIKE $2)
OR s.activity 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 ORDER BY
CASE CASE
WHEN lower(t.ticket_number) = lower($4) THEN 0 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 WHEN t.ticket_number ILIKE $3 THEN 2
ELSE 3 ELSE 3
END, END,
MAX(s.started_at) DESC, MAX(s.started_at) FILTER (WHERE s.user_id = $1) DESC NULLS LAST,
t.ticket_number ASC t.ticket_number ASC
LIMIT 12; LIMIT 12;
`, `,
@@ -2949,6 +3089,484 @@ app.get("/api/tickets/search", requireUser, async (req, res) => {
res.json({ tickets: result.rows }); 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<ProjectRow>(
`
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<ProjectRow>(
`
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<ProjectRow>(
`
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) => { app.get("/api/tickets/:ticketId/details", requireUser, async (req, res) => {
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
const result = await getTicketDetails(ticketId, currentUser(req).id, { 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; 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( const ticketResult = await client.query(
` `
UPDATE tickets UPDATE tickets
@@ -2989,7 +3617,7 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => {
WHERE id = $5 WHERE id = $5
RETURNING id, ticket_number, organization_id, customer_name, work_type; 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) { 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 work_type = CASE WHEN recurring_billing_id IS NULL THEN $3 ELSE work_type END
WHERE ticket_id = $4; WHERE ticket_id = $4;
`, `,
[organization.id, organization.name, workType, ticketId] [organization.id, organization.name, effectiveWorkType, ticketId]
); );
return ticketResult.rows[0]; return ticketResult.rows[0];
@@ -3487,6 +4115,7 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => {
work_type: "support" | "consulting"; work_type: "support" | "consulting";
recurring_billing_id: string | null; recurring_billing_id: string | null;
recurring_work_type: "support" | "consulting" | null; recurring_work_type: "support" | "consulting" | null;
project_id: string | null;
month_closed_at: 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.work_type,
s.recurring_billing_id, s.recurring_billing_id,
rb.work_type AS recurring_work_type, rb.work_type AS recurring_work_type,
p.id AS project_id,
mc.closed_at AS month_closed_at mc.closed_at AS month_closed_at
FROM sessions s FROM sessions s
LEFT JOIN recurring_billings rb ON rb.id = s.recurring_billing_id 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 LEFT JOIN month_closures mc
ON mc.month = date_trunc('month', s.started_at AT TIME ZONE 'Europe/Berlin')::date ON mc.month = date_trunc('month', s.started_at AT TIME ZONE 'Europe/Berlin')::date
AND mc.user_id = s.user_id 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; 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, new Date(current.started_at), userId);
await reopenPeriodsForSession(client, current.ticket_id, startedAt, userId); await reopenPeriodsForSession(client, current.ticket_id, startedAt, userId);
+40
View File
@@ -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("ticket_month_closures", "month", ["ticket_id", "month"], adminId);
await ensureClosureOwnership("month_closures", "month", ["month"], adminId); await ensureClosureOwnership("month_closures", "month", ["month"], adminId);
await ensureClosureOwnership("ticket_day_closures", "day", ["ticket_id", "day"], 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_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_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_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("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("UPDATE recurring_billings SET active = true WHERE active = false;");
await query(` await query(`
+34 -5
View File
@@ -6,8 +6,10 @@ import {
ChartNoAxesCombined, ChartNoAxesCombined,
CircleHelp, CircleHelp,
FileText, FileText,
FolderKanban,
GripVertical, GripVertical,
LayoutGrid, LayoutGrid,
ListTodo,
LogOut, LogOut,
Moon, Moon,
Pause, Pause,
@@ -65,11 +67,13 @@ import { FaqPage } from "./views/FaqPage";
import { LoginPage } from "./views/LoginPage"; import { LoginPage } from "./views/LoginPage";
import { MonthlyClosePage } from "./views/MonthlyClosePage"; import { MonthlyClosePage } from "./views/MonthlyClosePage";
import { ProfilePage } from "./views/ProfilePage"; import { ProfilePage } from "./views/ProfilePage";
import { ProjectsPage } from "./views/ProjectsPage";
import { RecurringBillingsPage } from "./views/RecurringBillingsPage"; import { RecurringBillingsPage } from "./views/RecurringBillingsPage";
import { StatisticsPage } from "./views/StatisticsPage"; import { StatisticsPage } from "./views/StatisticsPage";
import { SetupPage } from "./views/SetupPage"; import { SetupPage } from "./views/SetupPage";
import { TicketDetailPage } from "./views/TicketDetailPage"; import { TicketDetailPage } from "./views/TicketDetailPage";
import { TimerPage } from "./views/TimerPage"; import { TimerPage } from "./views/TimerPage";
import { TodosPage } from "./views/TodosPage";
import type { AuthUser, PeriodType, TicketDetailPeriodType, TicketSearchResult } from "./types"; import type { AuthUser, PeriodType, TicketDetailPeriodType, TicketSearchResult } from "./types";
import type { Dispatch, DragEvent, SetStateAction } from "react"; import type { Dispatch, DragEvent, SetStateAction } from "react";
@@ -177,6 +181,14 @@ function routeFromPath(pathWithSearch: string) {
return { page: "statistics" as const }; 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")) { if (pathname.startsWith("/faq")) {
return { page: "faq" as const }; return { page: "faq" as const };
} }
@@ -387,11 +399,15 @@ function HeaderTicketSearch({ className, onNavigate }: HeaderTicketSearchProps)
}} }}
> >
<span className="flex min-w-0 items-center justify-between gap-3"> <span className="flex min-w-0 items-center justify-between gap-3">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{ticket.ticket_number}</span> <span className="truncate text-sm font-medium">{ticket.ticket_number}</span>
{ticket.is_project ? <span className="rounded-full border px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">Projekt</span> : null}
</span>
<span className="shrink-0 text-xs text-muted-foreground">{formatMinutes(ticket.total_minutes)}</span> <span className="shrink-0 text-xs text-muted-foreground">{formatMinutes(ticket.total_minutes)}</span>
</span> </span>
<span className="truncate text-xs text-muted-foreground"> <span className="truncate text-xs text-muted-foreground">
{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)}` : ""}
</span> </span>
</button> </button>
)) ))
@@ -489,7 +505,8 @@ function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers,
</span> </span>
{timer.organizationName ? ( {timer.organizationName ? (
<span className="block truncate text-[11px] leading-4 text-muted-foreground"> <span className="block truncate text-[11px] leading-4 text-muted-foreground">
{timer.organizationName} {timer.isProject ? "Projekt · " : ""}
{timer.projectName || timer.organizationName}
</span> </span>
) : null} ) : null}
<span className="block font-mono text-[11px] font-semibold leading-4 tracking-normal text-muted-foreground"> <span className="block font-mono text-[11px] font-semibold leading-4 tracking-normal text-muted-foreground">
@@ -631,13 +648,17 @@ export function App() {
return { return {
id: timer.id, id: timer.id,
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null, 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 { } catch {
return { return {
id: timer.id, id: timer.id,
organizationName: null, organizationName: null,
workType: null workType: null,
isProject: false,
projectName: null
}; };
} }
}) })
@@ -659,6 +680,8 @@ export function App() {
...timer, ...timer,
organizationName: result.organizationName, organizationName: result.organizationName,
workType: result.workType, workType: result.workType,
isProject: result.isProject,
projectName: result.projectName,
ticketLookupDone: true ticketLookupDone: true
}; };
}) })
@@ -727,6 +750,8 @@ export function App() {
ticketNumber, ticketNumber,
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null, organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
workType: ticketResult.ticket?.work_type ?? null, workType: ticketResult.ticket?.work_type ?? null,
isProject: Boolean(ticketResult.ticket?.is_project),
projectName: ticketResult.ticket?.project_name ?? null,
ticketLookupDone: true, ticketLookupDone: true,
startedAt: now, startedAt: now,
pausedTotalMs: 0, pausedTotalMs: 0,
@@ -737,7 +762,7 @@ export function App() {
setTimers((current) => [...current.map((timer) => pauseEntry(timer, now)), newTimer]); setTimers((current) => [...current.map((timer) => pauseEntry(timer, now)), newTimer]);
setSelectedTimerId(newTimer.id); setSelectedTimerId(newTimer.id);
toast.success("Timer gestartet", { toast.success("Timer gestartet", {
description: ticketNumber description: ticketResult.ticket?.is_project ? `${ticketNumber} ist ein Projekt` : ticketNumber
}); });
return true; 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: "item" as const, href: "/monthly-close", label: "Monatsabschluss", icon: ClipboardCheck, active: path.startsWith("/monthly-close") },
{ type: "separator" as const, key: "after-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: "/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: "item" as const, href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
{ type: "separator" as const, key: "before-profile" }, { type: "separator" as const, key: "before-profile" },
{ type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/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" ? <AnalysisPage onNavigate={navigate} /> : null} {!isAdmin && route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null} {!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null} {!isAdmin && route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "projects" ? <ProjectsPage onNavigate={navigate} onStartTimer={startQuickTimer} /> : null}
{!isAdmin && route.page === "todos" ? <TodosPage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "recurring" ? <RecurringBillingsPage /> : null} {!isAdmin && route.page === "recurring" ? <RecurringBillingsPage /> : null}
{route.page === "faq" ? <FaqPage /> : null} {route.page === "faq" ? <FaqPage /> : null}
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null} {route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
+92
View File
@@ -5,11 +5,15 @@ import type {
Organization, Organization,
PeriodOverview, PeriodOverview,
PeriodType, PeriodType,
ProjectSummary,
RecurringBilling, RecurringBilling,
StatisticsOverview, StatisticsOverview,
TicketMeta, TicketMeta,
TicketPeriod, TicketPeriod,
TicketTodo,
TicketSearchResult, TicketSearchResult,
TodoPriority,
TodoStatus,
WorkType WorkType
} from "./types"; } 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) { export function createSession(payload: CreateSessionPayload) {
return request("/api/sessions", { return request("/api/sessions", {
method: "POST", method: "POST",
+235
View File
@@ -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<TodoPriority, string> = {
low: "Niedrig",
medium: "Normal",
high: "Hoch",
urgent: "Dringend"
};
const priorityClasses: Record<TodoPriority, string> = {
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> | 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<TodoPriority>("medium");
const [saving, setSaving] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editPriority, setEditPriority] = useState<TodoPriority>("medium");
const [busyId, setBusyId] = useState<string | null>(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 (
<div className="space-y-3">
{showCreate && ticketId ? (
<form className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_160px_auto] lg:items-end" onSubmit={submitTodo}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`todo-title-${ticketId}`}>
Aufgabe
</label>
<Input id={`todo-title-${ticketId}`} value={title} onChange={(event) => setTitle(event.currentTarget.value)} required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`todo-priority-${ticketId}`}>
Priorität
</label>
<select
id={`todo-priority-${ticketId}`}
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={priority}
onChange={(event) => setPriority(event.currentTarget.value as TodoPriority)}
>
{Object.entries(priorityLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<Button type="submit" disabled={saving}>
<Plus className="size-4" />
{saving ? "Speichert..." : "Anlegen"}
</Button>
<Textarea
className="min-h-16 lg:col-span-3"
placeholder="Details optional"
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
/>
</form>
) : null}
<div className="space-y-2">
{todos.map((todo) => {
const isEditing = editingId === todo.id;
return (
<div key={todo.id} className={cn("rounded-md border bg-card/50 p-3", todo.status === "done" && "opacity-70")}>
{showTicketMeta ? (
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<button type="button" className="font-medium text-foreground hover:underline" onClick={() => onNavigate?.(`/tickets/${todo.ticket_id}`)}>
{todo.ticket_number}
</button>
{todo.project_id ? <Badge variant="outline">Projekt</Badge> : null}
{todo.organization_name ? <span>{todo.organization_name}</span> : null}
</div>
) : null}
{isEditing ? (
<div className="space-y-2">
<Input value={editTitle} onChange={(event) => setEditTitle(event.currentTarget.value)} />
<Textarea className="min-h-16" value={editDescription} onChange={(event) => setEditDescription(event.currentTarget.value)} />
<div className="flex flex-wrap items-center justify-between gap-2">
<select
className="h-8 rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={editPriority}
onChange={(event) => setEditPriority(event.currentTarget.value as TodoPriority)}
>
{Object.entries(priorityLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<div className="flex gap-1">
<Button type="button" size="sm" variant="ghost" onClick={() => setEditingId(null)}>
Abbrechen
</Button>
<Button type="button" size="sm" disabled={busyId === todo.id} onClick={() => void saveTodo(todo)}>
<Save className="size-4" />
Speichern
</Button>
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<button
type="button"
className="flex min-w-0 flex-1 items-start gap-2 text-left"
disabled={busyId === todo.id}
onClick={() => void saveTodo(todo, todo.status === "done" ? "open" : "done")}
>
{todo.status === "done" ? <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-400" /> : <Circle className="mt-0.5 size-4 shrink-0 text-muted-foreground" />}
<span className="min-w-0">
<span className={cn("block break-words text-sm font-medium", todo.status === "done" && "line-through")}>{todo.title}</span>
{todo.description ? <span className="mt-1 block whitespace-pre-wrap break-words text-xs text-muted-foreground">{todo.description}</span> : null}
</span>
</button>
<div className="flex shrink-0 items-center gap-1">
<span className={cn("rounded-full border px-2 py-0.5 text-xs font-medium", priorityClasses[todo.priority])}>{priorityLabels[todo.priority]}</span>
<Button type="button" size="icon" variant="ghost" className="size-8" onClick={() => startEdit(todo)}>
<Pencil className="size-4" />
</Button>
<Button type="button" size="icon" variant="ghost" className="size-8 text-destructive hover:text-destructive" onClick={() => void removeTodo(todo)}>
<Trash2 className="size-4" />
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
{todos.length === 0 ? <p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine ToDos vorhanden.</p> : null}
</div>
);
}
+37
View File
@@ -237,6 +237,43 @@ export const helpSections: HelpSection[] = [
"Die offenen Bewertungen findest du gebündelt im Monatsabschluss." "Die offenen Bewertungen findest du gebündelt im Monatsabschluss."
] ]
}, },
{
id: "projekte",
title: "Projekte",
description: "Projekte erweitern ein normales Ticket um Consulting-Projektinformationen und bleiben trotzdem über dieselbe Ticketdetailansicht bearbeitbar.",
quickItems: [
"Projekte werden über eine Ticketnummer angelegt.",
"Vorhandene Ticketdaten werden automatisch übernommen.",
"Projekt-Sessions sind immer Consulting."
],
details: [
"Der Navipunkt Projekte ist für Consulting-Arbeiten gedacht, die länger laufen oder ein Stundenkontingent haben.",
"Beim Anlegen gibst du die Ticketnummer ein. Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Stammdaten automatisch.",
"Existiert das Ticket noch nicht, muss eine Organisation aus der synchronisierten Organisationsliste gewählt werden.",
"Ein Projekt-Ticket bleibt ein normales Ticket. Es kann über Suche, Auswertung, geöffnete Tickets und ToDos geöffnet werden.",
"Zusätzlich speicherst du Teamspace-Projekt, Beschreibung, Ziele, ungefähren Zeitraum und gesamtes Stundenkontingent.",
"Projekt-Tickets sind immer Consulting. Auch wenn Sessions später über den normalen Timer oder das Nachtragen erfasst werden, erkennt TicketTracker das Projekt und übernimmt Consulting.",
"Aus einem Projekt heraus kann direkt eine Session gestartet werden. Der Timer verhält sich wie jeder andere Timer und pausiert andere Timer automatisch."
]
},
{
id: "todos",
title: "ToDos",
description: "ToDos sind kleine Aufgabenlisten pro Ticket oder Projekt, damit offene Arbeit sichtbar bleibt.",
quickItems: [
"ToDos werden direkt im Ticket angelegt.",
"Die zentrale ToDo-Seite sammelt alle offenen Aufgaben.",
"Prioritäten helfen beim Sortieren der nächsten Schritte."
],
details: [
"Jedes Ticket kann ToDos enthalten, egal ob es ein Projekt oder ein normales Einzelticket ist.",
"In der Ticketdetailansicht legst du neue Aufgaben mit Titel, optionaler Beschreibung und Priorität an.",
"Die Prioritäten Niedrig, Normal, Hoch und Dringend helfen dabei, wichtige Punkte nicht zwischen Sessions zu verlieren.",
"ToDos lassen sich als erledigt markieren, wieder öffnen, bearbeiten oder löschen.",
"Die zentrale Seite ToDos zeigt Aufgaben aus allen deinen Tickets. Von dort öffnest du direkt das zugehörige Ticket.",
"ToDos sind benutzerbezogen. Andere Benutzer sehen ihre eigenen Aufgaben, auch wenn mehrere Benutzer am gleichen Ticket arbeiten."
]
},
{ {
id: "sessions-bearbeiten", id: "sessions-bearbeiten",
title: "Sessions Bearbeiten und Löschen", title: "Sessions Bearbeiten und Löschen",
+2
View File
@@ -5,6 +5,8 @@ export type TimerEntry = {
ticketNumber: string; ticketNumber: string;
organizationName?: string | null; organizationName?: string | null;
workType?: "support" | "consulting" | null; workType?: "support" | "consulting" | null;
isProject?: boolean;
projectName?: string | null;
ticketLookupDone?: boolean; ticketLookupDone?: boolean;
startedAt: number; startedAt: number;
pausedTotalMs: number; pausedTotalMs: number;
+50 -1
View File
@@ -60,6 +60,9 @@ export type TicketMeta = {
organization_name: string | null; organization_name: string | null;
customer_name: string | null; customer_name: string | null;
work_type: WorkType | null; work_type: WorkType | null;
project_id?: string | null;
is_project?: boolean;
project_name?: string | null;
}; };
export type TicketSearchResult = { export type TicketSearchResult = {
@@ -69,12 +72,56 @@ export type TicketSearchResult = {
organization_name: string | null; organization_name: string | null;
customer_name: string | null; customer_name: string | null;
work_type: WorkType | null; work_type: WorkType | null;
latest_started_at: string; project_id: string | null;
is_project: boolean;
project_name: string | null;
latest_started_at: string | null;
latest_period: string; latest_period: string;
session_count: number; session_count: number;
total_minutes: number; total_minutes: number;
}; };
export type ProjectSummary = {
id: string;
ticket_id: string;
ticket_number: string;
organization_id: string | null;
organization_name: string | null;
customer_name: string | null;
work_type: WorkType | 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;
session_count?: number;
billed_minutes?: number;
open_todos?: number;
done_todos?: number;
};
export type TodoPriority = "low" | "medium" | "high" | "urgent";
export type TodoStatus = "open" | "done";
export type TicketTodo = {
id: string;
ticket_id: string;
ticket_number?: string;
organization_name?: string | null;
project_id?: string | null;
teamspace_project?: string | null;
title: string;
description: string;
priority: TodoPriority;
status: TodoStatus;
completed_at: string | null;
created_at: string;
updated_at: string;
};
export type TicketSummary = { export type TicketSummary = {
id: string; id: string;
ticket_number: string; ticket_number: string;
@@ -190,6 +237,8 @@ export type TicketPeriod = {
}; };
sessions: SessionEntry[]; sessions: SessionEntry[];
dayBillings: TicketDayBilling[]; dayBillings: TicketDayBilling[];
project?: ProjectSummary | null;
todos?: TicketTodo[];
canClose: boolean; canClose: boolean;
openCount: number; openCount: number;
ticketOpenCount: number; ticketOpenCount: number;
+532
View File
@@ -0,0 +1,532 @@
import { CalendarDays, FolderKanban, Play, Save, Search, Target } from "lucide-react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { cn } from "@/lib/utils";
import { createProject, getProjects, lookupTicket, updateProject } from "../api";
import { currentMonth, formatMinutes } from "../format";
import { ticketPattern } from "../timers";
import type { ProjectSummary, TicketMeta } from "../types";
type ProjectsPageProps = {
onNavigate: (to: string) => void;
onStartTimer: (ticketNumber: string) => Promise<boolean>;
};
type ProjectForm = {
ticketNumber: string;
organizationId: string;
organizationName: string | null;
teamspaceProject: string;
description: string;
goals: string;
startsOn: string;
endsOn: string;
hourBudget: string;
};
function emptyProjectForm(): ProjectForm {
return {
ticketNumber: "",
organizationId: "",
organizationName: null,
teamspaceProject: "",
description: "",
goals: "",
startsOn: "",
endsOn: "",
hourBudget: ""
};
}
function formatHoursInput(minutes?: number | null) {
if (minutes === null || minutes === undefined) {
return "";
}
return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(minutes / 60);
}
function parseHoursInput(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const hours = Number(trimmed.replace(",", "."));
if (!Number.isFinite(hours) || hours < 0) {
return undefined;
}
return Math.round(hours * 60);
}
function formFromProject(project: ProjectSummary): ProjectForm {
return {
ticketNumber: project.ticket_number,
organizationId: project.organization_id ?? "",
organizationName: project.organization_name ?? project.customer_name ?? null,
teamspaceProject: project.teamspace_project ?? "",
description: project.description,
goals: project.goals,
startsOn: project.starts_on ?? "",
endsOn: project.ends_on ?? "",
hourBudget: formatHoursInput(project.hour_budget_minutes)
};
}
export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState<ProjectForm>(() => emptyProjectForm());
const [lookupStatus, setLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
const [lookupResult, setLookupResult] = useState<TicketMeta | null>(null);
const [saving, setSaving] = useState(false);
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
const [editForm, setEditForm] = useState<ProjectForm>(() => emptyProjectForm());
const [savingProjectId, setSavingProjectId] = useState<string | null>(null);
async function load() {
setLoading(true);
try {
const result = await getProjects();
setProjects(result.projects);
} catch (error) {
toast.error("Projekte konnten nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
}, []);
useEffect(() => {
const normalizedTicketNumber = form.ticketNumber.trim();
if (!ticketPattern.test(normalizedTicketNumber)) {
setLookupStatus("idle");
setLookupResult(null);
return;
}
let cancelled = false;
setLookupStatus("loading");
const timeout = window.setTimeout(async () => {
try {
const result = await lookupTicket(normalizedTicketNumber);
if (cancelled) {
return;
}
setLookupResult(result.ticket);
setLookupStatus(result.ticket ? "found" : "not-found");
if (result.ticket?.organization_id) {
setForm((current) =>
current.ticketNumber.trim() === normalizedTicketNumber
? {
...current,
organizationId: result.ticket?.organization_id ?? "",
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null
}
: current
);
}
} catch {
if (!cancelled) {
setLookupStatus("idle");
setLookupResult(null);
}
}
}, 250);
return () => {
cancelled = true;
window.clearTimeout(timeout);
};
}, [form.ticketNumber]);
const organizationLocked = Boolean(lookupResult?.organization_id);
const openTodos = useMemo(() => projects.reduce((sum, project) => sum + Number(project.open_todos ?? 0), 0), [projects]);
const billedMinutes = useMemo(() => projects.reduce((sum, project) => sum + Number(project.billed_minutes ?? 0), 0), [projects]);
async function submitProject(event: FormEvent) {
event.preventDefault();
const ticketNumber = form.ticketNumber.trim();
if (!ticketPattern.test(ticketNumber)) {
toast.error("Ticketnummer prüfen", {
description: "Das Format muss Ticket#XXXXXX sein."
});
return;
}
if (!form.organizationId) {
toast.error("Organisation wählen", {
description: "Wenn das Ticket noch keine Stammdaten hat, muss die Organisation gesetzt werden."
});
return;
}
const hourBudgetMinutes = parseHoursInput(form.hourBudget);
if (hourBudgetMinutes === undefined) {
toast.error("Stundenkontingent prüfen", {
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
});
return;
}
setSaving(true);
try {
const result = await createProject({
ticketNumber,
organizationId: form.organizationId,
teamspaceProject: form.teamspaceProject,
description: form.description,
goals: form.goals,
startsOn: form.startsOn || null,
endsOn: form.endsOn || null,
hourBudgetMinutes
});
toast.success("Projekt angelegt", {
description: `${result.project.ticket_number} ist jetzt als Consulting-Projekt markiert.`
});
setForm(emptyProjectForm());
setLookupResult(null);
setLookupStatus("idle");
await load();
} catch (error) {
toast.error("Projekt konnte nicht angelegt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSaving(false);
}
}
async function saveProject(project: ProjectSummary) {
const hourBudgetMinutes = parseHoursInput(editForm.hourBudget);
if (hourBudgetMinutes === undefined) {
toast.error("Stundenkontingent prüfen");
return;
}
if (!editForm.organizationId) {
toast.error("Organisation wählen");
return;
}
setSavingProjectId(project.id);
try {
await updateProject(project.id, {
ticketNumber: editForm.ticketNumber,
organizationId: editForm.organizationId,
teamspaceProject: editForm.teamspaceProject,
description: editForm.description,
goals: editForm.goals,
startsOn: editForm.startsOn || null,
endsOn: editForm.endsOn || null,
hourBudgetMinutes
});
toast.success("Projekt gespeichert");
setEditingProjectId(null);
await load();
} catch (error) {
toast.error("Projekt konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingProjectId(null);
}
}
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Projekte</h2>
<p className="text-sm text-muted-foreground">Consulting-Projekte als Erweiterung bestehender oder neuer Tickets.</p>
</div>
<div className="grid gap-2 text-sm sm:grid-cols-3">
<div className="rounded-md border bg-muted/20 px-3 py-2">
<span className="block text-muted-foreground">Projekte</span>
<span className="font-semibold">{projects.length}</span>
</div>
<div className="rounded-md border bg-muted/20 px-3 py-2">
<span className="block text-muted-foreground">Abgerechnet</span>
<span className="font-semibold">{formatMinutes(billedMinutes)}</span>
</div>
<div className="rounded-md border bg-muted/20 px-3 py-2">
<span className="block text-muted-foreground">Offene ToDos</span>
<span className="font-semibold">{openTodos}</span>
</div>
</div>
</div>
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Projekt anlegen</CardTitle>
<CardDescription>Ticketnummer eingeben. Vorhandene Ticketdaten werden automatisch übernommen.</CardDescription>
</div>
<HelpLink anchor="projekte" label="Hilfe zu Projekten" />
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
<form className="space-y-4" onSubmit={submitProject}>
<div className="grid gap-3 lg:grid-cols-[180px_minmax(240px,1fr)_220px]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-ticket-number">
Ticket
</label>
<Input
id="project-ticket-number"
placeholder="Ticket#123456"
value={form.ticketNumber}
onChange={(event) =>
setForm({
...form,
ticketNumber: event.currentTarget.value,
organizationId: "",
organizationName: null
})
}
required
/>
{form.ticketNumber && !ticketPattern.test(form.ticketNumber.trim()) ? <p className="text-xs text-destructive">Format: Ticket#XXXXXX</p> : null}
{lookupStatus === "loading" ? <p className="text-xs text-muted-foreground">Suche Ticket...</p> : null}
{lookupStatus === "found" ? <p className="text-xs text-muted-foreground">Ticket gefunden. Organisation wird übernommen.</p> : null}
{lookupStatus === "not-found" ? <p className="text-xs text-muted-foreground">Neues Ticket. Organisation bitte wählen.</p> : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Organisation</label>
<OrganizationSelect
value={form.organizationId}
selectedName={form.organizationName}
disabled={organizationLocked}
onChange={(organization) => setForm({ ...form, organizationId: organization.id, organizationName: organization.name })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Art</label>
<div className="flex h-9 items-center rounded-lg border bg-muted/30 px-3 text-sm">Consulting</div>
</div>
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_140px_140px_160px]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="teamspace-project">
Teamspace-Projekt
</label>
<Input id="teamspace-project" value={form.teamspaceProject} onChange={(event) => setForm({ ...form, teamspaceProject: event.currentTarget.value })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-starts-on">
Zeitraum von
</label>
<Input id="project-starts-on" type="date" value={form.startsOn} onChange={(event) => setForm({ ...form, startsOn: event.currentTarget.value })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-ends-on">
Zeitraum bis
</label>
<Input id="project-ends-on" type="date" value={form.endsOn} onChange={(event) => setForm({ ...form, endsOn: event.currentTarget.value })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-budget">
Kontingent h
</label>
<Input id="project-budget" inputMode="decimal" value={form.hourBudget} onChange={(event) => setForm({ ...form, hourBudget: event.currentTarget.value })} />
</div>
</div>
<div className="grid gap-3 lg:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-description">
Projektbeschreibung
</label>
<Textarea id="project-description" className="min-h-24" value={form.description} onChange={(event) => setForm({ ...form, description: event.currentTarget.value })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-goals">
Ziele
</label>
<Textarea id="project-goals" className="min-h-24" value={form.goals} onChange={(event) => setForm({ ...form, goals: event.currentTarget.value })} />
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
<Save className="size-4" />
{saving ? "Speichert..." : "Projekt anlegen"}
</Button>
</div>
</form>
</CardContent>
</Card>
<div className="grid gap-3 xl:grid-cols-2">
{projects.map((project) => {
const isEditing = editingProjectId === project.id;
const usedPercent =
project.hour_budget_minutes && project.hour_budget_minutes > 0
? Math.min(100, Math.round(((project.billed_minutes ?? 0) / project.hour_budget_minutes) * 100))
: null;
return (
<Card key={project.id} className="overflow-hidden">
<CardHeader className="border-b bg-muted/25 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="truncate text-base">{project.ticket_number}</CardTitle>
<Badge variant="outline">Projekt</Badge>
</div>
<CardDescription className="truncate">{project.teamspace_project || project.organization_name || "Ohne Teamspace-Projekt"}</CardDescription>
</div>
<div className="flex shrink-0 gap-1">
<Button type="button" size="sm" variant="secondary" onClick={() => void onStartTimer(project.ticket_number)}>
<Play className="size-4" />
Session
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}>
<Search className="size-4" />
Öffnen
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 p-4">
{isEditing ? (
<div className="space-y-3">
<div className="grid gap-3 md:grid-cols-[160px_minmax(200px,1fr)_160px]">
<Input value={editForm.ticketNumber} onChange={(event) => setEditForm({ ...editForm, ticketNumber: event.currentTarget.value })} />
<OrganizationSelect
value={editForm.organizationId}
selectedName={editForm.organizationName}
onChange={(organization) => setEditForm({ ...editForm, organizationId: organization.id, organizationName: organization.name })}
required
/>
<div className="flex h-9 items-center rounded-lg border bg-muted/30 px-3 text-sm">Consulting</div>
</div>
<Input
placeholder="Teamspace-Projekt"
value={editForm.teamspaceProject}
onChange={(event) => setEditForm({ ...editForm, teamspaceProject: event.currentTarget.value })}
/>
<div className="grid gap-3 md:grid-cols-3">
<Input type="date" value={editForm.startsOn} onChange={(event) => setEditForm({ ...editForm, startsOn: event.currentTarget.value })} />
<Input type="date" value={editForm.endsOn} onChange={(event) => setEditForm({ ...editForm, endsOn: event.currentTarget.value })} />
<Input inputMode="decimal" placeholder="Kontingent h" value={editForm.hourBudget} onChange={(event) => setEditForm({ ...editForm, hourBudget: event.currentTarget.value })} />
</div>
<Textarea className="min-h-20" value={editForm.description} onChange={(event) => setEditForm({ ...editForm, description: event.currentTarget.value })} />
<Textarea className="min-h-20" value={editForm.goals} onChange={(event) => setEditForm({ ...editForm, goals: event.currentTarget.value })} />
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={() => setEditingProjectId(null)}>
Abbrechen
</Button>
<Button type="button" disabled={savingProjectId === project.id} onClick={() => void saveProject(project)}>
<Save className="size-4" />
Speichern
</Button>
</div>
</div>
) : (
<>
<div className="grid gap-2 text-sm sm:grid-cols-3">
<div className="rounded-md border bg-background p-2">
<span className="block text-xs text-muted-foreground">Organisation</span>
<span className="line-clamp-1 font-medium">{project.organization_name ?? project.customer_name ?? "Keine Organisation"}</span>
</div>
<div className="rounded-md border bg-background p-2">
<span className="block text-xs text-muted-foreground">Zeitraum</span>
<span className="font-medium">{project.starts_on || "offen"} - {project.ends_on || "offen"}</span>
</div>
<div className="rounded-md border bg-background p-2">
<span className="block text-xs text-muted-foreground">Kontingent</span>
<span className="font-medium">{project.hour_budget_minutes ? formatMinutes(project.hour_budget_minutes) : "offen"}</span>
</div>
</div>
{usedPercent !== null ? (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Verbrauch</span>
<span>{usedPercent}% · {formatMinutes(project.billed_minutes ?? 0)}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div className={cn("h-full rounded-full", usedPercent >= 90 ? "bg-amber-500" : "bg-emerald-500")} style={{ width: `${usedPercent}%` }} />
</div>
</div>
) : null}
<div className="grid gap-3 text-sm md:grid-cols-2">
<div>
<div className="mb-1 flex items-center gap-2 font-medium">
<FolderKanban className="size-4 text-muted-foreground" />
Beschreibung
</div>
<p className="whitespace-pre-wrap text-muted-foreground">{project.description || "Noch keine Beschreibung."}</p>
</div>
<div>
<div className="mb-1 flex items-center gap-2 font-medium">
<Target className="size-4 text-muted-foreground" />
Ziele
</div>
<p className="whitespace-pre-wrap text-muted-foreground">{project.goals || "Noch keine Ziele."}</p>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-3">
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<Badge variant="secondary">{project.session_count ?? 0} Session(s)</Badge>
<Badge variant={(project.open_todos ?? 0) > 0 ? "warning" : "success"}>{project.open_todos ?? 0} offene ToDos</Badge>
<span className="inline-flex items-center gap-1">
<CalendarDays className="size-3.5" />
aktualisiert {new Date(project.updated_at).toLocaleDateString("de-DE")}
</span>
</div>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setEditingProjectId(project.id);
setEditForm(formFromProject(project));
}}
>
Bearbeiten
</Button>
</div>
</>
)}
</CardContent>
</Card>
);
})}
</div>
{!loading && projects.length === 0 ? <p className="rounded-md border bg-muted/20 p-6 text-center text-sm text-muted-foreground">Noch keine Projekte angelegt.</p> : null}
</div>
);
}
+72 -5
View File
@@ -1,4 +1,4 @@
import { ArrowLeft, Pencil, Save, Trash2 } from "lucide-react"; import { ArrowLeft, ClipboardList, FolderKanban, Pencil, Save, Target, Trash2 } from "lucide-react";
import { FormEvent, useEffect, useMemo, useState } from "react"; import { FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Alert } from "@/components/ui/alert"; import { Alert } from "@/components/ui/alert";
@@ -11,6 +11,7 @@ import { Textarea } from "@/components/ui/textarea";
import { CopyTicketButton } from "@/components/CopyTicketButton"; import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink"; import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect"; import { OrganizationSelect } from "@/components/OrganizationSelect";
import { TodoPanel } from "@/components/TodoPanel";
import { import {
createSession, createSession,
deleteSession, deleteSession,
@@ -231,6 +232,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
}, [data?.ticket]); }, [data?.ticket]);
const ticket = data?.ticket; const ticket = data?.ticket;
const project = data?.project ?? null;
const isProject = Boolean(project);
const isGlobalTicket = periodType === "all"; const isGlobalTicket = periodType === "all";
const isPeriodClosed = Boolean(data?.closed); const isPeriodClosed = Boolean(data?.closed);
const periodLabel = periodType === "month" ? "Monat" : periodType === "day" ? "Tag" : "Ticket"; const periodLabel = periodType === "month" ? "Monat" : periodType === "day" ? "Tag" : "Ticket";
@@ -385,7 +388,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
await updateTicket(ticket.id, { await updateTicket(ticket.id, {
ticketNumber: editTicketNumber, ticketNumber: editTicketNumber,
organizationId: editOrganizationId, organizationId: editOrganizationId,
workType: editWorkType workType: isProject ? "consulting" : editWorkType
}); });
toast.success("Ticketdaten gespeichert", { toast.success("Ticketdaten gespeichert", {
description: "Organisation und Art wurden auf die vorhandenen Sessions übernommen." description: "Organisation und Art wurden auf die vorhandenen Sessions übernommen."
@@ -408,7 +411,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
} }
const effectiveOrganizationId = ticket.organization_id ?? ticketSession.organizationId; const effectiveOrganizationId = ticket.organization_id ?? ticketSession.organizationId;
const effectiveWorkType = ticket.work_type ?? ticketSession.workType; const effectiveWorkType = isProject ? "consulting" : ticket.work_type ?? ticketSession.workType;
if (!effectiveOrganizationId) { if (!effectiveOrganizationId) {
toast.error("Organisation wählen"); toast.error("Organisation wählen");
@@ -503,7 +506,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
await updateSessionDetails(editingSession.id, { await updateSessionDetails(editingSession.id, {
organizationId: editSessionOrganizationId, organizationId: editSessionOrganizationId,
activity: editSessionActivity, activity: editSessionActivity,
workType: editSessionWorkType, workType: isProject ? "consulting" : editSessionWorkType,
startedAt: startedAt.toISOString(), startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString() endedAt: endedAt.toISOString()
}); });
@@ -670,11 +673,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
id="edit-ticket-work-type" id="edit-ticket-work-type"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50" className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={editWorkType} value={editWorkType}
disabled={isProject}
onChange={(event) => setEditWorkType(event.currentTarget.value as WorkType)} onChange={(event) => setEditWorkType(event.currentTarget.value as WorkType)}
> >
<option value="support">Support</option> <option value="support">Support</option>
<option value="consulting">Consulting</option> <option value="consulting">Consulting</option>
</select> </select>
{isProject ? <p className="text-xs text-muted-foreground">Projekte sind immer Consulting.</p> : null}
</div> </div>
<Button type="submit" disabled={savingTicket}> <Button type="submit" disabled={savingTicket}>
<Save className="size-4" /> <Save className="size-4" />
@@ -684,6 +689,67 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</CardContent> </CardContent>
</Card> </Card>
{project ? (
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<FolderKanban className="size-5 text-muted-foreground" />
Projekt
</CardTitle>
<CardDescription>{project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}</CardDescription>
</div>
<HelpLink anchor="projekte" label="Hilfe zu Projekten" />
</div>
</CardHeader>
<CardContent className="grid gap-3 px-4 pb-4 lg:grid-cols-[1fr_1fr_220px]">
<div className="rounded-md border bg-muted/20 p-3">
<div className="mb-1 flex items-center gap-2 text-sm font-medium">
<FolderKanban className="size-4 text-muted-foreground" />
Beschreibung
</div>
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{project.description || "Noch keine Beschreibung."}</p>
</div>
<div className="rounded-md border bg-muted/20 p-3">
<div className="mb-1 flex items-center gap-2 text-sm font-medium">
<Target className="size-4 text-muted-foreground" />
Ziele
</div>
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{project.goals || "Noch keine Ziele."}</p>
</div>
<div className="space-y-2 rounded-md border bg-muted/20 p-3 text-sm">
<div>
<span className="block text-xs text-muted-foreground">Zeitraum</span>
<span className="font-medium">{project.starts_on || "offen"} - {project.ends_on || "offen"}</span>
</div>
<div>
<span className="block text-xs text-muted-foreground">Kontingent</span>
<span className="font-medium">{project.hour_budget_minutes ? formatMinutes(project.hour_budget_minutes) : "offen"}</span>
</div>
</div>
</CardContent>
</Card>
) : null}
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<ClipboardList className="size-5 text-muted-foreground" />
ToDos
</CardTitle>
<CardDescription>Aufgaben zu diesem Ticket oder Projekt verwalten.</CardDescription>
</div>
<HelpLink anchor="todos" label="Hilfe zu ToDos" />
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
<TodoPanel ticketId={ticket?.id} todos={data?.todos ?? []} onChanged={load} />
</CardContent>
</Card>
<Card> <Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0"> <CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div> <div>
@@ -994,12 +1060,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
id="session-work-type" id="session-work-type"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50" className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={editSessionWorkType} value={editSessionWorkType}
disabled={Boolean(editingSession?.recurring_billing_id)} disabled={Boolean(editingSession?.recurring_billing_id) || isProject}
onChange={(event) => setEditSessionWorkType(event.currentTarget.value as WorkType)} onChange={(event) => setEditSessionWorkType(event.currentTarget.value as WorkType)}
> >
<option value="support">Support</option> <option value="support">Support</option>
<option value="consulting">Consulting</option> <option value="consulting">Consulting</option>
</select> </select>
{isProject ? <p className="text-xs text-muted-foreground">Projekt-Sessions bleiben Consulting.</p> : null}
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" disabled={savingSessionDetails}> <Button type="submit" disabled={savingSessionDetails}>
+10 -4
View File
@@ -163,6 +163,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
ticketNumber, ticketNumber,
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null, organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
workType: ticketResult.ticket?.work_type ?? null, workType: ticketResult.ticket?.work_type ?? null,
isProject: Boolean(ticketResult.ticket?.is_project),
projectName: ticketResult.ticket?.project_name ?? null,
ticketLookupDone: true, ticketLookupDone: true,
startedAt: now, startedAt: now,
pausedTotalMs: 0, pausedTotalMs: 0,
@@ -333,7 +335,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
durationSeconds durationSeconds
}); });
toast.success("Session nachgetragen", { toast.success("Session nachgetragen", {
description: "Der Eintrag ist in deiner Auswertung offen." description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Auswertung offen."
}); });
setManualSession({ setManualSession({
...emptyManualSession(), ...emptyManualSession(),
@@ -405,7 +407,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<CardDescription> <CardDescription>
{selectedTimer {selectedTimer
? selectedTimer.organizationName ? selectedTimer.organizationName
? `${selectedTimer.organizationName}${selectedTimer.workType ? ` · ${selectedTimer.workType === "support" ? "Support" : "Consulting"}` : ""}` ? `${selectedTimer.isProject ? "Projekt · " : ""}${selectedTimer.projectName || selectedTimer.organizationName}${selectedTimer.workType ? ` · ${selectedTimer.workType === "support" ? "Support" : "Consulting"}` : ""}`
: "Beim Umschalten wird dieser Timer aktiviert und alle anderen werden pausiert." : "Beim Umschalten wird dieser Timer aktiviert und alle anderen werden pausiert."
: "Starte einen Timer, um eine Session zu erfassen."} : "Starte einen Timer, um eine Session zu erfassen."}
</CardDescription> </CardDescription>
@@ -479,7 +481,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
{manualSession.ticketNumber.length > 0 && !isManualTicketValid ? <p className="text-xs text-destructive">Format: Ticket#XXXXXX</p> : null} {manualSession.ticketNumber.length > 0 && !isManualTicketValid ? <p className="text-xs text-destructive">Format: Ticket#XXXXXX</p> : null}
{manualLookupStatus === "loading" ? <p className="text-xs text-muted-foreground">Prüfe vorhandenes Ticket...</p> : null} {manualLookupStatus === "loading" ? <p className="text-xs text-muted-foreground">Prüfe vorhandenes Ticket...</p> : null}
{manualLookupStatus === "found" && manualTicketDefaultsLocked ? ( {manualLookupStatus === "found" && manualTicketDefaultsLocked ? (
<p className="text-xs text-muted-foreground">Ticket gefunden. Organisation und Art wurden übernommen.</p> <p className="text-xs text-muted-foreground">
{manualLookupTicket?.is_project ? "Projekt gefunden. Organisation und Consulting wurden übernommen." : "Ticket gefunden. Organisation und Art wurden übernommen."}
</p>
) : null} ) : null}
{manualLookupStatus === "found" && !manualTicketDefaultsLocked ? ( {manualLookupStatus === "found" && !manualTicketDefaultsLocked ? (
<p className="text-xs text-muted-foreground">Ticket gefunden, aber Stammdaten sind unvollständig. Bitte ergänzen.</p> <p className="text-xs text-muted-foreground">Ticket gefunden, aber Stammdaten sind unvollständig. Bitte ergänzen.</p>
@@ -582,7 +586,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
{finished?.ticket?.organization_id && finished.ticket.work_type ? ( {finished?.ticket?.organization_id && finished.ticket.work_type ? (
<div className="rounded-lg border bg-muted/40 p-3 text-sm"> <div className="rounded-lg border bg-muted/40 p-3 text-sm">
<p className="font-medium">{finished.ticket.organization_name ?? finished.ticket.customer_name}</p> <p className="font-medium">{finished.ticket.organization_name ?? finished.ticket.customer_name}</p>
<p className="text-muted-foreground">{finished.ticket.work_type === "support" ? "Support" : "Consulting"} wurde vom bestehenden Ticket übernommen.</p> <p className="text-muted-foreground">
{finished.ticket.is_project ? "Projekt erkannt. Consulting" : finished.ticket.work_type === "support" ? "Support" : "Consulting"} wurde vom bestehenden Ticket übernommen.
</p>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
+102
View File
@@ -0,0 +1,102 @@
import { ClipboardList } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { HelpLink } from "@/components/HelpLink";
import { TodoPanel } from "@/components/TodoPanel";
import { getTodos } from "../api";
import type { TicketTodo, TodoStatus } from "../types";
type TodosPageProps = {
onNavigate: (to: string) => void;
};
export function TodosPage({ onNavigate }: TodosPageProps) {
const [todos, setTodos] = useState<TicketTodo[]>([]);
const [status, setStatus] = useState<TodoStatus | "all">("open");
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
try {
const result = await getTodos(status);
setTodos(result.todos);
} catch (error) {
toast.error("ToDos konnten nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
}, [status]);
const counts = useMemo(
() => ({
open: todos.filter((todo) => todo.status === "open").length,
done: todos.filter((todo) => todo.status === "done").length,
urgent: todos.filter((todo) => todo.status === "open" && todo.priority === "urgent").length
}),
[todos]
);
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">ToDos</h2>
<p className="text-sm text-muted-foreground">Offene Aufgaben aus Projekten und normalen Tickets an einem Ort.</p>
</div>
<div className="grid gap-2 text-sm sm:grid-cols-3">
<div className="rounded-md border bg-muted/20 px-3 py-2">
<span className="block text-muted-foreground">Offen</span>
<span className="font-semibold">{counts.open}</span>
</div>
<div className="rounded-md border bg-muted/20 px-3 py-2">
<span className="block text-muted-foreground">Dringend</span>
<span className="font-semibold">{counts.urgent}</span>
</div>
<div className="rounded-md border bg-muted/20 px-3 py-2">
<span className="block text-muted-foreground">Erledigt</span>
<span className="font-semibold">{counts.done}</span>
</div>
</div>
</div>
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<ClipboardList className="size-5 text-muted-foreground" />
Aufgabenliste
</CardTitle>
<CardDescription>ToDos werden im jeweiligen Ticket angelegt und hier gesammelt angezeigt.</CardDescription>
</div>
<HelpLink anchor="todos" label="Hilfe zu ToDos" />
</div>
<div className="flex flex-wrap gap-2 pt-2">
{(["open", "all", "done"] as const).map((value) => (
<button
key={value}
type="button"
className={`rounded-md border px-3 py-1.5 text-sm ${status === value ? "bg-primary text-primary-foreground" : "bg-background hover:bg-muted"}`}
onClick={() => setStatus(value)}
>
{value === "open" ? "Offen" : value === "done" ? "Erledigt" : "Alle"}
</button>
))}
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
<TodoPanel todos={todos} showCreate={false} showTicketMeta onNavigate={onNavigate} onChanged={load} />
{!loading && todos.length === 0 ? null : null}
</CardContent>
</Card>
</div>
);
}