Add project and todo management
This commit is contained in:
1 parent
2792051a16
commit
3da436423f
12 files changed
+1865
-43
No files matched your search
+657
-26
@@ -144,6 +144,24 @@ type OrganizationRow = {
|
||||
synced_at: string;
|
||||
};
|
||||
|
||||
type ProjectRow = {
|
||||
id: string;
|
||||
ticket_id: string;
|
||||
ticket_number: string;
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
customer_name: string | null;
|
||||
work_type: "support" | "consulting" | null;
|
||||
teamspace_project: string | null;
|
||||
description: string;
|
||||
goals: string;
|
||||
starts_on: string | null;
|
||||
ends_on: string | null;
|
||||
hour_budget_minutes: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
const zammadBaseUrlSettingKey = "zammad.base_url";
|
||||
const zammadApiKeySettingKey = "zammad.api_key";
|
||||
|
||||
@@ -192,6 +210,44 @@ function optionalBoolean(value: unknown, fallback: boolean) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseOptionalDateOnly(value: unknown, field: string) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseDay(String(value)).start;
|
||||
}
|
||||
|
||||
function parseOptionalNonNegativeInteger(value: unknown, field: string) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const number = Number(value);
|
||||
|
||||
if (!Number.isInteger(number) || number < 0) {
|
||||
throw badRequest(`${field} must be a non-negative integer`);
|
||||
}
|
||||
|
||||
return number;
|
||||
}
|
||||
|
||||
function parseTodoPriority(value: unknown) {
|
||||
if (value !== "low" && value !== "medium" && value !== "high" && value !== "urgent") {
|
||||
throw badRequest("priority must be low, medium, high or urgent");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseTodoStatus(value: unknown) {
|
||||
if (value !== "open" && value !== "done") {
|
||||
throw badRequest("status must be open or done");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
const periodConfigs: Record<string, PeriodConfig> = {
|
||||
months: {
|
||||
type: "month",
|
||||
@@ -514,6 +570,16 @@ async function cleanupEmptyTickets(client: { query: (text: string, params?: unkn
|
||||
SELECT 1
|
||||
FROM sessions s
|
||||
WHERE s.ticket_id = t.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM projects p
|
||||
WHERE p.ticket_id = t.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM ticket_todos tt
|
||||
WHERE tt.ticket_id = t.id
|
||||
);
|
||||
`,
|
||||
[uniqueTicketIds]
|
||||
@@ -1261,7 +1327,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
|
||||
}
|
||||
|
||||
const sessionWhere = where.join(" AND ");
|
||||
const [sessionsResult, dayBillingResult, monthsResult, closureResult] = await Promise.all([
|
||||
const [sessionsResult, dayBillingResult, monthsResult, closureResult, projectResult, todosResult] = await Promise.all([
|
||||
query(
|
||||
`
|
||||
SELECT
|
||||
@@ -1329,7 +1395,62 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
|
||||
),
|
||||
filters.month
|
||||
? query<{ closed_at: string }>("SELECT closed_at FROM month_closures WHERE month = $1::date AND user_id = $2;", [filters.month.start, userId])
|
||||
: Promise.resolve({ rows: [] as { closed_at: string }[], rowCount: 0 })
|
||||
: Promise.resolve({ rows: [] as { closed_at: string }[], rowCount: 0 }),
|
||||
query(
|
||||
`
|
||||
SELECT
|
||||
p.id::text AS id,
|
||||
p.ticket_id::text AS ticket_id,
|
||||
t.ticket_number,
|
||||
t.organization_id::text AS organization_id,
|
||||
o.name AS organization_name,
|
||||
COALESCE(o.name, t.customer_name) AS customer_name,
|
||||
t.work_type,
|
||||
p.teamspace_project,
|
||||
p.description,
|
||||
p.goals,
|
||||
p.starts_on::text AS starts_on,
|
||||
p.ends_on::text AS ends_on,
|
||||
p.hour_budget_minutes,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM projects p
|
||||
JOIN tickets t ON t.id = p.ticket_id
|
||||
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
|
||||
WHERE p.ticket_id = $1
|
||||
AND p.user_id = $2;
|
||||
`,
|
||||
[ticketId, userId]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT
|
||||
tt.id::text AS id,
|
||||
tt.ticket_id::text AS ticket_id,
|
||||
t.ticket_number,
|
||||
COALESCE(o.name, t.customer_name) AS organization_name,
|
||||
p.id::text AS project_id,
|
||||
p.teamspace_project,
|
||||
tt.title,
|
||||
tt.description,
|
||||
tt.priority,
|
||||
tt.status,
|
||||
tt.completed_at,
|
||||
tt.created_at,
|
||||
tt.updated_at
|
||||
FROM ticket_todos tt
|
||||
JOIN tickets t ON t.id = tt.ticket_id
|
||||
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
|
||||
LEFT JOIN projects p ON p.ticket_id = tt.ticket_id AND p.user_id = tt.user_id
|
||||
WHERE tt.ticket_id = $1
|
||||
AND tt.user_id = $2
|
||||
ORDER BY
|
||||
CASE tt.status WHEN 'open' THEN 0 ELSE 1 END,
|
||||
CASE tt.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END,
|
||||
tt.updated_at DESC;
|
||||
`,
|
||||
[ticketId, userId]
|
||||
)
|
||||
]);
|
||||
|
||||
const sessions = sessionsResult.rows;
|
||||
@@ -1358,6 +1479,8 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
|
||||
},
|
||||
sessions,
|
||||
dayBillings: dayBillingResult.rows,
|
||||
project: projectResult.rows[0] ?? null,
|
||||
todos: todosResult.rows,
|
||||
canClose: false,
|
||||
openCount,
|
||||
ticketOpenCount: Number(ticket.ticket_open_count ?? 0)
|
||||
@@ -1866,6 +1989,8 @@ const databaseExportTables = [
|
||||
"app_settings",
|
||||
"organizations",
|
||||
"tickets",
|
||||
"projects",
|
||||
"ticket_todos",
|
||||
"recurring_billings",
|
||||
"recurring_billing_slots",
|
||||
"recurring_billing_exceptions",
|
||||
@@ -2775,14 +2900,15 @@ app.post("/api/sessions", requireUser, async (req, res) => {
|
||||
|
||||
const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60));
|
||||
const created = await withTransaction(async (client) => {
|
||||
const existingTicketResult = await client.query<{ id: string; organization_id: string | null; customer_name: string | null; work_type: string | null }>(
|
||||
const existingTicketResult = await client.query<{ id: string; organization_id: string | null; customer_name: string | null; work_type: string | null; project_id: string | null }>(
|
||||
`
|
||||
SELECT id, organization_id, customer_name, work_type
|
||||
FROM tickets
|
||||
WHERE ticket_number = $1
|
||||
FOR UPDATE;
|
||||
SELECT t.id, t.organization_id, t.customer_name, t.work_type, p.id AS project_id
|
||||
FROM tickets t
|
||||
LEFT JOIN projects p ON p.ticket_id = t.id AND p.user_id = $2
|
||||
WHERE t.ticket_number = $1
|
||||
FOR UPDATE OF t;
|
||||
`,
|
||||
[ticketNumber]
|
||||
[ticketNumber, user.id]
|
||||
);
|
||||
const existingTicket = existingTicketResult.rows[0] ?? null;
|
||||
const effectiveOrganizationId = existingTicket?.organization_id ? Number(existingTicket.organization_id) : requestedOrganizationId;
|
||||
@@ -2805,14 +2931,16 @@ app.post("/api/sessions", requireUser, async (req, res) => {
|
||||
DO UPDATE SET
|
||||
organization_id = COALESCE(tickets.organization_id, EXCLUDED.organization_id),
|
||||
customer_name = COALESCE(tickets.customer_name, EXCLUDED.customer_name),
|
||||
work_type = COALESCE(tickets.work_type, EXCLUDED.work_type)
|
||||
work_type = CASE WHEN EXISTS (
|
||||
SELECT 1 FROM projects p WHERE p.ticket_id = tickets.id AND p.user_id = $5
|
||||
) THEN 'consulting' ELSE COALESCE(tickets.work_type, EXCLUDED.work_type) END
|
||||
RETURNING id, ticket_number, organization_id, customer_name, work_type;
|
||||
`,
|
||||
[ticketNumber, organization.id, organization.name, requestedWorkType]
|
||||
[ticketNumber, organization.id, organization.name, existingTicket?.project_id ? "consulting" : requestedWorkType, user.id]
|
||||
);
|
||||
|
||||
const ticket = ticketResult.rows[0];
|
||||
const workType = ticket.work_type ?? requestedWorkType;
|
||||
const workType = existingTicket?.project_id ? "consulting" : ticket.work_type ?? requestedWorkType;
|
||||
|
||||
if (!workType) {
|
||||
throw badRequest("workType must be support or consulting");
|
||||
@@ -2872,6 +3000,7 @@ app.get("/api/statistics/months/:month", requireUser, async (req, res) => {
|
||||
|
||||
app.get("/api/tickets/lookup", requireUser, async (req, res) => {
|
||||
const ticketNumber = parseTicketNumber(req.query.ticketNumber);
|
||||
const userId = currentUser(req).id;
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
@@ -2880,12 +3009,16 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => {
|
||||
t.organization_id,
|
||||
o.name AS organization_name,
|
||||
COALESCE(o.name, t.customer_name) AS customer_name,
|
||||
t.work_type
|
||||
t.work_type,
|
||||
p.id::text AS project_id,
|
||||
(p.id IS NOT NULL) AS is_project,
|
||||
p.teamspace_project AS project_name
|
||||
FROM tickets t
|
||||
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
|
||||
LEFT JOIN projects p ON p.ticket_id = t.id AND p.user_id = $2
|
||||
WHERE t.ticket_number = $1;
|
||||
`,
|
||||
[ticketNumber]
|
||||
[ticketNumber, userId]
|
||||
);
|
||||
|
||||
res.json({
|
||||
@@ -2915,23 +3048,30 @@ app.get("/api/tickets/search", requireUser, async (req, res) => {
|
||||
COALESCE(o.name, t.customer_name, '') AS organization_name,
|
||||
COALESCE(o.name, t.customer_name, '') AS customer_name,
|
||||
t.work_type,
|
||||
MAX(s.started_at) AS latest_started_at,
|
||||
to_char(MAX(s.started_at) AT TIME ZONE 'Europe/Berlin', 'YYYY-MM') AS latest_period,
|
||||
COUNT(s.id)::int AS session_count,
|
||||
COALESCE(SUM(s.rounded_minutes), 0)::int AS total_minutes
|
||||
p.id::text AS project_id,
|
||||
(p.id IS NOT NULL) AS is_project,
|
||||
p.teamspace_project AS project_name,
|
||||
MAX(s.started_at) FILTER (WHERE s.user_id = $1) AS latest_started_at,
|
||||
COALESCE(to_char(MAX(s.started_at) FILTER (WHERE s.user_id = $1) AT TIME ZONE 'Europe/Berlin', 'YYYY-MM'), '') AS latest_period,
|
||||
COUNT(s.id) FILTER (WHERE s.user_id = $1)::int AS session_count,
|
||||
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.user_id = $1), 0)::int AS total_minutes
|
||||
FROM tickets t
|
||||
JOIN sessions s ON s.ticket_id = t.id
|
||||
LEFT JOIN sessions s ON s.ticket_id = t.id
|
||||
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
|
||||
WHERE s.user_id = $1
|
||||
LEFT JOIN projects p ON p.ticket_id = t.id AND p.user_id = $1
|
||||
WHERE (s.user_id = $1 OR p.id IS NOT NULL)
|
||||
AND (
|
||||
t.ticket_number ILIKE $2
|
||||
OR replace(t.ticket_number, 'Ticket#', '') ILIKE $3
|
||||
OR COALESCE(o.name, '') ILIKE $2
|
||||
OR COALESCE(t.customer_name, '') ILIKE $2
|
||||
OR COALESCE(s.customer_name, '') ILIKE $2
|
||||
OR s.activity ILIKE $2
|
||||
OR (s.user_id = $1 AND COALESCE(s.customer_name, '') ILIKE $2)
|
||||
OR (s.user_id = $1 AND s.activity ILIKE $2)
|
||||
OR COALESCE(p.teamspace_project, '') ILIKE $2
|
||||
OR COALESCE(p.description, '') ILIKE $2
|
||||
OR COALESCE(p.goals, '') ILIKE $2
|
||||
)
|
||||
GROUP BY t.id, t.ticket_number, t.organization_id, o.name, t.customer_name, t.work_type
|
||||
GROUP BY t.id, t.ticket_number, t.organization_id, o.name, t.customer_name, t.work_type, p.id, p.teamspace_project
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN lower(t.ticket_number) = lower($4) THEN 0
|
||||
@@ -2939,7 +3079,7 @@ app.get("/api/tickets/search", requireUser, async (req, res) => {
|
||||
WHEN t.ticket_number ILIKE $3 THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
MAX(s.started_at) DESC,
|
||||
MAX(s.started_at) FILTER (WHERE s.user_id = $1) DESC NULLS LAST,
|
||||
t.ticket_number ASC
|
||||
LIMIT 12;
|
||||
`,
|
||||
@@ -2949,6 +3089,484 @@ app.get("/api/tickets/search", requireUser, async (req, res) => {
|
||||
res.json({ tickets: result.rows });
|
||||
});
|
||||
|
||||
app.get("/api/projects", requireUser, async (req, res) => {
|
||||
const userId = currentUser(req).id;
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
p.id::text AS id,
|
||||
p.ticket_id::text AS ticket_id,
|
||||
t.ticket_number,
|
||||
t.organization_id::text AS organization_id,
|
||||
o.name AS organization_name,
|
||||
COALESCE(o.name, t.customer_name) AS customer_name,
|
||||
t.work_type,
|
||||
p.teamspace_project,
|
||||
p.description,
|
||||
p.goals,
|
||||
p.starts_on::text AS starts_on,
|
||||
p.ends_on::text AS ends_on,
|
||||
p.hour_budget_minutes,
|
||||
p.created_at,
|
||||
p.updated_at,
|
||||
COALESCE(session_totals.session_count, 0)::int AS session_count,
|
||||
COALESCE(session_totals.billed_minutes, 0)::int AS billed_minutes,
|
||||
COALESCE(todo_totals.open_todos, 0)::int AS open_todos,
|
||||
COALESCE(todo_totals.done_todos, 0)::int AS done_todos
|
||||
FROM projects p
|
||||
JOIN tickets t ON t.id = p.ticket_id
|
||||
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(*)::int AS session_count,
|
||||
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes
|
||||
FROM sessions s
|
||||
WHERE s.ticket_id = p.ticket_id
|
||||
AND s.user_id = p.user_id
|
||||
) session_totals ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'open')::int AS open_todos,
|
||||
COUNT(*) FILTER (WHERE status = 'done')::int AS done_todos
|
||||
FROM ticket_todos tt
|
||||
WHERE tt.ticket_id = p.ticket_id
|
||||
AND tt.user_id = p.user_id
|
||||
) todo_totals ON true
|
||||
WHERE p.user_id = $1
|
||||
ORDER BY p.updated_at DESC, t.ticket_number ASC;
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
res.json({ projects: result.rows });
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId", requireUser, async (req, res) => {
|
||||
const projectId = parsePositiveInteger(req.params.projectId, "projectId");
|
||||
const userId = currentUser(req).id;
|
||||
const result = await query<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) => {
|
||||
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
|
||||
const result = await getTicketDetails(ticketId, currentUser(req).id, {
|
||||
@@ -2979,6 +3597,16 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => {
|
||||
return "organization-not-found" as const;
|
||||
}
|
||||
|
||||
const projectResult = await client.query<{ id: string }>(
|
||||
`
|
||||
SELECT id
|
||||
FROM projects
|
||||
WHERE ticket_id = $1
|
||||
AND user_id = $2;
|
||||
`,
|
||||
[ticketId, currentUser(req).id]
|
||||
);
|
||||
const effectiveWorkType = projectResult.rowCount && projectResult.rowCount > 0 ? "consulting" : workType;
|
||||
const ticketResult = await client.query(
|
||||
`
|
||||
UPDATE tickets
|
||||
@@ -2989,7 +3617,7 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => {
|
||||
WHERE id = $5
|
||||
RETURNING id, ticket_number, organization_id, customer_name, work_type;
|
||||
`,
|
||||
[ticketNumber, organization.id, organization.name, workType, ticketId]
|
||||
[ticketNumber, organization.id, organization.name, effectiveWorkType, ticketId]
|
||||
);
|
||||
|
||||
if (ticketResult.rowCount === 0) {
|
||||
@@ -3004,7 +3632,7 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => {
|
||||
work_type = CASE WHEN recurring_billing_id IS NULL THEN $3 ELSE work_type END
|
||||
WHERE ticket_id = $4;
|
||||
`,
|
||||
[organization.id, organization.name, workType, ticketId]
|
||||
[organization.id, organization.name, effectiveWorkType, ticketId]
|
||||
);
|
||||
|
||||
return ticketResult.rows[0];
|
||||
@@ -3487,6 +4115,7 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => {
|
||||
work_type: "support" | "consulting";
|
||||
recurring_billing_id: string | null;
|
||||
recurring_work_type: "support" | "consulting" | null;
|
||||
project_id: string | null;
|
||||
month_closed_at: string | null;
|
||||
}>(
|
||||
`
|
||||
@@ -3497,9 +4126,11 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => {
|
||||
s.work_type,
|
||||
s.recurring_billing_id,
|
||||
rb.work_type AS recurring_work_type,
|
||||
p.id AS project_id,
|
||||
mc.closed_at AS month_closed_at
|
||||
FROM sessions s
|
||||
LEFT JOIN recurring_billings rb ON rb.id = s.recurring_billing_id
|
||||
LEFT JOIN projects p ON p.ticket_id = s.ticket_id AND p.user_id = s.user_id
|
||||
LEFT JOIN month_closures mc
|
||||
ON mc.month = date_trunc('month', s.started_at AT TIME ZONE 'Europe/Berlin')::date
|
||||
AND mc.user_id = s.user_id
|
||||
@@ -3529,7 +4160,7 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => {
|
||||
return "month-closed" as const;
|
||||
}
|
||||
|
||||
const effectiveWorkType = current.recurring_billing_id ? current.recurring_work_type ?? current.work_type : workType;
|
||||
const effectiveWorkType = current.project_id ? "consulting" : current.recurring_billing_id ? current.recurring_work_type ?? current.work_type : workType;
|
||||
await reopenPeriodsForSession(client, current.ticket_id, new Date(current.started_at), userId);
|
||||
await reopenPeriodsForSession(client, current.ticket_id, startedAt, userId);
|
||||
|
||||
|
||||
@@ -435,6 +435,42 @@ export async function migrate() {
|
||||
);
|
||||
`);
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
teamspace_project TEXT,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
goals TEXT NOT NULL DEFAULT '',
|
||||
starts_on DATE,
|
||||
ends_on DATE,
|
||||
hour_budget_minutes INTEGER,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (user_id, ticket_id),
|
||||
CONSTRAINT projects_budget_non_negative CHECK (hour_budget_minutes IS NULL OR hour_budget_minutes >= 0),
|
||||
CONSTRAINT projects_valid_range CHECK (starts_on IS NULL OR ends_on IS NULL OR ends_on >= starts_on)
|
||||
);
|
||||
`);
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS ticket_todos (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
priority TEXT NOT NULL DEFAULT 'medium',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT ticket_todos_priority_valid CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
|
||||
CONSTRAINT ticket_todos_status_valid CHECK (status IN ('open', 'done'))
|
||||
);
|
||||
`);
|
||||
|
||||
await ensureClosureOwnership("ticket_month_closures", "month", ["ticket_id", "month"], adminId);
|
||||
await ensureClosureOwnership("month_closures", "month", ["month"], adminId);
|
||||
await ensureClosureOwnership("ticket_day_closures", "day", ["ticket_id", "day"], adminId);
|
||||
@@ -450,6 +486,10 @@ export async function migrate() {
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_closures_day ON ticket_day_closures(day);");
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_user_day ON ticket_day_billings(user_id, day);");
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billing_ack_user_day ON ticket_day_billing_acknowledgements(user_id, day);");
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_projects_user_ticket ON projects(user_id, ticket_id);");
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_projects_user_updated ON projects(user_id, updated_at DESC);");
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_status_priority ON ticket_todos(user_id, status, priority);");
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_ticket_user ON ticket_todos(ticket_id, user_id);");
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_recurring_billings_user_active ON recurring_billings(user_id, active);");
|
||||
await query("UPDATE recurring_billings SET active = true WHERE active = false;");
|
||||
await query(`
|
||||
|
||||
+35
-6
@@ -6,8 +6,10 @@ import {
|
||||
ChartNoAxesCombined,
|
||||
CircleHelp,
|
||||
FileText,
|
||||
FolderKanban,
|
||||
GripVertical,
|
||||
LayoutGrid,
|
||||
ListTodo,
|
||||
LogOut,
|
||||
Moon,
|
||||
Pause,
|
||||
@@ -65,11 +67,13 @@ import { FaqPage } from "./views/FaqPage";
|
||||
import { LoginPage } from "./views/LoginPage";
|
||||
import { MonthlyClosePage } from "./views/MonthlyClosePage";
|
||||
import { ProfilePage } from "./views/ProfilePage";
|
||||
import { ProjectsPage } from "./views/ProjectsPage";
|
||||
import { RecurringBillingsPage } from "./views/RecurringBillingsPage";
|
||||
import { StatisticsPage } from "./views/StatisticsPage";
|
||||
import { SetupPage } from "./views/SetupPage";
|
||||
import { TicketDetailPage } from "./views/TicketDetailPage";
|
||||
import { TimerPage } from "./views/TimerPage";
|
||||
import { TodosPage } from "./views/TodosPage";
|
||||
import type { AuthUser, PeriodType, TicketDetailPeriodType, TicketSearchResult } from "./types";
|
||||
import type { Dispatch, DragEvent, SetStateAction } from "react";
|
||||
|
||||
@@ -177,6 +181,14 @@ function routeFromPath(pathWithSearch: string) {
|
||||
return { page: "statistics" as const };
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/projects")) {
|
||||
return { page: "projects" as const };
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/todos")) {
|
||||
return { page: "todos" as const };
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/faq")) {
|
||||
return { page: "faq" as const };
|
||||
}
|
||||
@@ -387,11 +399,15 @@ function HeaderTicketSearch({ className, onNavigate }: HeaderTicketSearchProps)
|
||||
}}
|
||||
>
|
||||
<span className="flex min-w-0 items-center justify-between gap-3">
|
||||
<span className="truncate text-sm font-medium">{ticket.ticket_number}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<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>
|
||||
<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>
|
||||
</button>
|
||||
))
|
||||
@@ -489,7 +505,8 @@ function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers,
|
||||
</span>
|
||||
{timer.organizationName ? (
|
||||
<span className="block truncate text-[11px] leading-4 text-muted-foreground">
|
||||
{timer.organizationName}
|
||||
{timer.isProject ? "Projekt · " : ""}
|
||||
{timer.projectName || timer.organizationName}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="block font-mono text-[11px] font-semibold leading-4 tracking-normal text-muted-foreground">
|
||||
@@ -631,13 +648,17 @@ export function App() {
|
||||
return {
|
||||
id: timer.id,
|
||||
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null,
|
||||
workType: result.ticket?.work_type ?? null
|
||||
workType: result.ticket?.work_type ?? null,
|
||||
isProject: Boolean(result.ticket?.is_project),
|
||||
projectName: result.ticket?.project_name ?? null
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: timer.id,
|
||||
organizationName: null,
|
||||
workType: null
|
||||
workType: null,
|
||||
isProject: false,
|
||||
projectName: null
|
||||
};
|
||||
}
|
||||
})
|
||||
@@ -659,6 +680,8 @@ export function App() {
|
||||
...timer,
|
||||
organizationName: result.organizationName,
|
||||
workType: result.workType,
|
||||
isProject: result.isProject,
|
||||
projectName: result.projectName,
|
||||
ticketLookupDone: true
|
||||
};
|
||||
})
|
||||
@@ -727,6 +750,8 @@ export function App() {
|
||||
ticketNumber,
|
||||
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
|
||||
workType: ticketResult.ticket?.work_type ?? null,
|
||||
isProject: Boolean(ticketResult.ticket?.is_project),
|
||||
projectName: ticketResult.ticket?.project_name ?? null,
|
||||
ticketLookupDone: true,
|
||||
startedAt: now,
|
||||
pausedTotalMs: 0,
|
||||
@@ -737,7 +762,7 @@ export function App() {
|
||||
setTimers((current) => [...current.map((timer) => pauseEntry(timer, now)), newTimer]);
|
||||
setSelectedTimerId(newTimer.id);
|
||||
toast.success("Timer gestartet", {
|
||||
description: ticketNumber
|
||||
description: ticketResult.ticket?.is_project ? `${ticketNumber} ist ein Projekt` : ticketNumber
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -870,6 +895,8 @@ export function App() {
|
||||
{ type: "item" as const, href: "/monthly-close", label: "Monatsabschluss", icon: ClipboardCheck, active: path.startsWith("/monthly-close") },
|
||||
{ type: "separator" as const, key: "after-monthly-close" },
|
||||
{ type: "item" as const, href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") },
|
||||
{ type: "item" as const, href: "/projects", label: "Projekte", icon: FolderKanban, active: path.startsWith("/projects") },
|
||||
{ type: "item" as const, href: "/todos", label: "ToDos", icon: ListTodo, active: path.startsWith("/todos") },
|
||||
{ type: "item" as const, href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
|
||||
{ type: "separator" as const, key: "before-profile" },
|
||||
{ type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
|
||||
@@ -1130,6 +1157,8 @@ export function App() {
|
||||
{!isAdmin && route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
|
||||
{!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage 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}
|
||||
{route.page === "faq" ? <FaqPage /> : null}
|
||||
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
|
||||
|
||||
@@ -5,11 +5,15 @@ import type {
|
||||
Organization,
|
||||
PeriodOverview,
|
||||
PeriodType,
|
||||
ProjectSummary,
|
||||
RecurringBilling,
|
||||
StatisticsOverview,
|
||||
TicketMeta,
|
||||
TicketPeriod,
|
||||
TicketTodo,
|
||||
TicketSearchResult,
|
||||
TodoPriority,
|
||||
TodoStatus,
|
||||
WorkType
|
||||
} from "./types";
|
||||
|
||||
@@ -290,6 +294,94 @@ export function deleteRecurringBilling(billingId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function getProjects() {
|
||||
return request<{ projects: ProjectSummary[] }>("/api/projects");
|
||||
}
|
||||
|
||||
export function getProject(projectId: string) {
|
||||
return request<{ project: ProjectSummary }>(`/api/projects/${projectId}`);
|
||||
}
|
||||
|
||||
export function createProject(payload: {
|
||||
ticketNumber: string;
|
||||
organizationId?: string | null;
|
||||
teamspaceProject?: string | null;
|
||||
description: string;
|
||||
goals: string;
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
hourBudgetMinutes?: number | null;
|
||||
}) {
|
||||
return request<{ project: ProjectSummary }>("/api/projects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateProject(
|
||||
projectId: string,
|
||||
payload: {
|
||||
ticketNumber: string;
|
||||
organizationId: string;
|
||||
teamspaceProject?: string | null;
|
||||
description: string;
|
||||
goals: string;
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
hourBudgetMinutes?: number | null;
|
||||
}
|
||||
) {
|
||||
return request<{ project: ProjectSummary }>(`/api/projects/${projectId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function getTodos(status?: TodoStatus | "all") {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (status && status !== "all") {
|
||||
params.set("status", status);
|
||||
}
|
||||
|
||||
return request<{ todos: TicketTodo[] }>(`/api/todos${params.size ? `?${params}` : ""}`);
|
||||
}
|
||||
|
||||
export function createTicketTodo(
|
||||
ticketId: string,
|
||||
payload: {
|
||||
title: string;
|
||||
description: string;
|
||||
priority: TodoPriority;
|
||||
}
|
||||
) {
|
||||
return request<{ todo: TicketTodo }>(`/api/tickets/${ticketId}/todos`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTodo(
|
||||
todoId: string,
|
||||
payload: {
|
||||
title: string;
|
||||
description: string;
|
||||
priority: TodoPriority;
|
||||
status: TodoStatus;
|
||||
}
|
||||
) {
|
||||
return request<{ todo: TicketTodo }>(`/api/todos/${todoId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTodo(todoId: string) {
|
||||
return request<{ deleted: { id: string } }>(`/api/todos/${todoId}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
}
|
||||
|
||||
export function createSession(payload: CreateSessionPayload) {
|
||||
return request("/api/sessions", {
|
||||
method: "POST",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -237,6 +237,43 @@ export const helpSections: HelpSection[] = [
|
||||
"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",
|
||||
title: "Sessions Bearbeiten und Löschen",
|
||||
|
||||
@@ -5,6 +5,8 @@ export type TimerEntry = {
|
||||
ticketNumber: string;
|
||||
organizationName?: string | null;
|
||||
workType?: "support" | "consulting" | null;
|
||||
isProject?: boolean;
|
||||
projectName?: string | null;
|
||||
ticketLookupDone?: boolean;
|
||||
startedAt: number;
|
||||
pausedTotalMs: number;
|
||||
|
||||
+50
-1
@@ -60,6 +60,9 @@ export type TicketMeta = {
|
||||
organization_name: string | null;
|
||||
customer_name: string | null;
|
||||
work_type: WorkType | null;
|
||||
project_id?: string | null;
|
||||
is_project?: boolean;
|
||||
project_name?: string | null;
|
||||
};
|
||||
|
||||
export type TicketSearchResult = {
|
||||
@@ -69,12 +72,56 @@ export type TicketSearchResult = {
|
||||
organization_name: string | null;
|
||||
customer_name: string | 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;
|
||||
session_count: 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 = {
|
||||
id: string;
|
||||
ticket_number: string;
|
||||
@@ -190,6 +237,8 @@ export type TicketPeriod = {
|
||||
};
|
||||
sessions: SessionEntry[];
|
||||
dayBillings: TicketDayBilling[];
|
||||
project?: ProjectSummary | null;
|
||||
todos?: TicketTodo[];
|
||||
canClose: boolean;
|
||||
openCount: number;
|
||||
ticketOpenCount: number;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 { toast } from "sonner";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
@@ -11,6 +11,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
||||
import { HelpLink } from "@/components/HelpLink";
|
||||
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
||||
import { TodoPanel } from "@/components/TodoPanel";
|
||||
import {
|
||||
createSession,
|
||||
deleteSession,
|
||||
@@ -231,6 +232,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
}, [data?.ticket]);
|
||||
|
||||
const ticket = data?.ticket;
|
||||
const project = data?.project ?? null;
|
||||
const isProject = Boolean(project);
|
||||
const isGlobalTicket = periodType === "all";
|
||||
const isPeriodClosed = Boolean(data?.closed);
|
||||
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, {
|
||||
ticketNumber: editTicketNumber,
|
||||
organizationId: editOrganizationId,
|
||||
workType: editWorkType
|
||||
workType: isProject ? "consulting" : editWorkType
|
||||
});
|
||||
toast.success("Ticketdaten gespeichert", {
|
||||
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 effectiveWorkType = ticket.work_type ?? ticketSession.workType;
|
||||
const effectiveWorkType = isProject ? "consulting" : ticket.work_type ?? ticketSession.workType;
|
||||
|
||||
if (!effectiveOrganizationId) {
|
||||
toast.error("Organisation wählen");
|
||||
@@ -503,7 +506,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
await updateSessionDetails(editingSession.id, {
|
||||
organizationId: editSessionOrganizationId,
|
||||
activity: editSessionActivity,
|
||||
workType: editSessionWorkType,
|
||||
workType: isProject ? "consulting" : editSessionWorkType,
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: endedAt.toISOString()
|
||||
});
|
||||
@@ -670,11 +673,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
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"
|
||||
value={editWorkType}
|
||||
disabled={isProject}
|
||||
onChange={(event) => setEditWorkType(event.currentTarget.value as WorkType)}
|
||||
>
|
||||
<option value="support">Support</option>
|
||||
<option value="consulting">Consulting</option>
|
||||
</select>
|
||||
{isProject ? <p className="text-xs text-muted-foreground">Projekte sind immer Consulting.</p> : null}
|
||||
</div>
|
||||
<Button type="submit" disabled={savingTicket}>
|
||||
<Save className="size-4" />
|
||||
@@ -684,6 +689,67 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
</CardContent>
|
||||
</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>
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
@@ -994,12 +1060,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
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"
|
||||
value={editSessionWorkType}
|
||||
disabled={Boolean(editingSession?.recurring_billing_id)}
|
||||
disabled={Boolean(editingSession?.recurring_billing_id) || isProject}
|
||||
onChange={(event) => setEditSessionWorkType(event.currentTarget.value as WorkType)}
|
||||
>
|
||||
<option value="support">Support</option>
|
||||
<option value="consulting">Consulting</option>
|
||||
</select>
|
||||
{isProject ? <p className="text-xs text-muted-foreground">Projekt-Sessions bleiben Consulting.</p> : null}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={savingSessionDetails}>
|
||||
|
||||
@@ -104,7 +104,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
|
||||
const timeout = window.setTimeout(async () => {
|
||||
try {
|
||||
const result = await lookupTicket(normalizedTicketNumber);
|
||||
const result = await lookupTicket(normalizedTicketNumber);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -163,6 +163,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
ticketNumber,
|
||||
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
|
||||
workType: ticketResult.ticket?.work_type ?? null,
|
||||
isProject: Boolean(ticketResult.ticket?.is_project),
|
||||
projectName: ticketResult.ticket?.project_name ?? null,
|
||||
ticketLookupDone: true,
|
||||
startedAt: now,
|
||||
pausedTotalMs: 0,
|
||||
@@ -333,7 +335,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
durationSeconds
|
||||
});
|
||||
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({
|
||||
...emptyManualSession(),
|
||||
@@ -405,7 +407,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
<CardDescription>
|
||||
{selectedTimer
|
||||
? 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."
|
||||
: "Starte einen Timer, um eine Session zu erfassen."}
|
||||
</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}
|
||||
{manualLookupStatus === "loading" ? <p className="text-xs text-muted-foreground">Prüfe vorhandenes Ticket...</p> : null}
|
||||
{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}
|
||||
{manualLookupStatus === "found" && !manualTicketDefaultsLocked ? (
|
||||
<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 ? (
|
||||
<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="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 className="space-y-2">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user