From 649dd6f02818e0fdd590c612e063b44042a7c3c3 Mon Sep 17 00:00:00 2001 From: Michael Date: Thu, 8 Oct 2026 09:03:15 +0200 Subject: [PATCH] Final --- backend/src/auth.ts | 9 + backend/src/index.ts | 2465 ++++++++++++++++++---- backend/src/migrations.ts | 214 +- bla.json | 1 + frontend/src/App.tsx | 8 +- frontend/src/api.ts | 105 +- frontend/src/components/ui/accordion.tsx | 2 +- frontend/src/types.ts | 28 + frontend/src/views/AdminUsersPage.tsx | 50 +- frontend/src/views/AnalysisPage.tsx | 13 +- frontend/src/views/DashboardPage.tsx | 121 +- frontend/src/views/ProfilePage.tsx | 93 + frontend/src/views/ProjectsPage.tsx | 160 +- frontend/src/views/StatisticsPage.tsx | 159 +- frontend/src/views/TicketDetailPage.tsx | 862 ++++++-- frontend/src/views/TimerPage.tsx | 12 +- frontend/src/views/ZammadSyncPage.tsx | 152 +- mails.json | 0 mails.json1 | 211 -- triple.json | 48 + 20 files changed, 3725 insertions(+), 988 deletions(-) create mode 100644 bla.json delete mode 100644 mails.json delete mode 100644 mails.json1 create mode 100644 triple.json diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 1be1cb3..0ef2664 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -17,8 +17,11 @@ export type AuthUser = { active: boolean; zammad_user_id: string | null; has_zammad_api_token: boolean; + teamspace_user_id: string | null; + has_teamspace_credentials: boolean; zammad_email_session_webhook_enabled: boolean; session_billing_prompt_enabled: boolean; + teamspace_time_tracking_enabled: boolean; zammad_session_note_default_enabled: boolean; zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_minutes: number; @@ -90,8 +93,11 @@ function publicUser(user: AuthUser) { active: user.active, zammad_user_id: user.zammad_user_id ?? null, has_zammad_api_token: Boolean(user.has_zammad_api_token), + teamspace_user_id: user.teamspace_user_id ?? null, + has_teamspace_credentials: Boolean(user.has_teamspace_credentials), zammad_email_session_webhook_enabled: Boolean(user.zammad_email_session_webhook_enabled), session_billing_prompt_enabled: Boolean(user.session_billing_prompt_enabled), + teamspace_time_tracking_enabled: Boolean(user.teamspace_time_tracking_enabled ?? true), zammad_session_note_default_enabled: Boolean(user.zammad_session_note_default_enabled), zammad_time_unit_rounding_enabled: Boolean(user.zammad_time_unit_rounding_enabled), zammad_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15), @@ -159,8 +165,11 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio u.active, u.zammad_user_id::text AS zammad_user_id, (u.zammad_api_token IS NOT NULL) AS has_zammad_api_token, + u.teamspace_user_id::text AS teamspace_user_id, + (u.teamspace_device_id IS NOT NULL AND u.teamspace_device_password IS NOT NULL) AS has_teamspace_credentials, u.zammad_email_session_webhook_enabled, u.session_billing_prompt_enabled, + u.teamspace_time_tracking_enabled, u.zammad_session_note_default_enabled, u.zammad_time_unit_rounding_enabled, u.zammad_time_unit_rounding_minutes, diff --git a/backend/src/index.ts b/backend/src/index.ts index 4d8f721..21c9946 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -92,6 +92,26 @@ async function zammadFetch(url: string, init: RequestInit = {}) { } } +async function teamspaceFetch(url: string, init: RequestInit = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), zammadRequestTimeoutMs); + + try { + return await fetch(url, { + ...init, + signal: controller.signal + }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw Object.assign(new Error(`Teamspace Anfrage hat länger als ${Math.round(zammadRequestTimeoutMs / 1000)} Sekunden gedauert`), { status: 504 }); + } + + throw Object.assign(new Error(`Teamspace ist nicht erreichbar: ${errorMessage(error)}`), { status: 502 }); + } finally { + clearTimeout(timeout); + } +} + process.on("uncaughtException", (error) => { logDebug("process", { event: "uncaughtException", @@ -524,6 +544,26 @@ function parseZammadBaseUrl(value: unknown) { } } +function parseTeamspaceBaseUrl(value: unknown) { + const raw = requireString(value, "baseUrl").replace(/\/+$/, ""); + + try { + const url = new URL(raw); + + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw badRequest("baseUrl must use http or https"); + } + + url.hash = ""; + url.search = ""; + url.pathname = url.pathname.replace(/\/+$/, "").replace(/\/api$/i, ""); + + return url.toString().replace(/\/+$/, ""); + } catch { + throw badRequest("baseUrl must be a valid URL"); + } +} + type OrganizationRow = { id: string; zammad_id: string; @@ -560,6 +600,8 @@ type ProjectRow = { consumption_minutes?: number; teamspace_entry_count?: number; teamspace_entries?: ProjectTeamspaceEntryRow[]; + unassigned_teamspace_session_count?: number; + unassigned_teamspace_subentry_count?: number; closed_at: string | null; close_review_required?: boolean; created_at: string; @@ -570,6 +612,9 @@ type ProjectTeamspaceEntryRow = { id: string; project_id?: string; teamspace_project: string | null; + teamspace_api_project_id?: string | null; + teamspace_api_project_url?: string | null; + subentries?: ProjectTeamspaceSubentryRow[]; is_default: boolean; hour_budget_minutes: number | null; budget_sync_disabled?: boolean; @@ -579,6 +624,27 @@ type ProjectTeamspaceEntryRow = { display_order?: number; }; +type ProjectTeamspaceSubentryRow = { + id: string; + user_id?: string; + project_teamspace_entry_id?: string; + teamspace_api_project_id: string; + teamspace_api_project_url: string | null; + teamspace_project_id: string | null; + billing_type: string | null; + name: string | null; + alias: string; + description: string; + progress: number | null; + planned_minutes: number | null; + sum_minutes: number | null; + planned_minutes_billable: number | null; + sum_minutes_billable: number | null; + planned_income: number | null; + sum_income: number | null; + display_order: number; +}; + type ProjectWithEntries = ProjectRow & { teamspace_entries: ProjectTeamspaceEntryRow[]; teamspace_entry_count: number; @@ -586,6 +652,7 @@ type ProjectWithEntries = ProjectRow & { const zammadBaseUrlSettingKey = "zammad.base_url"; const zammadApiKeySettingKey = "zammad.api_key"; +const teamspaceBaseUrlSettingKey = "teamspace.base_url"; type QueryClient = { query: (text: string, params?: unknown[]) => Promise<{ rows: T[]; rowCount: number | null }>; @@ -739,6 +806,104 @@ async function fetchZammadUserIdForToken(apiToken: string) { return zammadUserId; } +function teamspaceBasicAuthHeader(deviceId: string, devicePassword: string) { + return `Basic ${Buffer.from(`${deviceId}:${devicePassword}`, "utf8").toString("base64")}`; +} + +function parseTeamspaceUserIdFromDevice(payload: unknown) { + if (!isPlainObject(payload)) { + return null; + } + + const user = isPlainObject(payload.user) ? payload.user : {}; + const data = isPlainObject(user.data) ? user.data : {}; + + return numberOrNull(user.value) ?? numberOrNull(data._id); +} + +async function fetchTeamspaceUserIdForDevice(deviceId: string, devicePassword: string) { + const savedBaseUrl = await getAppSetting(teamspaceBaseUrlSettingKey); + + if (!savedBaseUrl) { + throw badRequest("Teamspace URL muss zuerst im Adminbereich gespeichert werden"); + } + + const baseUrl = parseTeamspaceBaseUrl(savedBaseUrl); + const response = await teamspaceFetch(`${baseUrl}/api/device/${encodeURIComponent(deviceId)}`, { + headers: { + Authorization: teamspaceBasicAuthHeader(deviceId, devicePassword), + Accept: "application/json" + } + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + const status = response.status === 401 || response.status === 403 ? 400 : 502; + const message = response.status === 401 || response.status === 403 + ? "Teamspace Geräte-ID oder Gerätepasswort ist ungültig oder gehört nicht zu dieser Teamspace-Instanz" + : `Teamspace Gerät konnte nicht geprüft werden (${response.status})`; + throw Object.assign(new Error(`${message}${body ? `: ${body.slice(0, 250)}` : ""}`), { status }); + } + + const teamspaceUserId = parseTeamspaceUserIdFromDevice(await response.json()); + + if (!teamspaceUserId) { + throw Object.assign(new Error("Teamspace Antwort enthält keine gültige User-ID"), { status: 502 }); + } + + return teamspaceUserId; +} + +async function getTeamspaceCredentialsForUser(userId: string) { + const [savedBaseUrl, userResult] = await Promise.all([ + getAppSetting(teamspaceBaseUrlSettingKey), + query<{ teamspace_device_id: string | null; teamspace_device_password: string | null }>( + "SELECT teamspace_device_id, teamspace_device_password FROM users WHERE id = $1;", + [userId] + ) + ]); + + if (!savedBaseUrl) { + throw badRequest("Teamspace URL muss zuerst im Adminbereich gespeichert werden"); + } + + let deviceId: string | null = null; + let devicePassword: string | null = null; + + try { + deviceId = decryptSecret(userResult.rows[0]?.teamspace_device_id); + devicePassword = decryptSecret(userResult.rows[0]?.teamspace_device_password); + } catch { + throw badRequest("Die gespeicherten Teamspace-Zugangsdaten können nicht entschlüsselt werden. Bitte Geräte-ID und Gerätepasswort im Profil neu speichern."); + } + + if (!deviceId || !devicePassword) { + throw badRequest("Teamspace Geräte-ID und Gerätepasswort müssen zuerst im Profil hinterlegt werden"); + } + + return { + baseUrl: parseTeamspaceBaseUrl(savedBaseUrl), + deviceId, + devicePassword + }; +} + +async function fetchTeamspaceJson(baseUrl: string, deviceId: string, devicePassword: string, path: string) { + const response = await teamspaceFetch(`${baseUrl}${path}`, { + headers: { + Authorization: teamspaceBasicAuthHeader(deviceId, devicePassword), + Accept: "application/json" + } + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw Object.assign(new Error(`Teamspace Anfrage fehlgeschlagen (${response.status})${body ? `: ${body.slice(0, 250)}` : ""}`), { status: 502 }); + } + + return response.json() as Promise; +} + function isPlainObject(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } @@ -1997,17 +2162,15 @@ async function createSessionForUser(input: SessionCreationInput) { } let projectTeamspaceEntryId: number | null = null; - if (workType === "consulting") { const projectOwnerUserId = await ticketOwnerUserId(client, ticket.id, ownerUserId) ?? ownerUserId; await ensureProjectForTicket(client, ticket.id, projectOwnerUserId, { teamspaceProject: zammadTicket?.teamspaceProject ?? null, teamspaceProjects: zammadTicket?.teamspaceProjects ?? [], - hourBudgetMinutes: zammadTicket?.teamspaceBudgetMinutes ?? null, closeReviewRequired: zammadTicket?.zammadStateId === 4, zammadOwnerId: zammadTicket?.zammadOwnerId ?? null }); - projectTeamspaceEntryId = await resolveProjectTeamspaceEntryForSession( + const teamspaceAssignment = await resolveProjectTeamspaceAssignmentForSession( client, ticket.id, userId, @@ -2015,6 +2178,7 @@ async function createSessionForUser(input: SessionCreationInput) { requestedProjectTeamspaceEntryId, zammadTicket?.zammadOwnerId ?? null ); + projectTeamspaceEntryId = teamspaceAssignment.projectTeamspaceEntryId; } const sessionResult = await client.query( @@ -2813,6 +2977,29 @@ async function assertUniqueZammadUserIdAvailable(client: QueryClient, zammadUser } } +async function assertUniqueTeamspaceUserIdAvailable(client: QueryClient, teamspaceUserId: number | null, currentUserId: string) { + if (!teamspaceUserId) { + return; + } + + const result = await client.query<{ id: string }>( + ` + SELECT id::text AS id + FROM users + WHERE role = 'user' + AND active + AND teamspace_user_id = $1 + AND id <> $2 + LIMIT 1; + `, + [teamspaceUserId, currentUserId] + ); + + if (result.rowCount && result.rowCount > 0) { + throw Object.assign(new Error("Diese Teamspace User-ID ist bereits einem anderen aktiven Benutzer zugeordnet."), { status: 409 }); + } +} + async function getOrganizationByZammadId( client: { query: (text: string, params?: unknown[]) => Promise<{ rows: OrganizationRow[]; rowCount: number | null }> }, zammadId: number @@ -2897,7 +3084,6 @@ async function importZammadTicketForUser(client: QueryClient, zammadTicket: Zamm await ensureProjectForTicket(client, result.rows[0].id, effectiveOwnerUserId, { teamspaceProject: zammadTicket.teamspaceProject, teamspaceProjects: zammadTicket.teamspaceProjects, - hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes, closeReviewRequired: zammadTicket.zammadStateId === 4, zammadOwnerId: zammadTicket.zammadOwnerId }); @@ -3195,6 +3381,13 @@ function parseProjectTeamspaceEntries( type ParsedProjectTeamspaceEntry = ReturnType[number]; +function normalizeTeamspaceEntryManualFields(entries: ParsedProjectTeamspaceEntry[]) { + return entries.map((entry) => ({ + ...entry, + budgetSyncDisabled: false + })); +} + function budgetMinutesChanged(left: number | null | undefined, right: number | null | undefined) { return (left ?? null) !== (right ?? null); } @@ -3654,19 +3847,6 @@ async function ensureProjectForTicket( ); if ("rows" in result && result.rows[0]) { - if (defaults.hourBudgetMinutes !== undefined && defaults.hourBudgetMinutes !== null) { - await client.query( - ` - UPDATE projects - SET hour_budget_minutes = $1, - updated_at = now() - WHERE id = $2; - `, - [defaults.hourBudgetMinutes, result.rows[0].id] - ); - result.rows[0].hour_budget_minutes = defaults.hourBudgetMinutes; - } - await client.query( ` INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order) @@ -3676,8 +3856,8 @@ async function ensureProjectForTicket( [ result.rows[0].id, result.rows[0].teamspace_project, - result.rows[0].hour_budget_minutes, - result.rows[0].manual_consumption_minutes ?? 0 + null, + 0 ] ); } @@ -3756,7 +3936,7 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro p.starts_on::text AS starts_on, p.ends_on::text AS ends_on, entry_totals.total_hour_budget_minutes AS hour_budget_minutes, - entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes, + entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes, EXISTS ( SELECT 1 FROM recurring_billings rb @@ -3766,11 +3946,25 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro COALESCE(viewer_sessions.session_count, 0)::int AS session_count, COALESCE(viewer_sessions.recurring_session_count, 0)::int AS recurring_session_count, COALESCE(viewer_sessions.manual_session_count, 0)::int AS manual_session_count, - COALESCE(teamspace_totals.teamspace_consumption_minutes, 0)::int AS teamspace_consumption_minutes, - (COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes)::int AS consumption_minutes, + CASE + WHEN COALESCE(viewer_settings.teamspace_time_tracking_enabled, true) + THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + ELSE COALESCE(teamspace_api_totals.teamspace_consumption_minutes, 0) + END::int AS teamspace_consumption_minutes, + CASE + WHEN COALESCE(viewer_settings.teamspace_time_tracking_enabled, true) + THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes + ELSE COALESCE(teamspace_api_totals.teamspace_consumption_minutes, 0) + END::int AS consumption_minutes, entry_totals.teamspace_entry_count, entry_totals.teamspace_entries, - CASE WHEN COALESCE(entry_totals.teamspace_entry_count, 0) > 1 THEN COALESCE(unassigned_sessions.session_count, 0) ELSE 0 END::int AS unassigned_teamspace_session_count, + CASE + WHEN COALESCE(viewer_settings.teamspace_time_tracking_enabled, true) + AND COALESCE(entry_totals.teamspace_entry_count, 0) > 1 + THEN COALESCE(unassigned_sessions.session_count, 0) + ELSE 0 + END::int AS unassigned_teamspace_session_count, + 0::int AS unassigned_teamspace_subentry_count, 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, @@ -3780,6 +3974,7 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro p.updated_at FROM projects p LEFT JOIN users owner ON owner.id = p.user_id + LEFT JOIN users viewer_settings ON viewer_settings.id = $3 JOIN tickets t ON t.id = p.ticket_id LEFT JOIN organizations o ON o.zammad_id = t.organization_id LEFT JOIN LATERAL ( @@ -3795,7 +3990,21 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_consumption_minutes FROM ticket_day_billings tdb WHERE tdb.ticket_id = p.ticket_id + AND tdb.user_id = $3 ) teamspace_totals ON true + LEFT JOIN LATERAL ( + SELECT COALESCE(SUM( + CASE + WHEN pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX' + THEN COALESCE(pts.sum_minutes_billable, pts.sum_minutes, 0) + ELSE 0 + END + ), 0)::int AS teamspace_consumption_minutes + FROM project_teamspace_entries pte_ts + JOIN project_teamspace_subentries pts ON pts.project_teamspace_entry_id = pte_ts.id + WHERE pte_ts.project_id = p.id + AND pts.user_id = $3 + ) teamspace_api_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, @@ -3807,29 +4016,92 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro 'id', pte.id::text, 'project_id', pte.project_id::text, 'teamspace_project', pte.teamspace_project, + 'teamspace_api_project_id', pte.teamspace_api_project_id::text, + 'teamspace_api_project_url', pte.teamspace_api_project_url, + 'subentries', COALESCE(( + SELECT json_agg( + json_build_object( + 'id', pts.id::text, + 'project_teamspace_entry_id', pts.project_teamspace_entry_id::text, + 'teamspace_api_project_id', pts.teamspace_api_project_id::text, + 'teamspace_api_project_url', pts.teamspace_api_project_url, + 'teamspace_project_id', pts.teamspace_project_id, + 'billing_type', pts.billing_type, + 'name', pts.name, + 'alias', pts.alias, + 'description', pts.description, + 'progress', pts.progress, + 'planned_minutes', pts.planned_minutes, + 'sum_minutes', pts.sum_minutes, + 'planned_minutes_billable', pts.planned_minutes_billable, + 'sum_minutes_billable', pts.sum_minutes_billable, + 'planned_income', pts.planned_income, + 'sum_income', pts.sum_income, + 'consumption_minutes', CASE + WHEN pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX' THEN COALESCE(pts.sum_minutes_billable, pts.sum_minutes, 0) + ELSE 0 + END, + 'display_order', pts.display_order + ) + ORDER BY pts.display_order ASC, pts.id ASC + ) + FROM project_teamspace_subentries pts + WHERE pts.project_teamspace_entry_id = pte.id + AND pts.user_id = $3 + ), '[]'::json), 'teamspace_description', pte.teamspace_description, 'is_default', pte.is_default, 'hour_budget_minutes', pte.hour_budget_minutes, 'budget_sync_disabled', pte.budget_sync_disabled, 'manual_consumption_minutes', pte.manual_consumption_minutes, - 'teamspace_consumption_minutes', COALESCE(( - SELECT SUM(tdb.billed_minutes)::int - FROM ticket_day_billings tdb - WHERE tdb.ticket_id = p.ticket_id - AND ( - tdb.project_teamspace_entry_id = pte.id - OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) - ) - ), 0), - 'consumption_minutes', COALESCE(( - SELECT SUM(tdb.billed_minutes)::int - FROM ticket_day_billings tdb - WHERE tdb.ticket_id = p.ticket_id - AND ( - tdb.project_teamspace_entry_id = pte.id - OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) - ) - ), 0) + pte.manual_consumption_minutes, + 'teamspace_consumption_minutes', CASE + WHEN COALESCE(viewer_settings.teamspace_time_tracking_enabled, true) THEN COALESCE(( + SELECT SUM(tdb.billed_minutes)::int + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND tdb.user_id = $3 + AND ( + tdb.project_teamspace_entry_id = pte.id + OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + ), 0) + ELSE COALESCE(( + SELECT SUM( + CASE + WHEN pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX' + THEN COALESCE(pts.sum_minutes_billable, pts.sum_minutes, 0) + ELSE 0 + END + )::int + FROM project_teamspace_subentries pts + WHERE pts.project_teamspace_entry_id = pte.id + AND pts.user_id = $3 + ), 0) + END, + 'consumption_minutes', CASE + WHEN COALESCE(viewer_settings.teamspace_time_tracking_enabled, true) THEN COALESCE(( + SELECT SUM(tdb.billed_minutes)::int + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND tdb.user_id = $3 + AND ( + tdb.project_teamspace_entry_id = pte.id + OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + ), 0) + pte.manual_consumption_minutes + ELSE COALESCE(( + SELECT SUM( + CASE + WHEN pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX' + THEN COALESCE(pts.sum_minutes_billable, pts.sum_minutes, 0) + ELSE 0 + END + )::int + FROM project_teamspace_subentries pts + WHERE pts.project_teamspace_entry_id = pte.id + AND pts.user_id = $3 + ), 0) + END, 'display_order', pte.display_order ) ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC @@ -3842,11 +4114,12 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro LEFT JOIN LATERAL ( SELECT COUNT(*)::int AS session_count FROM sessions s - WHERE s.ticket_id = p.ticket_id - AND s.user_id = $3 - AND s.work_type = 'consulting' - AND s.project_teamspace_entry_id IS NULL - ) unassigned_sessions ON true + WHERE s.ticket_id = p.ticket_id + AND s.user_id = $3 + AND s.work_type = 'consulting' + AND s.billing_status IN ('billable', 'billed') + AND s.project_teamspace_entry_id IS NULL + ) unassigned_sessions ON true LEFT JOIN LATERAL ( SELECT COUNT(*) FILTER (WHERE status = 'open')::int AS open_todos, @@ -3868,16 +4141,20 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro return result.rows[0] ?? null; } -async function resolveProjectTeamspaceEntryForSession( +type ResolvedProjectTeamspaceAssignment = { + projectTeamspaceEntryId: number | null; +}; + +async function resolveProjectTeamspaceAssignmentForSession( client: QueryClient, ticketId: string, userId: string, workType: "support" | "consulting", requestedEntryId: number | null, zammadOwnerId: number | null = null -) { +) : Promise { if (workType !== "consulting") { - return null; + return { projectTeamspaceEntryId: null }; } const ticketOwner = await ticketOwnerUserId(client, ticketId, null); @@ -3899,7 +4176,7 @@ async function resolveProjectTeamspaceEntryForSession( const projectId = projectResult.rows[0]?.id; if (!projectId) { - return null; + return { projectTeamspaceEntryId: null }; } const entriesResult = await client.query<{ id: string; is_default: boolean }>( @@ -3913,17 +4190,17 @@ async function resolveProjectTeamspaceEntryForSession( ); if (entriesResult.rows.length === 0) { - return null; + return { projectTeamspaceEntryId: null }; } if (entriesResult.rows.length === 1) { - return Number(entriesResult.rows[0].id); + return { projectTeamspaceEntryId: Number(entriesResult.rows[0].id) }; } const defaultEntries = entriesResult.rows.filter((entry) => entry.is_default); if (!requestedEntryId && defaultEntries.length === 1) { - return Number(defaultEntries[0].id); + return { projectTeamspaceEntryId: Number(defaultEntries[0].id) }; } if (!requestedEntryId) { @@ -3934,7 +4211,7 @@ async function resolveProjectTeamspaceEntryForSession( throw badRequest("projectTeamspaceEntryId must belong to the project"); } - return requestedEntryId; + return { projectTeamspaceEntryId: requestedEntryId }; } async function autoAssignSingleProjectTeamspaceEntry(client: QueryClient, ticketId: string, userId: string | null = null) { @@ -3955,10 +4232,10 @@ async function autoAssignSingleProjectTeamspaceEntry(client: QueryClient, ticket UPDATE sessions s SET project_teamspace_entry_id = sep.project_teamspace_entry_id FROM single_entry_projects sep - WHERE s.ticket_id = sep.ticket_id - AND s.user_id = sep.user_id - AND s.work_type = 'consulting' - AND s.project_teamspace_entry_id IS NULL; + WHERE s.ticket_id = sep.ticket_id + AND s.user_id = sep.user_id + AND s.work_type = 'consulting' + AND s.project_teamspace_entry_id IS NULL; `, [ticketId, userId] ); @@ -3978,10 +4255,10 @@ async function autoAssignSingleProjectTeamspaceEntry(client: QueryClient, ticket UPDATE sessions s SET project_teamspace_entry_id = single_ticket.project_teamspace_entry_id FROM single_entry_tickets single_ticket - WHERE s.ticket_id = single_ticket.ticket_id - AND s.work_type = 'consulting' - AND s.project_teamspace_entry_id IS NULL - AND ($2::bigint IS NULL OR s.user_id = $2); + WHERE s.ticket_id = single_ticket.ticket_id + AND s.work_type = 'consulting' + AND s.project_teamspace_entry_id IS NULL + AND ($2::bigint IS NULL OR s.user_id = $2); `, [ticketId, userId] ); @@ -4555,6 +4832,510 @@ function normalizedTeamspaceProjectList(projects: Array) { + const direct = teamspaceEnumValue(data.billingType); + + if (direct) { + return direct; + } + + if (!Array.isArray(data.contractPositions)) { + return null; + } + + for (const position of data.contractPositions) { + if (!isPlainObject(position)) { + continue; + } + + const positionData = isPlainObject(position.data) ? position.data : position; + const billingType = teamspaceEnumValue(positionData.billingType); + + if (billingType) { + return billingType; + } + } + + return null; +} + +function teamspaceContractPositionNumber(data: Record, field: string) { + const direct = teamspaceOptionalNumber(data[field]); + + if (direct !== null) { + return direct; + } + + if (!Array.isArray(data.contractPositions)) { + return null; + } + + for (const position of data.contractPositions) { + if (!isPlainObject(position)) { + continue; + } + + const positionData = isPlainObject(position.data) ? position.data : position; + const value = teamspaceOptionalNumber(positionData[field]); + + if (value !== null) { + return value; + } + } + + return null; +} + +function teamspaceRemoteProjectFromItem(item: unknown): TeamspaceRemoteProject | null { + if (!isPlainObject(item)) { + return null; + } + + const data = isPlainObject(item.data) ? item.data : item; + const projectId = teamspaceString(data.projectId); + const apiProjectId = numberOrNull(data._id) ?? numberOrNull(item.value); + + if (!projectId || !apiProjectId) { + return null; + } + + return { + projectId, + mainprojectId: teamspaceString(data.mainprojectId), + apiProjectId, + apiProjectUrl: teamspaceString(data._url) ?? teamspaceString(item.href), + mainProjectApiProjectId: teamspaceLinkApiProjectId(data.mainProject), + parentApiProjectId: teamspaceLinkApiProjectId(data.parent), + billingType: teamspaceBillingType(data), + name: teamspaceString(data.name), + description: teamspaceString(data.description) ?? "", + progress: teamspaceOptionalInteger(data.progress), + plannedMinutes: teamspaceOptionalInteger(data.plannedMinutes) ?? teamspaceOptionalInteger(data.plannedMinutesCalculated), + sumMinutes: teamspaceOptionalInteger(data.sumMinutes), + plannedMinutesBillable: teamspaceOptionalInteger(data.plannedMinutesBillable) ?? teamspaceOptionalInteger(data.plannedMinutesBillableCalculated), + sumMinutesBillable: teamspaceOptionalInteger(data.sumMinutesBillable), + plannedIncome: teamspaceContractPositionNumber(data, "plannedIncome"), + sumIncome: teamspaceContractPositionNumber(data, "sumIncome") + }; +} + +async function loadTeamspaceRemoteProjectsSingleCall(baseUrl: string, deviceId: string, devicePassword: string, teamspaceProjectIds: string[]) { + const projectIds = normalizedTeamspaceProjectList(teamspaceProjectIds); + + if (projectIds.length === 0) { + return { + projects: [], + scanned: 0 + }; + } + + const requestedLimit = 100_000; + const projectIdFilter = projectIds.map((projectId) => encodeURIComponent(projectId)).join(","); + const payload = await fetchTeamspaceJson(baseUrl, deviceId, devicePassword, `/api/project;depth=1;limit=${requestedLimit};sort=projectId?isActive=true&isTemplate=false&isDraft=false&projectId=${projectIdFilter}`); + + if (!isPlainObject(payload) || (payload.items !== null && !Array.isArray(payload.items))) { + throw Object.assign(new Error("Teamspace Projektliste hat ein unerwartetes Format"), { status: 502 }); + } + + const items = Array.isArray(payload.items) ? payload.items : []; + const size = Number(payload.size); + const offset = Number(payload.offset); + const limit = Number(payload.limit); + + if (Number.isInteger(size) && Number.isInteger(offset) && Number.isInteger(limit) && offset + limit < size) { + throw Object.assign( + new Error(`Teamspace hat die gefilterte Projektliste gekappt (${items.length} von ${size}). Mit nur einem API-Call kann TicketTracker nicht garantiert alle Projekt-IDs finden.`), + { status: 409 } + ); + } + + const projects = items + .map(teamspaceRemoteProjectFromItem) + .filter((project): project is TeamspaceRemoteProject => Boolean(project)); + + return { + projects, + scanned: items.length + }; +} + +async function loadTeamspaceRemoteSubprojectsSingleCall(baseUrl: string, deviceId: string, devicePassword: string, mainProjectApiIds: number[]) { + const ids = [...new Set(mainProjectApiIds.filter((id) => Number.isInteger(id) && id > 0))].sort((left, right) => left - right); + + if (ids.length === 0) { + return { + projects: [], + scanned: 0 + }; + } + + const requestedLimit = 100_000; + const mainProjectFilter = ids.map((id) => encodeURIComponent(String(id))).join(","); + const payload = await fetchTeamspaceJson(baseUrl, deviceId, devicePassword, `/api/project;depth=2;limit=${requestedLimit};sort=projectId?isActive=true&isTemplate=false&isDraft=false&mainProject=${mainProjectFilter}`); + + if (!isPlainObject(payload) || (payload.items !== null && !Array.isArray(payload.items))) { + throw Object.assign(new Error("Teamspace Unterprojektliste hat ein unerwartetes Format"), { status: 502 }); + } + + const items = Array.isArray(payload.items) ? payload.items : []; + const size = Number(payload.size); + const offset = Number(payload.offset); + const limit = Number(payload.limit); + + if (Number.isInteger(size) && Number.isInteger(offset) && Number.isInteger(limit) && offset + limit < size) { + throw Object.assign( + new Error(`Teamspace hat die gefilterte Unterprojektliste gekappt (${items.length} von ${size}). TicketTracker kann die Unterpositionen so nicht vollständig synchronisieren.`), + { status: 409 } + ); + } + + const projects = items + .map(teamspaceRemoteProjectFromItem) + .filter((project): project is TeamspaceRemoteProject => Boolean(project)); + + return { + projects, + scanned: items.length + }; +} + +function teamspaceRemoteProjectsByProjectId(projects: TeamspaceRemoteProject[]) { + const byProjectId = new Map(); + + for (const project of projects) { + byProjectId.set(project.projectId, [...(byProjectId.get(project.projectId) ?? []), project]); + } + + return byProjectId; +} + +function teamspaceRemoteProjectsByMainprojectId(projects: TeamspaceRemoteProject[]) { + const byMainprojectId = new Map(); + + for (const project of projects) { + if (!project.mainprojectId) { + continue; + } + + byMainprojectId.set(project.mainprojectId, [...(byMainprojectId.get(project.mainprojectId) ?? []), project]); + } + + return byMainprojectId; +} + +function isTeamspaceMainProject(project: TeamspaceRemoteProject) { + return project.projectId === project.mainprojectId || project.mainProjectApiProjectId === project.apiProjectId; +} + +function teamspaceProjectMatchesForLocalId( + teamspaceProject: string, + remoteByProjectId: Map, + remoteByMainprojectId: Map +) { + const exactProjectIdMatches = remoteByProjectId.get(teamspaceProject) ?? []; + + if (exactProjectIdMatches.length > 0) { + return exactProjectIdMatches; + } + + const treeMatches = remoteByMainprojectId.get(teamspaceProject) ?? []; + const mainProjectMatches = treeMatches.filter(isTeamspaceMainProject); + + if (mainProjectMatches.length > 0) { + return mainProjectMatches; + } + + return treeMatches.length === 1 ? treeMatches : []; +} + +type TeamspaceSubentrySyncEntry = { + entry_id: string; + project_id?: string; + teamspace_project: string; + teamspace_api_project_id: string; +}; + +function teamspaceSubprojectsForEntry(entry: TeamspaceSubentrySyncEntry, projects: TeamspaceRemoteProject[]) { + const mainApiProjectId = Number(entry.teamspace_api_project_id); + const subprojects = projects.filter((project) => + project.apiProjectId !== mainApiProjectId && + (project.mainProjectApiProjectId === mainApiProjectId || project.mainprojectId === entry.teamspace_project) + ); + const parentApiProjectIds = new Set( + subprojects + .map((project) => project.parentApiProjectId) + .filter((projectId): projectId is number => Number.isInteger(projectId)) + ); + + return subprojects + .filter((project) => + !parentApiProjectIds.has(project.apiProjectId) && + !subprojects.some((possibleChild) => + possibleChild.apiProjectId !== project.apiProjectId && + possibleChild.projectId.startsWith(`${project.projectId}.`) + ) + ) + .sort((left, right) => left.projectId.localeCompare(right.projectId, "de", { numeric: true, sensitivity: "base" })); +} + +function teamspaceSubentryAlias(project: TeamspaceRemoteProject) { + return project.name ?? project.projectId; +} + +function isTeamspaceFixedPriceBillingType(billingType: string | null | undefined) { + return billingType === "INVOICEABLE_FIX"; +} + +function teamspaceFixedPriceFulfilled(project: TeamspaceRemoteProject) { + if (!isTeamspaceFixedPriceBillingType(project.billingType)) { + return false; + } + + const planned = project.plannedIncome; + const used = project.sumIncome; + + return planned !== null && used !== null && planned > 0 && used >= planned; +} + +function teamspaceHourBudgetMinutes(project: TeamspaceRemoteProject) { + if (isTeamspaceFixedPriceBillingType(project.billingType)) { + return null; + } + + return project.plannedMinutesBillable ?? project.plannedMinutes ?? null; +} + +async function refreshProjectBudgetFromTeamspaceEntries(client: QueryClient, projectId: string) { + await client.query( + ` + UPDATE projects p + SET hour_budget_minutes = entry_budget.hour_budget_minutes, + updated_at = now() + FROM ( + SELECT + project_id, + COALESCE(SUM(hour_budget_minutes) FILTER (WHERE hour_budget_minutes IS NOT NULL), NULL)::int AS hour_budget_minutes + FROM project_teamspace_entries + WHERE project_id = $1 + GROUP BY project_id + ) entry_budget + WHERE p.id = entry_budget.project_id; + `, + [projectId] + ); +} + +async function syncTeamspaceSubentriesForEntries( + client: QueryClient, + userId: string, + entries: TeamspaceSubentrySyncEntry[], + projects: TeamspaceRemoteProject[], + baseUrl: string +) { + let synced = 0; + let removed = 0; + + for (const entry of entries) { + const subprojects = teamspaceSubprojectsForEntry(entry, projects); + const remoteIds = subprojects.map((project) => project.apiProjectId); + + for (const [index, project] of subprojects.entries()) { + await client.query( + ` + INSERT INTO project_teamspace_subentries ( + user_id, + project_teamspace_entry_id, + teamspace_api_project_id, + teamspace_api_project_url, + teamspace_project_id, + billing_type, + name, + alias, + description, + progress, + planned_minutes, + sum_minutes, + planned_minutes_billable, + sum_minutes_billable, + planned_income, + sum_income, + display_order, + synced_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, now(), now()) + ON CONFLICT (user_id, project_teamspace_entry_id, teamspace_api_project_id) + DO UPDATE SET + teamspace_api_project_url = EXCLUDED.teamspace_api_project_url, + teamspace_project_id = EXCLUDED.teamspace_project_id, + billing_type = EXCLUDED.billing_type, + name = EXCLUDED.name, + alias = CASE + WHEN btrim(project_teamspace_subentries.alias) = '' THEN EXCLUDED.alias + ELSE project_teamspace_subentries.alias + END, + description = EXCLUDED.description, + progress = EXCLUDED.progress, + planned_minutes = EXCLUDED.planned_minutes, + sum_minutes = EXCLUDED.sum_minutes, + planned_minutes_billable = EXCLUDED.planned_minutes_billable, + sum_minutes_billable = EXCLUDED.sum_minutes_billable, + planned_income = EXCLUDED.planned_income, + sum_income = EXCLUDED.sum_income, + display_order = EXCLUDED.display_order, + synced_at = now(), + updated_at = now(); + `, + [ + userId, + entry.entry_id, + project.apiProjectId, + project.apiProjectUrl ?? `${baseUrl}/api/project/${project.apiProjectId}`, + project.projectId, + project.billingType, + project.name, + teamspaceSubentryAlias(project), + project.description, + project.progress, + project.plannedMinutes, + project.sumMinutes, + project.plannedMinutesBillable, + project.sumMinutesBillable, + project.plannedIncome, + project.sumIncome, + index + ] + ); + synced += 1; + } + + const deleteResult = remoteIds.length > 0 + ? await client.query( + ` + DELETE FROM project_teamspace_subentries + WHERE user_id = $1 + AND project_teamspace_entry_id = $2 + AND NOT (teamspace_api_project_id = ANY($3::bigint[])); + `, + [userId, entry.entry_id, remoteIds] + ) + : await client.query( + ` + DELETE FROM project_teamspace_subentries + WHERE user_id = $1 + AND project_teamspace_entry_id = $2; + `, + [userId, entry.entry_id] + ); + removed += deleteResult.rowCount ?? 0; + + } + + return { synced, removed }; +} + function formatTeamspaceProjectList(projects: Array) { return normalizedTeamspaceProjectList(projects).join(" + "); } @@ -4795,7 +5576,9 @@ async function mergeTeamspaceEntriesIntoTarget(client: QueryClient, sourceEntryI COALESCE(SUM(manual_consumption_minutes), 0)::int AS manual_consumption_minutes, COALESCE(SUM(hour_budget_minutes) FILTER (WHERE hour_budget_minutes IS NOT NULL), NULL)::int AS hour_budget_minutes, BOOL_OR(budget_sync_disabled) AS budget_sync_disabled, - NULLIF(string_agg(DISTINCT NULLIF(teamspace_description, ''), E'\n\n'), '') AS teamspace_description + NULLIF(string_agg(DISTINCT NULLIF(teamspace_description, ''), E'\n\n'), '') AS teamspace_description, + (array_agg(teamspace_api_project_id ORDER BY id) FILTER (WHERE teamspace_api_project_id IS NOT NULL))[1] AS teamspace_api_project_id, + (array_agg(teamspace_api_project_url ORDER BY id) FILTER (WHERE teamspace_api_project_url IS NOT NULL))[1] AS teamspace_api_project_url FROM project_teamspace_entries WHERE id = ANY($1::bigint[]) ) @@ -4807,6 +5590,8 @@ async function mergeTeamspaceEntriesIntoTarget(client: QueryClient, sourceEntryI WHEN NULLIF(target.teamspace_description, '') IS NULL THEN COALESCE(source_totals.teamspace_description, target.teamspace_description) ELSE target.teamspace_description END, + teamspace_api_project_id = COALESCE(target.teamspace_api_project_id, source_totals.teamspace_api_project_id), + teamspace_api_project_url = COALESCE(target.teamspace_api_project_url, source_totals.teamspace_api_project_url), updated_at = now() FROM source_totals WHERE target.id = $2::bigint; @@ -4826,19 +5611,19 @@ async function mergeTeamspaceEntriesIntoTarget(client: QueryClient, sourceEntryI await client.query( ` WITH moved AS ( - SELECT ticket_id, user_id, day, SUM(billed_minutes)::int AS billed_minutes - FROM ticket_day_billings - WHERE project_teamspace_entry_id = ANY($1::bigint[]) - GROUP BY ticket_id, user_id, day - ), - deleted AS ( - DELETE FROM ticket_day_billings - WHERE project_teamspace_entry_id = ANY($1::bigint[]) - ) + SELECT ticket_id, user_id, day, SUM(billed_minutes)::int AS billed_minutes + FROM ticket_day_billings + WHERE project_teamspace_entry_id = ANY($1::bigint[]) + GROUP BY ticket_id, user_id, day + ), + deleted AS ( + DELETE FROM ticket_day_billings + WHERE project_teamspace_entry_id = ANY($1::bigint[]) + ) INSERT INTO ticket_day_billings (ticket_id, user_id, project_teamspace_entry_id, day, billed_minutes) SELECT ticket_id, user_id, $2::bigint, day, billed_minutes FROM moved - ON CONFLICT (ticket_id, user_id, day, project_teamspace_entry_id) WHERE project_teamspace_entry_id IS NOT NULL + ON CONFLICT (ticket_id, user_id, day, project_teamspace_entry_id) WHERE project_teamspace_entry_id IS NOT NULL DO UPDATE SET billed_minutes = ticket_day_billings.billed_minutes + EXCLUDED.billed_minutes, updated_at = now(); `, @@ -4888,14 +5673,16 @@ async function dedupeProjectTeamspaceEntries(client: QueryClient, projectId: str async function assignUnassignedProjectSessionsToTeamspaceEntry(client: QueryClient, projectId: string, targetEntryId: string) { await client.query( ` - UPDATE sessions s - SET project_teamspace_entry_id = $2::bigint - FROM projects p - WHERE p.id = $1 - AND s.ticket_id = p.ticket_id - AND s.user_id = p.user_id - AND s.work_type = 'consulting' - AND s.project_teamspace_entry_id IS NULL; + UPDATE sessions s + SET project_teamspace_entry_id = $2::bigint + FROM projects p + WHERE p.id = $1 + AND s.ticket_id = p.ticket_id + AND s.user_id = p.user_id + AND s.work_type = 'consulting' + AND s.billing_status = 'billed' + AND s.project_teamspace_entry_id IS NULL + ; `, [projectId, targetEntryId] ); @@ -4909,8 +5696,8 @@ async function mergeProjectTeamspaceBillingsToDefault(client: QueryClient, ticke FROM ticket_day_billings tdb JOIN project_teamspace_entries pte ON pte.id = tdb.project_teamspace_entry_id JOIN projects p ON p.id = pte.project_id - WHERE p.ticket_id = $1 - AND ($2::bigint IS NULL OR tdb.user_id = $2) + WHERE p.ticket_id = $1 + AND ($2::bigint IS NULL OR tdb.user_id = $2) GROUP BY tdb.ticket_id, tdb.user_id, tdb.day ), deleted AS ( @@ -4924,7 +5711,7 @@ async function mergeProjectTeamspaceBillingsToDefault(client: QueryClient, ticke INSERT INTO ticket_day_billings (ticket_id, user_id, project_teamspace_entry_id, day, billed_minutes) SELECT ticket_id, user_id, NULL, day, billed_minutes FROM moved - ON CONFLICT (ticket_id, user_id, day) WHERE project_teamspace_entry_id IS NULL + ON CONFLICT (ticket_id, user_id, day) WHERE project_teamspace_entry_id IS NULL DO UPDATE SET billed_minutes = ticket_day_billings.billed_minutes + EXCLUDED.billed_minutes, updated_at = now(); `, @@ -4936,8 +5723,8 @@ async function syncProjectTeamspaceEntriesFromZammad( client: QueryClient, projectId: string, teamspaceProjects: string[], - budgetMinutes: number | null, - syncBudget: boolean + _budgetMinutes: number | null, + _syncBudget: boolean ) { const zammadProjects = normalizedTeamspaceProjectList(teamspaceProjects); @@ -4954,7 +5741,7 @@ async function syncProjectTeamspaceEntriesFromZammad( INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order) VALUES ($1, $2, $3, $4, false, 0, $5); `, - [projectId, teamspaceProject, index === 0, syncBudget && index === 0 ? budgetMinutes : null, index * 1024] + [projectId, teamspaceProject, index === 0, null, index * 1024] ); } @@ -4962,11 +5749,10 @@ async function syncProjectTeamspaceEntriesFromZammad( ` UPDATE projects SET teamspace_project = $2, - hour_budget_minutes = CASE WHEN $3::boolean THEN $4 ELSE hour_budget_minutes END, updated_at = now() WHERE id = $1; `, - [projectId, zammadProjects[0] ?? null, syncBudget, budgetMinutes] + [projectId, zammadProjects[0] ?? null] ); return; } @@ -5003,12 +5789,11 @@ async function syncProjectTeamspaceEntriesFromZammad( ` UPDATE project_teamspace_entries SET teamspace_project = $2, - hour_budget_minutes = CASE WHEN $3::boolean THEN $4 ELSE hour_budget_minutes END, - budget_sync_disabled = CASE WHEN $3::boolean THEN false ELSE budget_sync_disabled END, + budget_sync_disabled = false, updated_at = now() WHERE id = $1::bigint; `, - [defaultEntry.id, firstZammadProject, syncBudget, budgetMinutes] + [defaultEntry.id, firstZammadProject] ); await assignUnassignedProjectSessionsToTeamspaceEntry(client, projectId, defaultEntry.id); defaultEntry = { ...defaultEntry, teamspace_project: firstZammadProject, is_default: true }; @@ -5040,29 +5825,14 @@ async function syncProjectTeamspaceEntriesFromZammad( nextDisplayOrder += 1024; } - if (syncBudget) { - await client.query( - ` - UPDATE project_teamspace_entries - SET hour_budget_minutes = $1, - budget_sync_disabled = false, - updated_at = now() - WHERE id = $2 - AND project_id = $3; - `, - [budgetMinutes, defaultEntry.id, projectId] - ); - } - await client.query( ` UPDATE projects SET teamspace_project = $2, - hour_budget_minutes = CASE WHEN $3::boolean THEN $4 ELSE hour_budget_minutes END, updated_at = now() WHERE id = $1; `, - [projectId, defaultEntry.teamspace_project, syncBudget, budgetMinutes] + [projectId, defaultEntry.teamspace_project] ); } @@ -5255,8 +6025,6 @@ async function planExistingZammadTicketSync( if (shouldManageConsultingProject) { const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket); const localTeamspaceProjects = project ? projectTeamspaceProjectsForMergeReview(project) : []; - const canSyncBudget = fields.budget && (!project?.default_budget_sync_disabled || fields.overrideBudget); - if (project?.close_review_required && zammadTicket.zammadStateId !== 4) { if (mergeReviewMatchesZammad(project, zammadTicket)) { changes.push({ field: "Merge-Prüfung", from: true, to: false }); @@ -5301,10 +6069,6 @@ async function planExistingZammadTicketSync( addTeamspaceProjectSyncChanges(changes, localTeamspaceProjects, zammadTeamspaceProjects); } - if (canSyncBudget) { - addSyncChange(changes, "Teamspace-Budget", project?.default_hour_budget_minutes ?? project?.hour_budget_minutes ?? null, zammadTicket.teamspaceBudgetMinutes); - } - if (zammadTicket.zammadStateId === 4 && project?.close_review_required !== true) { changes.push({ field: "TS/TT-Prüfung", from: project?.close_review_required ?? false, to: true }); } @@ -5396,7 +6160,6 @@ async function planConsultingImport(client: QueryClient, zammadTicket: ZammadTic { field: "Organisation", from: null, to: organization.id }, { field: "Typ", from: null, to: zammadTicket.workType }, { field: "Projekt", from: null, to: formatTeamspaceProjectList(zammadTeamspaceProjectList(zammadTicket)) }, - { field: "Teamspace-Budget", from: null, to: zammadTicket.teamspaceBudgetMinutes }, ...(zammadTicket.zammadStateId === 4 ? [{ field: "TS/TT-Prüfung", from: false, to: true }] : []) ] }; @@ -6171,7 +6934,6 @@ async function applyZammadConsultingProjectSync( await zammadSyncPhase("Projekt sicherstellen", () => ensureProjectForTicket(client, ticketId, projectOwnerUserId, { teamspaceProject: zammadTicket.teamspaceProject, teamspaceProjects: zammadTicket.teamspaceProjects, - hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes, closeReviewRequired: zammadTicket.zammadStateId === 4, zammadOwnerId: zammadTicket.zammadOwnerId })); @@ -6182,23 +6944,8 @@ async function applyZammadConsultingProjectSync( return; } - const syncBudget = fields.budget && (!project.default_budget_sync_disabled || fields.overrideBudget); - if (fields.projectName) { - await zammadSyncPhase("Teamspace-Projekte synchronisieren", () => syncProjectTeamspaceEntriesFromZammad(client, project.id, zammadTeamspaceProjectList(zammadTicket), zammadTicket.teamspaceBudgetMinutes, syncBudget)); - } else if (syncBudget) { - await zammadSyncPhase("Teamspace-Budget synchronisieren", () => updateDefaultProjectTeamspaceEntry(client, project.id, { - budgetMinutes: zammadTicket.teamspaceBudgetMinutes - })); - await zammadSyncPhase("Projekt-Budget synchronisieren", () => client.query( - ` - UPDATE projects - SET hour_budget_minutes = $1, - updated_at = now() - WHERE id = $2; - `, - [zammadTicket.teamspaceBudgetMinutes, project.id] - )); + await zammadSyncPhase("Teamspace-Projekte synchronisieren", () => syncProjectTeamspaceEntriesFromZammad(client, project.id, zammadTeamspaceProjectList(zammadTicket), null, false)); } if (zammadTicket.zammadStateId === 4) { @@ -6378,19 +7125,19 @@ async function mergeTicketDayBillingsIntoTarget(client: QueryClient, sourceTicke WITH moved AS ( SELECT user_id, project_teamspace_entry_id, day, SUM(billed_minutes)::int AS billed_minutes FROM ticket_day_billings - WHERE ticket_id = $1 - AND project_teamspace_entry_id IS NULL + WHERE ticket_id = $1 + AND project_teamspace_entry_id IS NULL GROUP BY user_id, project_teamspace_entry_id, day ), deleted AS ( DELETE FROM ticket_day_billings - WHERE ticket_id = $1 - AND project_teamspace_entry_id IS NULL + WHERE ticket_id = $1 + AND project_teamspace_entry_id IS NULL ) INSERT INTO ticket_day_billings (ticket_id, user_id, project_teamspace_entry_id, day, billed_minutes) SELECT $2::bigint, user_id, project_teamspace_entry_id, day, billed_minutes FROM moved - ON CONFLICT (ticket_id, user_id, day) WHERE project_teamspace_entry_id IS NULL + ON CONFLICT (ticket_id, user_id, day) WHERE project_teamspace_entry_id IS NULL DO UPDATE SET billed_minutes = ticket_day_billings.billed_minutes + EXCLUDED.billed_minutes, updated_at = now(); `, @@ -6402,19 +7149,19 @@ async function mergeTicketDayBillingsIntoTarget(client: QueryClient, sourceTicke WITH moved AS ( SELECT user_id, project_teamspace_entry_id, day, SUM(billed_minutes)::int AS billed_minutes FROM ticket_day_billings - WHERE ticket_id = $1 - AND project_teamspace_entry_id IS NOT NULL + WHERE ticket_id = $1 + AND project_teamspace_entry_id IS NOT NULL GROUP BY user_id, project_teamspace_entry_id, day ), deleted AS ( DELETE FROM ticket_day_billings - WHERE ticket_id = $1 - AND project_teamspace_entry_id IS NOT NULL + WHERE ticket_id = $1 + AND project_teamspace_entry_id IS NOT NULL ) INSERT INTO ticket_day_billings (ticket_id, user_id, project_teamspace_entry_id, day, billed_minutes) SELECT $2::bigint, user_id, project_teamspace_entry_id, day, billed_minutes FROM moved - ON CONFLICT (ticket_id, user_id, day, project_teamspace_entry_id) WHERE project_teamspace_entry_id IS NOT NULL + ON CONFLICT (ticket_id, user_id, day, project_teamspace_entry_id) WHERE project_teamspace_entry_id IS NOT NULL DO UPDATE SET billed_minutes = ticket_day_billings.billed_minutes + EXCLUDED.billed_minutes, updated_at = now(); `, @@ -6860,6 +7607,11 @@ async function reopenPeriodsForSession( async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, userId: string) { await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso); + const userSettingsResult = await query<{ teamspace_time_tracking_enabled: boolean }>( + "SELECT teamspace_time_tracking_enabled FROM users WHERE id = $1;", + [userId] + ); + const teamspaceTimeTrackingEnabled = Boolean(userSettingsResult.rows[0]?.teamspace_time_tracking_enabled ?? true); const bucketExpression = config.type === "month" @@ -6968,7 +7720,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use `, [period.startIso, period.endIso, userId] ), - query<{ crm_billed_minutes: number }>( + teamspaceTimeTrackingEnabled ? query<{ crm_billed_minutes: number }>( ` SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS crm_billed_minutes FROM ticket_day_billings tdb @@ -6985,10 +7737,15 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use ); `, [userId, period.start, period.endIso] - ) + ) : Promise.resolve({ rows: [{ crm_billed_minutes: 0 }], rowCount: 1 }) ]); - const tickets = ticketsResult.rows; + const tickets = teamspaceTimeTrackingEnabled + ? ticketsResult.rows + : ticketsResult.rows.map((ticket: any) => ({ + ...ticket, + crm_billed_minutes: ticket.total_minutes ?? 0 + })); const totalSessions = tickets.reduce((sum: number, ticket: any) => sum + ticket.session_count, 0); const totalMinutes = tickets.reduce((sum: number, ticket: any) => sum + ticket.total_minutes, 0); const openSessions = openResult.rows; @@ -7001,10 +7758,10 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use closedAt: periodClosed ? closureResult.rows[0]?.closed_at ?? null : null, canClose: config.type === "month" && tickets.length > 0 && openSessions.length === 0, totals: { - tickets: tickets.length, - sessions: totalSessions, - minutes: totalMinutes, - crmBilledMinutes: crmBillingResult.rows[0]?.crm_billed_minutes ?? 0, + tickets: tickets.length, + sessions: totalSessions, + minutes: totalMinutes, + crmBilledMinutes: teamspaceTimeTrackingEnabled ? crmBillingResult.rows[0]?.crm_billed_minutes ?? 0 : totalMinutes, billableMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.billable_minutes, 0), nonBillableMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.non_billable_minutes, 0), openMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.open_minutes, 0), @@ -7018,6 +7775,11 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use async function getStatisticsOverview(period: ParsedPeriod, userId: string, periodType: "month" | "week" = "month") { await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso); + const userSettingsResult = await query<{ teamspace_time_tracking_enabled: boolean }>( + "SELECT teamspace_time_tracking_enabled FROM users WHERE id = $1;", + [userId] + ); + const teamspaceTimeTrackingEnabled = Boolean(userSettingsResult.rows[0]?.teamspace_time_tracking_enabled ?? true); const baseCte = ` WITH session_base AS ( @@ -7405,6 +8167,13 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio active_days: 0, crm_billed_minutes: 0 }; + const effectiveCrmBilledMinutes = teamspaceTimeTrackingEnabled ? totals.crm_billed_minutes : totals.minutes; + const applyTeamspaceDelta = (row: any) => teamspaceTimeTrackingEnabled + ? { ...row, crm_delta_minutes: row.crm_billed_minutes - row.total_minutes } + : { ...row, crm_billed_minutes: row.total_minutes, crm_delta_minutes: 0 }; + const applyTeamspaceDaily = (row: any) => teamspaceTimeTrackingEnabled + ? row + : { ...row, crm_billed_minutes: row.total_minutes }; return { periodType, @@ -7429,29 +8198,20 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio manualSessions: totals.manual_sessions, averageSessionMinutes: totals.average_session_minutes, activeDays: totals.active_days, - crmBilledMinutes: totals.crm_billed_minutes, - crmDeltaMinutes: totals.crm_billed_minutes - totals.minutes + crmBilledMinutes: effectiveCrmBilledMinutes, + crmDeltaMinutes: teamspaceTimeTrackingEnabled ? effectiveCrmBilledMinutes - totals.minutes : 0 }, - dailySeries: dailyResult.rows, - organizations: organizationResult.rows.map((row: any) => ({ - ...row, - crm_delta_minutes: row.crm_billed_minutes - row.total_minutes - })), - workTypes: workTypeResult.rows.map((row: any) => ({ - ...row, - crm_delta_minutes: row.crm_billed_minutes - row.total_minutes - })), - tickets: ticketResult.rows.map((row: any) => ({ - ...row, - crm_delta_minutes: row.crm_billed_minutes - row.total_minutes - })), + dailySeries: dailyResult.rows.map(applyTeamspaceDaily), + organizations: organizationResult.rows.map(applyTeamspaceDelta), + workTypes: workTypeResult.rows.map(applyTeamspaceDelta), + tickets: ticketResult.rows.map(applyTeamspaceDelta), attention: { openSessions: openResult.rows, missingCrmDays: missingCrmResult.rows, acknowledgedMissingCrmDays: acknowledgedMissingCrmResult.rows, crmMismatches: mismatchResult.rows }, - teamspaceDays: teamspaceDayResult.rows + teamspaceDays: teamspaceTimeTrackingEnabled ? teamspaceDayResult.rows : [] }; } @@ -7521,9 +8281,14 @@ async function getProjectFollowups(userId: string) { session_updates.latest_session_at, session_updates.last_session_at, entry_totals.total_hour_budget_minutes, - (COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + COALESCE(entry_totals.total_manual_consumption_minutes, 0))::int AS consumption_minutes - FROM projects p - JOIN tickets ft ON ft.id = p.ticket_id + CASE + WHEN COALESCE(viewer_settings.teamspace_time_tracking_enabled, true) + THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + COALESCE(entry_totals.total_manual_consumption_minutes, 0) + ELSE COALESCE(teamspace_api_totals.teamspace_consumption_minutes, 0) + END::int AS consumption_minutes + FROM projects p + JOIN tickets ft ON ft.id = p.ticket_id + LEFT JOIN users viewer_settings ON viewer_settings.id = $1 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, @@ -7565,10 +8330,24 @@ async function getProjectFollowups(userId: string) { AND tdb.user_id = $1 ) day_billing_updates ON true LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_consumption_minutes - FROM ticket_day_billings tdb - WHERE tdb.ticket_id = p.ticket_id - ) teamspace_totals ON true + SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_consumption_minutes + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND tdb.user_id = $1 + ) teamspace_totals ON true + LEFT JOIN LATERAL ( + SELECT COALESCE(SUM( + CASE + WHEN pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX' + THEN COALESCE(pts.sum_minutes_billable, pts.sum_minutes, 0) + ELSE 0 + END + ), 0)::int AS teamspace_consumption_minutes + FROM project_teamspace_entries pte_ts + JOIN project_teamspace_subentries pts ON pts.project_teamspace_entry_id = pte_ts.id + WHERE pte_ts.project_id = p.id + AND pts.user_id = $1 + ) teamspace_api_totals ON true WHERE p.closed_at IS NULL AND COALESCE(ft.work_type, 'consulting') = 'consulting' AND ( @@ -7969,8 +8748,11 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke const dayBillingResult = await query( ` - SELECT day::text AS day, project_teamspace_entry_id::text AS project_teamspace_entry_id, billed_minutes - FROM ticket_day_billings + SELECT + day::text AS day, + project_teamspace_entry_id::text AS project_teamspace_entry_id, + billed_minutes + FROM ticket_day_billings WHERE ticket_id = $1 AND user_id = $2 AND day >= $3::date @@ -8160,8 +8942,11 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon FROM sessions s WHERE ${sessionWhere} ) - SELECT tdb.day::text AS day, tdb.project_teamspace_entry_id::text AS project_teamspace_entry_id, tdb.billed_minutes - FROM ticket_day_billings tdb + SELECT + tdb.day::text AS day, + tdb.project_teamspace_entry_id::text AS project_teamspace_entry_id, + tdb.billed_minutes + FROM ticket_day_billings tdb JOIN filtered_days fd ON fd.day = tdb.day WHERE tdb.ticket_id = $1 AND tdb.user_id = $2 @@ -8222,31 +9007,60 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon p.starts_on::text AS starts_on, p.ends_on::text AS ends_on, entry_totals.total_hour_budget_minutes AS hour_budget_minutes, - entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes, + entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes, EXISTS ( SELECT 1 FROM recurring_billings rb WHERE rb.user_id = $2 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) + entry_totals.total_manual_consumption_minutes)::int AS consumption_minutes, + CASE + WHEN COALESCE(viewer_settings.teamspace_time_tracking_enabled, true) + THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + ELSE COALESCE(teamspace_api_totals.teamspace_consumption_minutes, 0) + END::int AS teamspace_consumption_minutes, + CASE + WHEN COALESCE(viewer_settings.teamspace_time_tracking_enabled, true) + THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes + ELSE COALESCE(teamspace_api_totals.teamspace_consumption_minutes, 0) + END::int AS consumption_minutes, entry_totals.teamspace_entry_count, entry_totals.teamspace_entries, - CASE WHEN COALESCE(entry_totals.teamspace_entry_count, 0) > 1 THEN COALESCE(unassigned_sessions.session_count, 0) ELSE 0 END::int AS unassigned_teamspace_session_count, + CASE + WHEN COALESCE(viewer_settings.teamspace_time_tracking_enabled, true) + AND COALESCE(entry_totals.teamspace_entry_count, 0) > 1 + THEN COALESCE(unassigned_sessions.session_count, 0) + ELSE 0 + END::int AS unassigned_teamspace_session_count, + 0::int AS unassigned_teamspace_subentry_count, p.closed_at, p.close_review_required, p.created_at, p.updated_at FROM projects p LEFT JOIN users owner ON owner.id = p.user_id + LEFT JOIN users viewer_settings ON viewer_settings.id = $2 JOIN tickets t ON t.id = p.ticket_id LEFT JOIN organizations o ON o.zammad_id = t.organization_id LEFT JOIN LATERAL ( SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_consumption_minutes FROM ticket_day_billings tdb WHERE tdb.ticket_id = p.ticket_id - ) teamspace_totals ON true + AND tdb.user_id = $2 + ) teamspace_totals ON true + LEFT JOIN LATERAL ( + SELECT COALESCE(SUM( + CASE + WHEN pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX' + THEN COALESCE(pts.sum_minutes_billable, pts.sum_minutes, 0) + ELSE 0 + END + ), 0)::int AS teamspace_consumption_minutes + FROM project_teamspace_entries pte_ts + JOIN project_teamspace_subentries pts ON pts.project_teamspace_entry_id = pte_ts.id + WHERE pte_ts.project_id = p.id + AND pts.user_id = $2 + ) teamspace_api_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, @@ -8257,30 +9071,93 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon json_build_object( 'id', pte.id::text, 'project_id', pte.project_id::text, - 'teamspace_project', pte.teamspace_project, - 'teamspace_description', pte.teamspace_description, + 'teamspace_project', pte.teamspace_project, + 'teamspace_api_project_id', pte.teamspace_api_project_id::text, + 'teamspace_api_project_url', pte.teamspace_api_project_url, + 'subentries', COALESCE(( + SELECT json_agg( + json_build_object( + 'id', pts.id::text, + 'project_teamspace_entry_id', pts.project_teamspace_entry_id::text, + 'teamspace_api_project_id', pts.teamspace_api_project_id::text, + 'teamspace_api_project_url', pts.teamspace_api_project_url, + 'teamspace_project_id', pts.teamspace_project_id, + 'billing_type', pts.billing_type, + 'name', pts.name, + 'alias', pts.alias, + 'description', pts.description, + 'progress', pts.progress, + 'planned_minutes', pts.planned_minutes, + 'sum_minutes', pts.sum_minutes, + 'planned_minutes_billable', pts.planned_minutes_billable, + 'sum_minutes_billable', pts.sum_minutes_billable, + 'planned_income', pts.planned_income, + 'sum_income', pts.sum_income, + 'consumption_minutes', CASE + WHEN pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX' THEN COALESCE(pts.sum_minutes_billable, pts.sum_minutes, 0) + ELSE 0 + END, + 'display_order', pts.display_order + ) + ORDER BY pts.display_order ASC, pts.id ASC + ) + FROM project_teamspace_subentries pts + WHERE pts.project_teamspace_entry_id = pte.id + AND pts.user_id = $2 + ), '[]'::json), + 'teamspace_description', pte.teamspace_description, 'is_default', pte.is_default, 'hour_budget_minutes', pte.hour_budget_minutes, 'budget_sync_disabled', pte.budget_sync_disabled, 'manual_consumption_minutes', pte.manual_consumption_minutes, - 'teamspace_consumption_minutes', COALESCE(( - SELECT SUM(tdb.billed_minutes)::int - FROM ticket_day_billings tdb - WHERE tdb.ticket_id = p.ticket_id - AND ( - tdb.project_teamspace_entry_id = pte.id - OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) - ) - ), 0), - 'consumption_minutes', COALESCE(( - SELECT SUM(tdb.billed_minutes)::int - FROM ticket_day_billings tdb - WHERE tdb.ticket_id = p.ticket_id - AND ( - tdb.project_teamspace_entry_id = pte.id - OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) - ) - ), 0) + pte.manual_consumption_minutes, + 'teamspace_consumption_minutes', CASE + WHEN COALESCE(viewer_settings.teamspace_time_tracking_enabled, true) THEN COALESCE(( + SELECT SUM(tdb.billed_minutes)::int + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND tdb.user_id = $2 + AND ( + tdb.project_teamspace_entry_id = pte.id + OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + ), 0) + ELSE COALESCE(( + SELECT SUM( + CASE + WHEN pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX' + THEN COALESCE(pts.sum_minutes_billable, pts.sum_minutes, 0) + ELSE 0 + END + )::int + FROM project_teamspace_subentries pts + WHERE pts.project_teamspace_entry_id = pte.id + AND pts.user_id = $2 + ), 0) + END, + 'consumption_minutes', CASE + WHEN COALESCE(viewer_settings.teamspace_time_tracking_enabled, true) THEN COALESCE(( + SELECT SUM(tdb.billed_minutes)::int + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND tdb.user_id = $2 + AND ( + tdb.project_teamspace_entry_id = pte.id + OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + ), 0) + pte.manual_consumption_minutes + ELSE COALESCE(( + SELECT SUM( + CASE + WHEN pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX' + THEN COALESCE(pts.sum_minutes_billable, pts.sum_minutes, 0) + ELSE 0 + END + )::int + FROM project_teamspace_subentries pts + WHERE pts.project_teamspace_entry_id = pte.id + AND pts.user_id = $2 + ), 0) + END, 'display_order', pte.display_order ) ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC @@ -8293,11 +9170,12 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon LEFT JOIN LATERAL ( SELECT COUNT(*)::int AS session_count FROM sessions s - WHERE s.ticket_id = p.ticket_id - AND s.user_id = $2 - AND s.work_type = 'consulting' - AND s.project_teamspace_entry_id IS NULL - ) unassigned_sessions ON true + WHERE s.ticket_id = p.ticket_id + AND s.user_id = $2 + AND s.work_type = 'consulting' + AND s.billing_status IN ('billable', 'billed') + AND s.project_teamspace_entry_id IS NULL + ) unassigned_sessions ON true WHERE p.ticket_id = $1 AND ( p.user_id = $2 @@ -8420,8 +9298,11 @@ app.post("/api/setup/admin", async (req, res) => { active: boolean; zammad_user_id: string | null; has_zammad_api_token: boolean; + teamspace_user_id: string | null; + has_teamspace_credentials: boolean; zammad_email_session_webhook_enabled: boolean; session_billing_prompt_enabled: boolean; + teamspace_time_tracking_enabled: boolean; zammad_session_note_default_enabled: boolean; zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_minutes: number; @@ -8436,7 +9317,7 @@ app.post("/api/setup/admin", async (req, res) => { ` INSERT INTO users (username, display_name, password_hash, role, active) VALUES ($1, $2, $3, 'admin', true) - 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, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session; + 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, teamspace_user_id::text AS teamspace_user_id, (teamspace_device_id IS NOT NULL AND teamspace_device_password IS NOT NULL) AS has_teamspace_credentials, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, teamspace_time_tracking_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session; `, [username, displayName, await hashPassword(password)] ); @@ -8492,8 +9373,11 @@ app.post("/api/auth/login", async (req, res) => { password_hash: string; zammad_user_id: string | null; has_zammad_api_token: boolean; + teamspace_user_id: string | null; + has_teamspace_credentials: boolean; zammad_email_session_webhook_enabled: boolean; session_billing_prompt_enabled: boolean; + teamspace_time_tracking_enabled: boolean; zammad_session_note_default_enabled: boolean; zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_minutes: number; @@ -8514,9 +9398,10 @@ app.post("/api/auth/login", async (req, res) => { active, password_hash, zammad_user_id::text AS zammad_user_id, - (zammad_api_token IS NOT NULL) AS has_zammad_api_token, + (zammad_api_token IS NOT NULL) AS has_zammad_api_token, teamspace_user_id::text AS teamspace_user_id, (teamspace_device_id IS NOT NULL AND teamspace_device_password IS NOT NULL) AS has_teamspace_credentials, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, + teamspace_time_tracking_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, @@ -8572,8 +9457,11 @@ app.post("/api/auth/login", async (req, res) => { active: user.active, zammad_user_id: user.zammad_user_id, has_zammad_api_token: user.has_zammad_api_token, + teamspace_user_id: user.teamspace_user_id, + has_teamspace_credentials: user.has_teamspace_credentials, zammad_email_session_webhook_enabled: user.zammad_email_session_webhook_enabled, session_billing_prompt_enabled: user.session_billing_prompt_enabled, + teamspace_time_tracking_enabled: user.teamspace_time_tracking_enabled, zammad_session_note_default_enabled: user.zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled: user.zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes: user.zammad_time_unit_rounding_minutes, @@ -8626,6 +9514,256 @@ app.get("/api/auth/me", (req, res) => { res.json({ user: currentUser(req) }); }); +app.post("/api/teamspace/project-ids/sync", requireUser, async (req, res) => { + const user = currentUser(req); + const credentials = await getTeamspaceCredentialsForUser(user.id); + const localEntriesResult = await query<{ + entry_id: string; + project_id: string; + ticket_number: string; + teamspace_project: string; + teamspace_api_project_id: string | null; + teamspace_api_project_url: string | null; + }>( + ` + SELECT + pte.id::text AS entry_id, + p.id::text AS project_id, + t.ticket_number, + btrim(pte.teamspace_project) AS teamspace_project, + pte.teamspace_api_project_id::text AS teamspace_api_project_id, + pte.teamspace_api_project_url + FROM projects p + JOIN tickets t ON t.id = p.ticket_id + JOIN project_teamspace_entries pte ON pte.project_id = p.id + WHERE p.user_id = $1 + AND p.closed_at IS NULL + AND pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> '' + ORDER BY t.ticket_number ASC, pte.display_order ASC, pte.id ASC; + `, + [user.id] + ); + + const remote = await loadTeamspaceRemoteProjectsSingleCall( + credentials.baseUrl, + credentials.deviceId, + credentials.devicePassword, + localEntriesResult.rows.map((entry) => entry.teamspace_project) + ); + const remoteByProjectId = teamspaceRemoteProjectsByProjectId(remote.projects); + const remoteByMainprojectId = teamspaceRemoteProjectsByMainprojectId(remote.projects); + const ambiguousLocalTeamspaceProjects = new Set(); + const items: TeamspaceProjectIdSyncItem[] = []; + let matched = 0; + let updated = 0; + let alreadyCurrent = 0; + let missing = 0; + let ambiguous = 0; + let cleared = 0; + const subentrySyncEntries: TeamspaceSubentrySyncEntry[] = []; + + await withTransaction(async (client) => { + const touchedProjectIds = new Set(); + + for (const entry of localEntriesResult.rows) { + let matches = teamspaceProjectMatchesForLocalId(entry.teamspace_project, remoteByProjectId, remoteByMainprojectId); + const previousApiProjectId = entry.teamspace_api_project_id; + const previousApiMatch = previousApiProjectId + ? matches.filter((match) => String(match.apiProjectId) === previousApiProjectId) + : []; + + if (previousApiMatch.length === 1) { + matches = previousApiMatch; + } + + if (matches.length === 0) { + missing += 1; + + await client.query( + ` + UPDATE project_teamspace_entries + SET teamspace_api_project_id = NULL, + teamspace_api_project_url = NULL, + budget_sync_disabled = false, + updated_at = now() + WHERE id = $1; + `, + [entry.entry_id] + ); + if (entry.teamspace_api_project_id || entry.teamspace_api_project_url) { + cleared += 1; + } + touchedProjectIds.add(entry.project_id); + + items.push({ + entryId: entry.entry_id, + projectId: entry.project_id, + ticketNumber: entry.ticket_number, + teamspaceProject: entry.teamspace_project, + previousApiProjectId, + apiProjectId: null, + apiProjectUrl: null, + status: "missing", + message: "Keine Teamspace-Projektressource mit dieser projectId gefunden." + }); + continue; + } + + if (matches.length > 1) { + ambiguous += 1; + ambiguousLocalTeamspaceProjects.add(entry.teamspace_project); + + await client.query( + ` + UPDATE project_teamspace_entries + SET teamspace_api_project_id = NULL, + teamspace_api_project_url = NULL, + budget_sync_disabled = false, + updated_at = now() + WHERE id = $1; + `, + [entry.entry_id] + ); + if (entry.teamspace_api_project_id || entry.teamspace_api_project_url) { + cleared += 1; + } + touchedProjectIds.add(entry.project_id); + + items.push({ + entryId: entry.entry_id, + projectId: entry.project_id, + ticketNumber: entry.ticket_number, + teamspaceProject: entry.teamspace_project, + previousApiProjectId, + apiProjectId: null, + apiProjectUrl: null, + status: "ambiguous", + message: `${matches.length} Teamspace-Projektressourcen mit dieser projectId gefunden.` + }); + continue; + } + + const [match] = matches; + const nextApiProjectId = String(match.apiProjectId); + const nextApiProjectUrl = match.apiProjectUrl ?? `${credentials.baseUrl}/api/project/${match.apiProjectId}`; + const nextBudgetMinutes = teamspaceHourBudgetMinutes(match); + matched += 1; + + if (entry.teamspace_api_project_id === nextApiProjectId && entry.teamspace_api_project_url === nextApiProjectUrl) { + await client.query( + ` + UPDATE project_teamspace_entries + SET hour_budget_minutes = $1, + budget_sync_disabled = false, + updated_at = now() + WHERE id = $2; + `, + [nextBudgetMinutes, entry.entry_id] + ); + touchedProjectIds.add(entry.project_id); + subentrySyncEntries.push({ + project_id: entry.project_id, + entry_id: entry.entry_id, + teamspace_project: entry.teamspace_project, + teamspace_api_project_id: nextApiProjectId + }); + alreadyCurrent += 1; + items.push({ + entryId: entry.entry_id, + projectId: entry.project_id, + ticketNumber: entry.ticket_number, + teamspaceProject: entry.teamspace_project, + previousApiProjectId, + apiProjectId: nextApiProjectId, + apiProjectUrl: nextApiProjectUrl, + status: "current", + message: null + }); + continue; + } + + await client.query( + ` + UPDATE project_teamspace_entries + SET teamspace_api_project_id = $1, + teamspace_api_project_url = $2, + hour_budget_minutes = $3, + budget_sync_disabled = false, + updated_at = now() + WHERE id = $4; + `, + [match.apiProjectId, nextApiProjectUrl, nextBudgetMinutes, entry.entry_id] + ); + touchedProjectIds.add(entry.project_id); + subentrySyncEntries.push({ + project_id: entry.project_id, + entry_id: entry.entry_id, + teamspace_project: entry.teamspace_project, + teamspace_api_project_id: nextApiProjectId + }); + updated += 1; + items.push({ + entryId: entry.entry_id, + projectId: entry.project_id, + ticketNumber: entry.ticket_number, + teamspaceProject: entry.teamspace_project, + previousApiProjectId, + apiProjectId: nextApiProjectId, + apiProjectUrl: nextApiProjectUrl, + status: "updated", + message: null + }); + } + + for (const projectId of touchedProjectIds) { + await refreshProjectBudgetFromTeamspaceEntries(client, projectId); + } + }); + + let remoteSubprojectsScanned = 0; + let subentriesSynced = 0; + let subentriesRemoved = 0; + + if (subentrySyncEntries.length > 0) { + const subprojects = await loadTeamspaceRemoteSubprojectsSingleCall( + credentials.baseUrl, + credentials.deviceId, + credentials.devicePassword, + subentrySyncEntries.map((entry) => Number(entry.teamspace_api_project_id)).filter((id) => Number.isInteger(id) && id > 0) + ); + const syncResult = await withTransaction((client) => syncTeamspaceSubentriesForEntries( + client, + user.id, + subentrySyncEntries, + subprojects.projects, + credentials.baseUrl + )); + remoteSubprojectsScanned = subprojects.scanned; + subentriesSynced = syncResult.synced; + subentriesRemoved = syncResult.removed; + } + + const result: TeamspaceProjectIdSyncResult = { + checkedEntries: localEntriesResult.rows.length, + matched, + updated, + alreadyCurrent, + missing, + ambiguous, + cleared, + remoteProjectsScanned: remote.scanned, + remoteProjectIds: remote.projects.length, + remoteSubprojectsScanned, + subentriesSynced, + subentriesRemoved, + duplicateRemoteProjectIds: [...ambiguousLocalTeamspaceProjects].sort((left, right) => left.localeCompare(right, "de", { numeric: true, sensitivity: "base" })), + items + }; + + res.json({ result }); +}); + app.get("/api/dashboard/preferences", requireUser, async (req, res) => { const preference = await getUserPreference(currentUser(req).id, dashboardPreferenceKey); res.json({ preference }); @@ -8786,12 +9924,28 @@ app.patch("/api/auth/me", async (req, res) => { 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; + const updatesTeamspaceCredentials = + user.role === "user" + && (Object.prototype.hasOwnProperty.call(req.body, "teamspaceDeviceId") || Object.prototype.hasOwnProperty.call(req.body, "teamspaceDevicePassword")); + const teamspaceDeviceId = updatesTeamspaceCredentials ? optionalString(req.body.teamspaceDeviceId) : null; + const teamspaceDevicePassword = updatesTeamspaceCredentials ? optionalString(req.body.teamspaceDevicePassword) : null; + + if (updatesTeamspaceCredentials && Boolean(teamspaceDeviceId) !== Boolean(teamspaceDevicePassword)) { + throw badRequest("Teamspace Geräte-ID und Gerätepasswort müssen gemeinsam gespeichert werden"); + } + + const teamspaceUserId = teamspaceDeviceId && teamspaceDevicePassword + ? await fetchTeamspaceUserIdForDevice(teamspaceDeviceId, teamspaceDevicePassword) + : null; const zammadEmailSessionWebhookEnabled = user.role === "user" ? optionalBoolean(req.body.zammadEmailSessionWebhookEnabled, user.zammad_email_session_webhook_enabled) : false; const sessionBillingPromptEnabled = user.role === "user" ? optionalBoolean(req.body.sessionBillingPromptEnabled, user.session_billing_prompt_enabled) : false; + const teamspaceTimeTrackingEnabled = user.role === "user" + ? optionalBoolean(req.body.teamspaceTimeTrackingEnabled, user.teamspace_time_tracking_enabled) + : true; const zammadSessionNoteDefaultEnabled = user.role === "user" ? optionalBoolean(req.body.zammadSessionNoteDefaultEnabled, user.zammad_session_note_default_enabled) : false; @@ -8822,7 +9976,6 @@ app.patch("/api/auth/me", async (req, res) => { const projectFollowupCompleteOnSession = user.role === "user" ? optionalBoolean(req.body.projectFollowupCompleteOnSession, user.project_followup_complete_on_session) : true; - if (newPassword && newPassword.length < 6) { throw badRequest("newPassword must be at least 6 characters"); } @@ -8844,6 +9997,10 @@ app.patch("/api/auth/me", async (req, res) => { await assertUniqueZammadUserIdAvailable({ query }, zammadUserId, user.id); } + if (updatesTeamspaceCredentials) { + await assertUniqueTeamspaceUserIdAvailable({ query }, teamspaceUserId, user.id); + } + try { const updatedUser = await withTransaction(async (client) => { const result = await client.query( @@ -8854,21 +10011,25 @@ app.patch("/api/auth/me", async (req, res) => { 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, - zammad_email_session_webhook_enabled = CASE WHEN $8::boolean THEN $9 ELSE zammad_email_session_webhook_enabled END, - session_billing_prompt_enabled = CASE WHEN $8::boolean THEN $10 ELSE session_billing_prompt_enabled END, - zammad_session_note_default_enabled = CASE WHEN $8::boolean THEN $11 ELSE zammad_session_note_default_enabled END, - zammad_time_unit_rounding_enabled = CASE WHEN $8::boolean THEN $12 ELSE zammad_time_unit_rounding_enabled END, - zammad_time_unit_rounding_minutes = CASE WHEN $8::boolean THEN $13 ELSE zammad_time_unit_rounding_minutes END, - zammad_atoss_email_sync_enabled = CASE WHEN $8::boolean THEN $14 ELSE zammad_atoss_email_sync_enabled END, - zammad_article_type_id = CASE WHEN $8::boolean THEN $15 ELSE zammad_article_type_id END, - project_followup_enabled = CASE WHEN $8::boolean THEN $16 ELSE project_followup_enabled END, - project_followup_weeks = CASE WHEN $8::boolean THEN $17 ELSE project_followup_weeks END, - project_followup_second_enabled = CASE WHEN $8::boolean THEN $18 ELSE project_followup_second_enabled END, - project_followup_second_weeks = CASE WHEN $8::boolean THEN $19 ELSE project_followup_second_weeks END, - project_followup_complete_on_session = CASE WHEN $8::boolean THEN $20 ELSE project_followup_complete_on_session END, + teamspace_device_id = CASE WHEN $8::boolean THEN $9 ELSE teamspace_device_id END, + teamspace_device_password = CASE WHEN $8::boolean THEN $10 ELSE teamspace_device_password END, + teamspace_user_id = CASE WHEN $8::boolean THEN $11 ELSE teamspace_user_id END, + zammad_email_session_webhook_enabled = CASE WHEN $12::boolean THEN $13 ELSE zammad_email_session_webhook_enabled END, + session_billing_prompt_enabled = CASE WHEN $12::boolean THEN $14 ELSE session_billing_prompt_enabled END, + teamspace_time_tracking_enabled = CASE WHEN $12::boolean THEN $15 ELSE teamspace_time_tracking_enabled END, + zammad_session_note_default_enabled = CASE WHEN $12::boolean THEN $16 ELSE zammad_session_note_default_enabled END, + zammad_time_unit_rounding_enabled = CASE WHEN $12::boolean THEN $17 ELSE zammad_time_unit_rounding_enabled END, + zammad_time_unit_rounding_minutes = CASE WHEN $12::boolean THEN $18 ELSE zammad_time_unit_rounding_minutes END, + zammad_atoss_email_sync_enabled = CASE WHEN $12::boolean THEN $19 ELSE zammad_atoss_email_sync_enabled END, + zammad_article_type_id = CASE WHEN $12::boolean THEN $20 ELSE zammad_article_type_id END, + project_followup_enabled = CASE WHEN $12::boolean THEN $21 ELSE project_followup_enabled END, + project_followup_weeks = CASE WHEN $12::boolean THEN $22 ELSE project_followup_weeks END, + project_followup_second_enabled = CASE WHEN $12::boolean THEN $23 ELSE project_followup_second_enabled END, + project_followup_second_weeks = CASE WHEN $12::boolean THEN $24 ELSE project_followup_second_weeks END, + project_followup_complete_on_session = CASE WHEN $12::boolean THEN $25 ELSE project_followup_complete_on_session END, updated_at = now() WHERE id = $4 - 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, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session; + 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, teamspace_user_id::text AS teamspace_user_id, (teamspace_device_id IS NOT NULL AND teamspace_device_password IS NOT NULL) AS has_teamspace_credentials, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, teamspace_time_tracking_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session; `, [ username, @@ -8878,9 +10039,14 @@ app.patch("/api/auth/me", async (req, res) => { updatesZammadToken, zammadApiToken ? encryptSecret(zammadApiToken) : null, zammadUserId, + updatesTeamspaceCredentials, + teamspaceDeviceId ? encryptSecret(teamspaceDeviceId) : null, + teamspaceDevicePassword ? encryptSecret(teamspaceDevicePassword) : null, + teamspaceUserId, user.role === "user", zammadEmailSessionWebhookEnabled, sessionBillingPromptEnabled, + teamspaceTimeTrackingEnabled, zammadSessionNoteDefaultEnabled, zammadTimeUnitRoundingEnabled, zammadTimeUnitRoundingMinutes, @@ -8950,7 +10116,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, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_time_unit_rounding_minutes, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session, 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, teamspace_user_id::text AS teamspace_user_id, (teamspace_device_id IS NOT NULL AND teamspace_device_password IS NOT NULL) AS has_teamspace_credentials, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, teamspace_time_tracking_enabled, zammad_time_unit_rounding_minutes, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session, created_at, updated_at FROM users ORDER BY username ASC; ` @@ -8974,7 +10140,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, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session, 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, teamspace_user_id::text AS teamspace_user_id, (teamspace_device_id IS NOT NULL AND teamspace_device_password IS NOT NULL) AS has_teamspace_credentials, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, teamspace_time_tracking_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session, created_at, updated_at; `, [username, displayName, await hashPassword(password), "user", active] ); @@ -9035,7 +10201,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, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session, 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, teamspace_user_id::text AS teamspace_user_id, (teamspace_device_id IS NOT NULL AND teamspace_device_password IS NOT NULL) AS has_teamspace_credentials, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, teamspace_time_tracking_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session, created_at, updated_at; `, [username, displayName, active, password ? await hashPassword(password) : null, userId] ); @@ -9118,6 +10284,28 @@ app.put("/api/admin/zammad/settings", requireAdmin, async (req, res) => { }); }); +app.get("/api/admin/teamspace/settings", requireAdmin, async (_req, res) => { + const baseUrl = await getAppSetting(teamspaceBaseUrlSettingKey); + + res.json({ + settings: { + baseUrl: baseUrl ?? "" + } + }); +}); + +app.put("/api/admin/teamspace/settings", requireAdmin, async (req, res) => { + const baseUrl = parseTeamspaceBaseUrl(req.body.baseUrl); + + await setAppSetting(teamspaceBaseUrlSettingKey, baseUrl); + + res.json({ + settings: { + baseUrl + } + }); +}); + app.post("/api/admin/zammad/organizations/sync", requireAdmin, async (req, res) => { const requestBaseUrl = optionalString(req.body.baseUrl); const requestApiKey = optionalString(req.body.apiKey); @@ -10411,8 +11599,41 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => { json_build_object( 'id', pte.id::text, 'project_id', pte.project_id::text, - 'teamspace_project', pte.teamspace_project, - 'teamspace_description', pte.teamspace_description, + 'teamspace_project', pte.teamspace_project, + 'teamspace_api_project_id', pte.teamspace_api_project_id::text, + 'teamspace_api_project_url', pte.teamspace_api_project_url, + 'subentries', COALESCE(( + SELECT json_agg( + json_build_object( + 'id', pts.id::text, + 'project_teamspace_entry_id', pts.project_teamspace_entry_id::text, + 'teamspace_api_project_id', pts.teamspace_api_project_id::text, + 'teamspace_api_project_url', pts.teamspace_api_project_url, + 'teamspace_project_id', pts.teamspace_project_id, + 'billing_type', pts.billing_type, + 'name', pts.name, + 'alias', pts.alias, + 'description', pts.description, + 'progress', pts.progress, + 'planned_minutes', pts.planned_minutes, + 'sum_minutes', pts.sum_minutes, + 'planned_minutes_billable', pts.planned_minutes_billable, + 'sum_minutes_billable', pts.sum_minutes_billable, + 'planned_income', pts.planned_income, + 'sum_income', pts.sum_income, + 'consumption_minutes', CASE + WHEN pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX' THEN COALESCE(pts.sum_minutes_billable, pts.sum_minutes, 0) + ELSE 0 + END, + 'display_order', pts.display_order + ) + ORDER BY pts.display_order ASC, pts.id ASC + ) + FROM project_teamspace_subentries pts + WHERE pts.project_teamspace_entry_id = pte.id + AND pts.user_id = $2 + ), '[]'::json), + 'teamspace_description', pte.teamspace_description, 'is_default', pte.is_default, 'hour_budget_minutes', pte.hour_budget_minutes, 'budget_sync_disabled', pte.budget_sync_disabled, @@ -10454,12 +11675,12 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => { const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket); const previewEntries = - zammadTicket.workType === "consulting" && (zammadTeamspaceProjects.length > 0 || zammadTicket.teamspaceBudgetMinutes !== null) + zammadTicket.workType === "consulting" && zammadTeamspaceProjects.length > 0 ? (zammadTeamspaceProjects.length > 0 ? zammadTeamspaceProjects : [zammadTicket.teamspaceProject]).map((teamspaceProject, index) => ({ id: `zammad-${zammadTicket.zammadTicketId}-${index}`, teamspace_project: teamspaceProject, is_default: index === 0, - hour_budget_minutes: index === 0 ? zammadTicket.teamspaceBudgetMinutes : null, + hour_budget_minutes: null, budget_sync_disabled: false, manual_consumption_minutes: 0, display_order: index * 1024 @@ -10483,7 +11704,7 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => { project_id: null, is_project: zammadTicket.workType === "consulting", project_name: formatTeamspaceProjectList(zammadTeamspaceProjects), - project_hour_budget_minutes: zammadTicket.teamspaceBudgetMinutes, + project_hour_budget_minutes: null, project_teamspace_entry_count: previewEntries.length, project_teamspace_entries: previewEntries, project_owner_user_id: null, @@ -11546,11 +12767,25 @@ app.get("/api/projects", requireUser, async (req, res) => { WHERE rb.user_id = $1 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) + entry_totals.total_manual_consumption_minutes)::int AS consumption_minutes, + CASE + WHEN COALESCE(viewer_settings.teamspace_time_tracking_enabled, true) + THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + ELSE COALESCE(teamspace_api_totals.teamspace_consumption_minutes, 0) + END::int AS teamspace_consumption_minutes, + CASE + WHEN COALESCE(viewer_settings.teamspace_time_tracking_enabled, true) + THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes + ELSE COALESCE(teamspace_api_totals.teamspace_consumption_minutes, 0) + END::int AS consumption_minutes, entry_totals.teamspace_entry_count, entry_totals.teamspace_entries, - CASE WHEN COALESCE(entry_totals.teamspace_entry_count, 0) > 1 THEN COALESCE(unassigned_sessions.session_count, 0) ELSE 0 END::int AS unassigned_teamspace_session_count, + CASE + WHEN COALESCE(viewer_settings.teamspace_time_tracking_enabled, true) + AND COALESCE(entry_totals.teamspace_entry_count, 0) > 1 + THEN COALESCE(unassigned_sessions.session_count, 0) + ELSE 0 + END::int AS unassigned_teamspace_session_count, + 0::int AS unassigned_teamspace_subentry_count, EXISTS ( SELECT 1 FROM hidden_user_projects hup @@ -11562,6 +12797,7 @@ app.get("/api/projects", requireUser, async (req, res) => { COALESCE(todo_totals.done_todos, 0)::int AS done_todos FROM projects p LEFT JOIN users owner ON owner.id = p.user_id + LEFT JOIN users viewer_settings ON viewer_settings.id = $1 JOIN tickets t ON t.id = p.ticket_id LEFT JOIN organizations o ON o.zammad_id = t.organization_id LEFT JOIN LATERAL ( @@ -11573,11 +12809,25 @@ app.get("/api/projects", requireUser, async (req, res) => { WHERE s.ticket_id = p.ticket_id AND s.user_id = $1 ) session_totals ON true - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_consumption_minutes - FROM ticket_day_billings tdb - WHERE tdb.ticket_id = p.ticket_id - ) teamspace_totals ON true + LEFT JOIN LATERAL ( + SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_consumption_minutes + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND tdb.user_id = $1 + ) teamspace_totals ON true + LEFT JOIN LATERAL ( + SELECT COALESCE(SUM( + CASE + WHEN pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX' + THEN COALESCE(pts.sum_minutes_billable, pts.sum_minutes, 0) + ELSE 0 + END + ), 0)::int AS teamspace_consumption_minutes + FROM project_teamspace_entries pte_ts + JOIN project_teamspace_subentries pts ON pts.project_teamspace_entry_id = pte_ts.id + WHERE pte_ts.project_id = p.id + AND pts.user_id = $1 + ) teamspace_api_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, @@ -11589,29 +12839,92 @@ app.get("/api/projects", requireUser, async (req, res) => { 'id', pte.id::text, 'project_id', pte.project_id::text, 'teamspace_project', pte.teamspace_project, + 'teamspace_api_project_id', pte.teamspace_api_project_id::text, + 'teamspace_api_project_url', pte.teamspace_api_project_url, + 'subentries', COALESCE(( + SELECT json_agg( + json_build_object( + 'id', pts.id::text, + 'project_teamspace_entry_id', pts.project_teamspace_entry_id::text, + 'teamspace_api_project_id', pts.teamspace_api_project_id::text, + 'teamspace_api_project_url', pts.teamspace_api_project_url, + 'teamspace_project_id', pts.teamspace_project_id, + 'billing_type', pts.billing_type, + 'name', pts.name, + 'alias', pts.alias, + 'description', pts.description, + 'progress', pts.progress, + 'planned_minutes', pts.planned_minutes, + 'sum_minutes', pts.sum_minutes, + 'planned_minutes_billable', pts.planned_minutes_billable, + 'sum_minutes_billable', pts.sum_minutes_billable, + 'planned_income', pts.planned_income, + 'sum_income', pts.sum_income, + 'consumption_minutes', CASE + WHEN pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX' THEN COALESCE(pts.sum_minutes_billable, pts.sum_minutes, 0) + ELSE 0 + END, + 'display_order', pts.display_order + ) + ORDER BY pts.display_order ASC, pts.id ASC + ) + FROM project_teamspace_subentries pts + WHERE pts.project_teamspace_entry_id = pte.id + AND pts.user_id = $1 + ), '[]'::json), 'teamspace_description', pte.teamspace_description, 'is_default', pte.is_default, 'hour_budget_minutes', pte.hour_budget_minutes, 'budget_sync_disabled', pte.budget_sync_disabled, 'manual_consumption_minutes', pte.manual_consumption_minutes, - 'teamspace_consumption_minutes', COALESCE(( - SELECT SUM(tdb.billed_minutes)::int - FROM ticket_day_billings tdb - WHERE tdb.ticket_id = p.ticket_id - AND ( - tdb.project_teamspace_entry_id = pte.id - OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) - ) - ), 0), - 'consumption_minutes', COALESCE(( - SELECT SUM(tdb.billed_minutes)::int - FROM ticket_day_billings tdb - WHERE tdb.ticket_id = p.ticket_id - AND ( - tdb.project_teamspace_entry_id = pte.id - OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) - ) - ), 0) + pte.manual_consumption_minutes, + 'teamspace_consumption_minutes', CASE + WHEN COALESCE(viewer_settings.teamspace_time_tracking_enabled, true) THEN COALESCE(( + SELECT SUM(tdb.billed_minutes)::int + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND tdb.user_id = $1 + AND ( + tdb.project_teamspace_entry_id = pte.id + OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + ), 0) + ELSE COALESCE(( + SELECT SUM( + CASE + WHEN pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX' + THEN COALESCE(pts.sum_minutes_billable, pts.sum_minutes, 0) + ELSE 0 + END + )::int + FROM project_teamspace_subentries pts + WHERE pts.project_teamspace_entry_id = pte.id + AND pts.user_id = $1 + ), 0) + END, + 'consumption_minutes', CASE + WHEN COALESCE(viewer_settings.teamspace_time_tracking_enabled, true) THEN COALESCE(( + SELECT SUM(tdb.billed_minutes)::int + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND tdb.user_id = $1 + AND ( + tdb.project_teamspace_entry_id = pte.id + OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + ), 0) + pte.manual_consumption_minutes + ELSE COALESCE(( + SELECT SUM( + CASE + WHEN pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX' + THEN COALESCE(pts.sum_minutes_billable, pts.sum_minutes, 0) + ELSE 0 + END + )::int + FROM project_teamspace_subentries pts + WHERE pts.project_teamspace_entry_id = pte.id + AND pts.user_id = $1 + ), 0) + END, 'display_order', pte.display_order ) ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC @@ -11624,11 +12937,12 @@ app.get("/api/projects", requireUser, async (req, res) => { LEFT JOIN LATERAL ( SELECT COUNT(*)::int AS session_count FROM sessions s - WHERE s.ticket_id = p.ticket_id - AND s.user_id = $1 - AND s.work_type = 'consulting' - AND s.project_teamspace_entry_id IS NULL - ) unassigned_sessions ON true + WHERE s.ticket_id = p.ticket_id + AND s.user_id = $1 + AND s.work_type = 'consulting' + AND s.billing_status IN ('billable', 'billed') + AND s.project_teamspace_entry_id IS NULL + ) unassigned_sessions ON true LEFT JOIN LATERAL ( SELECT COUNT(*) FILTER (WHERE status = 'open')::int AS open_todos, @@ -11730,31 +13044,60 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { p.starts_on::text AS starts_on, p.ends_on::text AS ends_on, entry_totals.total_hour_budget_minutes AS hour_budget_minutes, - entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes, + entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes, EXISTS ( SELECT 1 FROM recurring_billings rb WHERE rb.user_id = $2 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) + entry_totals.total_manual_consumption_minutes)::int AS consumption_minutes, + CASE + WHEN COALESCE(viewer_settings.teamspace_time_tracking_enabled, true) + THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + ELSE COALESCE(teamspace_api_totals.teamspace_consumption_minutes, 0) + END::int AS teamspace_consumption_minutes, + CASE + WHEN COALESCE(viewer_settings.teamspace_time_tracking_enabled, true) + THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes + ELSE COALESCE(teamspace_api_totals.teamspace_consumption_minutes, 0) + END::int AS consumption_minutes, entry_totals.teamspace_entry_count, entry_totals.teamspace_entries, - CASE WHEN COALESCE(entry_totals.teamspace_entry_count, 0) > 1 THEN COALESCE(unassigned_sessions.session_count, 0) ELSE 0 END::int AS unassigned_teamspace_session_count, + CASE + WHEN COALESCE(viewer_settings.teamspace_time_tracking_enabled, true) + AND COALESCE(entry_totals.teamspace_entry_count, 0) > 1 + THEN COALESCE(unassigned_sessions.session_count, 0) + ELSE 0 + END::int AS unassigned_teamspace_session_count, + 0::int AS unassigned_teamspace_subentry_count, p.closed_at, p.close_review_required, p.created_at, p.updated_at FROM projects p LEFT JOIN users owner ON owner.id = p.user_id + LEFT JOIN users viewer_settings ON viewer_settings.id = $2 JOIN tickets t ON t.id = p.ticket_id LEFT JOIN organizations o ON o.zammad_id = t.organization_id - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_consumption_minutes - FROM ticket_day_billings tdb - WHERE tdb.ticket_id = p.ticket_id - ) teamspace_totals ON true + LEFT JOIN LATERAL ( + SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_consumption_minutes + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND tdb.user_id = $2 + ) teamspace_totals ON true + LEFT JOIN LATERAL ( + SELECT COALESCE(SUM( + CASE + WHEN pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX' + THEN COALESCE(pts.sum_minutes_billable, pts.sum_minutes, 0) + ELSE 0 + END + ), 0)::int AS teamspace_consumption_minutes + FROM project_teamspace_entries pte_ts + JOIN project_teamspace_subentries pts ON pts.project_teamspace_entry_id = pte_ts.id + WHERE pte_ts.project_id = p.id + AND pts.user_id = $2 + ) teamspace_api_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, @@ -11766,29 +13109,92 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { 'id', pte.id::text, 'project_id', pte.project_id::text, 'teamspace_project', pte.teamspace_project, + 'teamspace_api_project_id', pte.teamspace_api_project_id::text, + 'teamspace_api_project_url', pte.teamspace_api_project_url, + 'subentries', COALESCE(( + SELECT json_agg( + json_build_object( + 'id', pts.id::text, + 'project_teamspace_entry_id', pts.project_teamspace_entry_id::text, + 'teamspace_api_project_id', pts.teamspace_api_project_id::text, + 'teamspace_api_project_url', pts.teamspace_api_project_url, + 'teamspace_project_id', pts.teamspace_project_id, + 'billing_type', pts.billing_type, + 'name', pts.name, + 'alias', pts.alias, + 'description', pts.description, + 'progress', pts.progress, + 'planned_minutes', pts.planned_minutes, + 'sum_minutes', pts.sum_minutes, + 'planned_minutes_billable', pts.planned_minutes_billable, + 'sum_minutes_billable', pts.sum_minutes_billable, + 'planned_income', pts.planned_income, + 'sum_income', pts.sum_income, + 'consumption_minutes', CASE + WHEN pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX' THEN COALESCE(pts.sum_minutes_billable, pts.sum_minutes, 0) + ELSE 0 + END, + 'display_order', pts.display_order + ) + ORDER BY pts.display_order ASC, pts.id ASC + ) + FROM project_teamspace_subentries pts + WHERE pts.project_teamspace_entry_id = pte.id + AND pts.user_id = $2 + ), '[]'::json), 'teamspace_description', pte.teamspace_description, 'is_default', pte.is_default, 'hour_budget_minutes', pte.hour_budget_minutes, 'budget_sync_disabled', pte.budget_sync_disabled, 'manual_consumption_minutes', pte.manual_consumption_minutes, - 'teamspace_consumption_minutes', COALESCE(( - SELECT SUM(tdb.billed_minutes)::int - FROM ticket_day_billings tdb - WHERE tdb.ticket_id = p.ticket_id - AND ( - tdb.project_teamspace_entry_id = pte.id - OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) - ) - ), 0), - 'consumption_minutes', COALESCE(( - SELECT SUM(tdb.billed_minutes)::int - FROM ticket_day_billings tdb - WHERE tdb.ticket_id = p.ticket_id - AND ( - tdb.project_teamspace_entry_id = pte.id - OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) - ) - ), 0) + pte.manual_consumption_minutes, + 'teamspace_consumption_minutes', CASE + WHEN COALESCE(viewer_settings.teamspace_time_tracking_enabled, true) THEN COALESCE(( + SELECT SUM(tdb.billed_minutes)::int + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND tdb.user_id = $2 + AND ( + tdb.project_teamspace_entry_id = pte.id + OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + ), 0) + ELSE COALESCE(( + SELECT SUM( + CASE + WHEN pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX' + THEN COALESCE(pts.sum_minutes_billable, pts.sum_minutes, 0) + ELSE 0 + END + )::int + FROM project_teamspace_subentries pts + WHERE pts.project_teamspace_entry_id = pte.id + AND pts.user_id = $2 + ), 0) + END, + 'consumption_minutes', CASE + WHEN COALESCE(viewer_settings.teamspace_time_tracking_enabled, true) THEN COALESCE(( + SELECT SUM(tdb.billed_minutes)::int + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = p.ticket_id + AND tdb.user_id = $2 + AND ( + tdb.project_teamspace_entry_id = pte.id + OR (pte.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + ), 0) + pte.manual_consumption_minutes + ELSE COALESCE(( + SELECT SUM( + CASE + WHEN pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX' + THEN COALESCE(pts.sum_minutes_billable, pts.sum_minutes, 0) + ELSE 0 + END + )::int + FROM project_teamspace_subentries pts + WHERE pts.project_teamspace_entry_id = pte.id + AND pts.user_id = $2 + ), 0) + END, 'display_order', pte.display_order ) ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC @@ -11801,13 +13207,16 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { LEFT JOIN LATERAL ( SELECT COUNT(*)::int AS session_count FROM sessions s - WHERE s.ticket_id = p.ticket_id - AND s.user_id = $2 - AND s.work_type = 'consulting' - AND s.project_teamspace_entry_id IS NULL - ) unassigned_sessions ON true + WHERE s.ticket_id = p.ticket_id + AND s.user_id = $2 + AND s.work_type = 'consulting' + AND s.billing_status IN ('billable', 'billed') + AND s.project_teamspace_entry_id IS NULL + ) unassigned_sessions ON true WHERE p.id = $1 AND ( + p.user_id = $2 + OR t.user_id = $2 OR EXISTS ( SELECT 1 @@ -11836,8 +13245,238 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { res.json({ project: result.rows[0] }); }); +app.post("/api/projects/:projectId/teamspace-subentries/sync", requireUser, async (req, res) => { + const user = currentUser(req); + const projectId = parsePositiveInteger(req.params.projectId, "projectId"); + + const projectResult = await query<{ id: string; ticket_id: string }>( + ` + SELECT p.id::text AS id, p.ticket_id::text AS ticket_id + FROM projects p + JOIN tickets t ON t.id = p.ticket_id + WHERE p.id = $1 + AND ( + p.user_id = $2 + OR + t.user_id = $2 + OR EXISTS ( + SELECT 1 + FROM users viewer + WHERE viewer.id = $2 + AND viewer.role = 'user' + AND viewer.active + AND viewer.zammad_user_id = t.zammad_owner_id + ) + OR EXISTS ( + SELECT 1 + FROM sessions viewer_sessions + WHERE viewer_sessions.ticket_id = p.ticket_id + AND viewer_sessions.user_id = $2 + ) + ); + `, + [projectId, user.id] + ); + + if (projectResult.rowCount === 0) { + res.status(404).json({ error: "Projekt nicht gefunden" }); + return; + } + + const entriesResult = await query( + ` + SELECT + pte.project_id::text AS project_id, + pte.id::text AS entry_id, + btrim(pte.teamspace_project) AS teamspace_project, + pte.teamspace_api_project_id::text AS teamspace_api_project_id + FROM project_teamspace_entries pte + WHERE pte.project_id = $1 + AND pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> '' + AND pte.teamspace_api_project_id IS NOT NULL + ORDER BY pte.teamspace_api_project_id ASC; + `, + [projectId] + ); + + if (entriesResult.rows.length === 0) { + res.status(409).json({ error: "Für dieses Projekt sind noch keine Teamspace-API-IDs verknüpft. Bitte zuerst den Teamspace-ID-Sync ausführen." }); + return; + } + + const credentials = await getTeamspaceCredentialsForUser(user.id); + const mainApiIds = entriesResult.rows.map((entry) => Number(entry.teamspace_api_project_id)).filter((id) => Number.isInteger(id) && id > 0); + const subprojects = await loadTeamspaceRemoteSubprojectsSingleCall( + credentials.baseUrl, + credentials.deviceId, + credentials.devicePassword, + mainApiIds + ); + const syncResult = await withTransaction((client) => syncTeamspaceSubentriesForEntries( + client, + user.id, + entriesResult.rows, + subprojects.projects, + credentials.baseUrl + )); + const result: TeamspaceProjectSubentrySyncResult = { + projectId: String(projectId), + checkedEntries: entriesResult.rows.length, + remoteProjectsScanned: subprojects.scanned, + subentriesSynced: syncResult.synced, + subentriesRemoved: syncResult.removed + }; + + res.json({ result }); +}); + +app.post("/api/teamspace/subentries/sync", requireUser, async (req, res) => { + const user = currentUser(req); + + const entriesResult = await query( + ` + SELECT + p.id::text AS project_id, + pte.id::text AS entry_id, + btrim(pte.teamspace_project) AS teamspace_project, + pte.teamspace_api_project_id::text AS teamspace_api_project_id + FROM projects p + JOIN tickets t ON t.id = p.ticket_id + JOIN project_teamspace_entries pte ON pte.project_id = p.id + WHERE pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> '' + AND pte.teamspace_api_project_id IS NOT NULL + AND ( + p.user_id = $1 + OR + t.user_id = $1 + OR EXISTS ( + SELECT 1 + FROM users viewer + WHERE viewer.id = $1 + AND viewer.role = 'user' + AND viewer.active + AND viewer.zammad_user_id = t.zammad_owner_id + ) + OR EXISTS ( + SELECT 1 + FROM sessions viewer_sessions + WHERE viewer_sessions.ticket_id = p.ticket_id + AND viewer_sessions.user_id = $1 + ) + ) + ORDER BY p.id ASC, pte.teamspace_api_project_id ASC; + `, + [user.id] + ); + + if (entriesResult.rows.length === 0) { + res.status(409).json({ error: "Keine Teamspace-API-IDs für deine Projekte gefunden. Bitte zuerst den Teamspace-ID-Sync ausführen." }); + return; + } + + const credentials = await getTeamspaceCredentialsForUser(user.id); + const mainApiIds = entriesResult.rows.map((entry) => Number(entry.teamspace_api_project_id)).filter((id) => Number.isInteger(id) && id > 0); + const subprojects = await loadTeamspaceRemoteSubprojectsSingleCall( + credentials.baseUrl, + credentials.deviceId, + credentials.devicePassword, + mainApiIds + ); + const syncResult = await withTransaction((client) => syncTeamspaceSubentriesForEntries( + client, + user.id, + entriesResult.rows, + subprojects.projects, + credentials.baseUrl + )); + + const result: TeamspaceAllSubentrySyncResult = { + checkedProjects: new Set(entriesResult.rows.map((entry) => entry.project_id)).size, + checkedEntries: entriesResult.rows.length, + remoteProjectsScanned: subprojects.scanned, + subentriesSynced: syncResult.synced, + subentriesRemoved: syncResult.removed + }; + + res.json({ result }); +}); + +app.patch("/api/project-teamspace-subentries/:subentryId", requireUser, async (req, res) => { + const userId = currentUser(req).id; + const subentryId = parsePositiveInteger(req.params.subentryId, "subentryId"); + const alias = optionalString(req.body.alias) ?? ""; + + const result = await withTransaction(async (client) => { + const accessResult = await client.query<{ ticket_id: string; user_id: string | null }>( + ` + SELECT p.ticket_id::text AS ticket_id, pts.user_id::text AS user_id + FROM project_teamspace_subentries pts + JOIN project_teamspace_entries pte ON pte.id = pts.project_teamspace_entry_id + JOIN projects p ON p.id = pte.project_id + WHERE pts.id = $1; + `, + [subentryId] + ); + + const ticketId = accessResult.rows[0]?.ticket_id; + + if (!ticketId) { + return null; + } + + if (accessResult.rows[0].user_id !== userId) { + return "forbidden" as const; + } + + const updated = await client.query( + ` + UPDATE project_teamspace_subentries + SET alias = $2, + updated_at = now() + WHERE id = $1 + RETURNING + id::text AS id, + project_teamspace_entry_id::text AS project_teamspace_entry_id, + teamspace_api_project_id::text AS teamspace_api_project_id, + teamspace_api_project_url, + teamspace_project_id, + billing_type, + name, + alias, + description, + progress, + planned_minutes, + sum_minutes, + planned_minutes_billable, + sum_minutes_billable, + planned_income, + sum_income, + display_order; + `, + [subentryId, alias] + ); + + return updated.rows[0] ?? null; + }); + + if (result === "forbidden") { + res.status(403).json({ error: "Nur der Hauptuser kann Teamspace-Unterpositionen bearbeiten" }); + return; + } + + if (!result) { + res.status(404).json({ error: "Teamspace-Unterposition nicht gefunden" }); + return; + } + + res.json({ subentry: result }); +}); + app.post("/api/projects", requireUser, async (req, res) => { - const userId = currentUser(req).id; + const user = currentUser(req); + const userId = user.id; const ticketNumber = parseTicketNumber(req.body.ticketNumber); const useZammadLookup = optionalBoolean(req.body.zammadLookup, false); const title = optionalNullableString(req.body.title); @@ -11850,6 +13489,11 @@ app.post("/api/projects", requireUser, async (req, res) => { const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes"); const manualConsumptionMinutes = parseOptionalNonNegativeInteger(req.body.manualConsumptionMinutes, "manualConsumptionMinutes") ?? 0; let teamspaceEntries = parseProjectTeamspaceEntries(req.body.teamspaceEntries, { teamspaceProject, hourBudgetMinutes, manualConsumptionMinutes }); + teamspaceEntries = normalizeTeamspaceEntryManualFields(teamspaceEntries).map((entry) => ({ + ...entry, + hourBudgetMinutes: null, + manualConsumptionMinutes: user.teamspace_time_tracking_enabled ? entry.manualConsumptionMinutes : 0 + })); let defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; if (startsOn && endsOn && endsOn < startsOn) { @@ -11873,9 +13517,6 @@ app.post("/api/projects", requireUser, async (req, res) => { if (zammadTicket) { const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket); - const currentDefaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; - const defaultBudgetManuallyChanged = currentDefaultTeamspaceEntry.hourBudgetMinutes !== null - && budgetMinutesChanged(currentDefaultTeamspaceEntry.hourBudgetMinutes, zammadTicket.teamspaceBudgetMinutes); teamspaceEntries = zammadTeamspaceProjects.length > 0 ? zammadTeamspaceProjects.map((teamspaceProjectName, index) => ({ @@ -11883,9 +13524,9 @@ app.post("/api/projects", requireUser, async (req, res) => { teamspaceProject: teamspaceProjectName, teamspaceDescription: "", isDefault: index === 0, - hourBudgetMinutes: index === 0 ? currentDefaultTeamspaceEntry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes : null, - budgetSyncDisabled: index === 0 && (currentDefaultTeamspaceEntry.budgetSyncDisabled || defaultBudgetManuallyChanged), - manualConsumptionMinutes: index === 0 ? currentDefaultTeamspaceEntry.manualConsumptionMinutes : 0, + hourBudgetMinutes: null, + budgetSyncDisabled: false, + manualConsumptionMinutes: 0, displayOrder: index * 1024 })) : teamspaceEntries.map((entry) => @@ -11893,17 +13534,20 @@ app.post("/api/projects", requireUser, async (req, res) => { ? { ...entry, teamspaceProject: entry.teamspaceProject ?? zammadTicket.teamspaceProject, - hourBudgetMinutes: entry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes, - budgetSyncDisabled: entry.budgetSyncDisabled || (entry.hourBudgetMinutes !== null && budgetMinutesChanged(entry.hourBudgetMinutes, zammadTicket.teamspaceBudgetMinutes)) + budgetSyncDisabled: false } : entry ) - .map((entry) => !entry.isDefault && entry.hourBudgetMinutes !== null ? { ...entry, budgetSyncDisabled: true } : entry); - defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; - } else { - teamspaceEntries = protectNewManualBudgetEntries(teamspaceEntries); + .map((entry) => ({ ...entry, budgetSyncDisabled: false })); defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; } + teamspaceEntries = normalizeTeamspaceEntryManualFields(teamspaceEntries).map((entry) => ({ + ...entry, + hourBudgetMinutes: null, + manualConsumptionMinutes: user.teamspace_time_tracking_enabled ? entry.manualConsumptionMinutes : 0 + })); + defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; + const totalManualConsumptionMinutes = teamspaceEntries.reduce((sum, entry) => sum + entry.manualConsumptionMinutes, 0); try { const created = await withTransaction(async (client) => { @@ -12007,7 +13651,6 @@ app.post("/api/projects", requireUser, async (req, res) => { await ensureProjectForTicket(client, ticketId, projectOwnerUserId, { teamspaceProject: zammadTicket.teamspaceProject, teamspaceProjects: zammadTicket.teamspaceProjects, - hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes, closeReviewRequired: zammadTicket.zammadStateId === 4, zammadOwnerId: zammadTicket.zammadOwnerId }); @@ -12066,6 +13709,7 @@ app.post("/api/projects", requireUser, async (req, res) => { 0::int AS teamspace_consumption_minutes, manual_consumption_minutes AS consumption_minutes, 0::int AS unassigned_teamspace_session_count, + 0::int AS unassigned_teamspace_subentry_count, NULL::timestamptz AS closed_at, close_review_required, created_at, @@ -12080,7 +13724,7 @@ app.post("/api/projects", requireUser, async (req, res) => { startsOn, endsOn, defaultTeamspaceEntry.hourBudgetMinutes, - defaultTeamspaceEntry.manualConsumptionMinutes, + totalManualConsumptionMinutes, effectiveTicketNumber, organization.id, organization.name, @@ -12107,6 +13751,12 @@ app.post("/api/projects", requireUser, async (req, res) => { ); } await autoAssignSingleProjectTeamspaceEntry(client, project.ticket_id); + await refreshProjectBudgetFromTeamspaceEntries(client, project.id); + const refreshedBudget = await client.query<{ hour_budget_minutes: number | null }>( + "SELECT hour_budget_minutes FROM projects WHERE id = $1;", + [project.id] + ); + project.hour_budget_minutes = refreshedBudget.rows[0]?.hour_budget_minutes ?? null; project.teamspace_entries = teamspaceEntries.map((entry, index) => ({ id: `new-${index}`, @@ -12121,6 +13771,7 @@ app.post("/api/projects", requireUser, async (req, res) => { display_order: entry.displayOrder })); project.teamspace_entry_count = teamspaceEntries.length; + project.manual_consumption_minutes = teamspaceEntries.reduce((sum, entry) => sum + entry.manualConsumptionMinutes, 0); return project; }); @@ -12158,7 +13809,8 @@ app.post("/api/projects", requireUser, async (req, res) => { app.patch("/api/projects/:projectId", requireUser, async (req, res) => { const projectId = parsePositiveInteger(req.params.projectId, "projectId"); - const userId = currentUser(req).id; + const user = currentUser(req); + const userId = user.id; const ticketNumber = parseTicketNumber(req.body.ticketNumber); const title = optionalNullableString(req.body.title); const organizationId = parseOrganizationId(req.body.organizationId); @@ -12170,6 +13822,7 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes"); const manualConsumptionMinutes = parseOptionalNonNegativeInteger(req.body.manualConsumptionMinutes, "manualConsumptionMinutes") ?? 0; let teamspaceEntries = parseProjectTeamspaceEntries(req.body.teamspaceEntries, { teamspaceProject, hourBudgetMinutes, manualConsumptionMinutes }); + teamspaceEntries = normalizeTeamspaceEntryManualFields(teamspaceEntries); let defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; if (startsOn && endsOn && endsOn < startsOn) { @@ -12244,17 +13897,43 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { return "zammad-managed-ticket" as const; } - const currentTeamspaceEntries = await client.query<{ id: string; hour_budget_minutes: number | null }>( - ` - SELECT id::text AS id, hour_budget_minutes - FROM project_teamspace_entries - WHERE project_id = $1 - FOR UPDATE; + const currentTeamspaceEntries = await client.query<{ id: string; teamspace_project: string | null; hour_budget_minutes: number | null; manual_consumption_minutes: number; teamspace_api_project_id: string | null }>( + ` + SELECT id::text AS id, teamspace_project, hour_budget_minutes, manual_consumption_minutes, teamspace_api_project_id::text AS teamspace_api_project_id + FROM project_teamspace_entries + WHERE project_id = $1 + FOR UPDATE; `, [projectId] ); - teamspaceEntries = protectChangedManualBudgetEntries(teamspaceEntries, currentTeamspaceEntries.rows); + const currentEntryById = new Map(currentTeamspaceEntries.rows.map((entry) => [entry.id, entry])); + teamspaceEntries = teamspaceEntries.map((entry) => { + const currentEntry = entry.id ? currentEntryById.get(String(entry.id)) : null; + const keepsTeamspaceProject = (currentEntry?.teamspace_project?.trim() ?? null) === (entry.teamspaceProject?.trim() ?? null); + + return currentEntry?.teamspace_api_project_id && keepsTeamspaceProject + ? { + ...entry, + hourBudgetMinutes: currentEntry.hour_budget_minutes, + budgetSyncDisabled: false, + manualConsumptionMinutes: user.teamspace_time_tracking_enabled ? entry.manualConsumptionMinutes : currentEntry.manual_consumption_minutes + } + : currentEntry?.teamspace_api_project_id + ? { + ...entry, + hourBudgetMinutes: null, + budgetSyncDisabled: false, + manualConsumptionMinutes: user.teamspace_time_tracking_enabled ? entry.manualConsumptionMinutes : currentEntry.manual_consumption_minutes + } + : { + ...entry, + hourBudgetMinutes: null, + budgetSyncDisabled: false, + manualConsumptionMinutes: user.teamspace_time_tracking_enabled ? entry.manualConsumptionMinutes : 0 + }; + }); defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; + const totalManualConsumptionMinutes = teamspaceEntries.reduce((sum, entry) => sum + entry.manualConsumptionMinutes, 0); const organization = await getOrganizationByZammadId(client, organizationId); @@ -12331,6 +14010,7 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { AND tdb.user_id = projects.user_id ) + manual_consumption_minutes)::int AS consumption_minutes, 0::int AS unassigned_teamspace_session_count, + 0::int AS unassigned_teamspace_subentry_count, closed_at, close_review_required, created_at, @@ -12343,7 +14023,7 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { startsOn, endsOn, defaultTeamspaceEntry.hourBudgetMinutes, - defaultTeamspaceEntry.manualConsumptionMinutes, + totalManualConsumptionMinutes, projectId, userId, ticketNumber, @@ -12370,6 +14050,8 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { budget_sync_disabled = $5, manual_consumption_minutes = $6, display_order = $7, + teamspace_api_project_id = CASE WHEN teamspace_project IS DISTINCT FROM $1 THEN NULL ELSE teamspace_api_project_id END, + teamspace_api_project_url = CASE WHEN teamspace_project IS DISTINCT FROM $1 THEN NULL ELSE teamspace_api_project_url END, updated_at = now() WHERE id = $8 AND project_id = $9 @@ -12415,6 +14097,12 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { [project.id, savedEntryIds] ); await autoAssignSingleProjectTeamspaceEntry(client, project.ticket_id); + await refreshProjectBudgetFromTeamspaceEntries(client, project.id); + const refreshedBudget = await client.query<{ hour_budget_minutes: number | null }>( + "SELECT hour_budget_minutes FROM projects WHERE id = $1;", + [project.id] + ); + project.hour_budget_minutes = refreshedBudget.rows[0]?.hour_budget_minutes ?? null; project.teamspace_entries = teamspaceEntries.map((entry, index) => ({ id: savedEntryIds[index] ?? `new-${index}`, @@ -12429,6 +14117,7 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { display_order: entry.displayOrder })); project.teamspace_entry_count = teamspaceEntries.length; + project.manual_consumption_minutes = totalManualConsumptionMinutes; return project; }); @@ -13099,11 +14788,18 @@ app.get("/api/periods/:periodType/:period/tickets/:ticketId", requireUser, async }); app.patch("/api/tickets/:ticketId/day-billings/:day", requireUser, async (req, res) => { - const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); - const day = parseDay(String(req.params.day)); - const billedMinutes = parseOptionalBilledMinutes(req.body.billedMinutes); - const projectTeamspaceEntryId = parseOptionalTeamspaceEntryId(req.body.projectTeamspaceEntryId); - const userId = currentUser(req).id; + const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); + const day = parseDay(String(req.params.day)); + const billedMinutes = parseOptionalBilledMinutes(req.body.billedMinutes); + const projectTeamspaceEntryId = parseOptionalTeamspaceEntryId(req.body.projectTeamspaceEntryId); + const user = currentUser(req); + const userId = user.id; + let effectiveProjectTeamspaceEntryId = projectTeamspaceEntryId; + + if (!user.teamspace_time_tracking_enabled) { + res.status(409).json({ error: "Teamspace-Zeiterfassung in TT ist in deinem Profil deaktiviert." }); + return; + } const monthClosureResult = await query<{ closed_at: string }>( ` @@ -13120,13 +14816,13 @@ app.patch("/api/tickets/:ticketId/day-billings/:day", requireUser, async (req, r return; } - if (projectTeamspaceEntryId !== null) { - const entryResult = await query<{ id: string }>( - ` - SELECT pte.id::text AS id - FROM project_teamspace_entries pte - JOIN projects p ON p.id = pte.project_id - WHERE pte.id = $1 + if (effectiveProjectTeamspaceEntryId !== null) { + const entryResult = await query<{ id: string }>( + ` + SELECT pte.id::text AS id + FROM project_teamspace_entries pte + JOIN projects p ON p.id = pte.project_id + WHERE pte.id = $1 AND p.ticket_id = $2 AND ( p.user_id = $3 @@ -13139,8 +14835,8 @@ app.patch("/api/tickets/:ticketId/day-billings/:day", requireUser, async (req, r ) LIMIT 1; `, - [projectTeamspaceEntryId, ticketId, userId] - ); + [effectiveProjectTeamspaceEntryId, ticketId, userId] + ); if (entryResult.rowCount === 0) { res.status(404).json({ error: "Teamspace-Eintrag nicht gefunden" }); @@ -13153,62 +14849,62 @@ app.patch("/api/tickets/:ticketId/day-billings/:day", requireUser, async (req, r SELECT id FROM sessions WHERE ticket_id = $1 - AND user_id = $2 - AND (started_at AT TIME ZONE 'Europe/Berlin')::date = $3::date - AND ( - $4::bigint IS NULL - OR project_teamspace_entry_id = $4::bigint - ) - LIMIT 1; - `, - [ticketId, userId, day.start, projectTeamspaceEntryId] - ); + AND user_id = $2 + AND (started_at AT TIME ZONE 'Europe/Berlin')::date = $3::date + AND ( + $4::bigint IS NULL + OR project_teamspace_entry_id = $4::bigint + ) + LIMIT 1; + `, + [ticketId, userId, day.start, effectiveProjectTeamspaceEntryId] + ); if (sessionResult.rowCount === 0) { res.status(404).json({ error: "No sessions found for this ticket and day" }); return; } - if (billedMinutes === null) { - await query( + if (billedMinutes === null) { + await query( ` DELETE FROM ticket_day_billings - WHERE ticket_id = $1 - AND user_id = $2 - AND day = $3::date - AND ( - ($4::bigint IS NULL AND project_teamspace_entry_id IS NULL) - OR project_teamspace_entry_id = $4::bigint - ); - `, - [ticketId, userId, day.start, projectTeamspaceEntryId] - ); + WHERE ticket_id = $1 + AND user_id = $2 + AND day = $3::date + AND ( + ($4::bigint IS NULL AND project_teamspace_entry_id IS NULL) + OR project_teamspace_entry_id = $4::bigint + ); + `, + [ticketId, userId, day.start, effectiveProjectTeamspaceEntryId] + ); - res.json({ dayBilling: null }); - return; - } + res.json({ dayBilling: null }); + return; + } - const result = projectTeamspaceEntryId === null - ? await query( - ` - INSERT INTO ticket_day_billings (ticket_id, user_id, project_teamspace_entry_id, day, billed_minutes) - VALUES ($1, $2, NULL, $3::date, $4) - ON CONFLICT (ticket_id, user_id, day) WHERE project_teamspace_entry_id IS NULL - DO UPDATE SET billed_minutes = EXCLUDED.billed_minutes, updated_at = now() - RETURNING day::text AS day, project_teamspace_entry_id::text AS project_teamspace_entry_id, billed_minutes; - `, - [ticketId, userId, day.start, billedMinutes] - ) - : await query( - ` - INSERT INTO ticket_day_billings (ticket_id, user_id, project_teamspace_entry_id, day, billed_minutes) - VALUES ($1, $2, $3, $4::date, $5) - ON CONFLICT (ticket_id, user_id, day, project_teamspace_entry_id) WHERE project_teamspace_entry_id IS NOT NULL - DO UPDATE SET billed_minutes = EXCLUDED.billed_minutes, updated_at = now() - RETURNING day::text AS day, project_teamspace_entry_id::text AS project_teamspace_entry_id, billed_minutes; - `, - [ticketId, userId, projectTeamspaceEntryId, day.start, billedMinutes] - ); + const result = effectiveProjectTeamspaceEntryId === null + ? await query( + ` + INSERT INTO ticket_day_billings (ticket_id, user_id, project_teamspace_entry_id, day, billed_minutes) + VALUES ($1, $2, NULL, $3::date, $4) + ON CONFLICT (ticket_id, user_id, day) WHERE project_teamspace_entry_id IS NULL + DO UPDATE SET billed_minutes = EXCLUDED.billed_minutes, updated_at = now() + RETURNING day::text AS day, project_teamspace_entry_id::text AS project_teamspace_entry_id, billed_minutes; + `, + [ticketId, userId, day.start, billedMinutes] + ) + : await query( + ` + INSERT INTO ticket_day_billings (ticket_id, user_id, project_teamspace_entry_id, day, billed_minutes) + VALUES ($1, $2, $3, $4::date, $5) + ON CONFLICT (ticket_id, user_id, day, project_teamspace_entry_id) WHERE project_teamspace_entry_id IS NOT NULL + DO UPDATE SET billed_minutes = EXCLUDED.billed_minutes, updated_at = now() + RETURNING day::text AS day, project_teamspace_entry_id::text AS project_teamspace_entry_id, billed_minutes; + `, + [ticketId, userId, effectiveProjectTeamspaceEntryId, day.start, billedMinutes] + ); await query( ` @@ -13310,7 +15006,9 @@ app.post("/api/periods/:periodType/:period/close", requireUser, async (req, res) WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz AND s.user_id = $3 GROUP BY t.id, t.ticket_number - HAVING COUNT(*) FILTER (WHERE s.billing_status IS NULL OR s.billing_status = 'billable') > 0 + HAVING COUNT(*) FILTER ( + WHERE (s.billing_status IS NULL OR s.billing_status = 'billable') + ) > 0 ORDER BY t.ticket_number ASC; `, [period.startIso, period.endIso, userId] @@ -13374,9 +15072,9 @@ app.patch("/api/sessions/:sessionId/billing", requireUser, async (req, res) => { s.ticket_id, date_trunc('month', s.started_at)::date AS month, date_trunc('day', s.started_at)::date AS day, - mc.closed_at AS month_closed_at - FROM sessions s - LEFT JOIN month_closures mc + mc.closed_at AS month_closed_at + FROM sessions s + 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 WHERE s.id = $1 @@ -13395,7 +15093,7 @@ app.patch("/api/sessions/:sessionId/billing", requireUser, async (req, res) => { return; } - const result = await withTransaction(async (client) => { + const result = await withTransaction(async (client) => { const updated = await client.query( ` UPDATE sessions @@ -13524,13 +15222,14 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { await ensureProjectForTicket(client, current.ticket_id, ownerUserId); } - projectTeamspaceEntryId = await resolveProjectTeamspaceEntryForSession( + const teamspaceAssignment = await resolveProjectTeamspaceAssignmentForSession( client, current.ticket_id, userId, effectiveWorkType, requestedProjectTeamspaceEntryId ); + projectTeamspaceEntryId = teamspaceAssignment.projectTeamspaceEntryId; } await reopenPeriodsForSession(client, current.ticket_id, new Date(current.started_at), userId); diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index 33789f4..150aa2e 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -55,6 +55,33 @@ async function encryptStoredApiSecrets() { } } + const teamspaceCredentialResult = await query<{ id: string; teamspace_device_id: string | null; teamspace_device_password: string | null }>(` + SELECT id::text AS id, teamspace_device_id, teamspace_device_password + FROM users + WHERE teamspace_device_id IS NOT NULL + OR teamspace_device_password IS NOT NULL; + `); + + for (const row of teamspaceCredentialResult.rows) { + const updates: string[] = []; + const values: unknown[] = []; + + if (row.teamspace_device_id && !isEncryptedSecret(row.teamspace_device_id)) { + values.push(encryptSecret(row.teamspace_device_id)); + updates.push(`teamspace_device_id = $${values.length}`); + } + + if (row.teamspace_device_password && !isEncryptedSecret(row.teamspace_device_password)) { + values.push(encryptSecret(row.teamspace_device_password)); + updates.push(`teamspace_device_password = $${values.length}`); + } + + if (updates.length > 0) { + values.push(row.id); + await query(`UPDATE users SET ${updates.join(", ")}, updated_at = now() WHERE id = $${values.length};`, values); + } + } + const zammadApiKeyResult = await query<{ value: string }>("SELECT value FROM app_settings WHERE key = $1;", [zammadApiKeySettingKey]); const zammadApiKey = zammadApiKeyResult.rows[0]?.value ?? null; @@ -86,8 +113,12 @@ export async function migrate() { `); 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("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_device_id TEXT;"); + await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_device_password TEXT;"); + await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_user_id BIGINT;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_email_session_webhook_enabled BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS session_billing_prompt_enabled BOOLEAN NOT NULL DEFAULT true;"); + await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_time_tracking_enabled BOOLEAN NOT NULL DEFAULT true;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_session_note_default_enabled BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_enabled BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_minutes INTEGER NOT NULL DEFAULT 15;"); @@ -98,6 +129,7 @@ export async function migrate() { await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_second_enabled BOOLEAN NOT NULL DEFAULT true;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_second_weeks INTEGER NOT NULL DEFAULT 2;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_complete_on_session BOOLEAN NOT NULL DEFAULT true;"); + await query("ALTER TABLE users DROP COLUMN IF EXISTS teamspace_subproject_assignment_enabled;"); await query("UPDATE users SET zammad_article_type_id = 6 WHERE zammad_article_type_id IS NULL;"); await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET DEFAULT 6;"); await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET NOT NULL;"); @@ -717,10 +749,11 @@ export async function migrate() { CONSTRAINT ticket_day_billings_minutes_non_negative CHECK (billed_minutes >= 0) ); `); - await query("ALTER TABLE ticket_day_billings ADD COLUMN IF NOT EXISTS project_teamspace_entry_id BIGINT;"); - await query("ALTER TABLE ticket_day_billings DROP CONSTRAINT IF EXISTS ticket_day_billings_pkey;"); - await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_ticket_day_billings_ticket_user_day_default ON ticket_day_billings(ticket_id, user_id, day) WHERE project_teamspace_entry_id IS NULL;"); - await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_ticket_day_billings_ticket_user_day_entry ON ticket_day_billings(ticket_id, user_id, day, project_teamspace_entry_id) WHERE project_teamspace_entry_id IS NOT NULL;"); + await query("ALTER TABLE ticket_day_billings ADD COLUMN IF NOT EXISTS project_teamspace_entry_id BIGINT;"); + await query("ALTER TABLE ticket_day_billings DROP CONSTRAINT IF EXISTS ticket_day_billings_pkey;"); + await query("DROP INDEX IF EXISTS idx_ticket_day_billings_ticket_user_day_default;"); + await query("DROP INDEX IF EXISTS idx_ticket_day_billings_ticket_user_day_entry;"); + await query("DROP INDEX IF EXISTS idx_ticket_day_billings_ticket_user_day_subentry;"); await query(` CREATE TABLE IF NOT EXISTS ticket_day_billing_acknowledgements ( @@ -829,8 +862,84 @@ export async function migrate() { await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS teamspace_description TEXT NOT NULL DEFAULT '';"); await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS budget_sync_disabled BOOLEAN NOT NULL DEFAULT false;"); + await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS teamspace_api_project_id BIGINT;"); + await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS teamspace_api_project_url TEXT;"); 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("CREATE INDEX IF NOT EXISTS idx_project_teamspace_entries_ts_api_id ON project_teamspace_entries(teamspace_api_project_id) WHERE teamspace_api_project_id IS NOT NULL;"); + await query(` + CREATE TABLE IF NOT EXISTS project_teamspace_subentries ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT REFERENCES users(id) ON DELETE CASCADE, + project_teamspace_entry_id BIGINT NOT NULL REFERENCES project_teamspace_entries(id) ON DELETE CASCADE, + teamspace_api_project_id BIGINT NOT NULL, + teamspace_api_project_url TEXT, + teamspace_project_id TEXT, + billing_type TEXT, + name TEXT, + alias TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + progress INTEGER, + planned_minutes INTEGER, + sum_minutes INTEGER, + planned_minutes_billable INTEGER, + sum_minutes_billable INTEGER, + planned_income NUMERIC, + sum_income NUMERIC, + display_order INTEGER NOT NULL DEFAULT 0, + synced_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); + await query("ALTER TABLE project_teamspace_subentries ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id) ON DELETE CASCADE;"); + await query(` + UPDATE project_teamspace_subentries pts + SET user_id = p.user_id + FROM project_teamspace_entries pte + JOIN projects p ON p.id = pte.project_id + WHERE pte.id = pts.project_teamspace_entry_id + AND pts.user_id IS NULL + AND p.user_id IS NOT NULL; + `); + await query(` + DO $$ + DECLARE + constraint_name text; + BEGIN + SELECT c.conname + INTO constraint_name + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + WHERE n.nspname = current_schema() + AND t.relname = 'project_teamspace_subentries' + AND c.contype = 'u' + AND pg_get_constraintdef(c.oid) LIKE '%project_teamspace_entry_id%' + AND pg_get_constraintdef(c.oid) LIKE '%teamspace_api_project_id%' + AND pg_get_constraintdef(c.oid) NOT LIKE '%user_id%' + LIMIT 1; + + IF constraint_name IS NOT NULL THEN + EXECUTE format('ALTER TABLE project_teamspace_subentries DROP CONSTRAINT %I', constraint_name); + END IF; + END $$; + `); + await query("ALTER TABLE project_teamspace_subentries ADD COLUMN IF NOT EXISTS alias TEXT NOT NULL DEFAULT '';"); + await query("ALTER TABLE project_teamspace_subentries ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT '';"); + await query("ALTER TABLE project_teamspace_subentries ADD COLUMN IF NOT EXISTS billing_type TEXT;"); + await query("ALTER TABLE project_teamspace_subentries ADD COLUMN IF NOT EXISTS planned_income NUMERIC;"); + await query("ALTER TABLE project_teamspace_subentries ADD COLUMN IF NOT EXISTS sum_income NUMERIC;"); + await query("ALTER TABLE project_teamspace_subentries DROP CONSTRAINT IF EXISTS project_teamspace_subentries_hour_budget_nonnegative;"); + await query("ALTER TABLE project_teamspace_subentries DROP CONSTRAINT IF EXISTS project_teamspace_subentries_manual_consumption_nonnegative;"); + await query("ALTER TABLE project_teamspace_subentries DROP CONSTRAINT IF EXISTS project_teamspace_subentries_manual_unit_quantity_nonnegative;"); + await query("ALTER TABLE project_teamspace_subentries DROP COLUMN IF EXISTS hour_budget_minutes;"); + await query("ALTER TABLE project_teamspace_subentries DROP COLUMN IF EXISTS manual_consumption_minutes;"); + await query("ALTER TABLE project_teamspace_subentries DROP COLUMN IF EXISTS manual_unit_quantity;"); + await query("ALTER TABLE project_teamspace_subentries DROP COLUMN IF EXISTS is_fulfilled;"); + await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_subentries_entry_order ON project_teamspace_subentries(project_teamspace_entry_id, display_order, id);"); + await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_subentries_user_entry_order ON project_teamspace_subentries(user_id, project_teamspace_entry_id, display_order, id);"); + await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_teamspace_subentries_user_entry_api ON project_teamspace_subentries(user_id, project_teamspace_entry_id, teamspace_api_project_id);"); await query(` DO $$ BEGIN @@ -843,15 +952,18 @@ export async function migrate() { DROP CONSTRAINT ticket_day_billings_project_teamspace_entry_fkey; END IF; - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'ticket_day_billings_project_teamspace_entry_fkey' - ) THEN - ALTER TABLE ticket_day_billings - ADD CONSTRAINT ticket_day_billings_project_teamspace_entry_fkey - FOREIGN KEY (project_teamspace_entry_id) REFERENCES project_teamspace_entries(id) ON DELETE SET NULL; - END IF; - END $$; - `); + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'ticket_day_billings_project_teamspace_entry_fkey' + ) THEN + ALTER TABLE ticket_day_billings + ADD CONSTRAINT ticket_day_billings_project_teamspace_entry_fkey + FOREIGN KEY (project_teamspace_entry_id) REFERENCES project_teamspace_entries(id) ON DELETE SET NULL; + END IF; + + ALTER TABLE ticket_day_billings + DROP CONSTRAINT IF EXISTS ticket_day_billings_project_teamspace_subentry_fkey; + END $$; + `); await query(` CREATE TABLE IF NOT EXISTS hidden_user_projects ( user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -873,6 +985,78 @@ export async function migrate() { END IF; END $$; `); + await query("ALTER TABLE sessions DROP CONSTRAINT IF EXISTS sessions_project_teamspace_subentry_fkey;"); + await query(` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'sessions' + AND column_name = 'project_teamspace_subentry_id' + ) THEN + UPDATE sessions s + SET project_teamspace_entry_id = COALESCE(s.project_teamspace_entry_id, pts.project_teamspace_entry_id) + FROM project_teamspace_subentries pts + WHERE s.project_teamspace_subentry_id = pts.id; + END IF; + END $$; + `); + await query(` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'ticket_day_billings' + AND column_name = 'project_teamspace_subentry_id' + ) THEN + CREATE TEMP TABLE migrated_subentry_day_billings ON COMMIT DROP AS + SELECT + tdb.ticket_id, + tdb.user_id, + tdb.day, + pts.project_teamspace_entry_id, + SUM(tdb.billed_minutes)::int AS billed_minutes + FROM ticket_day_billings tdb + JOIN project_teamspace_subentries pts ON pts.id = tdb.project_teamspace_subentry_id + GROUP BY tdb.ticket_id, tdb.user_id, tdb.day, pts.project_teamspace_entry_id; + + DELETE FROM ticket_day_billings tdb + WHERE tdb.project_teamspace_subentry_id IS NOT NULL; + + UPDATE ticket_day_billings target + SET billed_minutes = target.billed_minutes + source.billed_minutes, + updated_at = now() + FROM migrated_subentry_day_billings source + WHERE target.ticket_id = source.ticket_id + AND target.user_id = source.user_id + AND target.day = source.day + AND target.project_teamspace_entry_id = source.project_teamspace_entry_id; + + INSERT INTO ticket_day_billings (ticket_id, user_id, project_teamspace_entry_id, day, billed_minutes) + SELECT source.ticket_id, source.user_id, source.project_teamspace_entry_id, source.day, source.billed_minutes + FROM migrated_subentry_day_billings source + WHERE NOT EXISTS ( + SELECT 1 + FROM ticket_day_billings target + WHERE target.ticket_id = source.ticket_id + AND target.user_id = source.user_id + AND target.day = source.day + AND target.project_teamspace_entry_id = source.project_teamspace_entry_id + ); + END IF; + END $$; + `); + await query("DROP INDEX IF EXISTS idx_sessions_project_teamspace_subentry;"); + await query("DROP INDEX IF EXISTS idx_ticket_day_billings_teamspace_subentry;"); + await query("DROP INDEX IF EXISTS idx_ticket_day_billings_ticket_user_day_subentry;"); + await query("ALTER TABLE sessions DROP COLUMN IF EXISTS project_teamspace_subentry_id;"); + await query("ALTER TABLE ticket_day_billings DROP COLUMN IF EXISTS project_teamspace_subentry_id;"); + await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_ticket_day_billings_ticket_user_day_default ON ticket_day_billings(ticket_id, user_id, day) WHERE project_teamspace_entry_id IS NULL;"); + await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_ticket_day_billings_ticket_user_day_entry ON ticket_day_billings(ticket_id, user_id, day, project_teamspace_entry_id) WHERE project_teamspace_entry_id IS NOT NULL;"); await query(` UPDATE tickets t @@ -1025,8 +1209,8 @@ export async function migrate() { await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_recurring_occurrence_key ON sessions(recurring_occurrence_key) WHERE recurring_occurrence_key IS NOT NULL;"); await query("CREATE INDEX IF NOT EXISTS idx_sessions_billing_status ON sessions(billing_status);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_closures_day ON ticket_day_closures(day);"); - await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_user_day ON ticket_day_billings(user_id, day);"); - await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_teamspace_entry ON ticket_day_billings(project_teamspace_entry_id);"); + await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_user_day ON ticket_day_billings(user_id, day);"); + await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_teamspace_entry ON ticket_day_billings(project_teamspace_entry_id);"); 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);"); diff --git a/bla.json b/bla.json new file mode 100644 index 0000000..05daf04 --- /dev/null +++ b/bla.json @@ -0,0 +1 @@ +{"_url":"https://app1.teamspace.de/api/project;limit=1000;depth=1;sort=projectId?parent=18260325&isActive=true&isTemplate=false","_links":[{"caption":"Self","href":"https://app1.teamspace.de/api/project;limit=1000;depth=1;sort=projectId?parent=18260325&isActive=true&isTemplate=false","rel":"self"},{"caption":"|< firstPage","href":"https://app1.teamspace.de/api/project;offset=0;limit=1000;sort=projectId?parent=18260325&isActive=true&isTemplate=false","rel":"first","title":"First page of projects of this CollectionResource"},{"caption":">| lastPage","href":"https://app1.teamspace.de/api/project;offset=0;limit=1000;sort=projectId?parent=18260325&isActive=true&isTemplate=false","rel":"last","title":"Last page of projects of this CollectionResource"}],"_etag":{"value":"ee49b390d3cca9c74112cb0d0dc361be","weak":false},"size":13,"offset":0,"limit":1000,"items":[{"caption":"Time Control Installation Software","title":"referenced resource","href":"https://app1.teamspace.de/api/project/18260341","lastModifiedDate":"2026-09-30T12:01:22.000+00:00","value":18260341,"idKey":"22","data":{"_url":"https://app1.teamspace.de/api/project/18260341","_id":18260341,"_idKey":"22","_links":[{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}],"_lastModifiedDate":"2026-09-30T12:01:22.000+00:00","name":"Time Control Installation Software","mainprojectId":"EP-2025-Comp-009","mainProject":{"caption":"Holzwerk Schilling GmbH & Co. KG / 49951 / Neukunde","title":"main project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22"},"projectId":"0001","path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Time Control Installation Software","description":"","isMainproject":false,"isWorkpackage":true,"isActive":true,"type":null,"projectFolder":null,"projectWorkflow":null,"priority":null,"state":{"caption":"Abgeschlossen","title":"projectState","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"projectState set to this project","optionsUrl":"https://app1.teamspace.de/api/projectstate?workflow=13338231"},"stateRelevant":{"caption":"Abgeschlossen","title":"projectStateRelevant","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"effective projectState of this project"},"progress":100,"progressRelevant":100,"plannedMinutesEnabled":true,"sumMinutes":360,"plannedMinutesBillableEnabled":true,"sumMinutesBillable":180,"plannedMinutesPerformanceEnabled":true,"sumMinutesPerformance":0,"plannedWorkingCostsEnabled":true,"sumWorkingCosts":0.0,"plannedExpensesEnabled":true,"sumExpenses":0.0,"plannedIncomeEnabled":true,"plannedIncome":360.0,"plannedIncomeCalculated":360.0,"sumIncome":360.0,"customer":{"caption":"CompData Computer GmbH","title":"customer","href":"https://app1.teamspace.de/api/organization/13905417","value":13905417,"idKey":"78"},"customerContact":null,"contractor":{"caption":"AIKAVA GmbH","title":"contractor","href":"https://app1.teamspace.de/api/organization/13343077","value":13343077,"idKey":"78"},"contractorContact":null,"currency":{"title":"currency","href":"https://app1.teamspace.de/api/currency/13337775","value":13337775,"idKey":"105"},"colorlabel":null,"colorlabelRelevant":null,"contractPositions":[{"caption":"Contract position","title":"contractPosition","href":"https://app1.teamspace.de/api/contractposition/18260213","value":18260213,"idKey":"104"}],"exportId":null,"exportComment":null,"exportDate":null,"customfields":[],"article":null,"articleRelevant":null,"ticket":null,"endless":false,"parent":{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}},"path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Time Control Installation Software","rel":"link"},{"caption":"Time Control Installation Kommunikationssoftware","title":"referenced resource","href":"https://app1.teamspace.de/api/project/18260349","lastModifiedDate":"2026-09-30T12:01:22.000+00:00","value":18260349,"idKey":"22","data":{"_url":"https://app1.teamspace.de/api/project/18260349","_id":18260349,"_idKey":"22","_links":[{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}],"_lastModifiedDate":"2026-09-30T12:01:22.000+00:00","name":"Time Control Installation Kommunikationssoftware","mainprojectId":"EP-2025-Comp-009","mainProject":{"caption":"Holzwerk Schilling GmbH & Co. KG / 49951 / Neukunde","title":"main project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22"},"projectId":"0002","path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Time Control Installation Kommunikationssoftware","description":"Installation PCS Intus Com, KABA B-COMM oder datafox. Ausführung per Fernwartung","isMainproject":false,"isWorkpackage":true,"isActive":true,"type":null,"projectFolder":null,"projectWorkflow":null,"priority":null,"state":{"caption":"Abgeschlossen","title":"projectState","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"projectState set to this project","optionsUrl":"https://app1.teamspace.de/api/projectstate?workflow=13338231"},"stateRelevant":{"caption":"Abgeschlossen","title":"projectStateRelevant","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"effective projectState of this project"},"progress":100,"progressRelevant":100,"plannedMinutesEnabled":true,"sumMinutes":60,"plannedMinutesBillableEnabled":true,"sumMinutesBillable":60,"plannedMinutesPerformanceEnabled":true,"sumMinutesPerformance":0,"plannedWorkingCostsEnabled":true,"sumWorkingCosts":0.0,"plannedExpensesEnabled":true,"sumExpenses":0.0,"plannedIncomeEnabled":true,"plannedIncome":120.0,"plannedIncomeCalculated":120.0,"sumIncome":120.0,"customer":{"caption":"CompData Computer GmbH","title":"customer","href":"https://app1.teamspace.de/api/organization/13905417","value":13905417,"idKey":"78"},"customerContact":null,"contractor":{"caption":"AIKAVA GmbH","title":"contractor","href":"https://app1.teamspace.de/api/organization/13343077","value":13343077,"idKey":"78"},"contractorContact":null,"currency":{"title":"currency","href":"https://app1.teamspace.de/api/currency/13337775","value":13337775,"idKey":"105"},"colorlabel":null,"colorlabelRelevant":null,"contractPositions":[{"caption":"Contract position","title":"contractPosition","href":"https://app1.teamspace.de/api/contractposition/18260214","value":18260214,"idKey":"104"}],"exportId":null,"exportComment":null,"exportDate":null,"customfields":[],"article":null,"articleRelevant":null,"ticket":null,"endless":false,"parent":{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}},"path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Time Control Installation Kommunikationssoftware","rel":"link"},{"caption":"Time Control Inbetriebnahme Zeiterfassung-/Zutrittsgeräte","title":"referenced resource","href":"https://app1.teamspace.de/api/project/18260357","lastModifiedDate":"2026-09-30T12:01:22.000+00:00","value":18260357,"idKey":"22","data":{"_url":"https://app1.teamspace.de/api/project/18260357","_id":18260357,"_idKey":"22","_links":[{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}],"_lastModifiedDate":"2026-09-30T12:01:22.000+00:00","name":"Time Control Inbetriebnahme Zeiterfassung-/Zutrittsgeräte","mainprojectId":"EP-2025-Comp-009","mainProject":{"caption":"Holzwerk Schilling GmbH & Co. KG / 49951 / Neukunde","title":"main project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22"},"projectId":"0003","path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Time Control Inbetriebnahme Zeiterfassung-∕Zutrittsgeräte","description":"","isMainproject":false,"isWorkpackage":true,"isActive":true,"type":null,"projectFolder":null,"projectWorkflow":null,"priority":null,"state":{"caption":"Abgeschlossen","title":"projectState","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"projectState set to this project","optionsUrl":"https://app1.teamspace.de/api/projectstate?workflow=13338231"},"stateRelevant":{"caption":"Abgeschlossen","title":"projectStateRelevant","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"effective projectState of this project"},"progress":100,"progressRelevant":100,"plannedMinutesEnabled":true,"sumMinutes":90,"plannedMinutesBillableEnabled":true,"sumMinutesBillable":90,"plannedMinutesPerformanceEnabled":true,"sumMinutesPerformance":0,"plannedWorkingCostsEnabled":true,"sumWorkingCosts":0.0,"plannedExpensesEnabled":true,"sumExpenses":0.0,"plannedIncomeEnabled":true,"plannedIncome":180.0,"plannedIncomeCalculated":180.0,"sumIncome":180.0,"customer":{"caption":"CompData Computer GmbH","title":"customer","href":"https://app1.teamspace.de/api/organization/13905417","value":13905417,"idKey":"78"},"customerContact":null,"contractor":{"caption":"AIKAVA GmbH","title":"contractor","href":"https://app1.teamspace.de/api/organization/13343077","value":13343077,"idKey":"78"},"contractorContact":null,"currency":{"title":"currency","href":"https://app1.teamspace.de/api/currency/13337775","value":13337775,"idKey":"105"},"colorlabel":null,"colorlabelRelevant":null,"contractPositions":[{"caption":"Contract position","title":"contractPosition","href":"https://app1.teamspace.de/api/contractposition/18260216","value":18260216,"idKey":"104"}],"exportId":null,"exportComment":null,"exportDate":null,"customfields":[],"article":null,"articleRelevant":null,"ticket":null,"endless":false,"parent":{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}},"path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Time Control Inbetriebnahme Zeiterfassung-∕Zutrittsgeräte","rel":"link"},{"caption":"Senior Consultant / Datafox","title":"referenced resource","href":"https://app1.teamspace.de/api/project/18260365","lastModifiedDate":"2026-09-30T12:01:22.000+00:00","value":18260365,"idKey":"22","data":{"_url":"https://app1.teamspace.de/api/project/18260365","_id":18260365,"_idKey":"22","_links":[{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}],"_lastModifiedDate":"2026-09-30T12:01:22.000+00:00","name":"Senior Consultant / Datafox","mainprojectId":"EP-2025-Comp-009","mainProject":{"caption":"Holzwerk Schilling GmbH & Co. KG / 49951 / Neukunde","title":"main project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22"},"projectId":"0004","path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Senior Consultant ∕ Datafox","description":"Aufwand für die Inbetriebnahme der Datafox Terminals im Kundennetzwerk.","isMainproject":false,"isWorkpackage":true,"isActive":true,"type":null,"projectFolder":null,"projectWorkflow":null,"priority":null,"state":{"caption":"Abgeschlossen","title":"projectState","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"projectState set to this project","optionsUrl":"https://app1.teamspace.de/api/projectstate?workflow=13338231"},"stateRelevant":{"caption":"Abgeschlossen","title":"projectStateRelevant","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"effective projectState of this project"},"progress":100,"progressRelevant":100,"plannedMinutesEnabled":true,"plannedMinutes":30,"plannedMinutesCalculated":30,"sumMinutes":30,"plannedMinutesBillableEnabled":true,"plannedMinutesBillable":30,"plannedMinutesBillableCalculated":30,"sumMinutesBillable":30,"plannedMinutesPerformanceEnabled":true,"sumMinutesPerformance":0,"plannedWorkingCostsEnabled":true,"sumWorkingCosts":0.0,"plannedExpensesEnabled":true,"sumExpenses":0.0,"plannedIncomeEnabled":true,"plannedIncome":60.0,"plannedIncomeCalculated":60.0,"sumIncome":60.0,"customer":{"caption":"CompData Computer GmbH","title":"customer","href":"https://app1.teamspace.de/api/organization/13905417","value":13905417,"idKey":"78"},"customerContact":null,"contractor":{"caption":"AIKAVA GmbH","title":"contractor","href":"https://app1.teamspace.de/api/organization/13343077","value":13343077,"idKey":"78"},"contractorContact":null,"currency":{"title":"currency","href":"https://app1.teamspace.de/api/currency/13337775","value":13337775,"idKey":"105"},"colorlabel":null,"colorlabelRelevant":null,"contractPositions":[{"caption":"Contract position","title":"contractPosition","href":"https://app1.teamspace.de/api/contractposition/18260221","value":18260221,"idKey":"104"}],"exportId":null,"exportComment":null,"exportDate":null,"customfields":[],"article":null,"articleRelevant":null,"ticket":null,"endless":false,"parent":{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}},"path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Senior Consultant ∕ Datafox","rel":"link"},{"caption":"Senior Consultant / AZE Projektanforderung","title":"referenced resource","href":"https://app1.teamspace.de/api/project/18260374","lastModifiedDate":"2026-09-30T12:01:22.000+00:00","value":18260374,"idKey":"22","data":{"_url":"https://app1.teamspace.de/api/project/18260374","_id":18260374,"_idKey":"22","_links":[{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}],"_lastModifiedDate":"2026-09-30T12:01:22.000+00:00","name":"Senior Consultant / AZE Projektanforderung","mainprojectId":"EP-2025-Comp-009","mainProject":{"caption":"Holzwerk Schilling GmbH & Co. KG / 49951 / Neukunde","title":"main project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22"},"projectId":"0005","path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Senior Consultant ∕ AZE Projektanforderung","description":"Aufwand für Implementierung der AZE-Projektanforderungen und 
Unterstützung Projektteam Auftraggeber. Ausführung überwiegend 
per Fernwartung","isMainproject":false,"isWorkpackage":true,"isActive":true,"type":null,"projectFolder":null,"projectWorkflow":null,"priority":null,"state":{"caption":"Abgeschlossen","title":"projectState","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"projectState set to this project","optionsUrl":"https://app1.teamspace.de/api/projectstate?workflow=13338231"},"stateRelevant":{"caption":"Abgeschlossen","title":"projectStateRelevant","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"effective projectState of this project"},"progress":100,"progressRelevant":100,"plannedMinutesEnabled":true,"plannedMinutes":960,"plannedMinutesCalculated":960,"sumMinutes":960,"plannedMinutesBillableEnabled":true,"plannedMinutesBillable":960,"plannedMinutesBillableCalculated":960,"sumMinutesBillable":960,"plannedMinutesPerformanceEnabled":true,"sumMinutesPerformance":0,"plannedWorkingCostsEnabled":true,"sumWorkingCosts":362.5,"plannedExpensesEnabled":true,"sumExpenses":0.0,"plannedIncomeEnabled":true,"plannedIncome":1920.0,"plannedIncomeCalculated":1920.0,"sumIncome":1920.0,"customer":{"caption":"CompData Computer GmbH","title":"customer","href":"https://app1.teamspace.de/api/organization/13905417","value":13905417,"idKey":"78"},"customerContact":null,"contractor":{"caption":"AIKAVA GmbH","title":"contractor","href":"https://app1.teamspace.de/api/organization/13343077","value":13343077,"idKey":"78"},"contractorContact":null,"currency":{"title":"currency","href":"https://app1.teamspace.de/api/currency/13337775","value":13337775,"idKey":"105"},"colorlabel":null,"colorlabelRelevant":null,"contractPositions":[{"caption":"Contract position","title":"contractPosition","href":"https://app1.teamspace.de/api/contractposition/18260229","value":18260229,"idKey":"104"}],"exportId":null,"exportComment":null,"exportDate":null,"customfields":[],"article":null,"articleRelevant":null,"ticket":null,"endless":false,"parent":{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}},"path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Senior Consultant ∕ AZE Projektanforderung","rel":"link"},{"caption":"Senior Consultant / AZE Schulung","title":"referenced resource","href":"https://app1.teamspace.de/api/project/18260383","lastModifiedDate":"2026-09-30T12:01:22.000+00:00","value":18260383,"idKey":"22","data":{"_url":"https://app1.teamspace.de/api/project/18260383","_id":18260383,"_idKey":"22","_links":[{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}],"_lastModifiedDate":"2026-09-30T12:01:22.000+00:00","name":"Senior Consultant / AZE Schulung","mainprojectId":"EP-2025-Comp-009","mainProject":{"caption":"Holzwerk Schilling GmbH & Co. KG / 49951 / Neukunde","title":"main project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22"},"projectId":"0006","path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Senior Consultant ∕ AZE Schulung","description":"Aufwand für Implementierungsschulung Zeiterfassung (Key User)","isMainproject":false,"isWorkpackage":true,"isActive":true,"type":null,"projectFolder":null,"projectWorkflow":null,"priority":null,"state":null,"stateRelevant":{"caption":"In Arbeit","title":"projectStateRelevant","href":"https://app1.teamspace.de/api/projectstate/13338165","value":13338165,"idKey":"154","rel":"effective projectState of this project"},"progress":25,"progressRelevant":25,"plannedMinutesEnabled":true,"plannedMinutes":960,"plannedMinutesCalculated":960,"sumMinutes":900,"plannedMinutesBillableEnabled":true,"plannedMinutesBillable":960,"plannedMinutesBillableCalculated":960,"sumMinutesBillable":900,"plannedMinutesPerformanceEnabled":true,"sumMinutesPerformance":0,"plannedWorkingCostsEnabled":true,"sumWorkingCosts":175.0,"plannedExpensesEnabled":true,"sumExpenses":0.0,"plannedIncomeEnabled":true,"plannedIncome":1920.0,"plannedIncomeCalculated":1920.0,"sumIncome":1800.0,"customer":{"caption":"CompData Computer GmbH","title":"customer","href":"https://app1.teamspace.de/api/organization/13905417","value":13905417,"idKey":"78"},"customerContact":null,"contractor":{"caption":"AIKAVA GmbH","title":"contractor","href":"https://app1.teamspace.de/api/organization/13343077","value":13343077,"idKey":"78"},"contractorContact":null,"currency":{"title":"currency","href":"https://app1.teamspace.de/api/currency/13337775","value":13337775,"idKey":"105"},"colorlabel":null,"colorlabelRelevant":null,"contractPositions":[{"caption":"Contract position","title":"contractPosition","href":"https://app1.teamspace.de/api/contractposition/18260236","value":18260236,"idKey":"104"}],"exportId":null,"exportComment":null,"exportDate":null,"customfields":[],"article":null,"articleRelevant":null,"ticket":null,"endless":false,"parent":{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}},"path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Senior Consultant ∕ AZE Schulung","rel":"link"},{"caption":"Senior Consultant / PEP Anforderung","title":"referenced resource","href":"https://app1.teamspace.de/api/project/18260392","lastModifiedDate":"2026-09-30T12:01:22.000+00:00","value":18260392,"idKey":"22","data":{"_url":"https://app1.teamspace.de/api/project/18260392","_id":18260392,"_idKey":"22","_links":[{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}],"_lastModifiedDate":"2026-09-30T12:01:22.000+00:00","name":"Senior Consultant / PEP Anforderung","mainprojectId":"EP-2025-Comp-009","mainProject":{"caption":"Holzwerk Schilling GmbH & Co. KG / 49951 / Neukunde","title":"main project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22"},"projectId":"0007","path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Senior Consultant ∕ PEP Anforderung","description":"Aufwand für Implementierung der PEP-Projektanforderungen und Unterstützung Projektteam Auftraggeber.","isMainproject":false,"isWorkpackage":true,"isActive":true,"type":null,"projectFolder":null,"projectWorkflow":null,"priority":null,"state":{"caption":"Abgeschlossen","title":"projectState","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"projectState set to this project","optionsUrl":"https://app1.teamspace.de/api/projectstate?workflow=13338231"},"stateRelevant":{"caption":"Abgeschlossen","title":"projectStateRelevant","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"effective projectState of this project"},"progress":100,"progressRelevant":100,"plannedMinutesEnabled":true,"plannedMinutes":480,"plannedMinutesCalculated":480,"sumMinutes":480,"plannedMinutesBillableEnabled":true,"plannedMinutesBillable":480,"plannedMinutesBillableCalculated":480,"sumMinutesBillable":480,"plannedMinutesPerformanceEnabled":true,"sumMinutesPerformance":0,"plannedWorkingCostsEnabled":true,"sumWorkingCosts":0.0,"plannedExpensesEnabled":true,"sumExpenses":0.0,"plannedIncomeEnabled":true,"plannedIncome":960.0,"plannedIncomeCalculated":960.0,"sumIncome":960.0,"customer":{"caption":"CompData Computer GmbH","title":"customer","href":"https://app1.teamspace.de/api/organization/13905417","value":13905417,"idKey":"78"},"customerContact":null,"contractor":{"caption":"AIKAVA GmbH","title":"contractor","href":"https://app1.teamspace.de/api/organization/13343077","value":13343077,"idKey":"78"},"contractorContact":null,"currency":{"title":"currency","href":"https://app1.teamspace.de/api/currency/13337775","value":13337775,"idKey":"105"},"colorlabel":null,"colorlabelRelevant":null,"contractPositions":[{"caption":"Contract position","title":"contractPosition","href":"https://app1.teamspace.de/api/contractposition/18260238","value":18260238,"idKey":"104"}],"exportId":null,"exportComment":null,"exportDate":null,"customfields":[],"article":null,"articleRelevant":null,"ticket":null,"endless":false,"parent":{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}},"path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Senior Consultant ∕ PEP Anforderung","rel":"link"},{"caption":"Senior Consultant / PEP Schulung","title":"referenced resource","href":"https://app1.teamspace.de/api/project/18260401","lastModifiedDate":"2026-09-30T12:01:22.000+00:00","value":18260401,"idKey":"22","data":{"_url":"https://app1.teamspace.de/api/project/18260401","_id":18260401,"_idKey":"22","_links":[{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}],"_lastModifiedDate":"2026-09-30T12:01:22.000+00:00","name":"Senior Consultant / PEP Schulung","mainprojectId":"EP-2025-Comp-009","mainProject":{"caption":"Holzwerk Schilling GmbH & Co. KG / 49951 / Neukunde","title":"main project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22"},"projectId":"0008","path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Senior Consultant ∕ PEP Schulung","description":"Aufwand für Implementierungsschulung Personaleinsatzplanung (Key User)","isMainproject":false,"isWorkpackage":true,"isActive":true,"type":null,"projectFolder":null,"projectWorkflow":null,"priority":null,"state":null,"stateRelevant":{"caption":"In Arbeit","title":"projectStateRelevant","href":"https://app1.teamspace.de/api/projectstate/13338165","value":13338165,"idKey":"154","rel":"effective projectState of this project"},"progress":80,"progressRelevant":80,"plannedMinutesEnabled":true,"plannedMinutes":480,"plannedMinutesCalculated":480,"sumMinutes":330,"plannedMinutesBillableEnabled":true,"plannedMinutesBillable":480,"plannedMinutesBillableCalculated":480,"sumMinutesBillable":330,"plannedMinutesPerformanceEnabled":true,"sumMinutesPerformance":0,"plannedWorkingCostsEnabled":true,"sumWorkingCosts":0.0,"plannedExpensesEnabled":true,"sumExpenses":0.0,"plannedIncomeEnabled":true,"plannedIncome":960.0,"plannedIncomeCalculated":960.0,"sumIncome":660.0,"customer":{"caption":"CompData Computer GmbH","title":"customer","href":"https://app1.teamspace.de/api/organization/13905417","value":13905417,"idKey":"78"},"customerContact":null,"contractor":{"caption":"AIKAVA GmbH","title":"contractor","href":"https://app1.teamspace.de/api/organization/13343077","value":13343077,"idKey":"78"},"contractorContact":null,"currency":{"title":"currency","href":"https://app1.teamspace.de/api/currency/13337775","value":13337775,"idKey":"105"},"colorlabel":null,"colorlabelRelevant":null,"contractPositions":[{"caption":"Contract position","title":"contractPosition","href":"https://app1.teamspace.de/api/contractposition/18260242","value":18260242,"idKey":"104"}],"exportId":null,"exportComment":null,"exportDate":null,"customfields":[],"article":null,"articleRelevant":null,"ticket":null,"endless":false,"parent":{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}},"path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Senior Consultant ∕ PEP Schulung","rel":"link"},{"caption":"Senior Consultant / Workflow","title":"referenced resource","href":"https://app1.teamspace.de/api/project/18260410","lastModifiedDate":"2026-09-30T12:01:22.000+00:00","value":18260410,"idKey":"22","data":{"_url":"https://app1.teamspace.de/api/project/18260410","_id":18260410,"_idKey":"22","_links":[{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}],"_lastModifiedDate":"2026-09-30T12:01:22.000+00:00","name":"Senior Consultant / Workflow","mainprojectId":"EP-2025-Comp-009","mainProject":{"caption":"Holzwerk Schilling GmbH & Co. KG / 49951 / Neukunde","title":"main project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22"},"projectId":"0009","path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Senior Consultant ∕ Workflow","description":"Aufwand für Implementierung Ihrer Workflow-Projektanforderungen, 
Unterstützung Projektteam Auftraggeber und Kurzeinweisung in die 
Bedienung der Workflowoberfläche.","isMainproject":false,"isWorkpackage":true,"isActive":true,"type":null,"projectFolder":null,"projectWorkflow":null,"priority":null,"state":{"caption":"Abgeschlossen","title":"projectState","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"projectState set to this project","optionsUrl":"https://app1.teamspace.de/api/projectstate?workflow=13338231"},"stateRelevant":{"caption":"Abgeschlossen","title":"projectStateRelevant","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"effective projectState of this project"},"progress":100,"progressRelevant":100,"plannedMinutesEnabled":true,"plannedMinutes":360,"plannedMinutesCalculated":360,"sumMinutes":360,"plannedMinutesBillableEnabled":true,"plannedMinutesBillable":360,"plannedMinutesBillableCalculated":360,"sumMinutesBillable":360,"plannedMinutesPerformanceEnabled":true,"sumMinutesPerformance":0,"plannedWorkingCostsEnabled":true,"sumWorkingCosts":0.0,"plannedExpensesEnabled":true,"sumExpenses":0.0,"plannedIncomeEnabled":true,"plannedIncome":720.0,"plannedIncomeCalculated":720.0,"sumIncome":720.0,"customer":{"caption":"CompData Computer GmbH","title":"customer","href":"https://app1.teamspace.de/api/organization/13905417","value":13905417,"idKey":"78"},"customerContact":null,"contractor":{"caption":"AIKAVA GmbH","title":"contractor","href":"https://app1.teamspace.de/api/organization/13343077","value":13343077,"idKey":"78"},"contractorContact":null,"currency":{"title":"currency","href":"https://app1.teamspace.de/api/currency/13337775","value":13337775,"idKey":"105"},"colorlabel":null,"colorlabelRelevant":null,"contractPositions":[{"caption":"Contract position","title":"contractPosition","href":"https://app1.teamspace.de/api/contractposition/18260245","value":18260245,"idKey":"104"}],"exportId":null,"exportComment":null,"exportDate":null,"customfields":[],"article":null,"articleRelevant":null,"ticket":null,"endless":false,"parent":{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}},"path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Senior Consultant ∕ Workflow","rel":"link"},{"caption":"Time Control Anbindung Mailserver","title":"referenced resource","href":"https://app1.teamspace.de/api/project/18260419","lastModifiedDate":"2026-09-30T12:01:22.000+00:00","value":18260419,"idKey":"22","data":{"_url":"https://app1.teamspace.de/api/project/18260419","_id":18260419,"_idKey":"22","_links":[{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}],"_lastModifiedDate":"2026-09-30T12:01:22.000+00:00","name":"Time Control Anbindung Mailserver","mainprojectId":"EP-2025-Comp-009","mainProject":{"caption":"Holzwerk Schilling GmbH & Co. KG / 49951 / Neukunde","title":"main project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22"},"projectId":"0010","path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Time Control Anbindung Mailserver","description":"","isMainproject":false,"isWorkpackage":true,"isActive":true,"type":null,"projectFolder":null,"projectWorkflow":null,"priority":null,"state":{"caption":"Abgeschlossen","title":"projectState","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"projectState set to this project","optionsUrl":"https://app1.teamspace.de/api/projectstate?workflow=13338231"},"stateRelevant":{"caption":"Abgeschlossen","title":"projectStateRelevant","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"effective projectState of this project"},"progress":100,"progressRelevant":100,"plannedMinutesEnabled":true,"sumMinutes":60,"plannedMinutesBillableEnabled":true,"sumMinutesBillable":60,"plannedMinutesPerformanceEnabled":true,"sumMinutesPerformance":0,"plannedWorkingCostsEnabled":true,"sumWorkingCosts":0.0,"plannedExpensesEnabled":true,"sumExpenses":0.0,"plannedIncomeEnabled":true,"plannedIncome":120.0,"plannedIncomeCalculated":120.0,"sumIncome":120.0,"customer":{"caption":"CompData Computer GmbH","title":"customer","href":"https://app1.teamspace.de/api/organization/13905417","value":13905417,"idKey":"78"},"customerContact":null,"contractor":{"caption":"AIKAVA GmbH","title":"contractor","href":"https://app1.teamspace.de/api/organization/13343077","value":13343077,"idKey":"78"},"contractorContact":null,"currency":{"title":"currency","href":"https://app1.teamspace.de/api/currency/13337775","value":13337775,"idKey":"105"},"colorlabel":null,"colorlabelRelevant":null,"contractPositions":[{"caption":"Contract position","title":"contractPosition","href":"https://app1.teamspace.de/api/contractposition/18260255","value":18260255,"idKey":"104"}],"exportId":null,"exportComment":null,"exportDate":null,"customfields":[],"article":null,"articleRelevant":null,"ticket":null,"endless":false,"parent":{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}},"path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Time Control Anbindung Mailserver","rel":"link"},{"caption":"Senior Consultant / Lohnexport","title":"referenced resource","href":"https://app1.teamspace.de/api/project/18260427","lastModifiedDate":"2026-09-30T12:01:22.000+00:00","value":18260427,"idKey":"22","data":{"_url":"https://app1.teamspace.de/api/project/18260427","_id":18260427,"_idKey":"22","_links":[{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}],"_lastModifiedDate":"2026-09-30T12:01:22.000+00:00","name":"Senior Consultant / Lohnexport","mainprojectId":"EP-2025-Comp-009","mainProject":{"caption":"Holzwerk Schilling GmbH & Co. KG / 49951 / Neukunde","title":"main project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22"},"projectId":"0011","path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Senior Consultant ∕ Lohnexport","description":"Aufwand für Einrichtung Lohnschnittstelle, Parametrierung Lohnarten, 
Test Übergabe. Pro Mandant/Übergabe/Abrechnungskreis","isMainproject":false,"isWorkpackage":true,"isActive":true,"type":null,"projectFolder":null,"projectWorkflow":null,"priority":null,"state":{"caption":"Abgeschlossen","title":"projectState","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"projectState set to this project","optionsUrl":"https://app1.teamspace.de/api/projectstate?workflow=13338231"},"stateRelevant":{"caption":"Abgeschlossen","title":"projectStateRelevant","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"effective projectState of this project"},"progress":100,"progressRelevant":100,"plannedMinutesEnabled":true,"plannedMinutes":120,"plannedMinutesCalculated":120,"sumMinutes":120,"plannedMinutesBillableEnabled":true,"plannedMinutesBillable":120,"plannedMinutesBillableCalculated":120,"sumMinutesBillable":120,"plannedMinutesPerformanceEnabled":true,"sumMinutesPerformance":0,"plannedWorkingCostsEnabled":true,"sumWorkingCosts":50.0,"plannedExpensesEnabled":true,"sumExpenses":0.0,"plannedIncomeEnabled":true,"plannedIncome":240.0,"plannedIncomeCalculated":240.0,"sumIncome":240.0,"customer":{"caption":"CompData Computer GmbH","title":"customer","href":"https://app1.teamspace.de/api/organization/13905417","value":13905417,"idKey":"78"},"customerContact":null,"contractor":{"caption":"AIKAVA GmbH","title":"contractor","href":"https://app1.teamspace.de/api/organization/13343077","value":13343077,"idKey":"78"},"contractorContact":null,"currency":{"title":"currency","href":"https://app1.teamspace.de/api/currency/13337775","value":13337775,"idKey":"105"},"colorlabel":null,"colorlabelRelevant":null,"contractPositions":[{"caption":"Contract position","title":"contractPosition","href":"https://app1.teamspace.de/api/contractposition/18260260","value":18260260,"idKey":"104"}],"exportId":null,"exportComment":null,"exportDate":null,"customfields":[],"article":null,"articleRelevant":null,"ticket":null,"endless":false,"parent":{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}},"path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Senior Consultant ∕ Lohnexport","rel":"link"},{"caption":"Senior Consultant / Projektkoordination","title":"referenced resource","href":"https://app1.teamspace.de/api/project/18260436","lastModifiedDate":"2026-09-30T12:01:22.000+00:00","value":18260436,"idKey":"22","data":{"_url":"https://app1.teamspace.de/api/project/18260436","_id":18260436,"_idKey":"22","_links":[{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}],"_lastModifiedDate":"2026-09-30T12:01:22.000+00:00","name":"Senior Consultant / Projektkoordination","mainprojectId":"EP-2025-Comp-009","mainProject":{"caption":"Holzwerk Schilling GmbH & Co. KG / 49951 / Neukunde","title":"main project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22"},"projectId":"0012","path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Senior Consultant ∕ Projektkoordination","description":"Aufwand für die Projektkoordination, -kommunikation und -dokumentation.","isMainproject":false,"isWorkpackage":true,"isActive":true,"type":null,"projectFolder":null,"projectWorkflow":null,"priority":null,"state":{"caption":"Abgeschlossen","title":"projectState","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"projectState set to this project","optionsUrl":"https://app1.teamspace.de/api/projectstate?workflow=13338231"},"stateRelevant":{"caption":"Abgeschlossen","title":"projectStateRelevant","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"effective projectState of this project"},"progress":100,"progressRelevant":100,"plannedMinutesEnabled":true,"plannedMinutes":480,"plannedMinutesCalculated":480,"sumMinutes":480,"plannedMinutesBillableEnabled":true,"plannedMinutesBillable":480,"plannedMinutesBillableCalculated":480,"sumMinutesBillable":480,"plannedMinutesPerformanceEnabled":true,"sumMinutesPerformance":0,"plannedWorkingCostsEnabled":true,"sumWorkingCosts":100.0,"plannedExpensesEnabled":true,"sumExpenses":0.0,"plannedIncomeEnabled":true,"plannedIncome":960.0,"plannedIncomeCalculated":960.0,"sumIncome":960.0,"customer":{"caption":"CompData Computer GmbH","title":"customer","href":"https://app1.teamspace.de/api/organization/13905417","value":13905417,"idKey":"78"},"customerContact":null,"contractor":{"caption":"AIKAVA GmbH","title":"contractor","href":"https://app1.teamspace.de/api/organization/13343077","value":13343077,"idKey":"78"},"contractorContact":null,"currency":{"title":"currency","href":"https://app1.teamspace.de/api/currency/13337775","value":13337775,"idKey":"105"},"colorlabel":null,"colorlabelRelevant":null,"contractPositions":[{"caption":"Contract position","title":"contractPosition","href":"https://app1.teamspace.de/api/contractposition/18260267","value":18260267,"idKey":"104"}],"exportId":null,"exportComment":null,"exportDate":null,"customfields":[],"article":null,"articleRelevant":null,"ticket":null,"endless":false,"parent":{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}},"path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Senior Consultant ∕ Projektkoordination","rel":"link"},{"caption":"Senior Consultant APP Einrichtung","title":"referenced resource","href":"https://app1.teamspace.de/api/project/26795821","lastModifiedDate":"2026-09-30T12:01:22.000+00:00","value":26795821,"idKey":"22","data":{"_url":"https://app1.teamspace.de/api/project/26795821","_id":26795821,"_idKey":"22","_links":[{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}],"_lastModifiedDate":"2026-09-30T12:01:22.000+00:00","name":"Senior Consultant APP Einrichtung","mainprojectId":"EP-2025-Comp-009","mainProject":{"caption":"Holzwerk Schilling GmbH & Co. KG / 49951 / Neukunde","title":"main project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22"},"projectId":"0013","path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Senior Consultant APP Einrichtung","description":"für die Serverseitige Einrichtung des APP Zugang und Parametrierung 
der Funktionen und Berechtigungen für die APP.
Für den Einsatz der Mobile App ist aus Sicherheitsgründen ein 
vertrauenswürdiges SSL-Zertifkat empfohlen.
Der Aufwand für das Einbinden eines Zertifkates ist nicht Bestandteil 
dieses Artikels.
Anpassungen an den Berechtigungen für die Mobile APP Anwender.","isMainproject":false,"isWorkpackage":true,"isActive":true,"type":null,"projectFolder":null,"projectWorkflow":null,"priority":null,"state":{"caption":"Abgeschlossen","title":"projectState","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"projectState set to this project","optionsUrl":"https://app1.teamspace.de/api/projectstate?workflow=13338231"},"stateRelevant":{"caption":"Abgeschlossen","title":"projectStateRelevant","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"effective projectState of this project"},"progress":100,"progressRelevant":100,"plannedMinutesEnabled":true,"plannedMinutes":120,"plannedMinutesCalculated":120,"sumMinutes":120,"plannedMinutesBillableEnabled":true,"plannedMinutesBillable":120,"plannedMinutesBillableCalculated":120,"sumMinutesBillable":120,"plannedMinutesPerformanceEnabled":true,"sumMinutesPerformance":0,"plannedWorkingCostsEnabled":true,"sumWorkingCosts":0.0,"plannedExpensesEnabled":true,"sumExpenses":0.0,"plannedIncomeEnabled":true,"plannedIncome":240.0,"plannedIncomeCalculated":240.0,"sumIncome":240.0,"customer":{"caption":"CompData Computer GmbH","title":"customer","href":"https://app1.teamspace.de/api/organization/13905417","value":13905417,"idKey":"78"},"customerContact":null,"contractor":{"caption":"AIKAVA GmbH","title":"contractor","href":"https://app1.teamspace.de/api/organization/13343077","value":13343077,"idKey":"78"},"contractorContact":null,"currency":{"title":"currency","href":"https://app1.teamspace.de/api/currency/13337775","value":13337775,"idKey":"105"},"colorlabel":null,"colorlabelRelevant":null,"contractPositions":[{"caption":"Contract position","title":"contractPosition","href":"https://app1.teamspace.de/api/contractposition/26795741","value":26795741,"idKey":"104"}],"exportId":null,"exportComment":null,"exportDate":null,"customfields":[],"article":null,"articleRelevant":null,"ticket":null,"endless":false,"parent":{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}},"path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Senior Consultant APP Einrichtung","rel":"link"}]} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 16d57e2..27db267 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1628,11 +1628,11 @@ export function App() { currentUser={currentUser} /> ) : null} - {!isAdmin && route.page === "dashboard" ? : null} - {!isAdmin && route.page === "analysis" ? : null} + {!isAdmin && route.page === "dashboard" ? : null} + {!isAdmin && route.page === "analysis" ? : null} {!isAdmin && route.page === "monthly-close" ? : null} - {!isAdmin && route.page === "statistics" ? : null} - {!isAdmin && route.page === "projects" ? : null} + {!isAdmin && route.page === "statistics" ? : null} + {!isAdmin && route.page === "projects" ? : null} {!isAdmin && route.page === "todos" ? : null} {!isAdmin && route.page === "recurring" ? : null} {!isAdmin && route.page === "zammad-sync" ? : null} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b190c63..42d99cf 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -7,6 +7,7 @@ import type { Organization, PeriodOverview, PeriodType, + ProjectTeamspaceSubentry, ProjectSummary, RecurringBilling, StatisticsOverview, @@ -125,6 +126,51 @@ export type ZammadSyncJob = { finishedAt: string | null; }; +export type TeamspaceProjectIdSyncItem = { + entryId: string; + projectId: string; + ticketNumber: string; + teamspaceProject: string; + previousApiProjectId: string | null; + apiProjectId: string | null; + apiProjectUrl: string | null; + status: "updated" | "current" | "missing" | "ambiguous"; + message: string | null; +}; + +export type TeamspaceProjectIdSyncResult = { + checkedEntries: number; + matched: number; + updated: number; + alreadyCurrent: number; + missing: number; + ambiguous: number; + cleared: number; + remoteProjectsScanned: number; + remoteProjectIds: number; + remoteSubprojectsScanned: number; + subentriesSynced: number; + subentriesRemoved: number; + duplicateRemoteProjectIds: string[]; + items: TeamspaceProjectIdSyncItem[]; +}; + +export type TeamspaceProjectSubentrySyncResult = { + projectId: string; + checkedEntries: number; + remoteProjectsScanned: number; + subentriesSynced: number; + subentriesRemoved: number; +}; + +export type TeamspaceAllSubentrySyncResult = { + checkedProjects: number; + checkedEntries: number; + remoteProjectsScanned: number; + subentriesSynced: number; + subentriesRemoved: number; +}; + export type TicketZammadMailSessionItem = { articleId: number; ticketNumber: string; @@ -248,8 +294,11 @@ export function updateCurrentUser(payload: { currentPassword?: string; newPassword?: string; zammadApiToken?: string; + teamspaceDeviceId?: string; + teamspaceDevicePassword?: string; zammadEmailSessionWebhookEnabled?: boolean; sessionBillingPromptEnabled?: boolean; + teamspaceTimeTrackingEnabled?: boolean; zammadSessionNoteDefaultEnabled?: boolean; zammadTimeUnitRoundingEnabled?: boolean; zammadTimeUnitRoundingMinutes?: number; @@ -432,6 +481,17 @@ export function saveZammadSettings(payload: { baseUrl: string; apiKey?: string } }); } +export function getTeamspaceSettings() { + return request<{ settings: { baseUrl: string } }>("/api/admin/teamspace/settings"); +} + +export function saveTeamspaceSettings(payload: { baseUrl: string }) { + return request<{ settings: { baseUrl: string } }>("/api/admin/teamspace/settings", { + method: "PUT", + body: JSON.stringify(payload) + }); +} + export function syncZammadOrganizations(payload: { baseUrl?: string; apiKey?: string }) { return request<{ synced: number; skipped: number; removed: number; unlinkedTickets: number; unlinkedSessions: number }>("/api/admin/zammad/organizations/sync", { method: "POST", @@ -537,6 +597,39 @@ export function applyZammadMailSessionsFromPreview(previewJobId: string, article }, onJobStarted); } +export function syncTeamspaceProjectIds() { + return request<{ result: TeamspaceProjectIdSyncResult }>("/api/teamspace/project-ids/sync", { + method: "POST", + timeoutMs: 5 * 60 * 1000 + }); +} + +export function syncProjectTeamspaceSubentries(projectId: string) { + return request<{ result: TeamspaceProjectSubentrySyncResult }>(`/api/projects/${projectId}/teamspace-subentries/sync`, { + method: "POST", + timeoutMs: 5 * 60 * 1000 + }); +} + +export function syncAllTeamspaceSubentries() { + return request<{ result: TeamspaceAllSubentrySyncResult }>("/api/teamspace/subentries/sync", { + method: "POST", + timeoutMs: 5 * 60 * 1000 + }); +} + +export function updateProjectTeamspaceSubentry( + subentryId: string, + payload: { + alias: string; + } +) { + return request<{ subentry: ProjectTeamspaceSubentry }>(`/api/project-teamspace-subentries/${subentryId}`, { + method: "PATCH", + body: JSON.stringify(payload) + }); +} + export function getRecurringBillings() { return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings"); } @@ -861,10 +954,18 @@ export function updateSessionDetails( }); } -export function updateTicketDayBilling(ticketId: string, day: string, billedMinutes: number | null, projectTeamspaceEntryId?: string | null) { +export function updateTicketDayBilling( + ticketId: string, + day: string, + billedMinutes: number | null, + projectTeamspaceEntryId?: string | null +) { return request(`/api/tickets/${ticketId}/day-billings/${day}`, { method: "PATCH", - body: JSON.stringify({ billedMinutes, projectTeamspaceEntryId: projectTeamspaceEntryId ?? null }) + body: JSON.stringify({ + billedMinutes, + projectTeamspaceEntryId: projectTeamspaceEntryId ?? null + }) }); } diff --git a/frontend/src/components/ui/accordion.tsx b/frontend/src/components/ui/accordion.tsx index f5c5984..10a5d33 100644 --- a/frontend/src/components/ui/accordion.tsx +++ b/frontend/src/components/ui/accordion.tsx @@ -68,7 +68,7 @@ function AccordionContent({ >
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 3d34a1b..e79d61e 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -12,8 +12,11 @@ export type AuthUser = { active: boolean; zammad_user_id: string | null; has_zammad_api_token: boolean; + teamspace_user_id: string | null; + has_teamspace_credentials: boolean; zammad_email_session_webhook_enabled: boolean; session_billing_prompt_enabled: boolean; + teamspace_time_tracking_enabled: boolean; zammad_session_note_default_enabled: boolean; zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_minutes: number; @@ -218,6 +221,7 @@ export type ProjectSummary = { teamspace_entry_count?: number; teamspace_entries?: ProjectTeamspaceEntry[]; unassigned_teamspace_session_count?: number; + unassigned_teamspace_subentry_count?: number; close_review_required?: boolean; hidden_for_user?: boolean; }; @@ -226,7 +230,10 @@ export type ProjectTeamspaceEntry = { id: string; project_id?: string; teamspace_project: string | null; + teamspace_api_project_id?: string | null; + teamspace_api_project_url?: string | null; teamspace_description?: string | null; + subentries?: ProjectTeamspaceSubentry[]; is_default: boolean; hour_budget_minutes: number | null; budget_sync_disabled?: boolean; @@ -236,6 +243,27 @@ export type ProjectTeamspaceEntry = { display_order?: number; }; +export type ProjectTeamspaceSubentry = { + id: string; + project_teamspace_entry_id?: string; + teamspace_api_project_id: string; + teamspace_api_project_url: string | null; + teamspace_project_id: string | null; + billing_type: string | null; + name: string | null; + alias: string; + description: string; + progress: number | null; + planned_minutes: number | null; + sum_minutes: number | null; + planned_minutes_billable: number | null; + sum_minutes_billable: number | null; + planned_income: number | string | null; + sum_income: number | string | null; + consumption_minutes?: number; + display_order: number; +}; + export type TodoPriority = "low" | "medium" | "high" | "urgent"; export type TodoStatus = "open" | "done"; diff --git a/frontend/src/views/AdminUsersPage.tsx b/frontend/src/views/AdminUsersPage.tsx index 33217d5..7e36974 100644 --- a/frontend/src/views/AdminUsersPage.tsx +++ b/frontend/src/views/AdminUsersPage.tsx @@ -1,5 +1,5 @@ import { ChangeEvent, FormEvent, useEffect, useState } from "react"; -import { CheckCircle2, DatabaseBackup, DatabaseZap, Save, Shield, Upload, UserPlus } from "lucide-react"; +import { CheckCircle2, DatabaseBackup, DatabaseZap, Save, Shield, Unplug, Upload, UserPlus } from "lucide-react"; import { toast } from "sonner"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; @@ -14,9 +14,11 @@ import { createAdminUser, downloadDatabaseExport, getAdminUsers, + getTeamspaceSettings, getZammadSettings, restoreDatabaseBackup, saveZammadSettings, + saveTeamspaceSettings, syncZammadOrganizations, updateAdminUser, validateDatabaseBackup, @@ -54,8 +56,10 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) { const [zammadBaseUrl, setZammadBaseUrl] = useState(""); const [zammadApiKey, setZammadApiKey] = useState(""); const [hasZammadApiKey, setHasZammadApiKey] = useState(false); + const [teamspaceBaseUrl, setTeamspaceBaseUrl] = useState(""); const [syncingOrganizations, setSyncingOrganizations] = useState(false); const [savingZammadSettings, setSavingZammadSettings] = useState(false); + const [savingTeamspaceSettings, setSavingTeamspaceSettings] = useState(false); const [exportingDatabase, setExportingDatabase] = useState(false); const [restoreSql, setRestoreSql] = useState(""); const [restoreValidation, setRestoreValidation] = useState(null); @@ -68,12 +72,13 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) { async function load(options: { preserveEdits?: boolean } = {}) { setLoading(true); try { - const [usersResult, zammadSettingsResult] = await Promise.all([getAdminUsers(), getZammadSettings()]); + const [usersResult, zammadSettingsResult, teamspaceSettingsResult] = await Promise.all([getAdminUsers(), getZammadSettings(), getTeamspaceSettings()]); setUsers(usersResult.users); if (!options.preserveEdits) { setZammadBaseUrl(zammadSettingsResult.settings.baseUrl); setHasZammadApiKey(zammadSettingsResult.settings.hasApiKey); + setTeamspaceBaseUrl(teamspaceSettingsResult.settings.baseUrl); } setForms((current) => @@ -212,6 +217,26 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) { } } + async function saveTeamspaceAccess() { + setSavingTeamspaceSettings(true); + + try { + const result = await saveTeamspaceSettings({ + baseUrl: teamspaceBaseUrl + }); + setTeamspaceBaseUrl(result.settings.baseUrl); + toast.success("Teamspace-Zugang gespeichert", { + description: "URL ist hinterlegt." + }); + } catch (error) { + toast.error("Teamspace-Zugang konnte nicht gespeichert werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } finally { + setSavingTeamspaceSettings(false); + } + } + async function exportDatabase() { setExportingDatabase(true); @@ -370,6 +395,27 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
+
+
+ +

Teamspace API

+
+
+
+ + setTeamspaceBaseUrl(event.currentTarget.value)} + /> +
+ +
+
diff --git a/frontend/src/views/AnalysisPage.tsx b/frontend/src/views/AnalysisPage.tsx index 7dad444..6d6f3ec 100644 --- a/frontend/src/views/AnalysisPage.tsx +++ b/frontend/src/views/AnalysisPage.tsx @@ -14,9 +14,10 @@ import { HelpLink } from "@/components/HelpLink"; import { MultiFilterDropdown, type MultiFilterOption } from "@/components/MultiFilterDropdown"; import { downloadMonthExport, getPeriodOverview } from "../api"; import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format"; -import type { PeriodOverview, PeriodType, TicketSummary } from "../types"; +import type { AuthUser, PeriodOverview, PeriodType, TicketSummary } from "../types"; type AnalysisPageProps = { + currentUser: AuthUser; onNavigate: (to: string) => void; }; @@ -327,7 +328,7 @@ function groupTicketSections(tickets: TicketSummary[], groupBy: TicketGroupBy): }); } -export function AnalysisPage({ onNavigate }: AnalysisPageProps) { +export function AnalysisPage({ currentUser, onNavigate }: AnalysisPageProps) { const [periodType, setPeriodType] = useState("month"); const [period, setPeriod] = useState(currentMonth()); const [overview, setOverview] = useState(null); @@ -438,11 +439,17 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) { } const totals = overview?.totals; + const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true; const periodLabel = periodType === "month" ? "Monat" : "Tag"; const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [ { label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket }, { label: "Sessions", value: totals?.sessions ?? 0, icon: Users }, - { label: "Abgerechnet", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 }, + { + label: "Abgerechnet", + value: formatMinutes(totals?.minutes ?? 0), + detail: teamspaceTimeTrackingEnabled ? `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}` : "Ist aus Teamspace", + icon: Clock3 + }, { label: "Abrechenbar", value: formatMinutes(totals?.billableMinutes ?? 0), icon: UserCheck }, { label: "Offen", value: totals?.openSessions ?? 0, detail: formatMinutes(totals?.openMinutes ?? 0), icon: UserCheck } ]; diff --git a/frontend/src/views/DashboardPage.tsx b/frontend/src/views/DashboardPage.tsx index 3347627..6ac696b 100644 --- a/frontend/src/views/DashboardPage.tsx +++ b/frontend/src/views/DashboardPage.tsx @@ -66,9 +66,10 @@ import type { DashboardFavorite, DashboardPreference, DashboardSortDirection, - DashboardWidget, - DashboardWidgetType, - PeriodOverview, + DashboardWidget, + DashboardWidgetType, + AuthUser, + PeriodOverview, ProjectTeamspaceEntry, ProjectSummary, RecurringBilling, @@ -78,6 +79,7 @@ import type { } from "../types"; type DashboardPageProps = { + currentUser: AuthUser; onNavigate: (to: string) => void; }; @@ -885,7 +887,7 @@ function sortedFavorites(favorites: DashboardFavorite[], metas: Record(null); const [preference, setPreference] = useState(() => normalizePreference({})); const [loading, setLoading] = useState(true); @@ -1007,11 +1009,12 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) { }; }, [favoriteMetas, preference.favorites]); - const maxGridRow = useMemo( - () => Math.max(6, dragPreview ? dragPreview.y + dragPreview.h + 1 : 0, ...preference.widgets.map((widget) => widget.y + widget.h + 1)), - [dragPreview, preference.widgets] - ); - const dashboardStats = useMemo(() => { + const maxGridRow = useMemo( + () => Math.max(6, dragPreview ? dragPreview.y + dragPreview.h + 1 : 0, ...preference.widgets.map((widget) => widget.y + widget.h + 1)), + [dragPreview, preference.widgets] + ); + const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true; + const dashboardStats = useMemo(() => { const monthTotals = monthOverview?.totals; const dayTotals = todayOverview?.totals; const overBudget = projects.filter((project) => project.hour_budget_minutes !== null && project.hour_budget_minutes !== undefined && (project.consumption_minutes ?? 0) > project.hour_budget_minutes); @@ -1048,17 +1051,18 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) { dayTotals, statistics: statisticsOverview, openRatings: monthOverview?.openSessions ?? [], - todayTickets: todayOverview?.tickets ?? [], - todayOpenRatings: todayOverview?.openSessions ?? [], - dayControlTickets, - fixedBillingTickets, - teamspaceDelta: (monthTotals?.minutes ?? 0) - (monthTotals?.crmBilledMinutes ?? 0), - overBudget, + todayTickets: todayOverview?.tickets ?? [], + todayOpenRatings: todayOverview?.openSessions ?? [], + dayControlTickets, + fixedBillingTickets, + teamspaceTimeTrackingEnabled, + teamspaceDelta: teamspaceTimeTrackingEnabled ? (monthTotals?.minutes ?? 0) - (monthTotals?.crmBilledMinutes ?? 0) : 0, + overBudget, nearlyFull, openTodos: todos.filter((todo) => todo.status === "open"), overdueTodos: todos.filter((todo) => todo.status === "open" && todo.ends_on && todo.ends_on < currentDay()) }; - }, [dayControlTickets, monthOverview, projects, recurringBillings, statisticsOverview, todayOverview, todos]); + }, [dayControlTickets, monthOverview, projects, recurringBillings, statisticsOverview, teamspaceTimeTrackingEnabled, todayOverview, todos]); function updateWidget(widgetId: string, changes: Partial) { setPreference((current) => ({ @@ -1384,9 +1388,10 @@ function useDashboardStatsPlaceholder() { monthTotals: null as PeriodOverview["totals"] | null | undefined, dayTotals: null as PeriodOverview["totals"] | null | undefined, statistics: null as StatisticsOverview | null, - openRatings: [] as PeriodOverview["openSessions"], - fixedBillingTickets: new Set(), - teamspaceDelta: 0, + openRatings: [] as PeriodOverview["openSessions"], + fixedBillingTickets: new Set(), + teamspaceTimeTrackingEnabled: true, + teamspaceDelta: 0, overBudget: [] as ProjectSummary[], nearlyFull: [] as ProjectSummary[], todayTickets: [] as PeriodOverview["tickets"], @@ -1559,17 +1564,22 @@ function WidgetContent(props: WidgetContentProps) { ); } - if (widget.type === "teamspace") { - const delta = stats.teamspaceDelta; - return ( - = 0 ? "+" : "-"}${formatMinutes(Math.abs(delta))}`, icon: ArrowUp, tone: delta === 0 ? "normal" : delta > 0 ? "warning" : "good" } - ]} - /> - ); - } + if (widget.type === "teamspace") { + const delta = stats.teamspaceDelta; + return ( + = 0 ? "+" : "-"}${formatMinutes(Math.abs(delta))}`, icon: ArrowUp, tone: delta === 0 ? "normal" : delta > 0 ? "warning" : "good" } + ] + : [ + { label: "TS-Erfassung", value: "Aus", icon: LayoutDashboard, tone: "normal" }, + { label: "Monatsprüfung", value: "Status", icon: CheckCircle2, tone: "good" } + ]} + /> + ); + } if (widget.type === "openRatings") { const filterValues = readFilterValues(widget.config, ["today", "older3", "fixedBilling", "manualBilling"]); @@ -1684,6 +1694,7 @@ type DayControlListItem = { function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentProps) { const today = currentDay(); + const teamspaceTimeTrackingEnabled = stats.teamspaceTimeTrackingEnabled; const overdueTodos = stableSort( todos.filter((todo) => todo.status === "open" && todo.ends_on && todo.ends_on < today), (left, right) => compareOptionalDate(left.ends_on, right.ends_on, "asc") || compareText(left.title, right.title) @@ -1701,12 +1712,21 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP ); const todayTickets = stableSort(stats.todayTickets, (left, right) => compareText(left.ticket_number, right.ticket_number)); const todayOpenRatings = stableSort(stats.todayOpenRatings, (left, right) => compareOptionalDate(left.started_at, right.started_at, "asc") || compareText(left.ticket_number, right.ticket_number)); - const teamspaceReviewTickets = todayTickets.filter((ticket) => ticket.open_count === 0 && teamspaceDeltaMinutes(ticket) > 0); - const afterworkTickets = todayTickets.filter((ticket) => ticket.open_count > 0 || teamspaceDeltaMinutes(ticket) > 0 || (ticket.total_minutes === 0 && (ticket.billable_count > 0 || ticket.non_billable_count > 0))); + const teamspaceReviewTickets = teamspaceTimeTrackingEnabled ? todayTickets.filter((ticket) => ticket.open_count === 0 && teamspaceDeltaMinutes(ticket) > 0) : []; + const afterworkTickets = todayTickets.filter((ticket) => + ticket.open_count > 0 + || (teamspaceTimeTrackingEnabled && teamspaceDeltaMinutes(ticket) > 0) + || (ticket.total_minutes === 0 && (ticket.billable_count > 0 || ticket.non_billable_count > 0)) + ); const budgetAttention = stableSort( projects.filter((project) => { const percent = projectConsumptionPercent(project); - return !project.closed_at && (Boolean(percent && percent >= 90) || Number(project.unassigned_teamspace_session_count ?? 0) > 0 || Boolean(project.close_review_required)); + return !project.closed_at && ( + Boolean(percent && percent >= 90) + || Number(project.unassigned_teamspace_session_count ?? 0) > 0 + || Number(project.unassigned_teamspace_subentry_count ?? 0) > 0 + || Boolean(project.close_review_required) + ); }), (left, right) => (compareNumber(projectConsumptionPercent(right) ?? 0, projectConsumptionPercent(left) ?? 0)) || compareText(left.ticket_number, right.ticket_number) ); @@ -1748,12 +1768,14 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP done: waitingTickets.length === 0, action: waitingTickets.length === 0 ? "Keine fälligen Wiedervorlagen." : `${waitingTickets.length} Wiedervorlage(n) bearbeiten oder neues Datum setzen.` }, - { - key: "teamspace", - label: "Teamspace geprüft", - done: teamspaceReviewTickets.length === 0, - action: teamspaceReviewTickets.length === 0 ? "Teamspace passt für heute." : `${teamspaceReviewTickets.length} Teamspace-Abgleich(e) korrigieren.` - }, + { + key: "teamspace", + label: teamspaceTimeTrackingEnabled ? "Teamspace geprüft" : "TS-Erfassung aus", + done: teamspaceReviewTickets.length === 0, + action: teamspaceTimeTrackingEnabled + ? teamspaceReviewTickets.length === 0 ? "Teamspace passt für heute." : `${teamspaceReviewTickets.length} Teamspace-Abgleich(e) korrigieren.` + : "Monatsprüfung läuft über Session-Status." + }, { key: "budget", label: "Budget geprüft", @@ -1791,8 +1813,8 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP key: `teamspace-review-${ticket.id}`, tone: "info" as const, title: ticket.title || ticket.ticket_number, - detail: `${ticket.ticket_number} · TT ${formatMinutes(ticket.total_minutes)} · Teamspace ${formatMinutes(ticket.crm_billed_minutes ?? 0)}`, - note: dayControlTodaySessionReason(ticket), + detail: `${ticket.ticket_number} · TT ${formatMinutes(ticket.total_minutes)} · Teamspace ${formatMinutes(ticket.crm_billed_minutes ?? 0)}`, + note: dayControlTodaySessionReason(ticket, teamspaceTimeTrackingEnabled), onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`) })) ].slice(0, 8); @@ -1836,8 +1858,8 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP items={afterworkTickets.slice(0, 4).map((ticket) => ({ key: `today-ticket-${ticket.id}`, title: ticket.title || ticket.ticket_number, - detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`, - note: dayControlTodaySessionReason(ticket), + detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`, + note: dayControlTodaySessionReason(ticket, teamspaceTimeTrackingEnabled), onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`) }))} /> @@ -1860,17 +1882,20 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP items={budgetAttention.slice(0, 4).map((project) => { const percent = projectConsumptionPercent(project); const hasUnassignedSessions = Number(project.unassigned_teamspace_session_count ?? 0) > 0; + const hasUnassignedSubentries = Number(project.unassigned_teamspace_subentry_count ?? 0) > 0; const detail = project.close_review_required ? project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" ? "Merge prüfen" : "Abschluss prüfen" : hasUnassignedSessions ? `${project.unassigned_teamspace_session_count} Session(s) ohne TS-Zuordnung` + : hasUnassignedSubentries + ? `${project.unassigned_teamspace_subentry_count} Session(s) ohne Unterposition` : `${percent ?? 0}% Kontingent verbraucht`; const note = project.close_review_required ? project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" ? "Lokale Projektdaten aus dem alten Ticket prüfen und in Zammad nachtragen." : "Abschluss in TT/Teamspace prüfen und ggf. Umbuchungen klären." - : hasUnassignedSessions - ? "Sessions einem Teamspace-Projekt zuordnen." + : hasUnassignedSessions || hasUnassignedSubentries + ? "Sessions über die Session-Bearbeitung zuordnen." : "Kontingent kritisch: Budget, Verbrauch und weitere Arbeit prüfen."; return { @@ -1888,18 +1913,18 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP ); } -function dayControlTodaySessionReason(ticket: PeriodOverview["tickets"][number]) { +function dayControlTodaySessionReason(ticket: PeriodOverview["tickets"][number], teamspaceTimeTrackingEnabled = true) { if (ticket.open_count > 0) { return `${ticket.open_count} offene Bewertung(en): Bewertungsstatus setzen.`; } const crmMinutes = ticket.crm_billed_minutes ?? 0; const deltaMinutes = teamspaceDeltaMinutes(ticket); - if (ticket.total_minutes > 0 && deltaMinutes > 0) { + if (teamspaceTimeTrackingEnabled && ticket.total_minutes > 0 && deltaMinutes > 0) { return `Teamspace nachtragen: ${formatMinutes(deltaMinutes)} fehlen.`; } - if (ticket.total_minutes > 0 && deltaMinutes === 0) { + if (teamspaceTimeTrackingEnabled && ticket.total_minutes > 0 && deltaMinutes === 0) { return `Teamspace ok: ${formatMinutes(crmMinutes)} erfasst.`; } diff --git a/frontend/src/views/ProfilePage.tsx b/frontend/src/views/ProfilePage.tsx index 3a3a54e..8fec0d8 100644 --- a/frontend/src/views/ProfilePage.tsx +++ b/frontend/src/views/ProfilePage.tsx @@ -24,9 +24,13 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { const [confirmPassword, setConfirmPassword] = useState(""); const [zammadApiToken, setZammadApiToken] = useState(""); const [zammadTokenDirty, setZammadTokenDirty] = useState(false); + const [teamspaceDeviceId, setTeamspaceDeviceId] = useState(""); + const [teamspaceDevicePassword, setTeamspaceDevicePassword] = useState(""); + const [teamspaceCredentialsDirty, setTeamspaceCredentialsDirty] = useState(false); const [zammadEmailSessionWebhookEnabled, setZammadEmailSessionWebhookEnabled] = useState(currentUser.zammad_email_session_webhook_enabled); const [zammadAtossEmailSyncEnabled, setZammadAtossEmailSyncEnabled] = useState(currentUser.zammad_atoss_email_sync_enabled); const [sessionBillingPromptEnabled, setSessionBillingPromptEnabled] = useState(currentUser.session_billing_prompt_enabled); + const [teamspaceTimeTrackingEnabled, setTeamspaceTimeTrackingEnabled] = useState(currentUser.teamspace_time_tracking_enabled ?? true); const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled); const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled); const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes); @@ -46,9 +50,13 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { setDisplayName(currentUser.display_name); setZammadApiToken(""); setZammadTokenDirty(false); + setTeamspaceDeviceId(""); + setTeamspaceDevicePassword(""); + setTeamspaceCredentialsDirty(false); setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled); setZammadAtossEmailSyncEnabled(currentUser.zammad_atoss_email_sync_enabled); setSessionBillingPromptEnabled(currentUser.session_billing_prompt_enabled); + setTeamspaceTimeTrackingEnabled(currentUser.teamspace_time_tracking_enabled ?? true); setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled); setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled); setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes); @@ -134,11 +142,13 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { currentPassword: wantsPasswordChange ? currentPassword : undefined, newPassword: wantsPasswordChange ? newPassword : undefined, ...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {}), + ...(currentUser.role === "user" && teamspaceCredentialsDirty ? { teamspaceDeviceId, teamspaceDevicePassword } : {}), ...(currentUser.role === "user" ? { zammadEmailSessionWebhookEnabled, zammadAtossEmailSyncEnabled, sessionBillingPromptEnabled, + teamspaceTimeTrackingEnabled, zammadSessionNoteDefaultEnabled, zammadTimeUnitRoundingEnabled, zammadTimeUnitRoundingMinutes, @@ -160,6 +170,9 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { setConfirmPassword(""); setZammadApiToken(""); setZammadTokenDirty(false); + setTeamspaceDeviceId(""); + setTeamspaceDevicePassword(""); + setTeamspaceCredentialsDirty(false); toast.success("Profil gespeichert"); } catch (error) { toast.error("Profil konnte nicht gespeichert werden", { @@ -301,6 +314,86 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { ) : null} +
+
+ +

Teamspace API

+
+
+
+ + { + setTeamspaceDeviceId(event.currentTarget.value); + setTeamspaceCredentialsDirty(true); + }} + /> +
+
+ + { + setTeamspaceDevicePassword(event.currentTarget.value); + setTeamspaceCredentialsDirty(true); + }} + /> +

Die Teamspace-ID wird nur geprüft, wenn du neue Zugangsdaten speicherst.

+
+
+ + + {currentUser.has_teamspace_credentials ? ( + + ) : null} +
+
+
+
+
+ +
+ +

+ Wenn aus, werden keine Teamspace-Tageswerte in TT erfasst; Monatsprüfung und Abschluss laufen nur über Session-Status. +

+
+
+ +
diff --git a/frontend/src/views/ProjectsPage.tsx b/frontend/src/views/ProjectsPage.tsx index 94815e3..09443e7 100644 --- a/frontend/src/views/ProjectsPage.tsx +++ b/frontend/src/views/ProjectsPage.tsx @@ -1,4 +1,4 @@ -import { ArrowDown, ArrowUp, CheckCircle2, Eye, EyeOff, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Save, Search, SlidersHorizontal, Star, Trash2 } from "lucide-react"; +import { AlertCircle, ArrowDown, ArrowUp, CheckCircle2, Eye, EyeOff, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Save, Search, SlidersHorizontal, Star, Trash2 } from "lucide-react"; import { FormEvent, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -19,7 +19,7 @@ import { acknowledgeProjectReview, addFavoriteTicket, createProject, getProjects import { currentMonth, formatMinutes } from "../format"; import { projectHasRecurringBilling } from "../recurring-billings"; import { ticketPattern } from "../timers"; -import type { ProjectSummary, ProjectTeamspaceEntry, TicketMeta } from "../types"; +import type { AuthUser, ProjectSummary, ProjectTeamspaceEntry, TicketMeta } from "../types"; import { matchesZammadPriorityFilter, matchesZammadStateFilter, @@ -36,6 +36,7 @@ import { } from "../zammad-meta"; type ProjectsPageProps = { + currentUser: AuthUser; onNavigate: (to: string) => void; onStartTimer: (ticketNumber: string) => Promise; }; @@ -411,21 +412,11 @@ function projectOrganizationKey(project: ProjectSummary) { return project.organization_id ? `id:${project.organization_id}` : `name:${projectOrganizationLabel(project)}`; } -function projectBudget(project: ProjectSummary, standardTeamspaceOnly = false) { - if (standardTeamspaceOnly) { - return defaultProjectTeamspaceEntry(project)?.hour_budget_minutes ?? null; - } - +function projectBudget(project: ProjectSummary, _standardTeamspaceOnly = false) { return project.hour_budget_minutes ?? null; } -function projectConsumption(project: ProjectSummary, standardTeamspaceOnly = false) { - if (standardTeamspaceOnly) { - const defaultEntry = defaultProjectTeamspaceEntry(project); - - return defaultEntry ? projectEntryConsumption(defaultEntry) : 0; - } - +function projectConsumption(project: ProjectSummary, _standardTeamspaceOnly = false) { return Number(project.consumption_minutes ?? 0); } @@ -746,6 +737,7 @@ type ProjectFormFieldsProps = { form: ProjectForm; setForm: (form: ProjectForm) => void; organizationLocked: boolean; + showManualConsumption?: boolean; lookupStatus: "idle" | "loading" | "found" | "not-found"; zammadLookupBusy?: boolean; saving: boolean; @@ -767,10 +759,9 @@ function normalizeProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) { const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries); const parsedEntries = normalizedEntries.map((entry) => { - const hourBudgetMinutes = parseHoursInput(entry.hourBudget); const manualConsumptionMinutes = parseHoursInput(entry.manualConsumption); - if (hourBudgetMinutes === undefined || manualConsumptionMinutes === undefined) { + if (manualConsumptionMinutes === undefined) { return undefined; } @@ -779,8 +770,8 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) { teamspaceProject: entry.teamspaceProject.trim() || null, teamspaceDescription: entry.teamspaceDescription.trim() || null, isDefault: entry.isDefault, - hourBudgetMinutes, - budgetSyncDisabled: entry.budgetSyncDisabled, + hourBudgetMinutes: null, + budgetSyncDisabled: false, manualConsumptionMinutes: manualConsumptionMinutes ?? 0 }; }); @@ -806,10 +797,12 @@ function defaultProjectTeamspacePayload(entries: ReturnType void; + showManualConsumption?: boolean; }) { const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries); @@ -836,7 +829,15 @@ function ProjectTeamspaceEntriesFields({
{normalizedEntries.map((entry, index) => ( -
+
-
- - updateEntry(entry.clientId, { hourBudget: event.currentTarget.value, budgetSyncDisabled: true })} - /> -
- -
- - updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })} - /> -
+ {showManualConsumption ? ( +
+ + updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })} + /> +
+ ) : null} @@ -894,14 +882,11 @@ function ProjectTeamspaceEntriesFields({
))}
-

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

); } -function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, zammadLookupBusy = false, saving, submitLabel, onZammadLookup, onSubmit }: ProjectFormFieldsProps) { +function ProjectFormFields({ form, setForm, organizationLocked, showManualConsumption = true, lookupStatus, zammadLookupBusy = false, saving, submitLabel, onZammadLookup, onSubmit }: ProjectFormFieldsProps) { return (
@@ -975,7 +960,11 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, za
- setForm({ ...form, teamspaceEntries })} /> + setForm({ ...form, teamspaceEntries })} + showManualConsumption={showManualConsumption} + />
- {usedPercent !== null ? ( -
-
-
- ) : ( + {usedPercent !== null ? ( +
+
+
+
+ {budgetExceeded ? : null} +
+ ) : (
)} - {(project.unassigned_teamspace_session_count ?? 0) > 0 ? ( + {unassignedTeamspaceEntryCount > 0 || unassignedTeamspaceSubentryCount > 0 ? (

- {project.unassigned_teamspace_session_count} Session(s) haben keinen Teamspace-Eintrag. + {[ + unassignedTeamspaceEntryCount > 0 ? `${unassignedTeamspaceEntryCount} Session(s) ohne Teamspace-Projekt` : null, + unassignedTeamspaceSubentryCount > 0 ? `${unassignedTeamspaceSubentryCount} Session(s) ohne Unterposition` : null + ].filter(Boolean).join(" · ")}

) : null} {project.close_review_required ? ( @@ -1788,8 +1790,8 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) { {project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" ? project.zammad_merged_from_ticket_id - ? `Dieses Projekt wurde aus ${project.zammad_merged_from_ticket_number ?? `Zammad-ID ${project.zammad_merged_from_ticket_id}`} übernommen. Teamspace-Projekte und Budget bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen.` - : "Dieses Projekt steht zur TS/TT-Prüfung, obwohl das Zammad-Ticket offen ist. Teamspace-Projekte und Budget bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen." + ? `Dieses Projekt wurde aus ${project.zammad_merged_from_ticket_number ?? `Zammad-ID ${project.zammad_merged_from_ticket_id}`} übernommen. Teamspace-Projekte bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen.` + : "Dieses Projekt steht zur TS/TT-Prüfung, obwohl das Zammad-Ticket offen ist. Teamspace-Projekte bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen." : "Zammad meldet das Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen."}