diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 49af5e5..d70e63a 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -15,6 +15,8 @@ export type AuthUser = { display_name: string; role: UserRole; active: boolean; + zammad_user_id: string | null; + has_zammad_api_token: boolean; }; declare global { @@ -73,7 +75,9 @@ function publicUser(user: AuthUser) { username: user.username, display_name: user.display_name, role: user.role, - active: user.active + active: user.active, + zammad_user_id: user.zammad_user_id ?? null, + has_zammad_api_token: Boolean(user.has_zammad_api_token) }; } @@ -123,7 +127,14 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio const tokenHash = hashToken(token); const result = await query( ` - SELECT u.id, u.username, u.display_name, u.role, u.active + SELECT + u.id, + u.username, + u.display_name, + u.role, + u.active, + u.zammad_user_id::text AS zammad_user_id, + (u.zammad_api_token IS NOT NULL) AS has_zammad_api_token FROM auth_sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1 diff --git a/backend/src/index.ts b/backend/src/index.ts index b227c25..846b111 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -175,11 +175,30 @@ type ProjectRow = { manual_consumption_minutes: number; teamspace_consumption_minutes?: number; consumption_minutes?: number; + teamspace_entry_count?: number; + teamspace_entries?: ProjectTeamspaceEntryRow[]; closed_at: string | null; created_at: string; updated_at: string; }; +type ProjectTeamspaceEntryRow = { + id: string; + project_id?: string; + teamspace_project: string | null; + is_default: boolean; + hour_budget_minutes: number | null; + manual_consumption_minutes: number; + teamspace_consumption_minutes?: number; + consumption_minutes?: number; + display_order?: number; +}; + +type ProjectWithEntries = ProjectRow & { + teamspace_entries: ProjectTeamspaceEntryRow[]; + teamspace_entry_count: number; +}; + const zammadBaseUrlSettingKey = "zammad.base_url"; const zammadApiKeySettingKey = "zammad.api_key"; @@ -200,6 +219,36 @@ async function setAppSetting(key: string, value: string) { ); } +async function fetchZammadUserIdForToken(apiToken: string) { + const savedBaseUrl = await getAppSetting(zammadBaseUrlSettingKey); + + if (!savedBaseUrl) { + throw badRequest("Zammad URL muss zuerst im Adminbereich gespeichert werden"); + } + + const baseUrl = parseZammadBaseUrl(savedBaseUrl); + const response = await fetch(`${baseUrl}/api/v1/users/me`, { + headers: { + Authorization: `Token token=${apiToken}`, + Accept: "application/json" + } + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw Object.assign(new Error(`Zammad Token konnte nicht geprüft werden (${response.status})${body ? `: ${body.slice(0, 250)}` : ""}`), { status: 502 }); + } + + const payload = (await response.json()) as { id?: unknown }; + const zammadUserId = Number(payload.id); + + if (!Number.isInteger(zammadUserId) || zammadUserId < 1) { + throw Object.assign(new Error("Zammad Antwort enthält keine gültige User-ID"), { status: 502 }); + } + + return zammadUserId; +} + async function getOrganizationByZammadId( client: { query: (text: string, params?: unknown[]) => Promise<{ rows: OrganizationRow[]; rowCount: number | null }> }, zammadId: number @@ -315,6 +364,58 @@ function parseOptionalNonNegativeInteger(value: unknown, field: string) { return number; } +function parseProjectTeamspaceEntries( + value: unknown, + fallback: { teamspaceProject: string | null; hourBudgetMinutes: number | null; manualConsumptionMinutes: number } +) { + const source = Array.isArray(value) && value.length > 0 + ? value + : [ + { + teamspaceProject: fallback.teamspaceProject, + isDefault: true, + hourBudgetMinutes: fallback.hourBudgetMinutes, + manualConsumptionMinutes: fallback.manualConsumptionMinutes + } + ]; + + if (source.length > 20) { + throw badRequest("teamspaceEntries must contain at most 20 entries"); + } + + let hasDefault = false; + const entries = source.map((item, index) => { + if (!item || typeof item !== "object" || Array.isArray(item)) { + throw badRequest("teamspaceEntries must be an array of objects"); + } + + const entry = item as Record; + const isDefault = entry.isDefault === undefined ? index === 0 : optionalBoolean(entry.isDefault, index === 0); + + if (isDefault) { + if (hasDefault) { + throw badRequest("Only one teamspace entry can be default"); + } + + hasDefault = true; + } + + return { + teamspaceProject: optionalString(entry.teamspaceProject), + isDefault, + hourBudgetMinutes: parseOptionalNonNegativeInteger(entry.hourBudgetMinutes, "teamspaceEntries.hourBudgetMinutes"), + manualConsumptionMinutes: parseOptionalNonNegativeInteger(entry.manualConsumptionMinutes, "teamspaceEntries.manualConsumptionMinutes") ?? 0, + displayOrder: index * 1024 + }; + }); + + if (!hasDefault) { + entries[0] = { ...entries[0], isDefault: true }; + } + + return entries; +} + function parseTodoPriority(value: unknown) { if (value !== "low" && value !== "medium" && value !== "high" && value !== "urgent") { throw badRequest("priority must be low, medium, high or urgent"); @@ -709,20 +810,37 @@ async function cleanupTicketDayBillingIfEmpty( } async function ensureProjectForTicket( - client: { query: (text: string, params?: unknown[]) => Promise }, + client: { query: (text: string, params?: unknown[]) => Promise<{ rows: T[] }> }, ticketId: string, userId: string ) { - await client.query( + const result = await client.query<{ id: string; teamspace_project: string | null; hour_budget_minutes: number | null; manual_consumption_minutes: number }>( ` INSERT INTO projects (user_id, ticket_id, teamspace_project, description, goals) SELECT $2, t.id, COALESCE(t.title, ''), '', '' FROM tickets t WHERE t.id = $1 - ON CONFLICT (user_id, ticket_id) DO NOTHING; + ON CONFLICT (user_id, ticket_id) DO NOTHING + RETURNING id::text, teamspace_project, hour_budget_minutes, manual_consumption_minutes; `, [ticketId, userId] ); + + if ("rows" in result && result.rows[0]) { + await client.query( + ` + INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order) + VALUES ($1, $2, true, $3, $4, 0) + ON CONFLICT DO NOTHING; + `, + [ + result.rows[0].id, + result.rows[0].teamspace_project, + result.rows[0].hour_budget_minutes, + result.rows[0].manual_consumption_minutes ?? 0 + ] + ); + } } async function deleteProjectDataForTicket( @@ -1600,8 +1718,8 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon p.goals, p.starts_on::text AS starts_on, p.ends_on::text AS ends_on, - p.hour_budget_minutes, - p.manual_consumption_minutes, + entry_totals.total_hour_budget_minutes AS hour_budget_minutes, + entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes, EXISTS ( SELECT 1 FROM recurring_billings rb @@ -1609,7 +1727,9 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number ) AS has_recurring_billing, COALESCE(teamspace_totals.teamspace_consumption_minutes, 0)::int AS teamspace_consumption_minutes, - (COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + p.manual_consumption_minutes)::int AS consumption_minutes, + (COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes)::int AS consumption_minutes, + entry_totals.teamspace_entry_count, + entry_totals.teamspace_entries, p.closed_at, p.created_at, p.updated_at @@ -1622,6 +1742,31 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon WHERE tdb.ticket_id = p.ticket_id AND tdb.user_id = p.user_id ) teamspace_totals ON true + LEFT JOIN LATERAL ( + SELECT + COALESCE(SUM(pte.hour_budget_minutes) FILTER (WHERE pte.hour_budget_minutes IS NOT NULL), NULL)::int AS total_hour_budget_minutes, + COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes, + COUNT(*)::int AS teamspace_entry_count, + COALESCE( + json_agg( + json_build_object( + 'id', pte.id::text, + 'project_id', pte.project_id::text, + 'teamspace_project', pte.teamspace_project, + 'is_default', pte.is_default, + 'hour_budget_minutes', pte.hour_budget_minutes, + 'manual_consumption_minutes', pte.manual_consumption_minutes, + 'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END, + 'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes, + 'display_order', pte.display_order + ) + ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC + ), + '[]'::json + ) AS teamspace_entries + FROM project_teamspace_entries pte + WHERE pte.project_id = p.id + ) entry_totals ON true WHERE p.ticket_id = $1 AND p.user_id = $2; `, @@ -1720,11 +1865,13 @@ app.post("/api/setup/admin", async (req, res) => { display_name: string; role: "admin" | "user"; active: boolean; + zammad_user_id: string | null; + has_zammad_api_token: boolean; }>( ` INSERT INTO users (username, display_name, password_hash, role, active) VALUES ($1, $2, $3, 'admin', true) - RETURNING id, username, display_name, role, active; + RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token; `, [username, displayName, await hashPassword(password)] ); @@ -1773,9 +1920,19 @@ app.post("/api/auth/login", async (req, res) => { role: "admin" | "user"; active: boolean; password_hash: string; + zammad_user_id: string | null; + has_zammad_api_token: boolean; }>( ` - SELECT id, username, display_name, role, active, password_hash + SELECT + id, + username, + display_name, + role, + active, + password_hash, + zammad_user_id::text AS zammad_user_id, + (zammad_api_token IS NOT NULL) AS has_zammad_api_token FROM users WHERE lower(username) = lower($1); `, @@ -1796,7 +1953,9 @@ app.post("/api/auth/login", async (req, res) => { username: user.username, display_name: user.display_name, role: user.role, - active: user.active + active: user.active, + zammad_user_id: user.zammad_user_id, + has_zammad_api_token: user.has_zammad_api_token } }); }); @@ -1867,6 +2026,9 @@ app.patch("/api/auth/me", async (req, res) => { const displayName = requireString(req.body.displayName, "displayName"); const currentPassword = optionalString(req.body.currentPassword); const newPassword = optionalString(req.body.newPassword); + const updatesZammadToken = Object.prototype.hasOwnProperty.call(req.body, "zammadApiToken") && user.role === "user"; + const zammadApiToken = updatesZammadToken ? optionalString(req.body.zammadApiToken) : null; + const zammadUserId = zammadApiToken ? await fetchZammadUserIdForToken(zammadApiToken) : null; if (newPassword && newPassword.length < 6) { throw badRequest("newPassword must be at least 6 characters"); @@ -1892,11 +2054,13 @@ app.patch("/api/auth/me", async (req, res) => { SET username = $1, display_name = $2, password_hash = COALESCE($3, password_hash), + zammad_api_token = CASE WHEN $5::boolean THEN $6 ELSE zammad_api_token END, + zammad_user_id = CASE WHEN $5::boolean THEN $7 ELSE zammad_user_id END, updated_at = now() WHERE id = $4 - RETURNING id, username, display_name, role, active; + RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token; `, - [username, displayName, newPassword ? await hashPassword(newPassword) : null, user.id] + [username, displayName, newPassword ? await hashPassword(newPassword) : null, user.id, updatesZammadToken, zammadApiToken, zammadUserId] ); if (result.rowCount === 0) { @@ -1923,7 +2087,7 @@ app.patch("/api/auth/me", async (req, res) => { app.get("/api/admin/users", requireAdmin, async (_req, res) => { const result = await query( ` - SELECT id, username, display_name, role, active, created_at, updated_at + SELECT id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, created_at, updated_at FROM users ORDER BY username ASC; ` @@ -1947,7 +2111,7 @@ app.post("/api/admin/users", requireAdmin, async (req, res) => { ` INSERT INTO users (username, display_name, password_hash, role, active) VALUES ($1, $2, $3, $4, $5) - RETURNING id, username, display_name, role, active, created_at, updated_at; + RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, created_at, updated_at; `, [username, displayName, await hashPassword(password), "user", active] ); @@ -2008,7 +2172,7 @@ app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => { password_hash = COALESCE($4, password_hash), updated_at = now() WHERE id = $5 - RETURNING id, username, display_name, role, active, created_at, updated_at; + RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, created_at, updated_at; `, [username, displayName, active, password ? await hashPassword(password) : null, userId] ); @@ -2248,6 +2412,7 @@ const databaseExportTables = [ "organizations", "tickets", "projects", + "project_teamspace_entries", "ticket_todos", "recurring_billings", "recurring_billing_slots", @@ -3395,8 +3560,8 @@ app.get("/api/projects", requireUser, async (req, res) => { p.goals, p.starts_on::text AS starts_on, p.ends_on::text AS ends_on, - p.hour_budget_minutes, - p.manual_consumption_minutes, + entry_totals.total_hour_budget_minutes AS hour_budget_minutes, + entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes, p.closed_at, p.created_at, p.updated_at, @@ -3410,7 +3575,9 @@ app.get("/api/projects", requireUser, async (req, res) => { AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number ) AS has_recurring_billing, COALESCE(teamspace_totals.teamspace_consumption_minutes, 0)::int AS teamspace_consumption_minutes, - (COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + p.manual_consumption_minutes)::int AS consumption_minutes, + (COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes)::int AS consumption_minutes, + entry_totals.teamspace_entry_count, + entry_totals.teamspace_entries, COALESCE(todo_totals.open_todos, 0)::int AS open_todos, COALESCE(todo_totals.overdue_todos, 0)::int AS overdue_todos, COALESCE(todo_totals.done_todos, 0)::int AS done_todos @@ -3432,6 +3599,31 @@ app.get("/api/projects", requireUser, async (req, res) => { WHERE tdb.ticket_id = p.ticket_id AND tdb.user_id = p.user_id ) teamspace_totals ON true + LEFT JOIN LATERAL ( + SELECT + COALESCE(SUM(pte.hour_budget_minutes) FILTER (WHERE pte.hour_budget_minutes IS NOT NULL), NULL)::int AS total_hour_budget_minutes, + COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes, + COUNT(*)::int AS teamspace_entry_count, + COALESCE( + json_agg( + json_build_object( + 'id', pte.id::text, + 'project_id', pte.project_id::text, + 'teamspace_project', pte.teamspace_project, + 'is_default', pte.is_default, + 'hour_budget_minutes', pte.hour_budget_minutes, + 'manual_consumption_minutes', pte.manual_consumption_minutes, + 'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END, + 'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes, + 'display_order', pte.display_order + ) + ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC + ), + '[]'::json + ) AS teamspace_entries + FROM project_teamspace_entries pte + WHERE pte.project_id = p.id + ) entry_totals ON true LEFT JOIN LATERAL ( SELECT COUNT(*) FILTER (WHERE status = 'open')::int AS open_todos, @@ -3473,8 +3665,8 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { p.goals, p.starts_on::text AS starts_on, p.ends_on::text AS ends_on, - p.hour_budget_minutes, - p.manual_consumption_minutes, + entry_totals.total_hour_budget_minutes AS hour_budget_minutes, + entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes, EXISTS ( SELECT 1 FROM recurring_billings rb @@ -3482,7 +3674,9 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number ) AS has_recurring_billing, COALESCE(teamspace_totals.teamspace_consumption_minutes, 0)::int AS teamspace_consumption_minutes, - (COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + p.manual_consumption_minutes)::int AS consumption_minutes, + (COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes)::int AS consumption_minutes, + entry_totals.teamspace_entry_count, + entry_totals.teamspace_entries, p.closed_at, p.created_at, p.updated_at @@ -3495,6 +3689,31 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { WHERE tdb.ticket_id = p.ticket_id AND tdb.user_id = p.user_id ) teamspace_totals ON true + LEFT JOIN LATERAL ( + SELECT + COALESCE(SUM(pte.hour_budget_minutes) FILTER (WHERE pte.hour_budget_minutes IS NOT NULL), NULL)::int AS total_hour_budget_minutes, + COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes, + COUNT(*)::int AS teamspace_entry_count, + COALESCE( + json_agg( + json_build_object( + 'id', pte.id::text, + 'project_id', pte.project_id::text, + 'teamspace_project', pte.teamspace_project, + 'is_default', pte.is_default, + 'hour_budget_minutes', pte.hour_budget_minutes, + 'manual_consumption_minutes', pte.manual_consumption_minutes, + 'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END, + 'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes, + 'display_order', pte.display_order + ) + ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC + ), + '[]'::json + ) AS teamspace_entries + FROM project_teamspace_entries pte + WHERE pte.project_id = p.id + ) entry_totals ON true WHERE p.id = $1 AND p.user_id = $2; `, @@ -3521,6 +3740,8 @@ app.post("/api/projects", requireUser, async (req, res) => { const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn"); const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes"); const manualConsumptionMinutes = parseOptionalNonNegativeInteger(req.body.manualConsumptionMinutes, "manualConsumptionMinutes") ?? 0; + const teamspaceEntries = parseProjectTeamspaceEntries(req.body.teamspaceEntries, { teamspaceProject, hourBudgetMinutes, manualConsumptionMinutes }); + const defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; if (startsOn && endsOn && endsOn < startsOn) { throw badRequest("endsOn must be after startsOn"); @@ -3624,13 +3845,13 @@ app.post("/api/projects", requireUser, async (req, res) => { [ userId, ticketId, - teamspaceProject, + defaultTeamspaceEntry.teamspaceProject, description, goals, startsOn, endsOn, - hourBudgetMinutes, - manualConsumptionMinutes, + defaultTeamspaceEntry.hourBudgetMinutes, + defaultTeamspaceEntry.manualConsumptionMinutes, ticketNumber, organization.id, organization.name, @@ -3639,7 +3860,31 @@ app.post("/api/projects", requireUser, async (req, res) => { ] ); - return projectResult.rows[0]; + const project = projectResult.rows[0]; + + for (const entry of teamspaceEntries) { + await client.query( + ` + INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order) + VALUES ($1, $2, $3, $4, $5, $6); + `, + [project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.manualConsumptionMinutes, entry.displayOrder] + ); + } + + project.teamspace_entries = teamspaceEntries.map((entry, index) => ({ + id: `new-${index}`, + teamspace_project: entry.teamspaceProject, + is_default: entry.isDefault, + hour_budget_minutes: entry.hourBudgetMinutes, + manual_consumption_minutes: entry.manualConsumptionMinutes, + teamspace_consumption_minutes: entry.isDefault ? 0 : 0, + consumption_minutes: entry.manualConsumptionMinutes, + display_order: entry.displayOrder + })); + project.teamspace_entry_count = teamspaceEntries.length; + + return project; }); if (created === "organization-required") { @@ -3676,6 +3921,8 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn"); const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes"); const manualConsumptionMinutes = parseOptionalNonNegativeInteger(req.body.manualConsumptionMinutes, "manualConsumptionMinutes") ?? 0; + const teamspaceEntries = parseProjectTeamspaceEntries(req.body.teamspaceEntries, { teamspaceProject, hourBudgetMinutes, manualConsumptionMinutes }); + const defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; if (startsOn && endsOn && endsOn < startsOn) { throw badRequest("endsOn must be after startsOn"); @@ -3774,10 +4021,50 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { created_at, updated_at; `, - [teamspaceProject, description, goals, startsOn, endsOn, hourBudgetMinutes, manualConsumptionMinutes, projectId, userId, ticketNumber, organization.id, organization.name, title] + [ + defaultTeamspaceEntry.teamspaceProject, + description, + goals, + startsOn, + endsOn, + defaultTeamspaceEntry.hourBudgetMinutes, + defaultTeamspaceEntry.manualConsumptionMinutes, + projectId, + userId, + ticketNumber, + organization.id, + organization.name, + title + ] ); - return result.rows[0]; + const project = result.rows[0]; + + await client.query("DELETE FROM project_teamspace_entries WHERE project_id = $1;", [project.id]); + + for (const entry of teamspaceEntries) { + await client.query( + ` + INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order) + VALUES ($1, $2, $3, $4, $5, $6); + `, + [project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.manualConsumptionMinutes, entry.displayOrder] + ); + } + + project.teamspace_entries = teamspaceEntries.map((entry, index) => ({ + id: `new-${index}`, + teamspace_project: entry.teamspaceProject, + is_default: entry.isDefault, + hour_budget_minutes: entry.hourBudgetMinutes, + manual_consumption_minutes: entry.manualConsumptionMinutes, + teamspace_consumption_minutes: entry.isDefault ? Number(project.teamspace_consumption_minutes ?? 0) : 0, + consumption_minutes: (entry.isDefault ? Number(project.teamspace_consumption_minutes ?? 0) : 0) + entry.manualConsumptionMinutes, + display_order: entry.displayOrder + })); + project.teamspace_entry_count = teamspaceEntries.length; + + return project; }); if (updated === "organization-not-found") { diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index b6f7ba4..54a0e8f 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -60,6 +60,8 @@ export async function migrate() { DROP CONSTRAINT IF EXISTS users_username_format, ADD CONSTRAINT users_username_format CHECK (char_length(username) BETWEEN 3 AND 40 AND username !~ '\\s'); `); + await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_api_token TEXT;"); + await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_user_id BIGINT;"); await query(` CREATE TABLE IF NOT EXISTS auth_sessions ( @@ -485,6 +487,25 @@ export async function migrate() { END $$; `); + await query(` + CREATE TABLE IF NOT EXISTS project_teamspace_entries ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + teamspace_project TEXT, + is_default BOOLEAN NOT NULL DEFAULT false, + hour_budget_minutes INTEGER, + manual_consumption_minutes INTEGER NOT NULL DEFAULT 0, + display_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT project_teamspace_entries_budget_non_negative CHECK (hour_budget_minutes IS NULL OR hour_budget_minutes >= 0), + CONSTRAINT project_teamspace_entries_manual_non_negative CHECK (manual_consumption_minutes >= 0) + ); + `); + + await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_teamspace_entries_default ON project_teamspace_entries(project_id) WHERE is_default;"); + await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_entries_project_order ON project_teamspace_entries(project_id, display_order, id);"); + await query(` UPDATE tickets t SET work_type = 'consulting' @@ -506,6 +527,17 @@ export async function migrate() { ON CONFLICT (user_id, ticket_id) DO NOTHING; `); + await query(` + INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order) + SELECT p.id, p.teamspace_project, true, p.hour_budget_minutes, p.manual_consumption_minutes, 0 + FROM projects p + WHERE NOT EXISTS ( + SELECT 1 + FROM project_teamspace_entries pte + WHERE pte.project_id = p.id + ); + `); + await query(` CREATE TABLE IF NOT EXISTS ticket_todos ( id BIGSERIAL PRIMARY KEY, diff --git a/docs/user-guide.md b/docs/user-guide.md index 2f92bac..fb62d11 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -510,9 +510,16 @@ Unter `Profil` kann jeder Benutzer seine eigenen Daten pflegen: - Benutzername - Voller Name - Passwort +- persoenlicher Zammad API-Token Passwortaenderungen erfordern das aktuelle Passwort. Bleiben die Passwortfelder leer, werden nur Benutzername und voller Name gespeichert. +Normale Benutzer koennen im Profil einen persoenlichen Zammad API-Token hinterlegen. Beim Speichern eines neuen Tokens ruft TicketTracker Zammad ueber `/api/v1/users/me` auf und speichert die User-ID, die Zammad fuer diesen Token zurueckliefert. Die User-ID wird danach read-only im Profil angezeigt. + +Die Pruefung findet nur statt, wenn der Token im Profil neu eingetragen oder entfernt wird. Wenn nur Benutzername, voller Name oder Passwort gespeichert werden, wird Zammad nicht erneut abgefragt. + +Der persoenliche Zammad Token ist unabhaengig vom Admin-Token fuer den Organisations-Sync. Der gleiche Token kann technisch in beiden Bereichen eingetragen sein, wird aber getrennt gespeichert und getrennt verwendet. Im Admin-Profil gibt es keinen persoenlichen Zammad-Token. + ## Adminbereich Der feste Admin ist nur fuer die Systemverwaltung vorgesehen. Er kann: diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d02a843..df61616 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -94,6 +94,7 @@ export function updateCurrentUser(payload: { displayName: string; currentPassword?: string; newPassword?: string; + zammadApiToken?: string; }) { return request<{ user: AuthUser }>("/api/auth/me", { method: "PATCH", @@ -338,6 +339,12 @@ export function createProject(payload: { title?: string | null; organizationId?: string | null; teamspaceProject?: string | null; + teamspaceEntries?: Array<{ + teamspaceProject?: string | null; + isDefault: boolean; + hourBudgetMinutes?: number | null; + manualConsumptionMinutes?: number | null; + }>; description: string; goals: string; startsOn?: string | null; @@ -358,6 +365,12 @@ export function updateProject( title?: string | null; organizationId: string; teamspaceProject?: string | null; + teamspaceEntries?: Array<{ + teamspaceProject?: string | null; + isDefault: boolean; + hourBudgetMinutes?: number | null; + manualConsumptionMinutes?: number | null; + }>; description: string; goals: string; startsOn?: string | null; diff --git a/frontend/src/help-content.ts b/frontend/src/help-content.ts index 15493ba..7608f4f 100644 --- a/frontend/src/help-content.ts +++ b/frontend/src/help-content.ts @@ -500,6 +500,7 @@ export const helpSections: HelpSection[] = [ quickItems: [ "Benutzername und voller Name sind änderbar.", "Passwortänderung benötigt das aktuelle Passwort.", + "Normale Benutzer können ihren persönlichen Zammad API-Token hinterlegen.", "Der volle Name wird in der Oberfläche angezeigt." ], details: [ @@ -507,6 +508,12 @@ export const helpSections: HelpSection[] = [ "Der volle Name erscheint in der App, zum Beispiel im Kopfbereich oder im Adminbereich.", "Wenn du dein Passwort ändern willst, musst du das aktuelle Passwort sowie das neue Passwort zweimal eingeben.", "Bleiben die Passwortfelder leer, werden nur Benutzername und voller Name gespeichert.", + "Normale Benutzer können im Profil einen persönlichen Zammad API-Token speichern.", + "Beim Speichern eines neuen Tokens fragt TicketTracker Zammad über /api/v1/users/me ab und speichert die zurückgelieferte Zammad User-ID.", + "Die gespeicherte Zammad User-ID wird danach read-only im Profil angezeigt.", + "Die Token-Prüfung läuft nur, wenn der Token im Profil geändert oder entfernt wird. Normale Profiländerungen prüfen Zammad nicht erneut.", + "Der persönliche Zammad Token ist vom Admin-Token für den Organisations-Sync getrennt. Es kann zufällig derselbe Token sein, wird aber separat gespeichert und verwendet.", + "Im Admin-Profil wird kein persönlicher Zammad-Token angezeigt.", "Admins können Benutzer ebenfalls verwalten, aber jeder Benutzer kann die eigenen Daten jederzeit selbst pflegen." ] }, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 47a727d..acb2751 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -10,6 +10,8 @@ export type AuthUser = { display_name: string; role: UserRole; active: boolean; + zammad_user_id: string | null; + has_zammad_api_token: boolean; }; export type DashboardWidgetType = "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes"; @@ -139,6 +141,20 @@ export type ProjectSummary = { open_todos?: number; overdue_todos?: number; done_todos?: number; + teamspace_entry_count?: number; + teamspace_entries?: ProjectTeamspaceEntry[]; +}; + +export type ProjectTeamspaceEntry = { + id: string; + project_id?: string; + teamspace_project: string | null; + is_default: boolean; + hour_budget_minutes: number | null; + manual_consumption_minutes: number; + teamspace_consumption_minutes?: number; + consumption_minutes?: number; + display_order?: number; }; export type TodoPriority = "low" | "medium" | "high" | "urgent"; diff --git a/frontend/src/views/DashboardPage.tsx b/frontend/src/views/DashboardPage.tsx index 1fd9846..8d62aa6 100644 --- a/frontend/src/views/DashboardPage.tsx +++ b/frontend/src/views/DashboardPage.tsx @@ -54,6 +54,7 @@ import type { DashboardWidget, DashboardWidgetType, PeriodOverview, + ProjectTeamspaceEntry, ProjectSummary, RecurringBilling, StatisticsOverview, @@ -571,6 +572,25 @@ function projectConsumptionPercent(project: ProjectSummary) { return Math.round(((project.consumption_minutes ?? 0) / budgetMinutes) * 100); } +function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry[] { + if (project.teamspace_entries?.length) { + return project.teamspace_entries; + } + + return [ + { + id: `${project.id}-default`, + teamspace_project: project.teamspace_project, + is_default: true, + hour_budget_minutes: project.hour_budget_minutes, + manual_consumption_minutes: project.manual_consumption_minutes ?? 0, + teamspace_consumption_minutes: project.teamspace_consumption_minutes ?? 0, + consumption_minutes: project.consumption_minutes ?? project.teamspace_consumption_minutes ?? 0, + display_order: 0 + } + ]; +} + function openRatingFilterGroup(filter: string) { if (filter === "fixedBilling" || filter === "manualBilling") { return "source"; @@ -1648,18 +1668,48 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU const consumptionMinutes = project.consumption_minutes ?? 0; const budgetMinutes = project.hour_budget_minutes ?? 0; const percent = projectConsumptionPercent(project); + const entries = projectTeamspaceEntries(project); + const hasMultipleTeamspaceEntries = entries.length > 1; return ( ); })} @@ -1973,6 +2023,46 @@ function NotesWidget({ widget, editing, onUpdateConfig }: WidgetContentProps) { const { notes, activeNoteId } = readWidgetNotes(widget.config); const activeNote = notes.find((note) => note.id === activeNoteId) ?? notes[0]; const tabsScrollerRef = useRef(null); + const [tabScrollState, setTabScrollState] = useState({ canScrollLeft: false, canScrollRight: false }); + + function updateTabScrollState() { + const scroller = tabsScrollerRef.current; + + if (!scroller) { + setTabScrollState({ canScrollLeft: false, canScrollRight: false }); + return; + } + + const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth; + setTabScrollState({ + canScrollLeft: scroller.scrollLeft > 1, + canScrollRight: scroller.scrollLeft < maxScrollLeft - 1 + }); + } + + useEffect(() => { + updateTabScrollState(); + const scroller = tabsScrollerRef.current; + + if (!scroller) { + return; + } + + const handleScroll = () => updateTabScrollState(); + const handleResize = () => updateTabScrollState(); + + scroller.addEventListener("scroll", handleScroll, { passive: true }); + window.addEventListener("resize", handleResize); + + return () => { + scroller.removeEventListener("scroll", handleScroll); + window.removeEventListener("resize", handleResize); + }; + }, [notes.length]); + + useEffect(() => { + updateTabScrollState(); + }, [activeNoteId, notes.length]); function saveNotes(nextNotes: DashboardNote[], nextActiveNoteId = activeNoteId) { const safeActiveNoteId = nextNotes.some((note) => note.id === nextActiveNoteId) ? nextActiveNoteId : nextNotes[0]?.id; @@ -2005,10 +2095,27 @@ function NotesWidget({ widget, editing, onUpdateConfig }: WidgetContentProps) { return ( onUpdateConfig({ activeNoteId: value })} className="flex h-full min-h-0 overflow-hidden gap-1">
- -
+
+
+
+
{notes.map((note) => ( @@ -2016,8 +2123,9 @@ function NotesWidget({ widget, editing, onUpdateConfig }: WidgetContentProps) { ))} +
-
+ {currentUser.role === "user" ? ( +
+
+
+ +

Zammad API

+
+ +
+
+
+ + { + setZammadApiToken(event.currentTarget.value); + setZammadTokenDirty(true); + }} + /> +

+ Die Zammad-User-ID wird nur geprüft, wenn du hier einen neuen Token speicherst. +

+
+
+ + + {currentUser.has_zammad_api_token ? ( + + ) : null} +
+
+
+ ) : null} +
+
+
+ {normalizedEntries.map((entry, index) => ( +
+
+ + updateEntry(entry.clientId, { teamspaceProject: event.currentTarget.value })} + /> +
+
+ + updateEntry(entry.clientId, { hourBudget: event.currentTarget.value })} + /> +
+
+ + updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })} + /> +
+ + +
+ ))} +
+

+ Der Standard-Eintrag bekommt die aus Teamspace gebuchten Tageswerte. Manueller Verbrauch zählt nur im Projektverbrauch. +

+
+ ); +} + function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, saving, submitLabel, onSubmit }: ProjectFormFieldsProps) { return (
@@ -331,7 +522,7 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, sa
-
+
-
- - setForm({ ...form, teamspaceProject: event.currentTarget.value })} /> -
-
- -
setForm({ ...form, endsOn: event.currentTarget.value })} />
-
- - setForm({ ...form, hourBudget: event.currentTarget.value })} /> -
-
- - setForm({ ...form, manualConsumption: event.currentTarget.value })} - /> -

Nur für Projektverbrauch, nicht für Auswertungen.

-
+ setForm({ ...form, teamspaceEntries })} /> +
+ + + + + + + Projekt anlegen + Consulting-Projekt mit einem oder mehreren Teamspace-Einträgen anlegen. + + + + @@ -915,9 +1094,11 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) { {filteredProjects.map((project) => { const isEditing = editingProjectId === project.id; + const teamspaceEntries = projectTeamspaceEntries(project); const consumptionMinutes = project.consumption_minutes ?? 0; const remaining = projectRemaining(project); const usedPercent = projectConsumptionPercent(project); + const hasMultipleTeamspaceEntries = teamspaceEntries.length > 1; return (
@@ -943,6 +1124,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {

{formatDecimalHours(consumptionMinutes)} / {project.hour_budget_minutes ? formatDecimalHours(project.hour_budget_minutes) : "offen"}

+ {hasMultipleTeamspaceEntries ?

{projectTeamspaceEntryCount(project)} Teamspace

: null}

Verbrauch %

@@ -999,7 +1181,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
-
+
{usedPercent !== null ? (
@@ -1009,6 +1191,35 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
)} + {hasMultipleTeamspaceEntries ? ( +
+ {teamspaceEntries.map((entry) => { + const entryPercent = projectEntryPercent(entry); + const entryConsumption = projectEntryConsumption(entry); + const label = entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID"); + + return ( +
+
+ + {label} + {entry.is_default ? " · Standard" : ""} + + + {formatMinutes(entryConsumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"} + +
+
+
+
+
+ ); + })} +
+ ) : null}
{isEditing ? (
@@ -1024,22 +1235,14 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
Consulting
*/} setEditForm({ ...editForm, title: event.currentTarget.value })} /> - setEditForm({ ...editForm, teamspaceProject: event.currentTarget.value })} - /> -
+
setEditForm({ ...editForm, startsOn: event.currentTarget.value })} /> setEditForm({ ...editForm, endsOn: event.currentTarget.value })} /> - setEditForm({ ...editForm, hourBudget: event.currentTarget.value })} /> - setEditForm({ ...editForm, manualConsumption: event.currentTarget.value })} - />
+ setEditForm({ ...editForm, teamspaceEntries })} + />