From 45ff46b7ebe8020713b9ca819234e4deb1738568 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 13 Oct 2026 10:43:53 +0200 Subject: [PATCH] 1.1.0-rc1 --- backend/src/databaseExport.ts | 5 + backend/src/index.ts | 2392 ++++++++++++++++- backend/src/migrations.ts | 564 ++++ bla.json | 1 - .../confluence/00-tickettracker-hauptseite.md | 89 + ...-zeiterfassung-sessions-monatsabschluss.md | 96 + .../02-projekte-budgets-teamspace.md | 150 ++ .../03-zammad-sync-und-integration.md | 82 + .../04-dashboard-statistiken-und-widgets.md | 95 + ...-todos-wiedervorlagen-fixe-abrechnungen.md | 79 + .../confluence/06-profil-admin-und-betrieb.md | 107 + frontend/src/App.tsx | 60 +- frontend/src/api.ts | 93 +- frontend/src/app/globals.css | 7 +- frontend/src/types.ts | 59 + frontend/src/views/AdminUsersPage.tsx | 147 +- frontend/src/views/AnalysisPage.tsx | 48 +- frontend/src/views/DashboardPage.tsx | 25 +- frontend/src/views/MonthlyClosePage.tsx | 127 +- frontend/src/views/ProjectsPage.tsx | 42 +- frontend/src/views/StatisticsPage.tsx | 73 +- frontend/src/views/TicketDetailPage.tsx | 237 +- frontend/src/views/TimerPage.tsx | 16 +- frontend/src/views/ZammadSyncPage.tsx | 1181 +++----- mailexample.json | 37 - triple.json | 48 - 26 files changed, 4752 insertions(+), 1108 deletions(-) delete mode 100644 bla.json create mode 100644 docs/confluence/00-tickettracker-hauptseite.md create mode 100644 docs/confluence/01-zeiterfassung-sessions-monatsabschluss.md create mode 100644 docs/confluence/02-projekte-budgets-teamspace.md create mode 100644 docs/confluence/03-zammad-sync-und-integration.md create mode 100644 docs/confluence/04-dashboard-statistiken-und-widgets.md create mode 100644 docs/confluence/05-todos-wiedervorlagen-fixe-abrechnungen.md create mode 100644 docs/confluence/06-profil-admin-und-betrieb.md delete mode 100644 mailexample.json delete mode 100644 triple.json diff --git a/backend/src/databaseExport.ts b/backend/src/databaseExport.ts index bb3f06f..ac7c4e5 100644 --- a/backend/src/databaseExport.ts +++ b/backend/src/databaseExport.ts @@ -15,7 +15,12 @@ export const databaseExportTables = [ "organizations", "tickets", "projects", + "teamspace_project_resources", "project_teamspace_entries", + "teamspace_project_exclusions", + "teamspace_monthly_user_actuals", + "project_teamspace_monthly_actuals", + "project_teamspace_subentries", "hidden_user_projects", "ticket_todos", "recurring_billings", diff --git a/backend/src/index.ts b/backend/src/index.ts index 21c9946..1387ab0 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -595,6 +595,7 @@ type ProjectRow = { starts_on: string | null; ends_on: string | null; hour_budget_minutes: number | null; + teamspace_budget_excluded?: boolean; manual_consumption_minutes: number; teamspace_consumption_minutes?: number; consumption_minutes?: number; @@ -617,10 +618,15 @@ type ProjectTeamspaceEntryRow = { subentries?: ProjectTeamspaceSubentryRow[]; is_default: boolean; hour_budget_minutes: number | null; + teamspace_budget_excluded?: boolean; budget_sync_disabled?: boolean; manual_consumption_minutes: number; teamspace_consumption_minutes?: number; consumption_minutes?: number; + teamspace_month?: string | null; + teamspace_month_minutes?: number; + teamspace_month_billable_minutes?: number; + teamspace_month_entry_count?: number; display_order?: number; }; @@ -653,6 +659,7 @@ type ProjectWithEntries = ProjectRow & { const zammadBaseUrlSettingKey = "zammad.base_url"; const zammadApiKeySettingKey = "zammad.api_key"; const teamspaceBaseUrlSettingKey = "teamspace.base_url"; +const teamspaceAdminCredentialsUserIdSettingKey = "teamspace.admin_credentials_user_id"; type QueryClient = { query: (text: string, params?: unknown[]) => Promise<{ rows: T[]; rowCount: number | null }>; @@ -888,6 +895,37 @@ async function getTeamspaceCredentialsForUser(userId: string) { }; } +async function getTeamspaceCredentialsForAdminAction() { + const userId = await getAppSetting(teamspaceAdminCredentialsUserIdSettingKey); + + if (!userId) { + throw badRequest("Bitte im Adminbereich einen Benutzer für Teamspace-Adminabfragen auswählen"); + } + + const userResult = await query<{ id: string; display_name: string; has_teamspace_credentials: boolean }>( + ` + SELECT id::text AS id, + display_name, + (teamspace_device_id IS NOT NULL AND teamspace_device_password IS NOT NULL) AS has_teamspace_credentials + FROM users + WHERE id = $1 + AND active = true; + `, + [userId] + ); + const user = userResult.rows[0]; + + if (!user) { + throw badRequest("Der ausgewählte Teamspace-Abfragebenutzer existiert nicht mehr oder ist deaktiviert"); + } + + if (!user.has_teamspace_credentials) { + throw badRequest(`Der ausgewählte Teamspace-Abfragebenutzer ${user.display_name} hat keine Geräte-Credentials hinterlegt`); + } + + return getTeamspaceCredentialsForUser(user.id); +} + async function fetchTeamspaceJson(baseUrl: string, deviceId: string, devicePassword: string, path: string) { const response = await teamspaceFetch(`${baseUrl}${path}`, { headers: { @@ -1120,7 +1158,7 @@ function zammadSessionArticleBodyForDelivery(body: string, contentType: "text/pl return body; } - return /<\s*[a-z][\s/>]/i.test(body) ? body : `
${body}
`; + return /<\s*[a-z][a-z0-9-]*(?:\s|\/?>)/i.test(body) ? body : `
${body}
`; } async function postZammadSessionArticleForUser(userId: string, payload: ZammadSessionArticlePayload) { @@ -3883,6 +3921,10 @@ async function ensureProjectForTicket( await syncProjectTeamspaceEntriesFromZammad(client, projectId, teamspaceProjects, defaults.hourBudgetMinutes ?? null, false); } + if (teamspaceProjects.length > 0) { + await consolidateSharedTeamspaceProjectData(client, teamspaceProjects); + } + if (defaults.closeReviewRequired) { await client.query( ` @@ -3936,6 +3978,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, + COALESCE(entry_totals.teamspace_budget_excluded, false) AS teamspace_budget_excluded, entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes, EXISTS ( SELECT 1 @@ -4008,6 +4051,7 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro 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, + BOOL_OR(tpe.teamspace_project IS NOT NULL) AS teamspace_budget_excluded, COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes, COUNT(*)::int AS teamspace_entry_count, COALESCE( @@ -4016,6 +4060,7 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro 'id', pte.id::text, 'project_id', pte.project_id::text, 'teamspace_project', pte.teamspace_project, + 'teamspace_budget_excluded', tpe.teamspace_project IS NOT NULL, 'teamspace_api_project_id', pte.teamspace_api_project_id::text, 'teamspace_api_project_url', pte.teamspace_api_project_url, 'subentries', COALESCE(( @@ -4109,6 +4154,7 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro '[]'::json ) AS teamspace_entries FROM project_teamspace_entries pte + LEFT JOIN teamspace_project_exclusions tpe ON lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) WHERE pte.project_id = p.id ) entry_totals ON true LEFT JOIN LATERAL ( @@ -4841,6 +4887,7 @@ type TeamspaceRemoteProject = { parentApiProjectId: number | null; billingType: string | null; name: string | null; + customerName: string | null; description: string; progress: number | null; plannedMinutes: number | null; @@ -4859,11 +4906,35 @@ type TeamspaceProjectIdSyncItem = { previousApiProjectId: string | null; apiProjectId: string | null; apiProjectUrl: string | null; - status: "updated" | "current" | "missing" | "ambiguous"; + ttMonthBillableMinutes: number; + teamspaceMonthMinutes: number; + teamspaceMonthBillableMinutes: number; + teamspaceMonthEntryCount: number; + teamspaceMonthDeltaMinutes: number; + teamspaceMonthPositions: TeamspaceProjectIdSyncPosition[]; + status: "updated" | "current" | "missing" | "ambiguous" | "excluded"; message: string | null; }; +type TeamspaceProjectIdSyncPosition = { + apiProjectId: string; + projectId: string | null; + label: string; + isSubentry: boolean; + billingType?: string | null; + minutes: number; + billableMinutes: number; + entryCount: number; + days?: Array<{ + day: string; + minutes: number; + billableMinutes: number; + entryCount: number; + }>; +}; + type TeamspaceProjectIdSyncResult = { + month: string; checkedEntries: number; matched: number; updated: number; @@ -4876,6 +4947,10 @@ type TeamspaceProjectIdSyncResult = { remoteSubprojectsScanned: number; subentriesSynced: number; subentriesRemoved: number; + teamspaceTimeEntriesScanned: number; + teamspaceTimeEntriesMapped: number; + teamspaceTimeBillableMinutes: number; + teamspaceTimeDeltaMinutes: number; duplicateRemoteProjectIds: string[]; items: TeamspaceProjectIdSyncItem[]; }; @@ -4886,6 +4961,10 @@ type TeamspaceProjectSubentrySyncResult = { remoteProjectsScanned: number; subentriesSynced: number; subentriesRemoved: number; + monthsSynced: string[]; + teamspaceTimeEntriesScanned: number; + teamspaceTimeEntriesMapped: number; + teamspaceTimeBillableMinutes: number; }; type TeamspaceAllSubentrySyncResult = { @@ -4961,6 +5040,45 @@ function teamspaceBillingType(data: Record) { return null; } +function teamspaceLinkedLabel(value: unknown): string | null { + if (isPlainObject(value)) { + return teamspaceString(value.caption) + ?? teamspaceString(value.name) + ?? teamspaceString(value.displayName) + ?? teamspaceString(value.title) + ?? teamspaceString(value.customerName) + ?? teamspaceString(value.companyName) + ?? (typeof value.value === "string" ? teamspaceString(value.value) : null); + } + + return teamspaceString(value); +} + +function teamspaceProjectCustomerName(data: Record) { + const fields = [ + data.customerName, + data.clientName, + data.companyName, + data.organizationName, + data.customer, + data.client, + data.company, + data.organization, + data.contact, + data.account + ]; + + for (const field of fields) { + const label = teamspaceLinkedLabel(field); + + if (label) { + return label; + } + } + + return null; +} + function teamspaceContractPositionNumber(data: Record, field: string) { const direct = teamspaceOptionalNumber(data[field]); @@ -5010,6 +5128,7 @@ function teamspaceRemoteProjectFromItem(item: unknown): TeamspaceRemoteProject | parentApiProjectId: teamspaceLinkApiProjectId(data.parent), billingType: teamspaceBillingType(data), name: teamspaceString(data.name), + customerName: teamspaceProjectCustomerName(data), description: teamspaceString(data.description) ?? "", progress: teamspaceOptionalInteger(data.progress), plannedMinutes: teamspaceOptionalInteger(data.plannedMinutes) ?? teamspaceOptionalInteger(data.plannedMinutesCalculated), @@ -5125,6 +5244,1096 @@ function teamspaceRemoteProjectsByMainprojectId(projects: TeamspaceRemoteProject return byMainprojectId; } +type TeamspaceRemoteTimeActual = { + projectApiId: number; + day: string | null; + minutes: number; + billableMinutes: number; +}; + +type TeamspaceTimeActualTotal = { + minutes: number; + billableMinutes: number; + count: number; + days: Map; +}; + +function currentBerlinMonthLabel() { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit" + }).formatToParts(new Date()); + const year = parts.find((part) => part.type === "year")?.value; + const month = parts.find((part) => part.type === "month")?.value; + + return `${year}-${month}`; +} + +function currentBerlinDateLabel() { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit" + }).formatToParts(new Date()); + const year = parts.find((part) => part.type === "year")?.value; + const month = parts.find((part) => part.type === "month")?.value; + const day = parts.find((part) => part.type === "day")?.value; + + return `${year}-${month}-${day}`; +} + +function teamspaceMonthEndDate(monthStart: string) { + const date = new Date(`${monthStart}T00:00:00.000Z`); + date.setUTCMonth(date.getUTCMonth() + 1); + date.setUTCDate(date.getUTCDate() - 1); + + return date.toISOString().slice(0, 10); +} + +function teamspaceTimeDraftStatus(value: unknown) { + if (isPlainObject(value)) { + return teamspaceString(value.value) ?? teamspaceString(value.caption); + } + + return teamspaceString(value); +} + +function teamspaceTimeProjectApiId(value: unknown) { + if (isPlainObject(value)) { + return numberOrNull(value.value) ?? numberOrNull(value.id) ?? numberOrNull(value._id); + } + + return numberOrNull(value); +} + +function teamspaceTimeDay(value: unknown) { + const raw = teamspaceString(value); + + if (!raw) { + return null; + } + + const match = raw.match(/\d{4}-\d{2}-\d{2}/); + + return match?.[0] ?? null; +} + +function teamspaceRemoteTimeActualFromItem(item: unknown): TeamspaceRemoteTimeActual | null { + if (!isPlainObject(item)) { + return null; + } + + const data = isPlainObject(item.data) ? item.data : item; + const draftStatus = teamspaceTimeDraftStatus(data.draftStatus); + + if (draftStatus && draftStatus !== "NORMAL") { + return null; + } + + const projectApiId = teamspaceTimeProjectApiId(data.project); + + if (!projectApiId) { + return null; + } + + const minutes = teamspaceOptionalInteger(data.amount) ?? 0; + const billableMinutes = teamspaceOptionalInteger(data.amountBillable) ?? minutes; + + return { + projectApiId, + day: teamspaceTimeDay(data.date), + minutes: Math.max(0, minutes), + billableMinutes: Math.max(0, billableMinutes) + }; +} + +function teamspaceTimeActualDays(total: TeamspaceTimeActualTotal | undefined) { + return Array.from(total?.days.entries() ?? []) + .map(([day, value]) => ({ + day, + minutes: value.minutes, + billableMinutes: value.billableMinutes, + entryCount: value.count + })) + .sort((left, right) => left.day.localeCompare(right.day)); +} + +function teamspaceTimeActualSummary(timeActuals: { totals: Map }) { + const positions = Array.from(timeActuals.totals.entries()) + .map(([apiProjectId, actual]) => ({ + apiProjectId: String(apiProjectId), + projectId: null, + label: `API ${apiProjectId}`, + isSubentry: false, + billingType: null, + minutes: actual.minutes, + billableMinutes: actual.billableMinutes, + entryCount: actual.count, + days: teamspaceTimeActualDays(actual) + })) + .sort((left, right) => Number(left.apiProjectId) - Number(right.apiProjectId)); + + return positions.reduce((summary, position) => ({ + minutes: summary.minutes + position.minutes, + billableMinutes: summary.billableMinutes + position.billableMinutes, + count: summary.count + position.entryCount, + positions + }), { minutes: 0, billableMinutes: 0, count: 0, positions }); +} + +async function upsertTeamspaceMonthlyUserActuals( + client: QueryClient, + userId: string, + month: ReturnType, + timeActuals: { totals: Map } +) { + const summary = teamspaceTimeActualSummary(timeActuals); + + await client.query( + ` + INSERT INTO teamspace_monthly_user_actuals ( + user_id, + month, + teamspace_minutes, + teamspace_billable_minutes, + teamspace_time_entry_count, + positions, + synced_at, + updated_at + ) + VALUES ($1, $2::date, $3, $4, $5, $6::jsonb, now(), now()) + ON CONFLICT (user_id, month) + DO UPDATE SET + teamspace_minutes = EXCLUDED.teamspace_minutes, + teamspace_billable_minutes = EXCLUDED.teamspace_billable_minutes, + teamspace_time_entry_count = EXCLUDED.teamspace_time_entry_count, + positions = EXCLUDED.positions, + synced_at = EXCLUDED.synced_at, + updated_at = now(); + `, + [userId, month.start, summary.minutes, summary.billableMinutes, summary.count, JSON.stringify(summary.positions)] + ); +} + +async function loadTeamspaceRemoteTimeActualsSingleCall(baseUrl: string, deviceId: string, devicePassword: string, workerId: string, month: ReturnType) { + const requestedLimit = 100_000; + const monthEnd = teamspaceMonthEndDate(month.start); + const payload = await fetchTeamspaceJson( + baseUrl, + deviceId, + devicePassword, + `/api/time;depth=1;limit=${requestedLimit};sort=date?worker=${encodeURIComponent(workerId)}&date=${encodeURIComponent(`${month.start}..${monthEnd}`)}` + ); + + if (!isPlainObject(payload) || (payload.items !== null && !Array.isArray(payload.items))) { + throw Object.assign(new Error("Teamspace Zeitliste 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 Zeitliste gekappt (${items.length} von ${size}). TicketTracker kann die Monatswerte so nicht vollständig synchronisieren.`), + { status: 409 } + ); + } + + const totals = new Map(); + + for (const actual of items.map(teamspaceRemoteTimeActualFromItem).filter((entry): entry is TeamspaceRemoteTimeActual => Boolean(entry))) { + const current = totals.get(actual.projectApiId) ?? { minutes: 0, billableMinutes: 0, count: 0, days: new Map() }; + current.minutes += actual.minutes; + current.billableMinutes += actual.billableMinutes; + current.count += 1; + + if (actual.day) { + const day = current.days.get(actual.day) ?? { minutes: 0, billableMinutes: 0, count: 0 }; + day.minutes += actual.minutes; + day.billableMinutes += actual.billableMinutes; + day.count += 1; + current.days.set(actual.day, day); + } + + totals.set(actual.projectApiId, current); + } + + return { + scanned: items.length, + totals + }; +} + +type TeamspaceMonthlyActualSyncEntry = { + entryId: string; + apiProjectId: number; +}; + +function monthLabelsForDateRange(startDate: string, endExclusiveDate: string) { + const start = new Date(`${startDate.slice(0, 7)}-01T00:00:00.000Z`); + const lastIncluded = new Date(`${endExclusiveDate}T00:00:00.000Z`); + lastIncluded.setUTCDate(lastIncluded.getUTCDate() - 1); + const labels: string[] = []; + + for (const date = start; date <= lastIncluded; date.setUTCMonth(date.getUTCMonth() + 1)) { + labels.push(date.toISOString().slice(0, 7)); + } + + return labels; +} + +async function syncTeamspaceMonthlyActualsForEntries( + userId: string, + credentials: Awaited>, + workerId: string, + month: ReturnType, + entries: TeamspaceMonthlyActualSyncEntry[] +) { + if (entries.length === 0) { + return { + scanned: 0, + mapped: 0, + billableMinutes: 0 + }; + } + + const timeActuals = await loadTeamspaceRemoteTimeActualsSingleCall( + credentials.baseUrl, + credentials.deviceId, + credentials.devicePassword, + workerId, + month + ); + const entryIds = entries.map((entry) => entry.entryId); + const entryApiIdByEntryId = new Map(entries.map((entry) => [entry.entryId, entry.apiProjectId])); + const entryApiIdsResult = await query<{ + entry_id: string; + api_project_id: string; + project_id: string | null; + label: string | null; + is_subentry: boolean; + billing_type: string | null; + }>( + ` + SELECT DISTINCT ON (entry_id, api_project_id) + entry_id, + api_project_id::text AS api_project_id, + project_id, + label, + is_subentry, + billing_type + FROM ( + SELECT + pte.id::text AS entry_id, + pte.teamspace_api_project_id AS api_project_id, + pte.teamspace_project AS project_id, + NULLIF(btrim(pte.teamspace_description), '') AS label, + false AS is_subentry, + NULL::text AS billing_type, + 0 AS display_order + FROM project_teamspace_entries pte + WHERE pte.id = ANY($2::bigint[]) + AND pte.teamspace_api_project_id IS NOT NULL + + UNION ALL + + SELECT + pte.id::text AS entry_id, + pts.teamspace_api_project_id AS api_project_id, + pts.teamspace_project_id AS project_id, + COALESCE(NULLIF(btrim(pts.alias), ''), NULLIF(btrim(pts.name), ''), pts.teamspace_project_id) AS label, + true AS is_subentry, + pts.billing_type, + pts.display_order + 1 AS display_order + FROM project_teamspace_entries pte + JOIN project_teamspace_subentries pts ON pts.project_teamspace_entry_id = pte.id + WHERE pte.id = ANY($2::bigint[]) + AND pts.user_id = $1 + ) api_ids + ORDER BY entry_id, api_project_id, display_order ASC; + `, + [userId, entryIds] + ); + const apiRowsByEntryId = new Map(); + + for (const row of entryApiIdsResult.rows) { + apiRowsByEntryId.set(row.entry_id, [...(apiRowsByEntryId.get(row.entry_id) ?? []), row]); + } + + const actualForEntry = (entry: TeamspaceMonthlyActualSyncEntry) => { + const apiRows = apiRowsByEntryId.get(entry.entryId) ?? [{ + entry_id: entry.entryId, + api_project_id: String(entryApiIdByEntryId.get(entry.entryId) ?? entry.apiProjectId), + project_id: null, + label: null, + is_subentry: false, + billing_type: null + }]; + const seen = new Set(); + const total = { minutes: 0, billableMinutes: 0, count: 0 }; + const positions: TeamspaceProjectIdSyncPosition[] = []; + + for (const apiRow of apiRows) { + const apiId = Number(apiRow.api_project_id); + + if (!Number.isInteger(apiId) || apiId <= 0 || seen.has(apiId)) { + continue; + } + + seen.add(apiId); + + if (apiRow.billing_type === "INVOICEABLE_FIX") { + continue; + } + + const actual = timeActuals.totals.get(apiId); + + if (!actual) { + continue; + } + + total.minutes += actual.minutes; + total.billableMinutes += actual.billableMinutes; + total.count += actual.count; + positions.push({ + apiProjectId: String(apiId), + projectId: apiRow.project_id, + label: apiRow.label || apiRow.project_id || `API ${apiId}`, + isSubentry: apiRow.is_subentry, + billingType: apiRow.billing_type, + minutes: actual.minutes, + billableMinutes: actual.billableMinutes, + entryCount: actual.count, + days: teamspaceTimeActualDays(actual) + }); + } + + return { ...total, positions }; + }; + + await withTransaction(async (client) => { + await upsertTeamspaceMonthlyUserActuals(client, userId, month, timeActuals); + + await client.query( + ` + DELETE FROM project_teamspace_monthly_actuals + WHERE user_id = $1 + AND month = $2::date + AND project_teamspace_entry_id = ANY($3::bigint[]); + `, + [userId, month.start, entryIds] + ); + + for (const entry of entries) { + const actual = actualForEntry(entry); + await client.query( + ` + INSERT INTO project_teamspace_monthly_actuals ( + user_id, + project_teamspace_entry_id, + month, + teamspace_minutes, + teamspace_billable_minutes, + teamspace_time_entry_count, + positions, + synced_at, + updated_at + ) + VALUES ($1, $2, $3::date, $4, $5, $6, $7::jsonb, now(), now()); + `, + [userId, entry.entryId, month.start, actual.minutes, actual.billableMinutes, actual.count, JSON.stringify(actual.positions)] + ); + } + }); + + return entries.reduce((stats, entry) => { + const actual = actualForEntry(entry); + + return { + scanned: timeActuals.scanned, + mapped: stats.mapped + actual.count, + billableMinutes: stats.billableMinutes + actual.billableMinutes + }; + }, { scanned: timeActuals.scanned, mapped: 0, billableMinutes: 0 }); +} + +async function refreshTeamspaceActualsForActivePeriod(userId: string, period: ParsedPeriod, teamspaceTimeTrackingEnabled: boolean) { + if (!teamspaceTimeTrackingEnabled) { + return; + } + + const periodStart = period.start; + const periodEndExclusive = period.endIso.slice(0, 10); + const today = currentBerlinDateLabel(); + + if (today < periodStart || today >= periodEndExclusive) { + return; + } + + const userResult = await query<{ teamspace_user_id: string | null; has_teamspace_credentials: boolean }>( + ` + SELECT 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 + FROM users + WHERE id = $1; + `, + [userId] + ); + const user = userResult.rows[0]; + const teamspaceUserId = user?.teamspace_user_id; + + if (!teamspaceUserId || !user?.has_teamspace_credentials) { + return; + } + + const credentials = await getTeamspaceCredentialsForUser(userId); + const entriesResult = await query( + ` + SELECT DISTINCT + pte.id::text AS "entryId", + pte.teamspace_api_project_id::int AS "apiProjectId" + FROM projects p + JOIN project_teamspace_entries pte ON pte.project_id = p.id + LEFT JOIN teamspace_project_exclusions tpe ON lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) + WHERE pte.teamspace_api_project_id IS NOT NULL + AND tpe.teamspace_project IS NULL + AND ( + p.user_id = $1 + OR EXISTS ( + SELECT 1 + FROM sessions viewer_sessions + WHERE viewer_sessions.ticket_id = p.ticket_id + AND viewer_sessions.user_id = $1 + AND viewer_sessions.started_at >= $2::timestamptz + AND viewer_sessions.started_at < $3::timestamptz + ) + ); + `, + [userId, period.startIso, period.endIso] + ); + + if (entriesResult.rows.length === 0) { + return; + } + + for (const monthLabel of monthLabelsForDateRange(periodStart, periodEndExclusive)) { + await syncTeamspaceMonthlyActualsForEntries( + userId, + credentials, + teamspaceUserId, + parseMonth(monthLabel), + entriesResult.rows + ); + } +} + +async function consolidateSharedTeamspaceProjectData(client: QueryClient, teamspaceProjects: Array = []) { + const normalizedProjects = normalizedTeamspaceProjectList(teamspaceProjects).map((project) => project.toLowerCase()); + const filterSql = normalizedProjects.length > 0 ? "AND lower(btrim(teamspace_project)) = ANY($1::text[])" : ""; + const params = normalizedProjects.length > 0 ? [normalizedProjects] : []; + + await client.query( + ` + INSERT INTO teamspace_project_resources ( + teamspace_project, + teamspace_api_project_id, + teamspace_api_project_url, + hour_budget_minutes, + budget_sync_disabled, + updated_at + ) + SELECT + canonical.teamspace_project, + canonical.teamspace_api_project_id, + canonical.teamspace_api_project_url, + canonical.hour_budget_minutes, + canonical.budget_sync_disabled, + now() + FROM ( + SELECT DISTINCT ON (lower(btrim(teamspace_project))) + btrim(teamspace_project) AS teamspace_project, + teamspace_api_project_id, + teamspace_api_project_url, + hour_budget_minutes, + COALESCE(budget_sync_disabled, false) AS budget_sync_disabled + FROM project_teamspace_entries + WHERE teamspace_project IS NOT NULL + AND btrim(teamspace_project) <> '' + ${filterSql} + AND NOT EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project)) + ) + ORDER BY + lower(btrim(teamspace_project)), + (teamspace_api_project_id IS NOT NULL) DESC, + (teamspace_api_project_url IS NOT NULL) DESC, + (hour_budget_minutes IS NOT NULL) DESC, + updated_at DESC, + id DESC + ) canonical + WHERE NOT EXISTS ( + SELECT 1 + FROM teamspace_project_resources existing + WHERE lower(btrim(existing.teamspace_project)) = lower(btrim(canonical.teamspace_project)) + ); + `, + params + ); + + await client.query( + ` + UPDATE project_teamspace_entries pte + SET teamspace_project_resource_id = resource.id, + updated_at = CASE + WHEN pte.teamspace_project_resource_id IS DISTINCT FROM resource.id THEN now() + ELSE pte.updated_at + END + FROM teamspace_project_resources resource + WHERE pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> '' + ${filterSql.replaceAll("teamspace_project", "pte.teamspace_project")} + AND NOT EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) + ) + AND lower(btrim(resource.teamspace_project)) = lower(btrim(pte.teamspace_project)) + AND pte.teamspace_project_resource_id IS DISTINCT FROM resource.id; + `, + params + ); + + await client.query( + ` + WITH candidate_keys AS ( + SELECT DISTINCT lower(btrim(teamspace_project)) AS teamspace_key + FROM project_teamspace_entries + WHERE teamspace_project IS NOT NULL + AND btrim(teamspace_project) <> '' + ${filterSql} + AND NOT EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project)) + ) + ), + canonical AS ( + SELECT DISTINCT ON (lower(btrim(pte.teamspace_project))) + lower(btrim(pte.teamspace_project)) AS teamspace_key, + btrim(pte.teamspace_project) AS teamspace_project, + pte.teamspace_api_project_id, + pte.teamspace_api_project_url, + pte.hour_budget_minutes, + COALESCE(pte.budget_sync_disabled, false) AS budget_sync_disabled + FROM project_teamspace_entries pte + JOIN candidate_keys ck ON ck.teamspace_key = lower(btrim(pte.teamspace_project)) + WHERE pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> '' + AND NOT EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) + ) + ORDER BY + lower(btrim(pte.teamspace_project)), + (pte.teamspace_api_project_id IS NOT NULL) DESC, + (pte.teamspace_api_project_url IS NOT NULL) DESC, + (pte.hour_budget_minutes IS NOT NULL) DESC, + pte.updated_at DESC, + pte.id DESC + ) + UPDATE teamspace_project_resources resource + SET teamspace_project = canonical.teamspace_project, + teamspace_api_project_id = COALESCE(canonical.teamspace_api_project_id, resource.teamspace_api_project_id), + teamspace_api_project_url = COALESCE(canonical.teamspace_api_project_url, resource.teamspace_api_project_url), + hour_budget_minutes = COALESCE(canonical.hour_budget_minutes, resource.hour_budget_minutes), + budget_sync_disabled = canonical.budget_sync_disabled, + updated_at = CASE + WHEN resource.teamspace_project IS DISTINCT FROM canonical.teamspace_project + OR resource.teamspace_api_project_id IS DISTINCT FROM COALESCE(canonical.teamspace_api_project_id, resource.teamspace_api_project_id) + OR resource.teamspace_api_project_url IS DISTINCT FROM COALESCE(canonical.teamspace_api_project_url, resource.teamspace_api_project_url) + OR resource.hour_budget_minutes IS DISTINCT FROM COALESCE(canonical.hour_budget_minutes, resource.hour_budget_minutes) + OR resource.budget_sync_disabled IS DISTINCT FROM canonical.budget_sync_disabled + THEN now() + ELSE resource.updated_at + END + FROM canonical + WHERE lower(btrim(resource.teamspace_project)) = canonical.teamspace_key; + `, + params + ); + + await client.query( + ` + UPDATE project_teamspace_entries pte + SET teamspace_api_project_id = COALESCE(resource.teamspace_api_project_id, pte.teamspace_api_project_id), + teamspace_api_project_url = COALESCE(resource.teamspace_api_project_url, pte.teamspace_api_project_url), + hour_budget_minutes = COALESCE(resource.hour_budget_minutes, pte.hour_budget_minutes), + budget_sync_disabled = resource.budget_sync_disabled, + updated_at = CASE + WHEN pte.teamspace_api_project_id IS DISTINCT FROM COALESCE(resource.teamspace_api_project_id, pte.teamspace_api_project_id) + OR pte.teamspace_api_project_url IS DISTINCT FROM COALESCE(resource.teamspace_api_project_url, pte.teamspace_api_project_url) + OR pte.hour_budget_minutes IS DISTINCT FROM COALESCE(resource.hour_budget_minutes, pte.hour_budget_minutes) + OR pte.budget_sync_disabled IS DISTINCT FROM resource.budget_sync_disabled + THEN now() + ELSE pte.updated_at + END + FROM teamspace_project_resources resource + WHERE pte.teamspace_project_resource_id = resource.id + AND pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> '' + ${filterSql.replaceAll("teamspace_project", "pte.teamspace_project")} + AND NOT EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) + ); + `, + params + ); + + await client.query( + ` + WITH candidate_keys AS ( + SELECT DISTINCT lower(btrim(teamspace_project)) AS teamspace_key + FROM project_teamspace_entries + WHERE teamspace_project IS NOT NULL + AND btrim(teamspace_project) <> '' + ${filterSql} + AND NOT EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project)) + ) + ), + shared_resources AS ( + SELECT + pte.teamspace_project_resource_id AS resource_id, + lower(btrim(pte.teamspace_project)) AS teamspace_key + FROM project_teamspace_entries pte + JOIN candidate_keys ck ON ck.teamspace_key = lower(btrim(pte.teamspace_project)) + WHERE pte.teamspace_project_resource_id IS NOT NULL + GROUP BY pte.teamspace_project_resource_id, lower(btrim(pte.teamspace_project)) + HAVING COUNT(*) > 1 + ), + resource_entries AS ( + SELECT + pte.id AS entry_id, + pte.teamspace_project_resource_id AS resource_id, + p.user_id, + p.ticket_id, + pte.is_default, + pte.updated_at + FROM project_teamspace_entries pte + JOIN projects p ON p.id = pte.project_id + JOIN shared_resources sr ON sr.resource_id = pte.teamspace_project_resource_id + WHERE p.user_id IS NOT NULL + ), + canonical_entries AS ( + SELECT DISTINCT ON (resource_id, user_id) + resource_id, + user_id, + entry_id + FROM resource_entries + ORDER BY + resource_id, + user_id, + EXISTS ( + SELECT 1 + FROM project_teamspace_subentries pts + WHERE pts.user_id = resource_entries.user_id + AND pts.project_teamspace_entry_id = resource_entries.entry_id + ) DESC, + updated_at DESC, + entry_id DESC + ), + resource_consumption AS ( + SELECT + ce.resource_id, + ce.user_id, + 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 shared_consumption_minutes + FROM canonical_entries ce + JOIN project_teamspace_subentries pts + ON pts.project_teamspace_entry_id = ce.entry_id + AND pts.user_id = ce.user_id + GROUP BY ce.resource_id, ce.user_id + ), + local_day_values AS ( + SELECT + re.entry_id, + COALESCE(SUM(tdb.billed_minutes), 0)::int AS local_day_minutes + FROM resource_entries re + LEFT JOIN ticket_day_billings tdb + ON tdb.ticket_id = re.ticket_id + AND tdb.user_id = re.user_id + AND ( + tdb.project_teamspace_entry_id = re.entry_id + OR (re.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + GROUP BY re.entry_id + ) + UPDATE project_teamspace_entries pte + SET manual_consumption_minutes = GREATEST(resource_consumption.shared_consumption_minutes - local_day_values.local_day_minutes, 0), + updated_at = CASE + WHEN pte.manual_consumption_minutes IS DISTINCT FROM GREATEST(resource_consumption.shared_consumption_minutes - local_day_values.local_day_minutes, 0) + THEN now() + ELSE pte.updated_at + END + FROM resource_entries re + JOIN resource_consumption + ON resource_consumption.resource_id = re.resource_id + AND resource_consumption.user_id = re.user_id + JOIN local_day_values ON local_day_values.entry_id = re.entry_id + WHERE pte.id = re.entry_id + AND resource_consumption.shared_consumption_minutes > 0; + `, + params + ); + + await client.query( + ` + WITH candidate_keys AS ( + SELECT DISTINCT lower(btrim(teamspace_project)) AS teamspace_key + FROM project_teamspace_entries + WHERE teamspace_project IS NOT NULL + AND btrim(teamspace_project) <> '' + ${filterSql} + AND NOT EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project)) + ) + ), + duplicate_keys AS ( + SELECT lower(btrim(pte.teamspace_project)) AS teamspace_key + FROM project_teamspace_entries pte + JOIN candidate_keys ck ON ck.teamspace_key = lower(btrim(pte.teamspace_project)) + WHERE pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> '' + GROUP BY lower(btrim(pte.teamspace_project)) + HAVING COUNT(*) > 1 + ), + canonical AS ( + SELECT DISTINCT ON (lower(btrim(pte.teamspace_project))) + lower(btrim(pte.teamspace_project)) AS teamspace_key, + pte.teamspace_api_project_id, + pte.teamspace_api_project_url, + pte.hour_budget_minutes, + COALESCE(pte.budget_sync_disabled, false) AS budget_sync_disabled + FROM project_teamspace_entries pte + JOIN duplicate_keys dk ON dk.teamspace_key = lower(btrim(pte.teamspace_project)) + ORDER BY + lower(btrim(pte.teamspace_project)), + (pte.teamspace_api_project_id IS NOT NULL) DESC, + (pte.teamspace_api_project_url IS NOT NULL) DESC, + (pte.hour_budget_minutes IS NOT NULL) DESC, + pte.updated_at DESC, + pte.id DESC + ) + UPDATE project_teamspace_entries target + SET teamspace_api_project_id = COALESCE(canonical.teamspace_api_project_id, target.teamspace_api_project_id), + teamspace_api_project_url = COALESCE(canonical.teamspace_api_project_url, target.teamspace_api_project_url), + hour_budget_minutes = COALESCE(canonical.hour_budget_minutes, target.hour_budget_minutes), + budget_sync_disabled = canonical.budget_sync_disabled, + updated_at = CASE + WHEN target.teamspace_api_project_id IS DISTINCT FROM COALESCE(canonical.teamspace_api_project_id, target.teamspace_api_project_id) + OR target.teamspace_api_project_url IS DISTINCT FROM COALESCE(canonical.teamspace_api_project_url, target.teamspace_api_project_url) + OR target.hour_budget_minutes IS DISTINCT FROM COALESCE(canonical.hour_budget_minutes, target.hour_budget_minutes) + OR target.budget_sync_disabled IS DISTINCT FROM canonical.budget_sync_disabled + THEN now() + ELSE target.updated_at + END + FROM canonical + WHERE target.teamspace_project IS NOT NULL + AND btrim(target.teamspace_project) <> '' + AND lower(btrim(target.teamspace_project)) = canonical.teamspace_key; + `, + params + ); + + await client.query( + ` + WITH candidate_keys AS ( + SELECT DISTINCT lower(btrim(teamspace_project)) AS teamspace_key + FROM project_teamspace_entries + WHERE teamspace_project IS NOT NULL + AND btrim(teamspace_project) <> '' + ${filterSql} + AND NOT EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project)) + ) + ), + duplicate_keys AS ( + SELECT lower(btrim(pte.teamspace_project)) AS teamspace_key + FROM project_teamspace_entries pte + JOIN candidate_keys ck ON ck.teamspace_key = lower(btrim(pte.teamspace_project)) + WHERE pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> '' + GROUP BY lower(btrim(pte.teamspace_project)) + HAVING COUNT(*) > 1 + ), + entry_keys AS ( + SELECT + pte.id AS entry_id, + p.user_id, + lower(btrim(pte.teamspace_project)) AS teamspace_key, + pte.teamspace_api_project_id, + pte.updated_at + FROM project_teamspace_entries pte + JOIN projects p ON p.id = pte.project_id + JOIN duplicate_keys dk ON dk.teamspace_key = lower(btrim(pte.teamspace_project)) + WHERE p.user_id IS NOT NULL + ), + canonical_entries AS ( + SELECT DISTINCT ON (entry_keys.user_id, entry_keys.teamspace_key) + entry_keys.user_id, + entry_keys.teamspace_key, + entry_keys.entry_id + FROM entry_keys + ORDER BY + entry_keys.user_id, + entry_keys.teamspace_key, + EXISTS ( + SELECT 1 + FROM project_teamspace_subentries pts + WHERE pts.user_id = entry_keys.user_id + AND pts.project_teamspace_entry_id = entry_keys.entry_id + ) DESC, + (entry_keys.teamspace_api_project_id IS NOT NULL) DESC, + entry_keys.updated_at DESC, + entry_keys.entry_id DESC + ), + targets AS ( + SELECT + entry_keys.entry_id AS target_entry_id, + entry_keys.user_id, + canonical_entries.entry_id AS source_entry_id + FROM entry_keys + JOIN canonical_entries + ON canonical_entries.user_id = entry_keys.user_id + AND canonical_entries.teamspace_key = entry_keys.teamspace_key + WHERE entry_keys.entry_id <> canonical_entries.entry_id + ), + deleted AS ( + DELETE FROM project_teamspace_subentries pts + USING targets + WHERE pts.user_id = targets.user_id + AND pts.project_teamspace_entry_id = targets.target_entry_id + ) + 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 + ) + SELECT + targets.user_id, + targets.target_entry_id, + source.teamspace_api_project_id, + source.teamspace_api_project_url, + source.teamspace_project_id, + source.billing_type, + source.name, + source.alias, + source.description, + source.progress, + source.planned_minutes, + source.sum_minutes, + source.planned_minutes_billable, + source.sum_minutes_billable, + source.planned_income, + source.sum_income, + source.display_order, + source.synced_at, + now() + FROM targets + JOIN project_teamspace_subentries source + ON source.user_id = targets.user_id + AND source.project_teamspace_entry_id = targets.source_entry_id + 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 = EXCLUDED.alias, + 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 = EXCLUDED.synced_at, + updated_at = now(); + `, + params + ); + + await client.query( + ` + WITH candidate_keys AS ( + SELECT DISTINCT lower(btrim(teamspace_project)) AS teamspace_key + FROM project_teamspace_entries + WHERE teamspace_project IS NOT NULL + AND btrim(teamspace_project) <> '' + ${filterSql} + AND NOT EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project)) + ) + ), + duplicate_keys AS ( + SELECT lower(btrim(pte.teamspace_project)) AS teamspace_key + FROM project_teamspace_entries pte + JOIN candidate_keys ck ON ck.teamspace_key = lower(btrim(pte.teamspace_project)) + WHERE pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> '' + GROUP BY lower(btrim(pte.teamspace_project)) + HAVING COUNT(*) > 1 + ), + entry_keys AS ( + SELECT + pte.id AS entry_id, + p.user_id, + lower(btrim(pte.teamspace_project)) AS teamspace_key + FROM project_teamspace_entries pte + JOIN projects p ON p.id = pte.project_id + JOIN duplicate_keys dk ON dk.teamspace_key = lower(btrim(pte.teamspace_project)) + WHERE p.user_id IS NOT NULL + ), + canonical_actuals AS ( + SELECT DISTINCT ON (ptma.user_id, entry_keys.teamspace_key, ptma.month) + ptma.user_id, + entry_keys.teamspace_key, + ptma.month, + ptma.teamspace_minutes, + ptma.teamspace_billable_minutes, + ptma.teamspace_time_entry_count, + ptma.positions, + ptma.synced_at + FROM project_teamspace_monthly_actuals ptma + JOIN entry_keys ON entry_keys.entry_id = ptma.project_teamspace_entry_id + ORDER BY + ptma.user_id, + entry_keys.teamspace_key, + ptma.month, + ptma.synced_at DESC, + ptma.updated_at DESC, + ptma.project_teamspace_entry_id DESC + ) + INSERT INTO project_teamspace_monthly_actuals ( + user_id, + project_teamspace_entry_id, + month, + teamspace_minutes, + teamspace_billable_minutes, + teamspace_time_entry_count, + positions, + synced_at, + updated_at + ) + SELECT + canonical_actuals.user_id, + entry_keys.entry_id, + canonical_actuals.month, + canonical_actuals.teamspace_minutes, + canonical_actuals.teamspace_billable_minutes, + canonical_actuals.teamspace_time_entry_count, + canonical_actuals.positions, + canonical_actuals.synced_at, + now() + FROM canonical_actuals + JOIN entry_keys + ON entry_keys.user_id = canonical_actuals.user_id + AND entry_keys.teamspace_key = canonical_actuals.teamspace_key + ON CONFLICT (user_id, project_teamspace_entry_id, month) + DO UPDATE SET + teamspace_minutes = EXCLUDED.teamspace_minutes, + teamspace_billable_minutes = EXCLUDED.teamspace_billable_minutes, + teamspace_time_entry_count = EXCLUDED.teamspace_time_entry_count, + positions = EXCLUDED.positions, + synced_at = EXCLUDED.synced_at, + updated_at = now(); + `, + params + ); + + await client.query( + ` + WITH candidate_keys AS ( + SELECT DISTINCT lower(btrim(teamspace_project)) AS teamspace_key + FROM project_teamspace_entries + WHERE teamspace_project IS NOT NULL + AND btrim(teamspace_project) <> '' + ${filterSql} + AND NOT EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project)) + ) + ), + affected_projects AS ( + SELECT DISTINCT pte.project_id + FROM project_teamspace_entries pte + JOIN candidate_keys ck ON ck.teamspace_key = lower(btrim(pte.teamspace_project)) + ), + entry_budget AS ( + SELECT + pte.project_id, + COALESCE(SUM(pte.hour_budget_minutes) FILTER (WHERE pte.hour_budget_minutes IS NOT NULL), NULL)::int AS hour_budget_minutes, + COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS manual_consumption_minutes + FROM project_teamspace_entries pte + JOIN affected_projects affected ON affected.project_id = pte.project_id + GROUP BY pte.project_id + ) + UPDATE projects p + SET hour_budget_minutes = entry_budget.hour_budget_minutes, + manual_consumption_minutes = entry_budget.manual_consumption_minutes, + updated_at = now() + FROM entry_budget + WHERE p.id = entry_budget.project_id + AND ( + p.hour_budget_minutes IS DISTINCT FROM entry_budget.hour_budget_minutes + OR p.manual_consumption_minutes IS DISTINCT FROM entry_budget.manual_consumption_minutes + ); + `, + params + ); +} + function isTeamspaceMainProject(project: TeamspaceRemoteProject) { return project.projectId === project.mainprojectId || project.mainProjectApiProjectId === project.apiProjectId; } @@ -5562,6 +6771,10 @@ async function ensureZammadPendingTodo(client: QueryClient, ticketId: string, us ); } +function zammadPendingTodoUserId(ownerMapping: ZammadOwnerUserMapping) { + return ownerMapping.status === "mapped" ? ownerMapping.userId : null; +} + async function mergeTeamspaceEntriesIntoTarget(client: QueryClient, sourceEntryIds: string[], targetEntryId: string) { const sourceIds = sourceEntryIds.filter((id) => id !== targetEntryId); @@ -5754,6 +6967,7 @@ async function syncProjectTeamspaceEntriesFromZammad( `, [projectId, zammadProjects[0] ?? null] ); + await consolidateSharedTeamspaceProjectData(client, zammadProjects); return; } @@ -5834,6 +7048,7 @@ async function syncProjectTeamspaceEntriesFromZammad( `, [projectId, defaultEntry.teamspace_project] ); + await consolidateSharedTeamspaceProjectData(client, zammadProjects); } async function planExistingZammadTicketSync( @@ -5854,13 +7069,14 @@ async function planExistingZammadTicketSync( }); } - const pendingTodo = zammadPendingTodoPayload(zammadTicket); - const pendingTodoExists = pendingTodo ? await zammadPendingTodoExists(client, localTicket.id, userId, zammadTicket) : null; const isMerge = isZammadMergedTicketSync(zammadTicket); const isMergeProtected = isMerge || isLocalMergedTicket(localTicket); const isOpenReviewProtected = zammadTicket.zammadStateId !== 4 && await ticketHasOpenZammadReviewProject(client, localTicket.id); const isProjectDataProtected = isMergeProtected || isOpenReviewProtected; const ownerMapping = await zammadOwnerUserMapping(client, zammadTicket.zammadOwnerId); + const pendingTodo = zammadPendingTodoPayload(zammadTicket); + const pendingTodoUserId = zammadPendingTodoUserId(ownerMapping); + const pendingTodoExists = pendingTodo && pendingTodoUserId ? await zammadPendingTodoExists(client, localTicket.id, pendingTodoUserId, zammadTicket) : null; const zammadOwnerUserId = ownerMapping.status === "ambiguous" ? localTicket.user_id : ownerMapping.userId; const currentZammadUserId = await zammadUserIdForUser(client, userId); const localZammadOwnerId = numberOrNull(localTicket.zammad_owner_id); @@ -5936,7 +7152,7 @@ async function planExistingZammadTicketSync( addSyncChange(changes, "Zammad-Priorität", localTicket.zammad_priority_id, zammadTicket.zammadPriorityId); addSyncChange(changes, "Zammad-Status", localTicket.zammad_state_id, zammadTicket.zammadStateId); - if (fields.pendingTodo && pendingTodo && !pendingTodoExists) { + if (fields.pendingTodo && pendingTodo && pendingTodoUserId && pendingTodoExists === false) { changes.push({ field: "ToDo", from: null, to: `${pendingTodo.title} (${pendingTodo.day})` }); } @@ -7527,7 +8743,11 @@ async function applyZammadTicketSync( } if (fields.pendingTodo) { - await ensureZammadPendingTodo(client, localTicket.id, userId, zammadTicket); + const pendingTodoUserId = zammadPendingTodoUserId(ownerMapping); + + if (pendingTodoUserId) { + await ensureZammadPendingTodo(client, localTicket.id, pendingTodoUserId, zammadTicket); + } } if (isMergeProtected || (zammadTicket.zammadStateId !== 4 && await ticketHasOpenZammadReviewProject(client, localTicket.id))) { @@ -7634,6 +8854,9 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use COUNT(*) FILTER (WHERE s.recurring_billing_id IS NULL)::int AS manual_session_count, COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'billed'), 0)::int AS total_minutes, ticket_crm.crm_billed_minutes, + COALESCE(ts_month.teamspace_month_billable_minutes, 0)::int AS teamspace_month_billable_minutes, + COALESCE(ts_month.teamspace_month_delta_minutes, 0)::int AS teamspace_month_delta_minutes, + COALESCE(ts_month.teamspace_month_actuals, '[]'::jsonb) AS teamspace_month_actuals, COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'billable'), 0)::int AS billable_minutes, COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'non_billable'), 0)::int AS non_billable_minutes, COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status IS NULL OR s.billing_status = 'billable'), 0)::int AS open_minutes, @@ -7657,6 +8880,94 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use AND tdb.day >= ($1::timestamptz AT TIME ZONE 'Europe/Berlin')::date AND tdb.day < ($2::timestamptz AT TIME ZONE 'Europe/Berlin')::date ) ticket_crm ON true + LEFT JOIN LATERAL ( + SELECT + COALESCE(SUM(ptma.teamspace_billable_minutes), 0)::int AS teamspace_month_billable_minutes, + COALESCE(SUM( + CASE + WHEN entry_time_comparison.ignored THEN 0 + ELSE ptma.teamspace_billable_minutes + - CASE WHEN entry_time_comparison.comparable THEN COALESCE(tt_entry.billed_minutes, 0) ELSE 0 END + END + ), 0)::int AS teamspace_month_delta_minutes, + COALESCE(jsonb_agg( + jsonb_build_object( + 'entry_id', pte.id::text, + 'teamspace_project', pte.teamspace_project, + 'teamspace_description', pte.teamspace_description, + 'teamspace_api_project_id', pte.teamspace_api_project_id::text, + 'teamspace_minutes', ptma.teamspace_minutes, + 'teamspace_billable_minutes', ptma.teamspace_billable_minutes, + 'teamspace_time_entry_count', ptma.teamspace_time_entry_count, + 'delta_minutes', ( + CASE + WHEN entry_time_comparison.ignored THEN 0 + ELSE ptma.teamspace_billable_minutes + - CASE WHEN entry_time_comparison.comparable THEN COALESCE(tt_entry.billed_minutes, 0) ELSE 0 END + END + )::int, + 'positions', ptma.positions + ) + ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC + ), '[]'::jsonb) AS teamspace_month_actuals + FROM project_teamspace_entries pte + JOIN project_teamspace_monthly_actuals ptma ON ptma.project_teamspace_entry_id = pte.id + LEFT JOIN LATERAL ( + SELECT + NOT EXISTS ( + SELECT 1 + FROM project_teamspace_subentries pts + WHERE pts.project_teamspace_entry_id = pte.id + AND pts.user_id = $3 + ) + OR EXISTS ( + SELECT 1 + FROM project_teamspace_subentries pts + WHERE pts.project_teamspace_entry_id = pte.id + AND pts.user_id = $3 + AND pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX' + ) AS comparable, + EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) + ) AS ignored + ) entry_time_comparison ON true + LEFT JOIN LATERAL ( + SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS billed_minutes + FROM project_teamspace_entries comparison_pte + JOIN projects comparison_project ON comparison_project.id = comparison_pte.project_id + JOIN ticket_day_billings tdb + ON tdb.ticket_id = comparison_project.ticket_id + AND tdb.user_id = $3 + AND tdb.day >= ($1::timestamptz AT TIME ZONE 'Europe/Berlin')::date + AND tdb.day < ($2::timestamptz AT TIME ZONE 'Europe/Berlin')::date + AND ( + tdb.project_teamspace_entry_id = comparison_pte.id + OR (comparison_pte.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + WHERE comparison_project.user_id = $3 + AND ( + comparison_pte.id = pte.id + OR ( + pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> '' + AND lower(btrim(comparison_pte.teamspace_project)) = lower(btrim(pte.teamspace_project)) + AND EXISTS ( + SELECT 1 + FROM project_teamspace_entries shared_pte + JOIN projects shared_project ON shared_project.id = shared_pte.project_id + WHERE shared_project.user_id = $3 + AND shared_pte.id <> pte.id + AND lower(btrim(shared_pte.teamspace_project)) = lower(btrim(pte.teamspace_project)) + ) + ) + ) + ) tt_entry ON true + WHERE pte.project_id = p.id + AND ptma.user_id = $3 + AND ptma.month = date_trunc('month', $1::timestamptz AT TIME ZONE 'Europe/Berlin')::date + ) ts_month ON true WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz AND s.user_id = $3 GROUP BY @@ -7668,7 +8979,10 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use t.customer_name, t.work_type, p.id, - ticket_crm.crm_billed_minutes + ticket_crm.crm_billed_minutes, + ts_month.teamspace_month_billable_minutes, + ts_month.teamspace_month_delta_minutes, + ts_month.teamspace_month_actuals ORDER BY t.ticket_number ASC; `, [period.startIso, period.endIso, userId] @@ -7780,6 +9094,165 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio [userId] ); const teamspaceTimeTrackingEnabled = Boolean(userSettingsResult.rows[0]?.teamspace_time_tracking_enabled ?? true); + await refreshTeamspaceActualsForActivePeriod(userId, period, teamspaceTimeTrackingEnabled); + const periodStartDateSql = "(($1::timestamptz AT TIME ZONE 'Europe/Berlin')::date)"; + const periodEndDateSql = "(($2::timestamptz AT TIME ZONE 'Europe/Berlin')::date)"; + const teamspaceActualMonthPredicate = + periodType === "week" + ? `ptma.month >= date_trunc('month', ${periodStartDateSql})::date + AND ptma.month < (date_trunc('month', (${periodEndDateSql} - 1)) + interval '1 month')::date` + : `ptma.month = date_trunc('month', ${periodStartDateSql})::date`; + const projectTeamspaceEntryPeriodActualsCte = + periodType === "week" + ? ` + project_teamspace_entry_period_actuals AS ( + SELECT + ptma.project_teamspace_entry_id, + COALESCE(SUM(period_position.minutes), 0)::int AS teamspace_minutes, + COALESCE(SUM(period_position.billable_minutes), 0)::int AS teamspace_billable_minutes, + COALESCE(SUM(period_position.entry_count), 0)::int AS teamspace_time_entry_count, + COALESCE(jsonb_agg(period_position.position ORDER BY ptma.month ASC, period_position.ordinality ASC) FILTER ( + WHERE period_position.minutes > 0 + OR period_position.billable_minutes > 0 + OR period_position.entry_count > 0 + ), '[]'::jsonb) AS positions + FROM project_teamspace_monthly_actuals ptma + LEFT JOIN LATERAL ( + SELECT + position.ordinality, + COALESCE(SUM((day.value->>'minutes')::int), 0)::int AS minutes, + COALESCE(SUM((day.value->>'billableMinutes')::int), 0)::int AS billable_minutes, + COALESCE(SUM((day.value->>'entryCount')::int), 0)::int AS entry_count, + jsonb_build_object( + 'apiProjectId', position.value->'apiProjectId', + 'projectId', position.value->'projectId', + 'label', position.value->'label', + 'isSubentry', COALESCE((position.value->>'isSubentry')::boolean, false), + 'billingType', position.value->'billingType', + 'minutes', COALESCE(SUM((day.value->>'minutes')::int), 0)::int, + 'billableMinutes', COALESCE(SUM((day.value->>'billableMinutes')::int), 0)::int, + 'entryCount', COALESCE(SUM((day.value->>'entryCount')::int), 0)::int, + 'days', COALESCE(jsonb_agg(day.value ORDER BY day.value->>'day') FILTER (WHERE day.value IS NOT NULL), '[]'::jsonb) + ) AS position + FROM jsonb_array_elements(COALESCE(ptma.positions, '[]'::jsonb)) WITH ORDINALITY AS position(value, ordinality) + LEFT JOIN LATERAL jsonb_array_elements(COALESCE(position.value->'days', '[]'::jsonb)) AS day(value) + ON (day.value->>'day') >= ${periodStartDateSql}::text + AND (day.value->>'day') < ${periodEndDateSql}::text + GROUP BY position.value, position.ordinality + ) period_position ON true + WHERE ptma.user_id = $3 + AND ${teamspaceActualMonthPredicate} + GROUP BY ptma.project_teamspace_entry_id + )` + : ` + project_teamspace_entry_period_actuals AS ( + SELECT + ptma.project_teamspace_entry_id, + COALESCE(SUM(ptma.teamspace_minutes), 0)::int AS teamspace_minutes, + COALESCE(SUM(ptma.teamspace_billable_minutes), 0)::int AS teamspace_billable_minutes, + COALESCE(SUM(ptma.teamspace_time_entry_count), 0)::int AS teamspace_time_entry_count, + COALESCE(jsonb_agg(position.value ORDER BY ptma.month ASC, position.ordinality ASC) FILTER (WHERE position.value IS NOT NULL), '[]'::jsonb) AS positions + FROM project_teamspace_monthly_actuals ptma + LEFT JOIN LATERAL jsonb_array_elements(COALESCE(ptma.positions, '[]'::jsonb)) WITH ORDINALITY AS position(value, ordinality) ON true + WHERE ptma.user_id = $3 + AND ${teamspaceActualMonthPredicate} + GROUP BY ptma.project_teamspace_entry_id + )`; + const teamspaceApiBillableMinutesSql = + periodType === "week" + ? ` + COALESCE(( + SELECT NULLIF(SUM((day.value->>'billableMinutes')::int)::int, 0) + FROM teamspace_monthly_user_actuals tmua + JOIN LATERAL jsonb_array_elements(COALESCE(tmua.positions, '[]'::jsonb)) AS position(value) ON true + JOIN LATERAL jsonb_array_elements(COALESCE(position.value->'days', '[]'::jsonb)) AS day(value) + ON (day.value->>'day') >= ${periodStartDateSql}::text + AND (day.value->>'day') < ${periodEndDateSql}::text + WHERE tmua.user_id = $3 + AND tmua.month >= date_trunc('month', ${periodStartDateSql})::date + AND tmua.month < (date_trunc('month', (${periodEndDateSql} - 1)) + interval '1 month')::date + ), ( + SELECT SUM(unique_actuals.teamspace_billable_minutes)::int + FROM ( + SELECT DISTINCT ON ( + CASE + WHEN pte.teamspace_project IS NOT NULL AND btrim(pte.teamspace_project) <> '' + THEN lower(btrim(pte.teamspace_project)) + ELSE concat('entry:', pte.id::text) + END + ) + CASE + WHEN pte.teamspace_project IS NOT NULL AND btrim(pte.teamspace_project) <> '' + THEN lower(btrim(pte.teamspace_project)) + ELSE concat('entry:', pte.id::text) + END AS actual_key, + ptma.teamspace_billable_minutes + FROM projects p + JOIN project_teamspace_entries pte ON pte.project_id = p.id + JOIN project_teamspace_entry_period_actuals ptma ON ptma.project_teamspace_entry_id = pte.id + WHERE p.user_id = $3 + OR EXISTS ( + SELECT 1 + FROM sessions viewer_sessions + WHERE viewer_sessions.ticket_id = p.ticket_id + AND viewer_sessions.user_id = $3 + ) + ORDER BY + CASE + WHEN pte.teamspace_project IS NOT NULL AND btrim(pte.teamspace_project) <> '' + THEN lower(btrim(pte.teamspace_project)) + ELSE concat('entry:', pte.id::text) + END, + pte.id DESC + ) unique_actuals + ), 0)::int` + : ` + COALESCE(( + SELECT tmua.teamspace_billable_minutes + FROM teamspace_monthly_user_actuals tmua + WHERE tmua.user_id = $3 + AND tmua.month = date_trunc('month', ${periodStartDateSql})::date + ), ( + SELECT SUM(unique_actuals.teamspace_billable_minutes)::int + FROM ( + SELECT DISTINCT ON ( + CASE + WHEN pte.teamspace_project IS NOT NULL AND btrim(pte.teamspace_project) <> '' + THEN lower(btrim(pte.teamspace_project)) + ELSE concat('entry:', pte.id::text) + END + ) + CASE + WHEN pte.teamspace_project IS NOT NULL AND btrim(pte.teamspace_project) <> '' + THEN lower(btrim(pte.teamspace_project)) + ELSE concat('entry:', pte.id::text) + END AS actual_key, + ptma.teamspace_billable_minutes + FROM projects p + JOIN project_teamspace_entries pte ON pte.project_id = p.id + JOIN project_teamspace_monthly_actuals ptma ON ptma.project_teamspace_entry_id = pte.id + WHERE ptma.user_id = $3 + AND ptma.month = date_trunc('month', ${periodStartDateSql})::date + AND ( + p.user_id = $3 + OR EXISTS ( + SELECT 1 + FROM sessions viewer_sessions + WHERE viewer_sessions.ticket_id = p.ticket_id + AND viewer_sessions.user_id = $3 + ) + ) + ORDER BY + CASE + WHEN pte.teamspace_project IS NOT NULL AND btrim(pte.teamspace_project) <> '' + THEN lower(btrim(pte.teamspace_project)) + ELSE concat('entry:', pte.id::text) + END, + ptma.synced_at DESC, + ptma.updated_at DESC, + pte.id DESC + ) unique_actuals + ), 0)::int`; const baseCte = ` WITH session_base AS ( @@ -7824,7 +9297,9 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio td.day, td.tracked_minutes, COALESCE(SUM(tdb.billed_minutes), 0)::int AS crm_billed_minutes, + COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_comparable_crm_billed_minutes, COUNT(tdb.billed_minutes) > 0 AS has_crm_value, + COUNT(tdb.billed_minutes) > 0 AS has_teamspace_comparable_crm_value, ack.acknowledged_at IS NOT NULL AS missing_crm_acknowledged, ack.acknowledged_at AS missing_crm_acknowledged_at FROM ticket_day td @@ -7869,11 +9344,109 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio session_group_with_crm AS ( SELECT sg.*, - ROUND(COALESCE(tdb.crm_billed_minutes, 0) * sg.total_minutes::numeric / NULLIF(tdb.tracked_minutes, 0))::int AS crm_billed_minutes + ROUND(COALESCE(tdb.teamspace_comparable_crm_billed_minutes, 0) * sg.total_minutes::numeric / NULLIF(tdb.tracked_minutes, 0))::int AS crm_billed_minutes FROM session_group sg LEFT JOIN ticket_day_billing tdb ON tdb.ticket_id = sg.ticket_id AND tdb.day = sg.day + ), + ${projectTeamspaceEntryPeriodActualsCte}, + ticket_month_actuals AS ( + SELECT + p.ticket_id, + COALESCE(SUM(ptma.teamspace_billable_minutes), 0)::int AS teamspace_month_billable_minutes, + COALESCE(SUM( + CASE + WHEN entry_time_comparison.ignored THEN 0 + ELSE ptma.teamspace_billable_minutes + - CASE WHEN entry_time_comparison.comparable THEN COALESCE(tt_entry.billed_minutes, 0) ELSE 0 END + END + ), 0)::int AS teamspace_month_delta_minutes, + COALESCE(jsonb_agg( + jsonb_build_object( + 'entry_id', pte.id::text, + 'teamspace_project', pte.teamspace_project, + 'teamspace_description', pte.teamspace_description, + 'teamspace_api_project_id', pte.teamspace_api_project_id::text, + 'teamspace_minutes', ptma.teamspace_minutes, + 'teamspace_billable_minutes', ptma.teamspace_billable_minutes, + 'teamspace_time_entry_count', ptma.teamspace_time_entry_count, + 'delta_minutes', ( + CASE + WHEN entry_time_comparison.ignored THEN 0 + ELSE ptma.teamspace_billable_minutes + - CASE WHEN entry_time_comparison.comparable THEN COALESCE(tt_entry.billed_minutes, 0) ELSE 0 END + END + )::int, + 'positions', ptma.positions + ) + ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC + ), '[]'::jsonb) AS teamspace_month_actuals + FROM projects p + JOIN project_teamspace_entries pte ON pte.project_id = p.id + JOIN project_teamspace_entry_period_actuals ptma ON ptma.project_teamspace_entry_id = pte.id + LEFT JOIN LATERAL ( + SELECT + NOT EXISTS ( + SELECT 1 + FROM project_teamspace_subentries pts + WHERE pts.project_teamspace_entry_id = pte.id + AND pts.user_id = $3 + ) + OR EXISTS ( + SELECT 1 + FROM project_teamspace_subentries pts + WHERE pts.project_teamspace_entry_id = pte.id + AND pts.user_id = $3 + AND pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX' + ) AS comparable, + EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) + ) AS ignored + ) entry_time_comparison ON true + LEFT JOIN LATERAL ( + SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS billed_minutes + FROM project_teamspace_entries comparison_pte + JOIN projects comparison_project ON comparison_project.id = comparison_pte.project_id + JOIN ticket_day_billings tdb + ON tdb.ticket_id = comparison_project.ticket_id + AND tdb.user_id = $3 + AND tdb.day >= ($1::timestamptz AT TIME ZONE 'Europe/Berlin')::date + AND tdb.day < ($2::timestamptz AT TIME ZONE 'Europe/Berlin')::date + AND ( + tdb.project_teamspace_entry_id = comparison_pte.id + OR (comparison_pte.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + WHERE comparison_project.user_id = $3 + AND ( + comparison_pte.id = pte.id + OR ( + pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> '' + AND lower(btrim(comparison_pte.teamspace_project)) = lower(btrim(pte.teamspace_project)) + AND EXISTS ( + SELECT 1 + FROM project_teamspace_entries shared_pte + JOIN projects shared_project ON shared_project.id = shared_pte.project_id + WHERE shared_project.user_id = $3 + AND shared_pte.id <> pte.id + AND lower(btrim(shared_pte.teamspace_project)) = lower(btrim(pte.teamspace_project)) + ) + ) + ) + ) tt_entry ON true + WHERE ( + p.user_id = $3 + OR EXISTS ( + SELECT 1 + FROM sessions viewer_sessions + WHERE viewer_sessions.ticket_id = p.ticket_id + AND viewer_sessions.user_id = $3 + ) + ) + GROUP BY p.ticket_id ) `; @@ -7888,6 +9461,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio acknowledgedMissingCrmResult, mismatchResult, teamspaceDayResult, + teamspaceMonthMismatchResult, closureResult ] = await Promise.all([ query( @@ -7911,7 +9485,8 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio COUNT(*) FILTER (WHERE recurring_billing_id IS NULL AND billing_status = 'billed')::int AS manual_sessions, COALESCE(ROUND(AVG(rounded_minutes) FILTER (WHERE billing_status = 'billed')), 0)::int AS average_session_minutes, COUNT(DISTINCT day) FILTER (WHERE billing_status = 'billed')::int AS active_days, - COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing), 0)::int AS crm_billed_minutes + COALESCE((SELECT SUM(teamspace_comparable_crm_billed_minutes)::int FROM ticket_day_billing), 0)::int AS crm_billed_minutes, + ${teamspaceApiBillableMinutesSql} AS teamspace_api_billable_minutes FROM session_base; `, [period.startIso, period.endIso, userId] @@ -7931,7 +9506,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio COALESCE(day_sessions.billable_sessions, 0)::int AS billable_sessions, COALESCE(day_sessions.non_billable_sessions, 0)::int AS non_billable_sessions, COALESCE(day_sessions.open_sessions, 0)::int AS open_sessions, - COALESCE(SUM(tdb.crm_billed_minutes), 0)::int AS crm_billed_minutes + COALESCE(SUM(tdb.teamspace_comparable_crm_billed_minutes), 0)::int AS crm_billed_minutes FROM ( SELECT day, SUM(tracked_minutes)::int AS tracked_minutes FROM ticket_day @@ -8009,8 +9584,8 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio ` ${baseCte} SELECT - ticket_id::text AS ticket_id, - ticket_number, + session_base.ticket_id::text AS ticket_id, + session_base.ticket_number, MIN(organization_id)::text AS organization_id, MIN(organization_name) AS organization_name, COUNT(DISTINCT day) FILTER (WHERE billing_status = 'billed')::int AS active_days, @@ -8024,9 +9599,13 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio COUNT(*) FILTER (WHERE billing_status = 'billable')::int AS billable_sessions, COUNT(*) FILTER (WHERE billing_status = 'non_billable')::int AS non_billable_sessions, COUNT(*) FILTER (WHERE billing_status IS NULL OR billing_status = 'billable')::int AS open_sessions, - COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing tdb WHERE tdb.ticket_id = session_base.ticket_id), 0)::int AS crm_billed_minutes + COALESCE((SELECT SUM(teamspace_comparable_crm_billed_minutes)::int FROM ticket_day_billing tdb WHERE tdb.ticket_id = session_base.ticket_id), 0)::int AS crm_billed_minutes, + COALESCE(tma.teamspace_month_billable_minutes, 0)::int AS teamspace_month_billable_minutes, + COALESCE(tma.teamspace_month_delta_minutes, 0)::int AS teamspace_month_delta_minutes, + COALESCE(tma.teamspace_month_actuals, '[]'::jsonb) AS teamspace_month_actuals FROM session_base - GROUP BY ticket_id, ticket_number + LEFT JOIN ticket_month_actuals tma ON tma.ticket_id = session_base.ticket_id + GROUP BY session_base.ticket_id, session_base.ticket_number, tma.teamspace_month_billable_minutes, tma.teamspace_month_delta_minutes, tma.teamspace_month_actuals HAVING SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed') > 0 ORDER BY total_minutes DESC, ticket_number ASC LIMIT 12; @@ -8106,39 +9685,117 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio MIN(sb.organization_name) AS organization_name, tdb.day::text AS day, tdb.tracked_minutes, - tdb.crm_billed_minutes, - (tdb.crm_billed_minutes - tdb.tracked_minutes)::int AS delta_minutes + tdb.teamspace_comparable_crm_billed_minutes AS crm_billed_minutes, + (tdb.teamspace_comparable_crm_billed_minutes - tdb.tracked_minutes)::int AS delta_minutes FROM ticket_day_billing tdb JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day - WHERE tdb.has_crm_value - AND tdb.tracked_minutes <> tdb.crm_billed_minutes - GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.crm_billed_minutes - ORDER BY ABS(tdb.tracked_minutes - tdb.crm_billed_minutes) DESC, tdb.day ASC; + WHERE tdb.has_teamspace_comparable_crm_value + AND tdb.tracked_minutes <> tdb.teamspace_comparable_crm_billed_minutes + GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.teamspace_comparable_crm_billed_minutes + ORDER BY ABS(tdb.tracked_minutes - tdb.teamspace_comparable_crm_billed_minutes) DESC, tdb.day ASC; `, [period.startIso, period.endIso, userId] ), query( ` - ${baseCte} + ${baseCte}, + teamspace_actual_day AS ( + SELECT + p.ticket_id, + (day.value->>'day') AS day, + COALESCE(SUM((day.value->>'billableMinutes')::int), 0)::int AS crm_billed_minutes, + COALESCE(SUM((day.value->>'entryCount')::int), 0)::int AS teamspace_entry_count, + NULLIF( + string_agg( + DISTINCT NULLIF(COALESCE(position.value->>'label', pte.teamspace_description, pte.teamspace_project), ''), + ' + ' + ORDER BY NULLIF(COALESCE(position.value->>'label', pte.teamspace_description, pte.teamspace_project), '') + ), + '' + ) AS project_teamspace_project + FROM projects p + JOIN project_teamspace_entries pte ON pte.project_id = p.id + JOIN project_teamspace_entry_period_actuals ptma ON ptma.project_teamspace_entry_id = pte.id + LEFT JOIN teamspace_project_exclusions tpe ON lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) + JOIN LATERAL jsonb_array_elements(COALESCE(ptma.positions, '[]'::jsonb)) AS position(value) ON true + JOIN LATERAL jsonb_array_elements(COALESCE(position.value->'days', '[]'::jsonb)) AS day(value) + ON (day.value->>'day') >= ${periodStartDateSql}::text + AND (day.value->>'day') < ${periodEndDateSql}::text + WHERE tpe.teamspace_project IS NULL + AND ( + p.user_id = $3 + OR EXISTS ( + SELECT 1 + FROM sessions viewer_sessions + WHERE viewer_sessions.ticket_id = p.ticket_id + AND viewer_sessions.user_id = $3 + AND viewer_sessions.started_at >= $1::timestamptz + AND viewer_sessions.started_at < $2::timestamptz + ) + ) + GROUP BY p.ticket_id, day.value->>'day' + ) SELECT - tdb.ticket_id::text AS ticket_id, - tdb.ticket_number, - MIN(sb.organization_name) AS organization_name, - tdb.day::text AS day, - tdb.tracked_minutes, - tdb.crm_billed_minutes, - (tdb.crm_billed_minutes - tdb.tracked_minutes)::int AS delta_minutes, - COUNT(*) FILTER (WHERE sb.billing_status = 'billed')::int AS session_count, - NULLIF( - string_agg(DISTINCT NULLIF(sb.project_teamspace_project, ''), ' + ' ORDER BY NULLIF(sb.project_teamspace_project, '')) - FILTER (WHERE sb.billing_status = 'billed'), - '' - ) AS project_teamspace_project - FROM ticket_day_billing tdb - JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day - WHERE tdb.has_crm_value - GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.crm_billed_minutes - ORDER BY tdb.day ASC, tdb.ticket_number ASC; + tad.ticket_id::text AS ticket_id, + t.ticket_number, + COALESCE(day_sessions.organization_name, o.name, t.customer_name, 'Keine Organisation') AS organization_name, + tad.day::text AS day, + COALESCE(td.tracked_minutes, 0)::int AS tracked_minutes, + tad.crm_billed_minutes, + tad.teamspace_entry_count, + (tad.crm_billed_minutes - COALESCE(td.tracked_minutes, 0))::int AS delta_minutes, + COALESCE(day_sessions.session_count, 0)::int AS session_count, + COALESCE(day_sessions.project_teamspace_project, tad.project_teamspace_project) AS project_teamspace_project + FROM teamspace_actual_day tad + JOIN tickets t ON t.id = tad.ticket_id + LEFT JOIN organizations o ON o.zammad_id = t.organization_id + LEFT JOIN ticket_day td ON td.ticket_id = tad.ticket_id AND td.day::text = tad.day + LEFT JOIN LATERAL ( + SELECT + MIN(sb.organization_name) AS organization_name, + COUNT(*) FILTER (WHERE sb.billing_status = 'billed')::int AS session_count, + NULLIF( + string_agg(DISTINCT NULLIF(sb.project_teamspace_project, ''), ' + ' ORDER BY NULLIF(sb.project_teamspace_project, '')) + FILTER (WHERE sb.billing_status = 'billed'), + '' + ) AS project_teamspace_project + FROM session_base sb + WHERE sb.ticket_id = tad.ticket_id + AND sb.day::text = tad.day + ) day_sessions ON true + WHERE tad.crm_billed_minutes > 0 + ORDER BY tad.day ASC, t.ticket_number ASC; + `, + [period.startIso, period.endIso, userId] + ), + query( + ` + ${baseCte}, + ticket_billing AS ( + SELECT + ticket_id, + COALESCE(SUM(teamspace_comparable_crm_billed_minutes), 0)::int AS crm_billed_minutes + FROM ticket_day_billing + GROUP BY ticket_id + ), + mismatch_keys AS ( + SELECT ticket_id FROM ticket_month_actuals + ) + SELECT + k.ticket_id::text AS ticket_id, + t.ticket_number, + COALESCE(o.name, t.customer_name, 'Keine Organisation') AS organization_name, + COALESCE(tb.crm_billed_minutes, 0)::int AS crm_billed_minutes, + COALESCE(tma.teamspace_month_billable_minutes, 0)::int AS teamspace_month_billable_minutes, + COALESCE(tma.teamspace_month_delta_minutes, 0)::int AS teamspace_month_delta_minutes, + COALESCE(tma.teamspace_month_actuals, '[]'::jsonb) AS teamspace_month_actuals + FROM mismatch_keys k + JOIN tickets t ON t.id = k.ticket_id + LEFT JOIN organizations o ON o.zammad_id = t.organization_id + LEFT JOIN ticket_billing tb ON tb.ticket_id = k.ticket_id + LEFT JOIN ticket_month_actuals tma ON tma.ticket_id = k.ticket_id + WHERE COALESCE(tma.teamspace_month_delta_minutes, 0) <> 0 + ORDER BY ABS(COALESCE(tma.teamspace_month_delta_minutes, 0)) DESC, t.ticket_number ASC; `, [period.startIso, period.endIso, userId] ), @@ -8165,9 +9822,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio manual_sessions: 0, average_session_minutes: 0, active_days: 0, - crm_billed_minutes: 0 + crm_billed_minutes: 0, + teamspace_api_billable_minutes: 0 }; const effectiveCrmBilledMinutes = teamspaceTimeTrackingEnabled ? totals.crm_billed_minutes : totals.minutes; + const teamspaceApiBillableMinutes = Number(totals.teamspace_api_billable_minutes ?? 0); 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 }; @@ -8199,7 +9858,9 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio averageSessionMinutes: totals.average_session_minutes, activeDays: totals.active_days, crmBilledMinutes: effectiveCrmBilledMinutes, - crmDeltaMinutes: teamspaceTimeTrackingEnabled ? effectiveCrmBilledMinutes - totals.minutes : 0 + crmDeltaMinutes: teamspaceTimeTrackingEnabled ? effectiveCrmBilledMinutes - totals.minutes : 0, + teamspaceApiBillableMinutes, + teamspaceApiDeltaMinutes: teamspaceApiBillableMinutes - effectiveCrmBilledMinutes }, dailySeries: dailyResult.rows.map(applyTeamspaceDaily), organizations: organizationResult.rows.map(applyTeamspaceDelta), @@ -8209,7 +9870,8 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio openSessions: openResult.rows, missingCrmDays: missingCrmResult.rows, acknowledgedMissingCrmDays: acknowledgedMissingCrmResult.rows, - crmMismatches: mismatchResult.rows + crmMismatches: mismatchResult.rows, + teamspaceMonthMismatches: teamspaceMonthMismatchResult.rows }, teamspaceDays: teamspaceTimeTrackingEnabled ? teamspaceDayResult.rows : [] }; @@ -8281,6 +9943,7 @@ async function getProjectFollowups(userId: string) { session_updates.latest_session_at, session_updates.last_session_at, entry_totals.total_hour_budget_minutes, + COALESCE(entry_totals.teamspace_budget_excluded, false) AS teamspace_budget_excluded, 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) @@ -8292,8 +9955,10 @@ async function getProjectFollowups(userId: string) { 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, + BOOL_OR(tpe.teamspace_project IS NOT NULL) AS teamspace_budget_excluded, COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes FROM project_teamspace_entries pte + LEFT JOIN teamspace_project_exclusions tpe ON lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) WHERE pte.project_id = p.id ) entry_totals ON true LEFT JOIN LATERAL ( @@ -8374,6 +10039,7 @@ async function getProjectFollowups(userId: string) { COALESCE(o.name, t.customer_name) AS organization_name, p.teamspace_project, fp.total_hour_budget_minutes AS hour_budget_minutes, + fp.teamspace_budget_excluded, fp.consumption_minutes, CASE WHEN fp.total_hour_budget_minutes IS NULL THEN NULL @@ -9007,6 +10673,7 @@ 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, + COALESCE(entry_totals.teamspace_budget_excluded, false) AS teamspace_budget_excluded, entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes, EXISTS ( SELECT 1 @@ -9064,6 +10731,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon 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, + BOOL_OR(tpe.teamspace_project IS NOT NULL) AS teamspace_budget_excluded, COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes, COUNT(*)::int AS teamspace_entry_count, COALESCE( @@ -9072,6 +10740,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon 'id', pte.id::text, 'project_id', pte.project_id::text, 'teamspace_project', pte.teamspace_project, + 'teamspace_budget_excluded', tpe.teamspace_project IS NOT NULL, 'teamspace_api_project_id', pte.teamspace_api_project_id::text, 'teamspace_api_project_url', pte.teamspace_api_project_url, 'subentries', COALESCE(( @@ -9165,6 +10834,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon '[]'::json ) AS teamspace_entries FROM project_teamspace_entries pte + LEFT JOIN teamspace_project_exclusions tpe ON lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) WHERE pte.project_id = p.id ) entry_totals ON true LEFT JOIN LATERAL ( @@ -9231,6 +10901,95 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon const recurringSessionCount = sessions.length - manualSessionCount; const ticket = ticketResult.rows[0] as any; const closed = Boolean(filters.month && closureResult.rows.length > 0); + const teamspaceMonthActualsResult = filters.month ? await query( + ` + SELECT + pte.id::text AS entry_id, + pte.teamspace_project, + pte.teamspace_description, + pte.teamspace_api_project_id::text AS teamspace_api_project_id, + ptma.teamspace_minutes, + ptma.teamspace_billable_minutes, + ptma.teamspace_time_entry_count, + ( + CASE + WHEN entry_time_comparison.ignored THEN 0 + ELSE ptma.teamspace_billable_minutes + - CASE WHEN entry_time_comparison.comparable THEN COALESCE(tt_entry.billed_minutes, 0) ELSE 0 END + END + )::int AS delta_minutes, + ptma.positions + FROM projects p + JOIN project_teamspace_entries pte ON pte.project_id = p.id + JOIN project_teamspace_monthly_actuals ptma ON ptma.project_teamspace_entry_id = pte.id + LEFT JOIN LATERAL ( + SELECT + NOT EXISTS ( + SELECT 1 + FROM project_teamspace_subentries pts + WHERE pts.project_teamspace_entry_id = pte.id + AND pts.user_id = $2 + ) + OR EXISTS ( + SELECT 1 + FROM project_teamspace_subentries pts + WHERE pts.project_teamspace_entry_id = pte.id + AND pts.user_id = $2 + AND pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX' + ) AS comparable, + EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) + ) AS ignored + ) entry_time_comparison ON true + LEFT JOIN LATERAL ( + SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS billed_minutes + FROM project_teamspace_entries comparison_pte + JOIN projects comparison_project ON comparison_project.id = comparison_pte.project_id + JOIN ticket_day_billings tdb + ON tdb.ticket_id = comparison_project.ticket_id + AND tdb.user_id = $2 + AND tdb.day >= $3::date + AND tdb.day < $4::timestamptz::date + AND ( + tdb.project_teamspace_entry_id = comparison_pte.id + OR (comparison_pte.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + WHERE comparison_project.user_id = $2 + AND ( + comparison_pte.id = pte.id + OR ( + pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> '' + AND lower(btrim(comparison_pte.teamspace_project)) = lower(btrim(pte.teamspace_project)) + AND EXISTS ( + SELECT 1 + FROM project_teamspace_entries shared_pte + JOIN projects shared_project ON shared_project.id = shared_pte.project_id + WHERE shared_project.user_id = $2 + AND shared_pte.id <> pte.id + AND lower(btrim(shared_pte.teamspace_project)) = lower(btrim(pte.teamspace_project)) + ) + ) + ) + ) tt_entry ON true + WHERE p.ticket_id = $1 + AND ptma.user_id = $2 + AND ptma.month = $3::date + AND ( + p.user_id = $2 + OR EXISTS ( + SELECT 1 + FROM sessions viewer_sessions + WHERE viewer_sessions.ticket_id = p.ticket_id + AND viewer_sessions.user_id = $2 + ) + ) + ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC; + `, + [ticketId, userId, filters.month.start, filters.month.endIso] + ) : { rows: [] as any[] }; return { periodType: "all", @@ -9251,6 +11010,9 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon }, sessions, dayBillings: dayBillingResult.rows, + teamspaceMonthActuals: teamspaceMonthActualsResult.rows, + teamspaceMonthBillableMinutes: teamspaceMonthActualsResult.rows.reduce((sum: number, actual: any) => sum + Number(actual.teamspace_billable_minutes ?? 0), 0), + teamspaceMonthDeltaMinutes: teamspaceMonthActualsResult.rows.reduce((sum: number, actual: any) => sum + Number(actual.delta_minutes ?? 0), 0), project: projectResult.rows[0] ?? null, todos: todosResult.rows, canClose: false, @@ -9516,6 +11278,12 @@ app.get("/api/auth/me", (req, res) => { app.post("/api/teamspace/project-ids/sync", requireUser, async (req, res) => { const user = currentUser(req); + const month = parseMonth(typeof req.body?.month === "string" && req.body.month.trim() ? req.body.month.trim() : currentBerlinMonthLabel()); + + if (!user.teamspace_user_id) { + throw badRequest("Teamspace User-ID fehlt. Bitte Geräte-ID und Gerätepasswort im Profil speichern."); + } + const credentials = await getTeamspaceCredentialsForUser(user.id); const localEntriesResult = await query<{ entry_id: string; @@ -9524,6 +11292,8 @@ app.post("/api/teamspace/project-ids/sync", requireUser, async (req, res) => { teamspace_project: string; teamspace_api_project_id: string | null; teamspace_api_project_url: string | null; + tt_month_billable_minutes: number; + is_excluded: boolean; }>( ` SELECT @@ -9532,24 +11302,58 @@ app.post("/api/teamspace/project-ids/sync", requireUser, async (req, res) => { 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 + pte.teamspace_api_project_url, + (tpe.teamspace_project IS NOT NULL) AS is_excluded, + COALESCE(month_billings.billed_minutes, 0)::int AS tt_month_billable_minutes FROM projects p JOIN tickets t ON t.id = p.ticket_id JOIN project_teamspace_entries pte ON pte.project_id = p.id + LEFT JOIN teamspace_project_exclusions tpe ON lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) + LEFT JOIN LATERAL ( + SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS billed_minutes + FROM project_teamspace_entries comparison_pte + JOIN projects comparison_project ON comparison_project.id = comparison_pte.project_id + JOIN ticket_day_billings tdb + ON tdb.ticket_id = comparison_project.ticket_id + AND tdb.user_id = $1 + AND tdb.day >= $2::date + AND tdb.day < ($2::date + interval '1 month')::date + AND ( + tdb.project_teamspace_entry_id = comparison_pte.id + OR (comparison_pte.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + WHERE comparison_project.user_id = $1 + AND ( + comparison_pte.id = pte.id + OR ( + pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> '' + AND lower(btrim(comparison_pte.teamspace_project)) = lower(btrim(pte.teamspace_project)) + AND EXISTS ( + SELECT 1 + FROM project_teamspace_entries shared_pte + JOIN projects shared_project ON shared_project.id = shared_pte.project_id + WHERE shared_project.user_id = $1 + AND shared_pte.id <> pte.id + AND lower(btrim(shared_pte.teamspace_project)) = lower(btrim(pte.teamspace_project)) + ) + ) + ) + ) month_billings ON true 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] + [user.id, month.start] ); const remote = await loadTeamspaceRemoteProjectsSingleCall( credentials.baseUrl, credentials.deviceId, credentials.devicePassword, - localEntriesResult.rows.map((entry) => entry.teamspace_project) + localEntriesResult.rows.filter((entry) => !entry.is_excluded).map((entry) => entry.teamspace_project) ); const remoteByProjectId = teamspaceRemoteProjectsByProjectId(remote.projects); const remoteByMainprojectId = teamspaceRemoteProjectsByMainprojectId(remote.projects); @@ -9562,11 +11366,38 @@ app.post("/api/teamspace/project-ids/sync", requireUser, async (req, res) => { let ambiguous = 0; let cleared = 0; const subentrySyncEntries: TeamspaceSubentrySyncEntry[] = []; + const monthSyncEntries: Array<{ + entryId: string; + apiProjectId: number; + teamspaceProject: string; + ttMonthBillableMinutes: number; + }> = []; await withTransaction(async (client) => { const touchedProjectIds = new Set(); for (const entry of localEntriesResult.rows) { + if (entry.is_excluded) { + items.push({ + entryId: entry.entry_id, + projectId: entry.project_id, + ticketNumber: entry.ticket_number, + teamspaceProject: entry.teamspace_project, + previousApiProjectId: entry.teamspace_api_project_id, + apiProjectId: entry.teamspace_api_project_id, + apiProjectUrl: entry.teamspace_api_project_url, + ttMonthBillableMinutes: entry.tt_month_billable_minutes, + teamspaceMonthMinutes: 0, + teamspaceMonthBillableMinutes: 0, + teamspaceMonthEntryCount: 0, + teamspaceMonthDeltaMinutes: -entry.tt_month_billable_minutes, + teamspaceMonthPositions: [], + status: "excluded", + message: "Teamspace-Projekt ist im Adminbereich von Budget- und Differenzprüfung ausgenommen." + }); + continue; + } + let matches = teamspaceProjectMatchesForLocalId(entry.teamspace_project, remoteByProjectId, remoteByMainprojectId); const previousApiProjectId = entry.teamspace_api_project_id; const previousApiMatch = previousApiProjectId @@ -9604,6 +11435,12 @@ app.post("/api/teamspace/project-ids/sync", requireUser, async (req, res) => { previousApiProjectId, apiProjectId: null, apiProjectUrl: null, + ttMonthBillableMinutes: entry.tt_month_billable_minutes, + teamspaceMonthMinutes: 0, + teamspaceMonthBillableMinutes: 0, + teamspaceMonthEntryCount: 0, + teamspaceMonthDeltaMinutes: -entry.tt_month_billable_minutes, + teamspaceMonthPositions: [], status: "missing", message: "Keine Teamspace-Projektressource mit dieser projectId gefunden." }); @@ -9638,6 +11475,12 @@ app.post("/api/teamspace/project-ids/sync", requireUser, async (req, res) => { previousApiProjectId, apiProjectId: null, apiProjectUrl: null, + ttMonthBillableMinutes: entry.tt_month_billable_minutes, + teamspaceMonthMinutes: 0, + teamspaceMonthBillableMinutes: 0, + teamspaceMonthEntryCount: 0, + teamspaceMonthDeltaMinutes: -entry.tt_month_billable_minutes, + teamspaceMonthPositions: [], status: "ambiguous", message: `${matches.length} Teamspace-Projektressourcen mit dieser projectId gefunden.` }); @@ -9668,6 +11511,12 @@ app.post("/api/teamspace/project-ids/sync", requireUser, async (req, res) => { teamspace_project: entry.teamspace_project, teamspace_api_project_id: nextApiProjectId }); + monthSyncEntries.push({ + entryId: entry.entry_id, + apiProjectId: match.apiProjectId, + teamspaceProject: entry.teamspace_project, + ttMonthBillableMinutes: entry.tt_month_billable_minutes + }); alreadyCurrent += 1; items.push({ entryId: entry.entry_id, @@ -9677,6 +11526,12 @@ app.post("/api/teamspace/project-ids/sync", requireUser, async (req, res) => { previousApiProjectId, apiProjectId: nextApiProjectId, apiProjectUrl: nextApiProjectUrl, + ttMonthBillableMinutes: entry.tt_month_billable_minutes, + teamspaceMonthMinutes: 0, + teamspaceMonthBillableMinutes: 0, + teamspaceMonthEntryCount: 0, + teamspaceMonthDeltaMinutes: -entry.tt_month_billable_minutes, + teamspaceMonthPositions: [], status: "current", message: null }); @@ -9702,6 +11557,12 @@ app.post("/api/teamspace/project-ids/sync", requireUser, async (req, res) => { teamspace_project: entry.teamspace_project, teamspace_api_project_id: nextApiProjectId }); + monthSyncEntries.push({ + entryId: entry.entry_id, + apiProjectId: match.apiProjectId, + teamspaceProject: entry.teamspace_project, + ttMonthBillableMinutes: entry.tt_month_billable_minutes + }); updated += 1; items.push({ entryId: entry.entry_id, @@ -9711,11 +11572,19 @@ app.post("/api/teamspace/project-ids/sync", requireUser, async (req, res) => { previousApiProjectId, apiProjectId: nextApiProjectId, apiProjectUrl: nextApiProjectUrl, + ttMonthBillableMinutes: entry.tt_month_billable_minutes, + teamspaceMonthMinutes: 0, + teamspaceMonthBillableMinutes: 0, + teamspaceMonthEntryCount: 0, + teamspaceMonthDeltaMinutes: -entry.tt_month_billable_minutes, + teamspaceMonthPositions: [], status: "updated", message: null }); } + await consolidateSharedTeamspaceProjectData(client, localEntriesResult.rows.map((entry) => entry.teamspace_project)); + for (const projectId of touchedProjectIds) { await refreshProjectBudgetFromTeamspaceEntries(client, projectId); } @@ -9742,9 +11611,201 @@ app.post("/api/teamspace/project-ids/sync", requireUser, async (req, res) => { remoteSubprojectsScanned = subprojects.scanned; subentriesSynced = syncResult.synced; subentriesRemoved = syncResult.removed; + await withTransaction((client) => consolidateSharedTeamspaceProjectData(client, subentrySyncEntries.map((entry) => entry.teamspace_project))); + } + + let teamspaceTimeEntriesScanned = 0; + let teamspaceTimeEntriesMapped = 0; + let teamspaceTimeBillableMinutes = 0; + let teamspaceTimeDeltaMinutes = 0; + + if (monthSyncEntries.length > 0) { + const timeActuals = await loadTeamspaceRemoteTimeActualsSingleCall( + credentials.baseUrl, + credentials.deviceId, + credentials.devicePassword, + user.teamspace_user_id, + month + ); + teamspaceTimeEntriesScanned = timeActuals.scanned; + + const monthSyncEntryById = new Map(monthSyncEntries.map((entry) => [entry.entryId, entry])); + const entryApiIdsResult = await query<{ + entry_id: string; + api_project_id: string; + project_id: string | null; + label: string | null; + is_subentry: boolean; + billing_type: string | null; + }>( + ` + SELECT DISTINCT ON (entry_id, api_project_id) + entry_id, + api_project_id::text AS api_project_id, + project_id, + label, + is_subentry, + billing_type + FROM ( + SELECT + pte.id::text AS entry_id, + pte.teamspace_api_project_id AS api_project_id, + pte.teamspace_project AS project_id, + NULLIF(btrim(pte.teamspace_description), '') AS label, + false AS is_subentry, + NULL::text AS billing_type, + 0 AS display_order + FROM project_teamspace_entries pte + WHERE pte.id = ANY($2::bigint[]) + AND pte.teamspace_api_project_id IS NOT NULL + + UNION ALL + + SELECT + pte.id::text AS entry_id, + pts.teamspace_api_project_id AS api_project_id, + pts.teamspace_project_id AS project_id, + COALESCE(NULLIF(btrim(pts.alias), ''), NULLIF(btrim(pts.name), ''), pts.teamspace_project_id) AS label, + true AS is_subentry, + pts.billing_type, + pts.display_order + 1 AS display_order + FROM project_teamspace_entries pte + JOIN project_teamspace_subentries pts ON pts.project_teamspace_entry_id = pte.id + WHERE pte.id = ANY($2::bigint[]) + AND pts.user_id = $1 + ) api_ids + ORDER BY entry_id, api_project_id, display_order ASC; + `, + [user.id, monthSyncEntries.map((entry) => entry.entryId)] + ); + const apiRowsByEntryId = new Map(); + + for (const row of entryApiIdsResult.rows) { + apiRowsByEntryId.set(row.entry_id, [...(apiRowsByEntryId.get(row.entry_id) ?? []), row]); + } + + const actualForEntry = (entry: { entryId: string; apiProjectId: number }) => { + const apiRows = apiRowsByEntryId.get(entry.entryId) ?? [{ + entry_id: entry.entryId, + api_project_id: String(entry.apiProjectId), + project_id: null, + label: null, + is_subentry: false, + billing_type: null + }]; + const seen = new Set(); + const total = { minutes: 0, billableMinutes: 0, count: 0 }; + const positions: TeamspaceProjectIdSyncPosition[] = []; + + for (const apiRow of apiRows) { + const apiId = Number(apiRow.api_project_id); + + if (!Number.isInteger(apiId) || apiId <= 0) { + continue; + } + + if (seen.has(apiId)) { + continue; + } + + seen.add(apiId); + + if (apiRow.billing_type === "INVOICEABLE_FIX") { + continue; + } + + const actual = timeActuals.totals.get(apiId); + + if (!actual) { + continue; + } + + total.minutes += actual.minutes; + total.billableMinutes += actual.billableMinutes; + total.count += actual.count; + positions.push({ + apiProjectId: String(apiId), + projectId: apiRow.project_id, + label: apiRow.label || apiRow.project_id || `API ${apiId}`, + isSubentry: apiRow.is_subentry, + billingType: apiRow.billing_type, + minutes: actual.minutes, + billableMinutes: actual.billableMinutes, + entryCount: actual.count, + days: teamspaceTimeActualDays(actual) + }); + } + + return { ...total, positions }; + }; + + await withTransaction(async (client) => { + await upsertTeamspaceMonthlyUserActuals(client, user.id, month, timeActuals); + + await client.query( + ` + DELETE FROM project_teamspace_monthly_actuals + WHERE user_id = $1 + AND month = $2::date + AND project_teamspace_entry_id = ANY($3::bigint[]); + `, + [user.id, month.start, monthSyncEntries.map((entry) => entry.entryId)] + ); + + for (const entry of monthSyncEntries) { + const actual = actualForEntry(entry); + await client.query( + ` + INSERT INTO project_teamspace_monthly_actuals ( + user_id, + project_teamspace_entry_id, + month, + teamspace_minutes, + teamspace_billable_minutes, + teamspace_time_entry_count, + positions, + synced_at, + updated_at + ) + VALUES ($1, $2, $3::date, $4, $5, $6, $7::jsonb, now(), now()); + `, + [user.id, entry.entryId, month.start, actual.minutes, actual.billableMinutes, actual.count, JSON.stringify(actual.positions)] + ); + } + }); + + const countedTeamspaceTimeKeys = new Set(); + let teamspaceTimeComparableMinutes = 0; + + for (const item of items) { + const entry = monthSyncEntryById.get(item.entryId); + + if (!entry) { + continue; + } + + const actual = actualForEntry(entry); + item.teamspaceMonthMinutes = actual.minutes; + item.teamspaceMonthBillableMinutes = actual.billableMinutes; + item.teamspaceMonthEntryCount = actual.count; + item.teamspaceMonthDeltaMinutes = actual.billableMinutes - item.ttMonthBillableMinutes; + item.teamspaceMonthPositions = actual.positions; + const teamspaceTimeKey = entry.teamspaceProject.trim().toLowerCase() || `entry:${entry.entryId}`; + + if (!countedTeamspaceTimeKeys.has(teamspaceTimeKey)) { + countedTeamspaceTimeKeys.add(teamspaceTimeKey); + teamspaceTimeEntriesMapped += actual.count; + teamspaceTimeBillableMinutes += actual.billableMinutes; + teamspaceTimeComparableMinutes += entry.ttMonthBillableMinutes; + } + } + + teamspaceTimeDeltaMinutes = teamspaceTimeBillableMinutes - teamspaceTimeComparableMinutes; + await withTransaction((client) => consolidateSharedTeamspaceProjectData(client, localEntriesResult.rows.map((entry) => entry.teamspace_project))); } const result: TeamspaceProjectIdSyncResult = { + month: month.label, checkedEntries: localEntriesResult.rows.length, matched, updated, @@ -9757,6 +11818,10 @@ app.post("/api/teamspace/project-ids/sync", requireUser, async (req, res) => { remoteSubprojectsScanned, subentriesSynced, subentriesRemoved, + teamspaceTimeEntriesScanned, + teamspaceTimeEntriesMapped, + teamspaceTimeBillableMinutes, + teamspaceTimeDeltaMinutes, duplicateRemoteProjectIds: [...ambiguousLocalTeamspaceProjects].sort((left, right) => left.localeCompare(right, "de", { numeric: true, sensitivity: "base" })), items }; @@ -10285,27 +12350,158 @@ 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); + const [baseUrl, credentialsUserId] = await Promise.all([ + getAppSetting(teamspaceBaseUrlSettingKey), + getAppSetting(teamspaceAdminCredentialsUserIdSettingKey) + ]); res.json({ settings: { - baseUrl: baseUrl ?? "" + baseUrl: baseUrl ?? "", + credentialsUserId: credentialsUserId || null } }); }); app.put("/api/admin/teamspace/settings", requireAdmin, async (req, res) => { const baseUrl = parseTeamspaceBaseUrl(req.body.baseUrl); + const credentialsUserId = optionalString(req.body.credentialsUserId); + + if (credentialsUserId) { + const userResult = await query<{ exists: boolean }>( + ` + SELECT EXISTS ( + SELECT 1 + FROM users + WHERE id = $1 + AND active = true + AND teamspace_device_id IS NOT NULL + AND teamspace_device_password IS NOT NULL + ) AS exists; + `, + [credentialsUserId] + ); + + if (!userResult.rows[0]?.exists) { + throw badRequest("Der ausgewählte Benutzer hat keine aktiven Teamspace-Credentials"); + } + } await setAppSetting(teamspaceBaseUrlSettingKey, baseUrl); + await setAppSetting(teamspaceAdminCredentialsUserIdSettingKey, credentialsUserId ?? ""); res.json({ settings: { - baseUrl + baseUrl, + credentialsUserId: credentialsUserId ?? null } }); }); +type TeamspaceProjectExclusionRow = { + teamspace_project: string; + teamspace_name: string | null; + customer_name: string | null; + note: string; +}; + +app.get("/api/admin/teamspace/project-exclusions", requireAdmin, async (_req, res) => { + const result = await query( + ` + SELECT teamspace_project, teamspace_name, customer_name, note + FROM teamspace_project_exclusions + ORDER BY teamspace_project ASC; + ` + ); + + res.json({ exclusions: result.rows }); +}); + +app.put("/api/admin/teamspace/project-exclusions", requireAdmin, async (req, res) => { + const rawProjects: unknown[] = Array.isArray(req.body.projects) + ? req.body.projects + : typeof req.body.projectsText === "string" + ? req.body.projectsText.split(/\r?\n|,/) + : []; + const projects = Array.from(new Map( + rawProjects + .map((value) => typeof value === "string" ? value.trim() : "") + .filter(Boolean) + .map((value) => [value.toLocaleLowerCase("de"), value]) + ).values()); + + const remoteProjectsById = new Map(); + + if (projects.length > 0) { + const credentials = await getTeamspaceCredentialsForAdminAction(); + const remoteProjectsResult = await loadTeamspaceRemoteProjectsSingleCall(credentials.baseUrl, credentials.deviceId, credentials.devicePassword, projects); + + for (const project of remoteProjectsResult.projects) { + remoteProjectsById.set(project.projectId.toLocaleLowerCase("de"), project); + } + } + + const missingProjects = projects.filter((project) => !remoteProjectsById.has(project.toLocaleLowerCase("de"))); + + if (missingProjects.length > 0) { + throw Object.assign(new Error(`Teamspace-Projekt nicht gefunden: ${missingProjects.join(", ")}`), { status: 404 }); + } + + await withTransaction(async (client) => { + await client.query("DELETE FROM teamspace_project_exclusions;"); + + for (const project of projects) { + const remoteProject = remoteProjectsById.get(project.toLocaleLowerCase("de")); + await client.query( + ` + INSERT INTO teamspace_project_exclusions (teamspace_project, teamspace_name, customer_name, updated_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (teamspace_project) + DO UPDATE SET + teamspace_name = EXCLUDED.teamspace_name, + customer_name = EXCLUDED.customer_name, + updated_at = now(); + `, + [project, remoteProject?.name ?? remoteProject?.description ?? null, remoteProject?.customerName ?? null] + ); + } + + await client.query( + ` + UPDATE project_teamspace_entries pte + SET teamspace_project_resource_id = NULL, + updated_at = now() + WHERE EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) + ) + AND pte.teamspace_project_resource_id IS NOT NULL; + ` + ); + await client.query( + ` + DELETE FROM teamspace_project_resources resource + WHERE EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(resource.teamspace_project)) + ); + ` + ); + }); + + const result = await query( + ` + SELECT teamspace_project, teamspace_name, customer_name, note + FROM teamspace_project_exclusions + ORDER BY teamspace_project ASC; + ` + ); + + res.json({ exclusions: result.rows }); +}); + app.post("/api/admin/zammad/organizations/sync", requireAdmin, async (req, res) => { const requestBaseUrl = optionalString(req.body.baseUrl); const requestApiKey = optionalString(req.body.apiKey); @@ -12753,6 +14949,7 @@ app.get("/api/projects", 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, + COALESCE(entry_totals.teamspace_budget_excluded, false) AS teamspace_budget_excluded, entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes, p.closed_at, p.close_review_required, @@ -12831,6 +15028,7 @@ app.get("/api/projects", requireUser, async (req, res) => { 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, + BOOL_OR(tpe.teamspace_project IS NOT NULL) AS teamspace_budget_excluded, COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes, COUNT(*)::int AS teamspace_entry_count, COALESCE( @@ -12839,6 +15037,7 @@ app.get("/api/projects", requireUser, async (req, res) => { 'id', pte.id::text, 'project_id', pte.project_id::text, 'teamspace_project', pte.teamspace_project, + 'teamspace_budget_excluded', tpe.teamspace_project IS NOT NULL, 'teamspace_api_project_id', pte.teamspace_api_project_id::text, 'teamspace_api_project_url', pte.teamspace_api_project_url, 'subentries', COALESCE(( @@ -12932,6 +15131,7 @@ app.get("/api/projects", requireUser, async (req, res) => { '[]'::json ) AS teamspace_entries FROM project_teamspace_entries pte + LEFT JOIN teamspace_project_exclusions tpe ON lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) WHERE pte.project_id = p.id ) entry_totals ON true LEFT JOIN LATERAL ( @@ -13044,6 +15244,7 @@ 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, + COALESCE(entry_totals.teamspace_budget_excluded, false) AS teamspace_budget_excluded, entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes, EXISTS ( SELECT 1 @@ -13101,6 +15302,7 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { 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, + BOOL_OR(tpe.teamspace_project IS NOT NULL) AS teamspace_budget_excluded, COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes, COUNT(*)::int AS teamspace_entry_count, COALESCE( @@ -13109,6 +15311,7 @@ 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_budget_excluded', tpe.teamspace_project IS NOT NULL, 'teamspace_api_project_id', pte.teamspace_api_project_id::text, 'teamspace_api_project_url', pte.teamspace_api_project_url, 'subentries', COALESCE(( @@ -13202,6 +15405,7 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { '[]'::json ) AS teamspace_entries FROM project_teamspace_entries pte + LEFT JOIN teamspace_project_exclusions tpe ON lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) WHERE pte.project_id = p.id ) entry_totals ON true LEFT JOIN LATERAL ( @@ -13291,10 +15495,12 @@ app.post("/api/projects/:projectId/teamspace-subentries/sync", requireUser, asyn btrim(pte.teamspace_project) AS teamspace_project, pte.teamspace_api_project_id::text AS teamspace_api_project_id FROM project_teamspace_entries pte + LEFT JOIN teamspace_project_exclusions tpe ON lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) 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 + AND tpe.teamspace_project IS NULL ORDER BY pte.teamspace_api_project_id ASC; `, [projectId] @@ -13306,6 +15512,21 @@ app.post("/api/projects/:projectId/teamspace-subentries/sync", requireUser, asyn } const credentials = await getTeamspaceCredentialsForUser(user.id); + if (!user.teamspace_user_id) { + res.status(409).json({ error: "Bitte zuerst deine Teamspace-Worker-ID im Profil hinterlegen." }); + return; + } + const rawMonths = isPlainObject(req.body) && Array.isArray(req.body.months) + ? req.body.months + : isPlainObject(req.body) && typeof req.body.month === "string" + ? [req.body.month] + : [currentBerlinMonthLabel()]; + const months = Array.from(new Set( + rawMonths + .filter((value): value is string => typeof value === "string" && value.trim() !== "" && value !== "all") + .map((value) => parseMonth(value.trim())) + .map((month) => month.label) + )).slice(0, 24).map((label) => parseMonth(label)); 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, @@ -13320,12 +15541,39 @@ app.post("/api/projects/:projectId/teamspace-subentries/sync", requireUser, asyn subprojects.projects, credentials.baseUrl )); + let teamspaceTimeEntriesScanned = 0; + let teamspaceTimeEntriesMapped = 0; + let teamspaceTimeBillableMinutes = 0; + const monthlySyncEntries = entriesResult.rows + .map((entry) => ({ + entryId: entry.entry_id, + apiProjectId: Number(entry.teamspace_api_project_id) + })) + .filter((entry) => Number.isInteger(entry.apiProjectId) && entry.apiProjectId > 0); + + for (const month of months) { + const monthSync = await syncTeamspaceMonthlyActualsForEntries( + user.id, + credentials, + user.teamspace_user_id, + month, + monthlySyncEntries + ); + teamspaceTimeEntriesScanned += monthSync.scanned; + teamspaceTimeEntriesMapped += monthSync.mapped; + teamspaceTimeBillableMinutes += monthSync.billableMinutes; + } + await withTransaction((client) => consolidateSharedTeamspaceProjectData(client, entriesResult.rows.map((entry) => entry.teamspace_project))); const result: TeamspaceProjectSubentrySyncResult = { projectId: String(projectId), checkedEntries: entriesResult.rows.length, remoteProjectsScanned: subprojects.scanned, subentriesSynced: syncResult.synced, - subentriesRemoved: syncResult.removed + subentriesRemoved: syncResult.removed, + monthsSynced: months.map((month) => month.label), + teamspaceTimeEntriesScanned, + teamspaceTimeEntriesMapped, + teamspaceTimeBillableMinutes }; res.json({ result }); @@ -13344,9 +15592,11 @@ app.post("/api/teamspace/subentries/sync", requireUser, async (req, res) => { FROM projects p JOIN tickets t ON t.id = p.ticket_id JOIN project_teamspace_entries pte ON pte.project_id = p.id + LEFT JOIN teamspace_project_exclusions tpe ON lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) WHERE pte.teamspace_project IS NOT NULL AND btrim(pte.teamspace_project) <> '' AND pte.teamspace_api_project_id IS NOT NULL + AND tpe.teamspace_project IS NULL AND ( p.user_id = $1 OR @@ -13391,6 +15641,7 @@ app.post("/api/teamspace/subentries/sync", requireUser, async (req, res) => { subprojects.projects, credentials.baseUrl )); + await withTransaction((client) => consolidateSharedTeamspaceProjectData(client, entriesResult.rows.map((entry) => entry.teamspace_project))); const result: TeamspaceAllSubentrySyncResult = { checkedProjects: new Set(entriesResult.rows.map((entry) => entry.project_id)).size, @@ -13409,9 +15660,13 @@ app.patch("/api/project-teamspace-subentries/:subentryId", requireUser, async (r const alias = optionalString(req.body.alias) ?? ""; const result = await withTransaction(async (client) => { - const accessResult = await client.query<{ ticket_id: string; user_id: string | null }>( + const accessResult = await client.query<{ ticket_id: string; user_id: string | null; project_teamspace_entry_id: string; teamspace_project: string | null }>( ` - SELECT p.ticket_id::text AS ticket_id, pts.user_id::text AS user_id + SELECT + p.ticket_id::text AS ticket_id, + pts.user_id::text AS user_id, + pte.id::text AS project_teamspace_entry_id, + pte.teamspace_project 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 @@ -13458,6 +15713,11 @@ app.patch("/api/project-teamspace-subentries/:subentryId", requireUser, async (r [subentryId, alias] ); + if (updated.rows[0]) { + await client.query("UPDATE project_teamspace_entries SET updated_at = now() WHERE id = $1;", [accessResult.rows[0].project_teamspace_entry_id]); + await consolidateSharedTeamspaceProjectData(client, [accessResult.rows[0].teamspace_project]); + } + return updated.rows[0] ?? null; }); @@ -13750,6 +16010,7 @@ app.post("/api/projects", requireUser, async (req, res) => { [project.id, entry.teamspaceProject, entry.teamspaceDescription, entry.isDefault, entry.hourBudgetMinutes, entry.budgetSyncDisabled, entry.manualConsumptionMinutes, entry.displayOrder] ); } + await consolidateSharedTeamspaceProjectData(client, teamspaceEntries.map((entry) => entry.teamspaceProject)); await autoAssignSingleProjectTeamspaceEntry(client, project.ticket_id); await refreshProjectBudgetFromTeamspaceEntries(client, project.id); const refreshedBudget = await client.query<{ hour_budget_minutes: number | null }>( @@ -14096,6 +16357,7 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { `, [project.id, savedEntryIds] ); + await consolidateSharedTeamspaceProjectData(client, teamspaceEntries.map((entry) => entry.teamspaceProject)); await autoAssignSingleProjectTeamspaceEntry(client, project.ticket_id); await refreshProjectBudgetFromTeamspaceEntries(client, project.id); const refreshedBudget = await client.query<{ hour_budget_minutes: number | null }>( @@ -14880,6 +17142,20 @@ app.patch("/api/tickets/:ticketId/day-billings/:day", requireUser, async (req, r [ticketId, userId, day.start, effectiveProjectTeamspaceEntryId] ); + const projectsResult = await query<{ teamspace_project: string | null }>( + ` + SELECT DISTINCT pte.teamspace_project + FROM projects p + JOIN project_teamspace_entries pte ON pte.project_id = p.id + WHERE p.ticket_id = $1 + AND p.user_id = $2 + AND pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> ''; + `, + [ticketId, userId] + ); + await withTransaction((client) => consolidateSharedTeamspaceProjectData(client, projectsResult.rows.map((entry) => entry.teamspace_project))); + res.json({ dayBilling: null }); return; } @@ -14916,6 +17192,20 @@ app.patch("/api/tickets/:ticketId/day-billings/:day", requireUser, async (req, r [ticketId, userId, day.start] ); + const projectsResult = await query<{ teamspace_project: string | null }>( + ` + SELECT DISTINCT pte.teamspace_project + FROM projects p + JOIN project_teamspace_entries pte ON pte.project_id = p.id + WHERE p.ticket_id = $1 + AND p.user_id = $2 + AND pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> ''; + `, + [ticketId, userId] + ); + await withTransaction((client) => consolidateSharedTeamspaceProjectData(client, projectsResult.rows.map((entry) => entry.teamspace_project))); + res.json({ dayBilling: result.rows[0] }); }); diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index 150aa2e..bd91128 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -182,6 +182,41 @@ export async function migrate() { ); `); + await query(` + CREATE TABLE IF NOT EXISTS teamspace_project_exclusions ( + teamspace_project TEXT PRIMARY KEY, + teamspace_name TEXT, + customer_name TEXT, + note TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT teamspace_project_exclusions_project_not_blank CHECK (btrim(teamspace_project) <> '') + ); + `); + await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS teamspace_name TEXT;"); + await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS customer_name TEXT;"); + await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_teamspace_project_exclusions_lower ON teamspace_project_exclusions (lower(btrim(teamspace_project)));"); + + await query(` + CREATE TABLE IF NOT EXISTS teamspace_project_resources ( + id BIGSERIAL PRIMARY KEY, + teamspace_project TEXT NOT NULL, + teamspace_api_project_id BIGINT, + teamspace_api_project_url TEXT, + hour_budget_minutes INTEGER, + budget_sync_disabled BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT teamspace_project_resources_project_not_blank CHECK (btrim(teamspace_project) <> ''), + CONSTRAINT teamspace_project_resources_budget_non_negative CHECK (hour_budget_minutes IS NULL OR hour_budget_minutes >= 0) + ); + `); + await query("ALTER TABLE teamspace_project_resources ADD COLUMN IF NOT EXISTS teamspace_api_project_id BIGINT;"); + await query("ALTER TABLE teamspace_project_resources ADD COLUMN IF NOT EXISTS teamspace_api_project_url TEXT;"); + await query("ALTER TABLE teamspace_project_resources ADD COLUMN IF NOT EXISTS hour_budget_minutes INTEGER;"); + await query("ALTER TABLE teamspace_project_resources ADD COLUMN IF NOT EXISTS budget_sync_disabled BOOLEAN NOT NULL DEFAULT false;"); + await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_teamspace_project_resources_lower ON teamspace_project_resources (lower(btrim(teamspace_project)));"); + await encryptStoredApiSecrets(); await query(` @@ -864,9 +899,154 @@ export async function migrate() { 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("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS teamspace_project_resource_id BIGINT REFERENCES teamspace_project_resources(id) ON DELETE SET NULL;"); 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 INDEX IF NOT EXISTS idx_project_teamspace_entries_resource ON project_teamspace_entries(teamspace_project_resource_id) WHERE teamspace_project_resource_id IS NOT NULL;"); + await query(` + INSERT INTO teamspace_project_resources ( + teamspace_project, + teamspace_api_project_id, + teamspace_api_project_url, + hour_budget_minutes, + budget_sync_disabled, + updated_at + ) + SELECT + canonical.teamspace_project, + canonical.teamspace_api_project_id, + canonical.teamspace_api_project_url, + canonical.hour_budget_minutes, + canonical.budget_sync_disabled, + now() + FROM ( + SELECT DISTINCT ON (lower(btrim(teamspace_project))) + btrim(teamspace_project) AS teamspace_project, + teamspace_api_project_id, + teamspace_api_project_url, + hour_budget_minutes, + COALESCE(budget_sync_disabled, false) AS budget_sync_disabled + FROM project_teamspace_entries + WHERE teamspace_project IS NOT NULL + AND btrim(teamspace_project) <> '' + AND NOT EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project)) + ) + ORDER BY + lower(btrim(teamspace_project)), + (teamspace_api_project_id IS NOT NULL) DESC, + (teamspace_api_project_url IS NOT NULL) DESC, + (hour_budget_minutes IS NOT NULL) DESC, + updated_at DESC, + id DESC + ) canonical + WHERE NOT EXISTS ( + SELECT 1 + FROM teamspace_project_resources existing + WHERE lower(btrim(existing.teamspace_project)) = lower(btrim(canonical.teamspace_project)) + ); + `); + await query(` + UPDATE project_teamspace_entries pte + SET teamspace_project_resource_id = resource.id, + updated_at = CASE + WHEN pte.teamspace_project_resource_id IS DISTINCT FROM resource.id THEN now() + ELSE pte.updated_at + END + FROM teamspace_project_resources resource + WHERE pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> '' + AND NOT EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) + ) + AND lower(btrim(resource.teamspace_project)) = lower(btrim(pte.teamspace_project)) + AND pte.teamspace_project_resource_id IS DISTINCT FROM resource.id; + `); + await query(` + UPDATE project_teamspace_entries pte + SET teamspace_project_resource_id = NULL, + updated_at = now() + WHERE EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) + ) + AND pte.teamspace_project_resource_id IS NOT NULL; + `); + await query(` + DELETE FROM teamspace_project_resources resource + WHERE EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(resource.teamspace_project)) + ); + `); + await query(` + UPDATE project_teamspace_entries pte + SET teamspace_api_project_id = COALESCE(resource.teamspace_api_project_id, pte.teamspace_api_project_id), + teamspace_api_project_url = COALESCE(resource.teamspace_api_project_url, pte.teamspace_api_project_url), + hour_budget_minutes = COALESCE(resource.hour_budget_minutes, pte.hour_budget_minutes), + budget_sync_disabled = resource.budget_sync_disabled, + updated_at = CASE + WHEN pte.teamspace_api_project_id IS DISTINCT FROM COALESCE(resource.teamspace_api_project_id, pte.teamspace_api_project_id) + OR pte.teamspace_api_project_url IS DISTINCT FROM COALESCE(resource.teamspace_api_project_url, pte.teamspace_api_project_url) + OR pte.hour_budget_minutes IS DISTINCT FROM COALESCE(resource.hour_budget_minutes, pte.hour_budget_minutes) + OR pte.budget_sync_disabled IS DISTINCT FROM resource.budget_sync_disabled + THEN now() + ELSE pte.updated_at + END + FROM teamspace_project_resources resource + WHERE pte.teamspace_project_resource_id = resource.id + AND pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> '' + AND NOT EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) + ); + `); + await query(` + CREATE TABLE IF NOT EXISTS project_teamspace_monthly_actuals ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + project_teamspace_entry_id BIGINT NOT NULL REFERENCES project_teamspace_entries(id) ON DELETE CASCADE, + month DATE NOT NULL, + teamspace_minutes INTEGER NOT NULL DEFAULT 0, + teamspace_billable_minutes INTEGER NOT NULL DEFAULT 0, + teamspace_time_entry_count INTEGER NOT NULL DEFAULT 0, + positions JSONB NOT NULL DEFAULT '[]'::jsonb, + synced_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, project_teamspace_entry_id, month), + CONSTRAINT project_teamspace_monthly_actuals_minutes_nonnegative CHECK (teamspace_minutes >= 0), + CONSTRAINT project_teamspace_monthly_actuals_billable_nonnegative CHECK (teamspace_billable_minutes >= 0), + CONSTRAINT project_teamspace_monthly_actuals_count_nonnegative CHECK (teamspace_time_entry_count >= 0) + ); + `); + await query("ALTER TABLE project_teamspace_monthly_actuals ADD COLUMN IF NOT EXISTS positions JSONB NOT NULL DEFAULT '[]'::jsonb;"); + await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_monthly_actuals_entry_month ON project_teamspace_monthly_actuals(project_teamspace_entry_id, month);"); + await query(` + CREATE TABLE IF NOT EXISTS teamspace_monthly_user_actuals ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + month DATE NOT NULL, + teamspace_minutes INTEGER NOT NULL DEFAULT 0, + teamspace_billable_minutes INTEGER NOT NULL DEFAULT 0, + teamspace_time_entry_count INTEGER NOT NULL DEFAULT 0, + positions JSONB NOT NULL DEFAULT '[]'::jsonb, + synced_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, month), + CONSTRAINT teamspace_monthly_user_actuals_minutes_nonnegative CHECK (teamspace_minutes >= 0), + CONSTRAINT teamspace_monthly_user_actuals_billable_nonnegative CHECK (teamspace_billable_minutes >= 0), + CONSTRAINT teamspace_monthly_user_actuals_count_nonnegative CHECK (teamspace_time_entry_count >= 0) + ); + `); await query(` CREATE TABLE IF NOT EXISTS project_teamspace_subentries ( id BIGSERIAL PRIMARY KEY, @@ -940,6 +1120,390 @@ export async function migrate() { 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(` + WITH duplicate_keys AS ( + SELECT lower(btrim(teamspace_project)) AS teamspace_key + FROM project_teamspace_entries + WHERE teamspace_project IS NOT NULL + AND btrim(teamspace_project) <> '' + AND NOT EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project)) + ) + GROUP BY lower(btrim(teamspace_project)) + HAVING COUNT(*) > 1 + ), + canonical AS ( + SELECT DISTINCT ON (lower(btrim(pte.teamspace_project))) + lower(btrim(pte.teamspace_project)) AS teamspace_key, + pte.teamspace_api_project_id, + pte.teamspace_api_project_url, + pte.hour_budget_minutes, + COALESCE(pte.budget_sync_disabled, false) AS budget_sync_disabled + FROM project_teamspace_entries pte + JOIN duplicate_keys dk ON dk.teamspace_key = lower(btrim(pte.teamspace_project)) + ORDER BY + lower(btrim(pte.teamspace_project)), + (pte.teamspace_api_project_id IS NOT NULL) DESC, + (pte.teamspace_api_project_url IS NOT NULL) DESC, + (pte.hour_budget_minutes IS NOT NULL) DESC, + pte.updated_at DESC, + pte.id DESC + ) + UPDATE project_teamspace_entries target + SET teamspace_api_project_id = COALESCE(canonical.teamspace_api_project_id, target.teamspace_api_project_id), + teamspace_api_project_url = COALESCE(canonical.teamspace_api_project_url, target.teamspace_api_project_url), + hour_budget_minutes = COALESCE(canonical.hour_budget_minutes, target.hour_budget_minutes), + budget_sync_disabled = canonical.budget_sync_disabled, + updated_at = CASE + WHEN target.teamspace_api_project_id IS DISTINCT FROM COALESCE(canonical.teamspace_api_project_id, target.teamspace_api_project_id) + OR target.teamspace_api_project_url IS DISTINCT FROM COALESCE(canonical.teamspace_api_project_url, target.teamspace_api_project_url) + OR target.hour_budget_minutes IS DISTINCT FROM COALESCE(canonical.hour_budget_minutes, target.hour_budget_minutes) + OR target.budget_sync_disabled IS DISTINCT FROM canonical.budget_sync_disabled + THEN now() + ELSE target.updated_at + END + FROM canonical + WHERE target.teamspace_project IS NOT NULL + AND btrim(target.teamspace_project) <> '' + AND lower(btrim(target.teamspace_project)) = canonical.teamspace_key; + `); + await query(` + WITH duplicate_keys AS ( + SELECT lower(btrim(teamspace_project)) AS teamspace_key + FROM project_teamspace_entries + WHERE teamspace_project IS NOT NULL + AND btrim(teamspace_project) <> '' + AND NOT EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project)) + ) + GROUP BY lower(btrim(teamspace_project)) + HAVING COUNT(*) > 1 + ), + entry_keys AS ( + SELECT + pte.id AS entry_id, + p.user_id, + lower(btrim(pte.teamspace_project)) AS teamspace_key, + pte.teamspace_api_project_id, + pte.updated_at + FROM project_teamspace_entries pte + JOIN projects p ON p.id = pte.project_id + JOIN duplicate_keys dk ON dk.teamspace_key = lower(btrim(pte.teamspace_project)) + WHERE p.user_id IS NOT NULL + ), + canonical_entries AS ( + SELECT DISTINCT ON (entry_keys.user_id, entry_keys.teamspace_key) + entry_keys.user_id, + entry_keys.teamspace_key, + entry_keys.entry_id + FROM entry_keys + ORDER BY + entry_keys.user_id, + entry_keys.teamspace_key, + EXISTS ( + SELECT 1 + FROM project_teamspace_subentries pts + WHERE pts.user_id = entry_keys.user_id + AND pts.project_teamspace_entry_id = entry_keys.entry_id + ) DESC, + (entry_keys.teamspace_api_project_id IS NOT NULL) DESC, + entry_keys.updated_at DESC, + entry_keys.entry_id DESC + ), + targets AS ( + SELECT + entry_keys.entry_id AS target_entry_id, + entry_keys.user_id, + canonical_entries.entry_id AS source_entry_id + FROM entry_keys + JOIN canonical_entries + ON canonical_entries.user_id = entry_keys.user_id + AND canonical_entries.teamspace_key = entry_keys.teamspace_key + WHERE entry_keys.entry_id <> canonical_entries.entry_id + ), + deleted AS ( + DELETE FROM project_teamspace_subentries pts + USING targets + WHERE pts.user_id = targets.user_id + AND pts.project_teamspace_entry_id = targets.target_entry_id + ) + 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 + ) + SELECT + targets.user_id, + targets.target_entry_id, + source.teamspace_api_project_id, + source.teamspace_api_project_url, + source.teamspace_project_id, + source.billing_type, + source.name, + source.alias, + source.description, + source.progress, + source.planned_minutes, + source.sum_minutes, + source.planned_minutes_billable, + source.sum_minutes_billable, + source.planned_income, + source.sum_income, + source.display_order, + source.synced_at, + now() + FROM targets + JOIN project_teamspace_subentries source + ON source.user_id = targets.user_id + AND source.project_teamspace_entry_id = targets.source_entry_id + 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 = EXCLUDED.alias, + 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 = EXCLUDED.synced_at, + updated_at = now(); + `); + await query(` + WITH shared_resources AS ( + SELECT pte.teamspace_project_resource_id AS resource_id + FROM project_teamspace_entries pte + WHERE pte.teamspace_project_resource_id IS NOT NULL + AND pte.teamspace_project IS NOT NULL + AND btrim(pte.teamspace_project) <> '' + AND NOT EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project)) + ) + GROUP BY pte.teamspace_project_resource_id + HAVING COUNT(*) > 1 + ), + resource_entries AS ( + SELECT + pte.id AS entry_id, + pte.teamspace_project_resource_id AS resource_id, + p.user_id, + p.ticket_id, + pte.is_default, + pte.updated_at + FROM project_teamspace_entries pte + JOIN projects p ON p.id = pte.project_id + JOIN shared_resources sr ON sr.resource_id = pte.teamspace_project_resource_id + WHERE p.user_id IS NOT NULL + ), + canonical_entries AS ( + SELECT DISTINCT ON (resource_id, user_id) + resource_id, + user_id, + entry_id + FROM resource_entries + ORDER BY + resource_id, + user_id, + EXISTS ( + SELECT 1 + FROM project_teamspace_subentries pts + WHERE pts.user_id = resource_entries.user_id + AND pts.project_teamspace_entry_id = resource_entries.entry_id + ) DESC, + updated_at DESC, + entry_id DESC + ), + resource_consumption AS ( + SELECT + ce.resource_id, + ce.user_id, + 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 shared_consumption_minutes + FROM canonical_entries ce + JOIN project_teamspace_subentries pts + ON pts.project_teamspace_entry_id = ce.entry_id + AND pts.user_id = ce.user_id + GROUP BY ce.resource_id, ce.user_id + ), + local_day_values AS ( + SELECT + re.entry_id, + COALESCE(SUM(tdb.billed_minutes), 0)::int AS local_day_minutes + FROM resource_entries re + LEFT JOIN ticket_day_billings tdb + ON tdb.ticket_id = re.ticket_id + AND tdb.user_id = re.user_id + AND ( + tdb.project_teamspace_entry_id = re.entry_id + OR (re.is_default AND tdb.project_teamspace_entry_id IS NULL) + ) + GROUP BY re.entry_id + ) + UPDATE project_teamspace_entries pte + SET manual_consumption_minutes = GREATEST(resource_consumption.shared_consumption_minutes - local_day_values.local_day_minutes, 0), + updated_at = CASE + WHEN pte.manual_consumption_minutes IS DISTINCT FROM GREATEST(resource_consumption.shared_consumption_minutes - local_day_values.local_day_minutes, 0) + THEN now() + ELSE pte.updated_at + END + FROM resource_entries re + JOIN resource_consumption + ON resource_consumption.resource_id = re.resource_id + AND resource_consumption.user_id = re.user_id + JOIN local_day_values ON local_day_values.entry_id = re.entry_id + WHERE pte.id = re.entry_id + AND resource_consumption.shared_consumption_minutes > 0; + `); + await query(` + WITH duplicate_keys AS ( + SELECT lower(btrim(teamspace_project)) AS teamspace_key + FROM project_teamspace_entries + WHERE teamspace_project IS NOT NULL + AND btrim(teamspace_project) <> '' + AND NOT EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project)) + ) + GROUP BY lower(btrim(teamspace_project)) + HAVING COUNT(*) > 1 + ), + entry_keys AS ( + SELECT + pte.id AS entry_id, + p.user_id, + lower(btrim(pte.teamspace_project)) AS teamspace_key + FROM project_teamspace_entries pte + JOIN projects p ON p.id = pte.project_id + JOIN duplicate_keys dk ON dk.teamspace_key = lower(btrim(pte.teamspace_project)) + WHERE p.user_id IS NOT NULL + ), + canonical_actuals AS ( + SELECT DISTINCT ON (ptma.user_id, entry_keys.teamspace_key, ptma.month) + ptma.user_id, + entry_keys.teamspace_key, + ptma.month, + ptma.teamspace_minutes, + ptma.teamspace_billable_minutes, + ptma.teamspace_time_entry_count, + ptma.positions, + ptma.synced_at + FROM project_teamspace_monthly_actuals ptma + JOIN entry_keys ON entry_keys.entry_id = ptma.project_teamspace_entry_id + ORDER BY + ptma.user_id, + entry_keys.teamspace_key, + ptma.month, + ptma.synced_at DESC, + ptma.updated_at DESC, + ptma.project_teamspace_entry_id DESC + ) + INSERT INTO project_teamspace_monthly_actuals ( + user_id, + project_teamspace_entry_id, + month, + teamspace_minutes, + teamspace_billable_minutes, + teamspace_time_entry_count, + positions, + synced_at, + updated_at + ) + SELECT + canonical_actuals.user_id, + entry_keys.entry_id, + canonical_actuals.month, + canonical_actuals.teamspace_minutes, + canonical_actuals.teamspace_billable_minutes, + canonical_actuals.teamspace_time_entry_count, + canonical_actuals.positions, + canonical_actuals.synced_at, + now() + FROM canonical_actuals + JOIN entry_keys + ON entry_keys.user_id = canonical_actuals.user_id + AND entry_keys.teamspace_key = canonical_actuals.teamspace_key + ON CONFLICT (user_id, project_teamspace_entry_id, month) + DO UPDATE SET + teamspace_minutes = EXCLUDED.teamspace_minutes, + teamspace_billable_minutes = EXCLUDED.teamspace_billable_minutes, + teamspace_time_entry_count = EXCLUDED.teamspace_time_entry_count, + positions = EXCLUDED.positions, + synced_at = EXCLUDED.synced_at, + updated_at = now(); + `); + await query(` + WITH duplicate_keys AS ( + SELECT lower(btrim(teamspace_project)) AS teamspace_key + FROM project_teamspace_entries + WHERE teamspace_project IS NOT NULL + AND btrim(teamspace_project) <> '' + AND NOT EXISTS ( + SELECT 1 + FROM teamspace_project_exclusions tpe + WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project)) + ) + GROUP BY lower(btrim(teamspace_project)) + HAVING COUNT(*) > 1 + ), + affected_projects AS ( + SELECT DISTINCT pte.project_id + FROM project_teamspace_entries pte + JOIN duplicate_keys dk ON dk.teamspace_key = lower(btrim(pte.teamspace_project)) + ), + entry_budget AS ( + SELECT + pte.project_id, + COALESCE(SUM(pte.hour_budget_minutes) FILTER (WHERE pte.hour_budget_minutes IS NOT NULL), NULL)::int AS hour_budget_minutes, + COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS manual_consumption_minutes + FROM project_teamspace_entries pte + JOIN affected_projects affected ON affected.project_id = pte.project_id + GROUP BY pte.project_id + ) + UPDATE projects p + SET hour_budget_minutes = entry_budget.hour_budget_minutes, + manual_consumption_minutes = entry_budget.manual_consumption_minutes, + updated_at = now() + FROM entry_budget + WHERE p.id = entry_budget.project_id + AND ( + p.hour_budget_minutes IS DISTINCT FROM entry_budget.hour_budget_minutes + OR p.manual_consumption_minutes IS DISTINCT FROM entry_budget.manual_consumption_minutes + ); + `); await query(` DO $$ BEGIN diff --git a/bla.json b/bla.json deleted file mode 100644 index 05daf04..0000000 --- a/bla.json +++ /dev/null @@ -1 +0,0 @@ -{"_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/docs/confluence/00-tickettracker-hauptseite.md b/docs/confluence/00-tickettracker-hauptseite.md new file mode 100644 index 0000000..4d447db --- /dev/null +++ b/docs/confluence/00-tickettracker-hauptseite.md @@ -0,0 +1,89 @@ +# TicketTracker + +Stand: Oktober 2026 + +TicketTracker ist ein Arbeits-, Projekt- und Abrechnungscockpit für ticketbasierte Arbeit. Das System ergänzt Zammad und Teamspace: Zammad bleibt das Ticketsystem, Teamspace bleibt die Quelle für Projektbudgets und gebuchte Projektwerte, TicketTracker führt Erfassung, Bewertung, ToDos, Projektüberwachung und Monatsabschluss zusammen. + +Der Kernnutzen: Jeder Benutzer sieht, was er gearbeitet hat, was noch bewertet werden muss, welche Projektbudgets kritisch sind, welche Teamspace-Daten fehlen oder abweichen und wann ein Monat sauber abgeschlossen werden kann. + +## Zielgruppe und Einsatz + +TicketTracker ist für Teams oder Einzelpersonen gedacht, die viel mit Tickets arbeiten und ihre Zeiten zuverlässig nachvollziehen müssen. + +Typische Einsatzfälle: + +- Support- und Consulting-Zeiten per Timer erfassen +- Sessions nachträglich ergänzen +- Arbeit am Ticket dokumentieren +- Sessions als offen, abrechenbar, abgerechnet oder nicht abrechenbar bewerten +- Teamspace-Tageswerte für abrechnungsrelevante Projektzeiten pflegen oder Teamspace als Ist-Quelle nutzen +- Monatsabschlüsse kontrolliert vorbereiten +- Consulting-Projekte mit Teamspace-Budgets, Reststunden und Unterpositionen überwachen +- ToDos und Wiedervorlagen direkt am Ticket oder Projekt organisieren +- wiederkehrende Leistungen automatisch erzeugen lassen +- interne Zammad-Notizen aus TicketTracker heraus erzeugen + +## Grundprinzip + +TicketTracker arbeitet auf drei Ebenen: + +1. **Session-Zeit** + Die tatsächlich gemessene oder manuell erfasste Arbeitszeit. +2. **Teamspace-/Projekt-Ist** + Je nach Profilmodus entweder in TicketTracker gepflegte Teamspace-Tageswerte oder direkt aus Teamspace synchronisierte Ist-Werte. +3. **Monatsabschluss** + Der kontrollierte Abschluss eines Monats, sobald alle Sessions final bewertet und die benötigten Prüfpunkte erledigt sind. + +Wichtig: Tickets werden nicht abgeschlossen. Tickets dienen als Arbeits- und Auswertungsrahmen. Abgeschlossen wird der Monat. + +## Die wichtigsten Bereiche + +- **Timer und Sessions:** Zeiten erfassen, nachtragen, bewerten und dokumentieren. +- **Tickets:** Sessions pro Ticket, Tag und Monat prüfen. +- **Projekte:** Consulting-Projekte, Teamspace-Projekte, Budgets, Reststunden und Unterpositionen überwachen. +- **Teamspace:** Projekt-IDs, Budgets, Unterpositionen, Monatszeiten und Istwerte synchronisieren. +- **Zammad:** Organisationen, Ticketstammdaten, Status, Priorität und interne Notizen anbinden. +- **ToDos:** Aufgaben, Termine, Wiedervorlagen und Projektfollow-ups verwalten. +- **Dashboard:** Tagessteuerung für offene Bewertungen, ToDos, Teamspace-Nacharbeit und Projektwarnungen. +- **Statistiken:** Monatswerte, Organisationen, Tickets, Projektbudgets und Teamspace-Abgleich auswerten. +- **Monatsabschluss:** Einen Monat kontrolliert schließen oder wieder öffnen. + +## Unterseiten für Confluence + +Diese Dokumentation ist für den Import als Confluence-Seitenbaum vorbereitet: + +1. `01-zeiterfassung-sessions-monatsabschluss.md` +2. `02-projekte-budgets-teamspace.md` +3. `03-zammad-sync-und-integration.md` +4. `04-dashboard-statistiken-und-widgets.md` +5. `05-todos-wiedervorlagen-fixe-abrechnungen.md` +6. `06-profil-admin-und-betrieb.md` + +## Typischer Tagesablauf + +1. Ticketnummer eingeben und Timer starten. +2. Tätigkeit dokumentieren. +3. Timer beenden und Session speichern. +4. Bei Bedarf Session nachtragen. +5. Sessions im Ticket bewerten. +6. Wenn Teamspace-Erfassung in TT aktiv ist: Teamspace-Tageswerte pro Projekt/Tag pflegen. +7. ToDos und Wiedervorlagen bearbeiten. +8. Dashboard prüfen, ob offene Bewertungen oder Teamspace-Nacharbeiten vorhanden sind. + +## Typischer Monatsablauf + +1. Monat in Tickets, Statistiken oder Monatsabschluss auswählen. +2. Offene Sessions prüfen. +3. Sessions final als **Abgerechnet** oder **Nicht abrechenbar** bewerten. +4. **Abrechenbar**-Zwischenstände klären, weil sie den Abschluss blockieren. +5. Bei aktivierter Teamspace-Erfassung: Teamspace-Tageswerte und Differenzen prüfen. +6. Im Zammad-Sync bei Bedarf den Teamspace-Monatsabgleich ausführen, um echte TS-Monatszeiten pro Projekt gegen TT zu prüfen. +7. Bei deaktivierter Teamspace-Erfassung: Teamspace-Differenzen entfallen; nur Session-Status ist relevant. +8. ToDos, Wiedervorlagen und Projektwarnungen prüfen. +9. Monat abschließen. + +TicketTracker ist damit das Kontrollzentrum zwischen täglicher Ticketarbeit, Zammad-Dokumentation, Teamspace-Projekten und sauberer Abrechnung. + +Die zentrale Frage bleibt: + +**Ist alles vollständig, bewertet, dokumentiert und abrechnungsbereit?** diff --git a/docs/confluence/01-zeiterfassung-sessions-monatsabschluss.md b/docs/confluence/01-zeiterfassung-sessions-monatsabschluss.md new file mode 100644 index 0000000..bae9381 --- /dev/null +++ b/docs/confluence/01-zeiterfassung-sessions-monatsabschluss.md @@ -0,0 +1,96 @@ +# Zeiterfassung, Sessions und Monatsabschluss + +Diese Seite beschreibt die tägliche Erfassung, Bewertung und Monatsprüfung in TicketTracker. + +## Timer + +Benutzer starten einen Timer über eine Ticketnummer. Es können mehrere Timer vorbereitet sein, aber immer nur einer läuft aktiv. Wird ein anderer Timer gestartet, pausiert TicketTracker die übrigen Timer automatisch. + +Beim Beenden werden Tätigkeit, Zeit, Ticket, Organisation, Art und optional Bewertung gespeichert. Wenn die Zammad-Notiz im Profil aktiv ist, kann TicketTracker beim Speichern einen internen Zammad-Article erzeugen. + +## Sessions nachtragen + +Vergessene Arbeit kann manuell als Session nachgetragen werden. Dabei werden erfasst: + +- Datum +- Start und Ende +- Ticket +- Organisation +- Art, zum Beispiel Support oder Consulting +- Tätigkeit +- Bewertung +- optional interne Zammad-Notiz + +Nachgetragene Sessions laufen genauso in Statistiken, Ticketansichten und Monatsabschluss ein wie per Timer erzeugte Sessions. + +## Session-Bewertungen + +Jede Session kann einen dieser Zustände haben: + +- **Offen** +- **Abrechenbar** +- **Abgerechnet** +- **Nicht abrechenbar** + +Nur **Abgerechnet** und **Nicht abrechenbar** gelten als final. **Abrechenbar** ist ein Zwischenstatus und blockiert weiterhin den Monatsabschluss. + +Ein erneuter Klick auf den bereits gesetzten Status nimmt die Bewertung wieder zurück und macht die Session offen. + +## Ticketdetailansicht + +In der Ticketdetailansicht werden Sessions nach Tagen gruppiert. Dort werden Sessions bewertet, bearbeitet, gelöscht und aufgeklappt. + +Pro Tag sieht man unter anderem: + +- Summe abgerechneter Session-Zeit +- Summe abrechenbarer Session-Zeit +- Summe nicht abrechenbarer Session-Zeit +- offene Session-Zeit +- Teamspace-Tageswert, falls die Teamspace-Erfassung in TT aktiv ist + +Die Tageswerte sind pro Benutzer und Projekt/Teamspace-Eintrag gespeichert. Sie sind nicht an einzelne Sessions gebunden. + +## Teamspace-Tageswerte + +Wenn im Profil **Teamspace-Zeiten in TT erfassen** aktiv ist, kann der Benutzer in der Ticketdetailansicht Teamspace-Tageswerte pflegen. + +Die Eingabe erfolgt in Stunden, zum Beispiel: + +- `1` +- `1,5` +- `2.25` +- `0,75` + +Der Wert wird nicht während der Eingabe gespeichert. Sobald sich der Wert ändert, erscheint ein Speichern-Button. Ein leeres Feld entfernt den gespeicherten Teamspace-Wert für diesen Tag. + +Wenn mehrere Teamspace-Projekte an einem Ticket hängen, wird der Tageswert pro Teamspace-Projekt-Eintrag geführt. + +Wenn die Teamspace-Erfassung im Profil deaktiviert ist, sind diese Eingaben ausgeblendet und auch serverseitig gesperrt. + +## Monatsabschluss + +Der Monatsabschluss ist die zentrale Kontrollseite für einen Benutzer und Monat. + +Sie zeigt unter anderem: + +- offene Bewertungen +- Sessions mit Zwischenstatus **Abrechenbar** +- fehlende oder abweichende Teamspace-Werte, wenn Teamspace-Erfassung in TT aktiv ist +- Prüfpunkte vor dem Abschluss +- Möglichkeit, den Monat zu schließen oder wieder zu öffnen + +Ein abgeschlossener Monat ist gegen versehentliche Änderungen geschützt. Wird eine Session in einem abgeschlossenen Zeitraum geändert, muss der Zeitraum vorher wieder geöffnet werden. + +## Regeln bei deaktivierter Teamspace-Erfassung + +Der Profil-Schalter **Teamspace-Zeiten in TT erfassen** ist userbezogen. + +Wenn er ausgeschaltet ist: + +- Teamspace-Tageswerte können nicht in TicketTracker gepflegt werden. +- Manuelle Projektstunden sind nicht editierbar. +- Monatsprüfung und Widgets prüfen nur Session-Status. +- Teamspace-Differenzen werden nicht angezeigt. +- Projekt-Istwerte kommen aus Teamspace. + +Das ist für Benutzer gedacht, die keine Teamspace-Zeiten in TicketTracker nachpflegen, sondern Teamspace als führende Ist-Quelle verwenden. diff --git a/docs/confluence/02-projekte-budgets-teamspace.md b/docs/confluence/02-projekte-budgets-teamspace.md new file mode 100644 index 0000000..9abce5e --- /dev/null +++ b/docs/confluence/02-projekte-budgets-teamspace.md @@ -0,0 +1,150 @@ +# Projekte, Budgets und Teamspace + +Diese Seite beschreibt die aktuelle Projekt- und Teamspace-Logik in TicketTracker. + +## Projekte + +Projekte erweitern normale Tickets um Consulting-Projektinformationen. + +Ein Projekt enthält unter anderem: + +- Ticketnummer +- Titel und Organisation +- Teamspace-Projekt-Einträge +- Beschreibung und Ziele +- Zeitraum +- Projektstatus offen oder geschlossen +- ToDos +- Teamspace-Budgets und Verbrauchswerte +- Teamspace-Unterpositionen + +Projekt-Tickets sind Consulting-Tickets. Wird ein Projekt gelöscht, bleiben Ticket, Sessions und ToDos erhalten. Gelöscht werden nur die Projektinformationen. + +## Teamspace als Budgetquelle + +Teamspace ist die fachliche Quelle für Projektbudgets. + +Aktuelle Budgetregeln: + +- Teamspace ist die Source of Truth für Projektbudgets. +- Zammad-Budgets werden nicht mehr als Budgetquelle verwendet. +- Das Gesamtbudget eines Projekts kommt aus Teamspace. +- Wenn mehrere Teamspace-Projekte an einem Ticket hängen, wird das Gesamtbudget aus diesen Teamspace-Projekten zusammengesetzt. +- Unterpositionen zeigen ihre eigenen Teamspace-Budgets und Teamspace-Istwerte. +- Stückpositionen werden nicht als Stundenbudget in die Stundenbetrachtung eingerechnet. + +## Verbrauchslogik + +Der Projektverbrauch hängt vom Profil-Schalter **Teamspace-Zeiten in TT erfassen** ab. + +Wenn der Schalter aktiv ist: + +- Projekt-Ist kommt aus Teamspace-Tageswerten, die in TicketTracker gepflegt werden. +- Zusätzlich können manuelle Projektstunden erfasst werden. +- Progress Bars, Reststunden und Budgetwarnungen verwenden diese Werte. + +Wenn der Schalter deaktiviert ist: + +- Projekt-Ist kommt aus Teamspace. +- Manuelle Projektstunden sind ausgeblendet und serverseitig gesperrt. +- Teamspace-Tageswerte können nicht in TicketTracker erfasst werden. +- Statistiken und Widgets zeigen keine TT/Teamspace-Differenzprüfung für diesen Benutzer. + +## Mehrere Teamspace-Projekte pro Ticket + +Ein Ticket kann mehrere Teamspace-Projekt-IDs haben. TicketTracker speichert dafür mehrere Teamspace-Einträge am Projekt. + +Beispiel: + +- `EP-2025-Comp-009` +- `EP-2025-Comp-025` + +Einer der Einträge ist der Standard. Sessions können einem Teamspace-Projekt-Eintrag zugeordnet werden. Diese Zuordnung erfolgt auf Ebene des Teamspace-Projekts, nicht auf Ebene einzelner Unterpositionen. + +## Gemeinsames Teamspace-Budget über mehrere Tickets + +Mehrere unterschiedliche Ticketnummern können dieselbe Teamspace-Projekt-ID verwenden. TicketTracker behandelt diese Teamspace-Nummer dann als gemeinsame Budgetquelle. + +Dabei gilt: + +- Es gibt keine automatische Aufteilung auf die Ticketnummern. +- Teamspace-API-ID, Teamspace-API-URL, Budget, Unterpositionen und Teamspace-Monatswerte werden für dieselbe Teamspace-Nummer konsolidiert. +- Der Teamspace-Sync zieht gleiche Teamspace-Nummern nach jedem Lauf wieder auf denselben Datenstand. +- Die Migration konsolidiert bereits vorhandene doppelte Teamspace-Nummern beim Start. +- Ticket-spezifische Felder wie Standard-Eintrag, Reihenfolge, Session-Zuordnung und manuelle Projektstunden bleiben am jeweiligen Ticket. + +## Teamspace-Projekt-ID-Sync + +Der Sync auf der Zammad-Sync-Seite löst offene Consulting-Projekte gegen die Teamspace-API auf. + +Gespeichert werden unter anderem: + +- Teamspace-API-ID des Projekts +- Teamspace-API-URL +- Teamspace-Gesamtbudget +- Teamspace-Unterpositionen +- Teamspace-Budgets und Istwerte der Unterpositionen +- Stückpositionen und deren Erfüllungsstand +- echte Teamspace-Monatszeiten aus `/api/time` für den gewählten Monat + +Die fachliche Teamspace-ID aus Zammad oder Projektinformationen, zum Beispiel `EP-2026-A-035`, ist nicht identisch mit der API-ID für Einzelabfragen. TicketTracker speichert deshalb die interne Teamspace-API-ID, sobald sie gefunden wurde. + +## Teamspace-Monatsabgleich + +Der Teamspace-Projekt-ID-Sync auf der Zammad-Sync-Seite enthält zusätzlich einen Monatsabgleich. Der Benutzer wählt einen Monat aus, TicketTracker ruft für diesen Zeitraum die Teamspace-Zeitbuchungen des angemeldeten Teamspace-Benutzers ab und speichert einen Snapshot pro Teamspace-Projekt-Eintrag. + +Technische Grundlage: + +- API-Endpoint: `/api/time` +- Filter: `worker={Teamspace User-ID}` und `date=YYYY-MM-01..YYYY-MM-letzterTag` +- gezählt werden Einträge mit `draftStatus.value = NORMAL` +- relevante Minuten sind `amountBillable` +- `amount` wird als Gesamtzeit mitgeführt, ist aber nicht die Vergleichsgröße + +Die Monatswerte werden pro Benutzer, Teamspace-Projekt-Eintrag und Monat gespeichert. Dadurch bleiben drei Dinge getrennt: + +- TT-Tageswerte, die in TicketTracker gepflegt werden +- echte Teamspace-Monatszeiten aus Teamspace +- Teamspace-Budget- und Unterpositionswerte aus dem Projektsync + +Im Ergebnis des Syncs sieht man pro Teamspace-Projekt: + +- TT-Monatswert +- TS-Monatswert +- Differenz +- Anzahl der gefundenen TS-Zeitbuchungen +- betroffene Haupt- oder Unterpositionen mit TS-Minuten + +Wenn Teamspace-Zeiten auf Unterpositionen gebucht wurden, werden diese ebenfalls dem passenden Teamspace-Projekt-Eintrag zugeordnet. TicketTracker mappt dafür nicht nur die Hauptprojekt-API-ID, sondern auch die synchronisierten Unterpositions-API-IDs. Die Positionsaufschlüsselung wird im Monats-Snapshot gespeichert und kann dadurch später in der Monatsübersicht angezeigt werden. + +## Unterpositionen + +Unterpositionen werden aus Teamspace synchronisiert und im Projektdetail angezeigt. Sie dienen als Teamspace-Anzeige, um Budget, Verbrauch und freie Ressourcen pro Teamspace-Position sichtbar zu machen. + +Wichtig: + +- Zeiten werden nicht auf Unterpositionen in TicketTracker erfasst. +- Unterpositionen bekommen ihre Budgets und Istwerte aus Teamspace. +- Teamspace-Descriptions werden dekodiert und formatiert ausgegeben. +- Unterpositionen können einen Alias in TicketTracker bekommen. +- Akkordions der Unterpositionsgruppen merken sich den letzten offenen oder geschlossenen Zustand pro Benutzer und Projekt. +- Bei Teamspace-Strukturen mit Überpositionen werden nur echte bebuchbare Unterpositionen angezeigt, nicht reine Summenpositionen. +- Positionsnummern werden lesbar dargestellt, zum Beispiel `1` statt `0001` oder `1.1` statt `0001.0001`. + +## Stückpositionen + +Teamspace-Positionen mit `billingType = INVOICEABLE_FIX` sind Stück- oder Fixpositionen. + +Für diese Positionen gelten andere Regeln: + +- Stundenwerte werden nicht als Stundenbudget interpretiert. +- TicketTracker zeigt sie als Stückpositionen an. +- Der Erfüllungsstand wird über `plannedIncome` gegen `sumIncome` geprüft. +- Ist das Soll erreicht, wird die Position als abgerechnet beziehungsweise erfüllt markiert. +- Überschreitungen werden mit einem Hinweis-Icon angezeigt. + +## Projektansichten + +Die Projektliste zeigt Gesamtbudget, Verbrauch, Reststunden und Projektwarnungen. Bei mehreren Teamspace-Projekten können Detailbalken pro Teamspace-Projekt angezeigt oder ausgeblendet werden. Der Hauptbalken zeigt weiterhin das Gesamtbudget aller Teamspace-Projekte. + +Im Projektdetail stehen Projektinformationen und ToDos getrennt von den Teamspace-Unterpositionen. Die Unterpositionen liegen in einer eigenen breiten Card unterhalb der Projekt-/ToDo-Informationen. diff --git a/docs/confluence/03-zammad-sync-und-integration.md b/docs/confluence/03-zammad-sync-und-integration.md new file mode 100644 index 0000000..e445d52 --- /dev/null +++ b/docs/confluence/03-zammad-sync-und-integration.md @@ -0,0 +1,82 @@ +# Zammad Sync und Integration + +TicketTracker nutzt Zammad, ohne Zammad zu ersetzen. Zammad bleibt das führende Ticketsystem für Ticketstammdaten, Organisationen, Status und Prioritäten. + +## Persönlicher Zammad-Token + +Jeder Benutzer kann im Profil einen persönlichen Zammad API-Token speichern. Beim Speichern ruft TicketTracker Zammad ab, prüft den Token und speichert die Zammad User-ID. + +Der Token wird verschlüsselt in der Datenbank gespeichert. + +Der persönliche Token wird unter anderem für benutzerbezogene Zammad-Funktionen verwendet, zum Beispiel interne Notizen oder personenbezogene Synchronisierungen. + +## Admin-Zammad-Zugang + +Im Adminbereich wird der Zammad-Zugang für systemweite Synchronisierungen gepflegt. + +Der Admin kann Organisationen aus Zammad synchronisieren. Dabei werden lokale Organisationen angelegt, aktualisiert oder entfernt beziehungsweise von alten Verknüpfungen gelöst. + +## Ticketdaten aus Zammad + +TicketTracker kann Ticketdaten aus Zammad übernehmen: + +- Ticketnummer +- Titel +- Organisation +- Work-Type, zum Beispiel Support oder Consulting +- Status +- Priorität +- Owner +- Teamspace-Projektinformationen + +Zammad-verwaltete Ticketstammdaten bleiben geschützt. Ticketnummer, Titel, Art und Organisation können dann in TicketTracker nicht beliebig überschrieben werden. + +## Consulting-Projekte aus Zammad + +Beim Zammad-Sync oder beim Anlegen eines Projekts können Consulting-Projektinformationen übernommen werden. + +Dazu gehören insbesondere: + +- Teamspace-Projekt-ID oder mehrere Teamspace-Projekt-IDs +- Zammad-Owner +- Projektstatus-Hinweise, zum Beispiel Review bei Abschlussstatus + +Die Teamspace-Projekt-ID aus Zammad ist die fachliche ID, zum Beispiel `EP-2026-A-035`. Für Teamspace-API-Abfragen speichert TicketTracker zusätzlich die interne Teamspace-API-ID. + +## Teamspace-Projekt-ID-Sync auf der Zammad-Sync-Seite + +Auf der Zammad-Sync-Seite gibt es den Sync für Teamspace-Projekt-IDs, Budgets und Positionen. + +Voraussetzungen: + +- Admin hat die Teamspace-Base-URL gespeichert. +- Benutzer hat im Profil Teamspace-Geraete-ID und Geraetepasswort gespeichert. +- Die Teamspace User-ID ist bekannt. +- Das Projekt hat eine Teamspace-Projekt-ID. + +Der Sync speichert: + +- Teamspace-API-ID +- Teamspace-API-URL +- Gesamtbudget +- Unterpositionen +- Budgets und Istwerte der Unterpositionen +- Stückpositionen + +## Interne Zammad-Notizen + +Beim Timerabschluss und beim manuellen Nachtragen kann TicketTracker einen internen Zammad-Article erzeugen. + +Der Article enthält die Tätigkeit und optional den `time_unit`-Wert. Im Profil kann eingestellt werden: + +- ob die interne Notiz standardmäßig aktiv ist +- welcher Zammad-Zeitbuchungstyp vorausgewählt wird +- ob `time_unit` auf 5, 10, 15 oder 30 Minuten aufgerundet wird + +Wenn Zammad nicht erreichbar ist oder die Notiz fehlschlägt, speichert TicketTracker die Session trotzdem. Die fehlgeschlagene Notiz wird geparkt und kann später im Profil erneut gesendet werden. + +## eMail-/Article-Import + +TicketTracker kann Zammad-Articles prüfen und daraus Sessions vorbereiten. Das ist besonders hilfreich, wenn Arbeit per eMail-Import ins Ticket kam. + +Solche Sessions können nachträglich geprüft, zugeordnet und bewertet werden. Wenn ein importierter Eintrag keinem Teamspace-Projekt zugeordnet ist, kann das im Projektdetail sichtbar werden. diff --git a/docs/confluence/04-dashboard-statistiken-und-widgets.md b/docs/confluence/04-dashboard-statistiken-und-widgets.md new file mode 100644 index 0000000..b77dbc8 --- /dev/null +++ b/docs/confluence/04-dashboard-statistiken-und-widgets.md @@ -0,0 +1,95 @@ +# Dashboard, Statistiken und Widgets + +Diese Seite beschreibt die Kontroll- und Auswertungsflächen in TicketTracker. + +## Dashboard + +Das Dashboard ist das Tagescockpit. Es zeigt, ob heute oder in der aktuellen Arbeitslage noch etwas offen ist. + +Typische Hinweise: + +- offene Session-Bewertungen +- Sessions im Zwischenstatus **Abrechenbar** +- heutige oder überfällige ToDos +- fällige Wiedervorlagen +- fehlende Teamspace-Tageswerte, wenn Teamspace-Erfassung in TT aktiv ist +- Projektbudget-Hinweise +- Nacharbeit für heutige Tickets + +Widgets können je nach Konfiguration angeordnet und genutzt werden. Favoriten und persönliche Notizen unterstützen die tägliche Arbeit. + +## Teamspace-Verhalten im Dashboard + +Der Profil-Schalter **Teamspace-Zeiten in TT erfassen** beeinflusst die Dashboard-Logik. + +Wenn aktiv: + +- Dashboard kann fehlende Teamspace-Tageswerte anzeigen. +- Nacharbeitslisten können Teamspace-Differenzen berücksichtigen. +- Projektverbrauch basiert auf TT-Teamspace-Tageswerten plus manuellen Projektstunden. + +Wenn deaktiviert: + +- Teamspace-Erfassung wird als ausgeschaltet angezeigt. +- Monatsprüfung läuft statusbasiert. +- Teamspace-Differenzhinweise werden ausgeblendet. +- Projekt-Istwerte kommen aus Teamspace. + +## Statistiken + +Die Statistikseite betrachtet einen Monat. + +Sie zeigt unter anderem: + +- abgerechnete Zeit +- abrechenbare Zwischenstände +- nicht abrechenbare Zeit +- offene Sessions +- Organisationen mit Aufwand +- Tickets mit Aufwand +- Projektbudget-Hinweise +- manuelle und fixe Sessions +- Teamspace-Abgleich, wenn Teamspace-Erfassung in TT aktiv ist +- Teamspace als Ist-Quelle, wenn die Erfassung in TT deaktiviert ist + +## Abgerechnet, abrechenbar und offen + +Für statistische Auswertungen gilt: + +- **Abgerechnet** zählt in die abgerechnete Session-Zeit. +- **Abrechenbar** ist ein Zwischenstatus und bleibt offen für den Monatsabschluss. +- **Nicht abrechenbar** ist final, zählt aber nicht als abgerechnete Zeit. +- **Offen** blockiert den Monatsabschluss. + +## Teamspace-Abgleich in Statistiken + +Wenn Teamspace-Erfassung in TT aktiv ist, vergleicht TicketTracker: + +`Teamspace-Zeit minus abgerechnete Session-Zeit` + +Bedeutung: + +- Positive Differenz: In Teamspace steht mehr Zeit als abgerechnet wurde. +- Negative Differenz: In Teamspace steht weniger Zeit als abgerechnet wurde. +- Null: Teamspace und abgerechnete Session-Zeit sind ausgeglichen. + +Wenn Teamspace-Erfassung deaktiviert ist, werden Differenzspalten, Differenzbadges und Teamspace-Linien ausgeblendet oder angepasst. Die Statistik zeigt dann Teamspace als Ist-Quelle und prüft primär die Session-Status. + +## Teamspace-Monatsabgleich aus dem Sync + +Zusätzlich zum Tageswert-Abgleich kann der Zammad-Sync echte Teamspace-Monatszeiten abrufen. Diese Daten kommen aus Teamspace `/api/time` und verwenden `amountBillable` der Projektzeitbuchungen. + +Der Sync speichert die Werte als Monats-Snapshot pro Benutzer und Teamspace-Projekt-Eintrag. Dadurch wird sichtbar, ob der in TicketTracker gepflegte Monatswert zu den echten Teamspace-Buchungen passt und bei welchem Projekt die Abweichung liegt. + +Der Monatsabgleich ist besonders hilfreich, wenn mehrere Teamspace-Projekte an einem Ticket hängen oder wenn Teamspace-Zeiten direkt auf Unterpositionen gebucht wurden. Diese Unterpositionszeiten werden dem passenden Teamspace-Projekt-Eintrag zugerechnet. + +In der Monatsübersicht wird der gespeicherte Snapshot direkt am Ticket ausgegeben: TS-Monatswert, Differenz und die betroffenen Haupt- oder Unterpositionen. So ist nicht nur sichtbar, dass eine Abweichung existiert, sondern auch, auf welcher Teamspace-Position die Zeit gebucht wurde. + +## Projektbudget-Hinweise + +Projektbudget-Hinweise basieren auf den aktuellen Projektregeln: + +- Budget kommt aus Teamspace. +- Ist kommt entweder aus TT-Teamspace-Tageswerten plus manuellen Projektstunden oder aus Teamspace, abhängig vom Profil-Schalter. +- Überschreitungen werden als Warnung angezeigt. +- Stückpositionen werden gesondert bewertet und nicht als Stundenbudget behandelt. diff --git a/docs/confluence/05-todos-wiedervorlagen-fixe-abrechnungen.md b/docs/confluence/05-todos-wiedervorlagen-fixe-abrechnungen.md new file mode 100644 index 0000000..3824abb --- /dev/null +++ b/docs/confluence/05-todos-wiedervorlagen-fixe-abrechnungen.md @@ -0,0 +1,79 @@ +# ToDos, Wiedervorlagen und fixe Abrechnungen + +Diese Seite beschreibt die Aufgabenlogik und automatisch erzeugte Sessions. + +## ToDos + +ToDos können direkt an Tickets oder Projekte gehängt werden. + +Ein ToDo enthält: + +- Aufgabe +- Priorität +- Status offen oder erledigt +- optional Umsetzung von +- optional Umsetzung bis +- optionale Beschreibung + +ToDos können bearbeitet, erledigt, wieder geöffnet oder gelöscht werden. + +## ToDo-Seite + +Die zentrale ToDo-Seite bietet: + +- heutige ToDos +- offene, erledigte oder alle ToDos +- Suche nach Ticketnummer +- Sortierung +- Gruppierung +- freie Reihenfolge per Drag and Drop +- Kalenderansicht + +Überfällige ToDos werden hervorgehoben. ToDos mit Zeitraum zählen an jedem Arbeitstag im Zeitraum. Ein-Tages-ToDos erscheinen einzeln am jeweiligen Tag. + +## Wiedervorlagen + +Für Consulting-Projekte können automatische Wiedervorlagen entstehen, wenn länger keine Aktivität im TicketTracker passiert ist. + +Mögliche Regeln: + +- erste Wiedervorlage nach einer definierten Anzahl Wochen ohne Aktivität +- optionale zweite Wiedervorlage nach weiterem Stillstand +- automatische Erledigung, wenn später wieder eine Session auf dem Projekt erfasst wird + +Die Einstellungen dafür liegen im Profil. + +## Zammad-Termine + +Aus Zammad-Tickets im Status **Wartend** oder **Abschluss** mit Datum kann TicketTracker Termin-ToDos erzeugen. + +So werden Zammad-Termine direkt im TicketTracker-Dashboard, in der ToDo-Seite und im Kalender sichtbar. + +## Fixe Abrechnungen + +Regelmäßige Leistungen können als fixe Abrechnung angelegt werden. + +Beispiele: + +- jeden Donnerstag 4 Stunden +- bestimmte Wochentage mit festen Zeiten +- alle 2 Wochen eine Leistung + +TicketTracker erzeugt daraus automatisch Sessions. + +## Feiertage und Ausnahmen + +Bayerische Feiertage werden berücksichtigt. Für Feiertage werden keine fixen Sessions erzeugt. + +Einzelne automatisch erzeugte Sessions können gelöscht werden, zum Beispiel wegen Urlaub, Krankheit oder Verschiebung. Diese Löschung wird als Ausnahme gespeichert, damit die Session beim nächsten Aufruf nicht automatisch wieder erscheint. + +Wenn eine wiederkehrende Leistung an einem anderen Tag erbracht wurde, kann die erzeugte Session bearbeitet oder verschoben werden. + +## Bewertung fixer Sessions + +Fix erzeugte Sessions verhalten sich im Abschluss wie normale Sessions: + +- Sie müssen bewertet werden. +- **Abrechenbar** bleibt ein Zwischenstatus. +- **Abgerechnet** und **Nicht abrechenbar** sind final. +- Offene oder abrechenbare Sessions blockieren den Monatsabschluss. diff --git a/docs/confluence/06-profil-admin-und-betrieb.md b/docs/confluence/06-profil-admin-und-betrieb.md new file mode 100644 index 0000000..f1bc07d --- /dev/null +++ b/docs/confluence/06-profil-admin-und-betrieb.md @@ -0,0 +1,107 @@ +# Profil, Adminbereich und Betrieb + +Diese Seite beschreibt persönliche Einstellungen, Adminfunktionen und betriebliche Hinweise. + +## Profil + +Jeder Benutzer pflegt im Profil seine persönlichen Einstellungen: + +- Benutzername und Anzeigename +- Passwort +- persönlicher Zammad API-Token +- Teamspace-Geraete-ID und Geraetepasswort +- angezeigte Zammad User-ID +- angezeigte Teamspace User-ID +- Bewertungsabfrage beim Timerabschluss +- Teamspace-Zeiten in TT erfassen +- interne Zammad-Notiz beim Timerabschluss +- Standard-Typ für interne Zammad-Notizen +- optionale Zammad-Zeitaufrundung für `time_unit` +- ATOSS/eMail-Sync-Optionen, sofern aktiviert +- Projekt-Wiedervorlagen + +Passwortänderungen erfordern das aktuelle Passwort. Bleiben die Passwortfelder leer, werden nur die übrigen Profildaten gespeichert. + +## Zammad-Zugang im Profil + +Normale Benutzer können im Profil einen persönlichen Zammad API-Token hinterlegen. Beim Speichern eines neuen Tokens ruft TicketTracker Zammad ab und speichert die User-ID, die Zammad für diesen Token zurückliefert. + +Der Token wird verschlüsselt gespeichert. Die User-ID wird danach read-only im Profil angezeigt. + +## Teamspace-Zugang im Profil + +Jeder Benutzer kann seine Teamspace-Geraete-ID und sein Geraetepasswort speichern. + +Beim Speichern werden die Zugangsdaten gegen Teamspace geprüft. Wenn die Prüfung erfolgreich ist, speichert TicketTracker die Teamspace User-ID und zeigt sie im Profil an. + +Geraete-ID und Passwort werden verschlüsselt in der Datenbank gespeichert. + +## Teamspace-Zeiten in TT erfassen + +Der Schalter **Teamspace-Zeiten in TT erfassen** ist userbezogen und steuert, ob ein Benutzer Teamspace-Tageswerte in TicketTracker pflegt. + +**An:** + +- Teamspace-Tageswerte können in TicketTracker erfasst werden. +- Manuelle Projektstunden sind editierbar. +- Projekt-Ist kommt aus TT-Teamspace-Tageswerten plus manuellen Projektstunden. +- Statistiken und Monatsprüfung vergleichen TT gegen Teamspace-Werte. + +**Aus:** + +- Teamspace-Tageswerte können nicht erfasst werden. +- Manuelle Projektstunden sind ausgeblendet und serverseitig gesperrt. +- Projekt-Ist kommt aus Teamspace. +- Monatsprüfung und Widgets prüfen nur Session-Status. +- Teamspace-Differenzen werden ausgeblendet. + +Der Schalter ist bewusst userbezogen. Zwei Benutzer können am gleichen Projekt arbeiten und trotzdem unterschiedliche Erfassungs- und Prüflogiken nutzen. + +## Adminbereich + +Der feste Admin ist für Systemverwaltung vorgesehen. Er kann: + +- Benutzer anlegen und bearbeiten +- Passwörter für Benutzer setzen +- Zammad-Adminzugang für Organisationssync pflegen +- Teamspace-Base-URL speichern +- Organisationen aus Zammad synchronisieren +- SQL-Backups herunterladen +- Restore-fähige SQL-Backups wieder einspielen + +Der Admin sieht keine Timer, keine persönlichen Auswertungen, keine Statistiken, keine fixen Abrechnungen und keinen Monatsabschluss. Diese Bereiche sind für normale Benutzer vorgesehen. + +## Teamspace-Base-URL + +Die Teamspace-Base-URL wird im Adminbereich gespeichert. Ohne diese URL können Benutzer ihre Teamspace-Zugangsdaten nicht sinnvoll für API-Syncs nutzen. + +Die persönlichen Teamspace-Zugangsdaten liegen dagegen im Benutzerprofil. + +## Backup und Restore + +Der Adminbereich kann SQL-Backups erzeugen und wieder einspielen. + +Der Export enthält alle Tabellen, die für den Systemstand notwendig sind, unter anderem: + +- Benutzer +- Einstellungen +- Organisationen +- Tickets +- Sessions +- Teamspace-Werte +- Projektinformationen +- Unterpositionen +- fixe Abrechnungen +- Ausnahmen +- Monatsabschlüsse + +Ein Restore ersetzt den aktuellen Datenbestand durch den Stand des Backups. Vor dem Restore wird die Struktur geprüft. Nach dem Restore ist eine neue Anmeldung mit den Zugangsdaten aus dem Backup notwendig. + +## Technischer Stack + +- Frontend: Next.js, React, shadcn/ui, Tailwind CSS +- Backend: Node.js, Express, TypeScript +- Datenbank: PostgreSQL +- Deployment: Docker-fähig + +Secrets wie Zammad-Token und Teamspace-Zugangsdaten werden verschlüsselt gespeichert. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 27db267..92769dc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -136,6 +136,10 @@ function pathWithoutSearch(value: string) { return value.split("?")[0]; } +function openTicketKey(ticketId: string) { + return `ticket:${ticketId}`; +} + function readOpenTickets(userId: string): OpenTicketNavItem[] { try { const raw = localStorage.getItem(openTicketsStorageKey(userId)); @@ -145,11 +149,15 @@ function readOpenTickets(userId: string): OpenTicketNavItem[] { } const parsed = JSON.parse(raw) as Partial[]; + const tickets: OpenTicketNavItem[] = []; - return parsed - .filter((item) => item.key && item.ticketId && item.ticketNumber && item.path) - .map((item) => ({ - key: String(item.key), + for (const item of parsed) { + if (!item.ticketId || !item.ticketNumber || !item.path) { + continue; + } + + const ticket: OpenTicketNavItem = { + key: openTicketKey(String(item.ticketId)), ticketId: String(item.ticketId), ticketNumber: String(item.ticketNumber), periodType: item.periodType === "day" ? "day" : item.periodType === "month" ? "month" : "all", @@ -157,7 +165,17 @@ function readOpenTickets(userId: string): OpenTicketNavItem[] { path: String(item.path), returnPath: typeof item.returnPath === "string" ? item.returnPath : undefined, dirty: false - })); + }; + const existingIndex = tickets.findIndex((existing) => existing.ticketId === ticket.ticketId); + + if (existingIndex >= 0) { + tickets[existingIndex] = ticket; + } else { + tickets.push(ticket); + } + } + + return tickets.slice(-12); } catch { return []; } @@ -1245,13 +1263,31 @@ export function App() { const updateOpenTicket = useCallback((ticket: OpenTicketNavItem) => { setOpenTickets((current) => { - const existingIndex = current.findIndex((item) => item.key === ticket.key); + const normalizedTicket = { + ...ticket, + key: openTicketKey(ticket.ticketId) + }; + const existingIndex = current.findIndex((item) => item.ticketId === normalizedTicket.ticketId || item.key === normalizedTicket.key); if (existingIndex >= 0) { - return current.map((item, index) => (index === existingIndex ? ticket : item)); + return current + .map((item, index) => { + if (item.ticketId === normalizedTicket.ticketId || item.key === normalizedTicket.key) { + return index === existingIndex + ? { + ...item, + ...normalizedTicket, + returnPath: normalizedTicket.returnPath ?? item.returnPath + } + : null; + } + + return item; + }) + .filter((item): item is OpenTicketNavItem => item !== null); } - return [...current, ticket].slice(-12); + return [...current, normalizedTicket].slice(-12); }); }, []); @@ -1313,7 +1349,7 @@ export function App() { return; } - setOpenTickets((items) => items.filter((item) => item.key !== ticket.key)); + setOpenTickets((items) => items.filter((item) => item.ticketId !== ticket.ticketId && item.key !== ticket.key)); if (route.page === "ticket" && route.ticketId === ticket.ticketId) { const returnPath = current.returnPath && pathWithoutSearch(current.returnPath) !== pathWithoutSearch(ticket.path) ? current.returnPath : "/analysis"; @@ -1472,7 +1508,7 @@ export function App() {
{openTickets.map((ticket) => { - const isActive = pathWithoutSearch(path) === pathWithoutSearch(ticket.path); + const isActive = route.page === "ticket" && route.ticketId === ticket.ticketId; return ( @@ -1531,7 +1567,7 @@ export function App() { - v1.0.11-rc1 + v1.1.0-rc1 @@ -1630,7 +1666,7 @@ export function App() { ) : null} {!isAdmin && route.page === "dashboard" ? : null} {!isAdmin && route.page === "analysis" ? : null} - {!isAdmin && route.page === "monthly-close" ? : null} + {!isAdmin && route.page === "monthly-close" ? : null} {!isAdmin && route.page === "statistics" ? : null} {!isAdmin && route.page === "projects" ? : null} {!isAdmin && route.page === "todos" ? : null} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 42d99cf..774bf4d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -134,11 +134,35 @@ export type TeamspaceProjectIdSyncItem = { previousApiProjectId: string | null; apiProjectId: string | null; apiProjectUrl: string | null; - status: "updated" | "current" | "missing" | "ambiguous"; + ttMonthBillableMinutes: number; + teamspaceMonthMinutes: number; + teamspaceMonthBillableMinutes: number; + teamspaceMonthEntryCount: number; + teamspaceMonthDeltaMinutes: number; + teamspaceMonthPositions: TeamspaceProjectIdSyncPosition[]; + status: "updated" | "current" | "missing" | "ambiguous" | "excluded"; message: string | null; }; +export type TeamspaceProjectIdSyncPosition = { + apiProjectId: string; + projectId: string | null; + label: string; + isSubentry: boolean; + billingType?: string | null; + minutes: number; + billableMinutes: number; + entryCount: number; + days?: Array<{ + day: string; + minutes: number; + billableMinutes: number; + entryCount: number; + }>; +}; + export type TeamspaceProjectIdSyncResult = { + month: string; checkedEntries: number; matched: number; updated: number; @@ -151,6 +175,10 @@ export type TeamspaceProjectIdSyncResult = { remoteSubprojectsScanned: number; subentriesSynced: number; subentriesRemoved: number; + teamspaceTimeEntriesScanned: number; + teamspaceTimeEntriesMapped: number; + teamspaceTimeBillableMinutes: number; + teamspaceTimeDeltaMinutes: number; duplicateRemoteProjectIds: string[]; items: TeamspaceProjectIdSyncItem[]; }; @@ -161,6 +189,10 @@ export type TeamspaceProjectSubentrySyncResult = { remoteProjectsScanned: number; subentriesSynced: number; subentriesRemoved: number; + monthsSynced: string[]; + teamspaceTimeEntriesScanned: number; + teamspaceTimeEntriesMapped: number; + teamspaceTimeBillableMinutes: number; }; export type TeamspaceAllSubentrySyncResult = { @@ -482,16 +514,34 @@ export function saveZammadSettings(payload: { baseUrl: string; apiKey?: string } } export function getTeamspaceSettings() { - return request<{ settings: { baseUrl: string } }>("/api/admin/teamspace/settings"); + return request<{ settings: { baseUrl: string; credentialsUserId: string | null } }>("/api/admin/teamspace/settings"); } -export function saveTeamspaceSettings(payload: { baseUrl: string }) { - return request<{ settings: { baseUrl: string } }>("/api/admin/teamspace/settings", { +export function saveTeamspaceSettings(payload: { baseUrl: string; credentialsUserId?: string | null }) { + return request<{ settings: { baseUrl: string; credentialsUserId: string | null } }>("/api/admin/teamspace/settings", { method: "PUT", body: JSON.stringify(payload) }); } +export type TeamspaceProjectExclusion = { + teamspace_project: string; + teamspace_name: string | null; + customer_name: string | null; + note: string; +}; + +export function getTeamspaceProjectExclusions() { + return request<{ exclusions: TeamspaceProjectExclusion[] }>("/api/admin/teamspace/project-exclusions"); +} + +export function saveTeamspaceProjectExclusions(projects: string[]) { + return request<{ exclusions: TeamspaceProjectExclusion[] }>("/api/admin/teamspace/project-exclusions", { + method: "PUT", + body: JSON.stringify({ projects }) + }); +} + 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", @@ -538,25 +588,46 @@ async function runZammadSyncJob(url: string, body: Record, onJo return { jobId: job.id, result }; } -export async function syncExistingZammadTickets(fields: ZammadSyncExistingFields, options: { dryRun?: boolean; ticketNumbers?: string[] } = {}) { +export async function syncExistingZammadTickets(fields: ZammadSyncExistingFields, options: { dryRun?: boolean; ticketNumbers?: string[] } = {}, onJobStarted?: (job: ZammadSyncJob) => void) { const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/existing", { fields, dryRun: options.dryRun ?? true, ticketNumbers: options.ticketNumbers - }); + }, onJobStarted); return result; } -export async function importOwnZammadConsultingTickets(options: { dryRun?: boolean; ticketNumbers?: string[] } = {}) { +export async function importOwnZammadConsultingTickets(options: { dryRun?: boolean; ticketNumbers?: string[] } = {}, onJobStarted?: (job: ZammadSyncJob) => void) { const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", { dryRun: options.dryRun ?? true, ticketNumbers: options.ticketNumbers - }); + }, onJobStarted); return result; } +export function syncExistingZammadTicketsDirect(fields: ZammadSyncExistingFields, onJobStarted?: (job: ZammadSyncJob) => void) { + return runZammadSyncJob("/api/zammad-sync/jobs/existing", { + fields, + dryRun: false + }, onJobStarted); +} + +export function importOwnZammadConsultingTicketsDirect(onJobStarted?: (job: ZammadSyncJob) => void) { + return runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", { + dryRun: false + }, onJobStarted); +} + +export function syncZammadMailSessionsDirect(daysBack: number, perPage: number, onJobStarted?: (job: ZammadSyncJob) => void) { + return runZammadSyncJob("/api/zammad-sync/jobs/mail-sessions", { + dryRun: false, + daysBack, + perPage + }, onJobStarted); +} + export function previewExistingZammadTickets(fields: ZammadSyncExistingFields, onJobStarted?: (job: ZammadSyncJob) => void) { return runZammadSyncJob("/api/zammad-sync/jobs/existing", { fields, @@ -597,16 +668,18 @@ export function applyZammadMailSessionsFromPreview(previewJobId: string, article }, onJobStarted); } -export function syncTeamspaceProjectIds() { +export function syncTeamspaceProjectIds(month?: string) { return request<{ result: TeamspaceProjectIdSyncResult }>("/api/teamspace/project-ids/sync", { method: "POST", + body: JSON.stringify(month ? { month } : {}), timeoutMs: 5 * 60 * 1000 }); } -export function syncProjectTeamspaceSubentries(projectId: string) { +export function syncProjectTeamspaceSubentries(projectId: string, months?: string[]) { return request<{ result: TeamspaceProjectSubentrySyncResult }>(`/api/projects/${projectId}/teamspace-subentries/sync`, { method: "POST", + body: JSON.stringify(months?.length ? { months } : {}), timeoutMs: 5 * 60 * 1000 }); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 5e1f690..e34b59c 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -54,7 +54,7 @@ --font-sans: var(--font-sans); --breakpoint-xxl: 108.125rem; --breakpoint-details: 85rem; - + --text-xxs: 0.65rem; } /* Default theme styles (used when no data-theme-preset is set or when 'default' is selected). @@ -166,6 +166,11 @@ These serve as the fallback; there is no separate default.css file. */ } @layer utilities { + .text-xxs { + font-size: var(--text-xxs); + line-height: 0.65rem; + } + [data-theme-preset]:not([data-theme-preset="default"]) .shadow-2xs { box-shadow: var(--shadow-2xs); } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index e79d61e..fc90207 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -204,6 +204,7 @@ export type ProjectSummary = { starts_on: string | null; ends_on: string | null; hour_budget_minutes: number | null; + teamspace_budget_excluded?: boolean; manual_consumption_minutes: number; teamspace_consumption_minutes?: number; consumption_minutes?: number; @@ -236,6 +237,7 @@ export type ProjectTeamspaceEntry = { subentries?: ProjectTeamspaceSubentry[]; is_default: boolean; hour_budget_minutes: number | null; + teamspace_budget_excluded?: boolean; budget_sync_disabled?: boolean; manual_consumption_minutes: number; teamspace_consumption_minutes?: number; @@ -311,6 +313,40 @@ export type TicketSummary = { requires_closure: boolean; closed: boolean; closed_at: string | null; + teamspace_month_billable_minutes?: number; + teamspace_month_delta_minutes?: number; + teamspace_month_actuals?: TeamspaceMonthActual[]; +}; + +export type TeamspaceMonthActual = { + entry_id: string; + teamspace_project: string | null; + teamspace_description: string | null; + teamspace_api_project_id: string | null; + teamspace_minutes: number; + teamspace_billable_minutes: number; + teamspace_time_entry_count: number; + delta_minutes: number; + positions: TeamspaceMonthActualPosition[]; +}; + +export type TeamspaceMonthActualPosition = { + apiProjectId: string; + projectId: string | null; + label: string; + isSubentry: boolean; + billingType?: string | null; + minutes: number; + billableMinutes: number; + entryCount: number; + days?: TeamspaceMonthActualPositionDay[]; +}; + +export type TeamspaceMonthActualPositionDay = { + day: string; + minutes: number; + billableMinutes: number; + entryCount: number; }; export type OpenSession = { @@ -432,6 +468,9 @@ export type TicketPeriod = { dayBillings: TicketDayBilling[]; project?: ProjectSummary | null; todos?: TicketTodo[]; + teamspaceMonthActuals?: TeamspaceMonthActual[]; + teamspaceMonthBillableMinutes?: number; + teamspaceMonthDeltaMinutes?: number; canClose: boolean; openCount: number; ticketOpenCount: number; @@ -472,6 +511,9 @@ export type StatisticsGroup = { open_sessions: number; crm_billed_minutes: number; crm_delta_minutes: number; + teamspace_month_billable_minutes?: number; + teamspace_month_delta_minutes?: number; + teamspace_month_actuals?: TeamspaceMonthActual[]; }; export type StatisticsTicket = { @@ -492,6 +534,9 @@ export type StatisticsTicket = { open_sessions: number; crm_billed_minutes: number; crm_delta_minutes: number; + teamspace_month_billable_minutes?: number; + teamspace_month_delta_minutes?: number; + teamspace_month_actuals?: TeamspaceMonthActual[]; }; export type StatisticsOpenSession = { @@ -528,6 +573,16 @@ export type StatisticsTeamspaceDay = { project_teamspace_project: string | null; }; +export type StatisticsTeamspaceMonthMismatch = { + ticket_id: string; + ticket_number: string; + organization_name: string; + crm_billed_minutes: number; + teamspace_month_billable_minutes: number; + teamspace_month_delta_minutes: number; + teamspace_month_actuals: TeamspaceMonthActual[]; +}; + export type StatisticsProjectFollowup = { project_id: string; ticket_id: string; @@ -536,6 +591,7 @@ export type StatisticsProjectFollowup = { organization_name: string | null; teamspace_project: string | null; hour_budget_minutes: number | null; + teamspace_budget_excluded?: boolean; consumption_minutes: number; remaining_minutes: number | null; latest_tt_update: string; @@ -582,6 +638,8 @@ export type StatisticsOverview = { activeDays: number; crmBilledMinutes: number; crmDeltaMinutes: number; + teamspaceApiBillableMinutes?: number; + teamspaceApiDeltaMinutes?: number; }; dailySeries: StatisticsDailyBucket[]; organizations: StatisticsGroup[]; @@ -592,6 +650,7 @@ export type StatisticsOverview = { missingCrmDays: StatisticsCrmDay[]; acknowledgedMissingCrmDays: StatisticsCrmDay[]; crmMismatches: StatisticsCrmDay[]; + teamspaceMonthMismatches: StatisticsTeamspaceMonthMismatch[]; }; teamspaceDays: StatisticsTeamspaceDay[]; }; diff --git a/frontend/src/views/AdminUsersPage.tsx b/frontend/src/views/AdminUsersPage.tsx index 7e36974..05971af 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, Unplug, Upload, UserPlus } from "lucide-react"; +import { CheckCircle2, DatabaseBackup, DatabaseZap, Plus, Save, Shield, Trash2, Unplug, Upload, UserPlus } from "lucide-react"; import { toast } from "sonner"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; @@ -15,13 +15,16 @@ import { downloadDatabaseExport, getAdminUsers, getTeamspaceSettings, + getTeamspaceProjectExclusions, getZammadSettings, restoreDatabaseBackup, saveZammadSettings, saveTeamspaceSettings, + saveTeamspaceProjectExclusions, syncZammadOrganizations, updateAdminUser, validateDatabaseBackup, + type TeamspaceProjectExclusion, type RestoreValidationResult } from "../api"; import type { AdminUser, AuthUser } from "../types"; @@ -57,9 +60,13 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) { const [zammadApiKey, setZammadApiKey] = useState(""); const [hasZammadApiKey, setHasZammadApiKey] = useState(false); const [teamspaceBaseUrl, setTeamspaceBaseUrl] = useState(""); + const [teamspaceCredentialsUserId, setTeamspaceCredentialsUserId] = useState(""); + const [teamspaceProjectExclusions, setTeamspaceProjectExclusions] = useState([]); + const [newTeamspaceProjectExclusion, setNewTeamspaceProjectExclusion] = useState(""); const [syncingOrganizations, setSyncingOrganizations] = useState(false); const [savingZammadSettings, setSavingZammadSettings] = useState(false); const [savingTeamspaceSettings, setSavingTeamspaceSettings] = useState(false); + const [savingTeamspaceProjectExclusions, setSavingTeamspaceProjectExclusions] = useState(false); const [exportingDatabase, setExportingDatabase] = useState(false); const [restoreSql, setRestoreSql] = useState(""); const [restoreValidation, setRestoreValidation] = useState(null); @@ -72,13 +79,20 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) { async function load(options: { preserveEdits?: boolean } = {}) { setLoading(true); try { - const [usersResult, zammadSettingsResult, teamspaceSettingsResult] = await Promise.all([getAdminUsers(), getZammadSettings(), getTeamspaceSettings()]); + const [usersResult, zammadSettingsResult, teamspaceSettingsResult, teamspaceProjectExclusionsResult] = await Promise.all([ + getAdminUsers(), + getZammadSettings(), + getTeamspaceSettings(), + getTeamspaceProjectExclusions() + ]); setUsers(usersResult.users); if (!options.preserveEdits) { setZammadBaseUrl(zammadSettingsResult.settings.baseUrl); setHasZammadApiKey(zammadSettingsResult.settings.hasApiKey); setTeamspaceBaseUrl(teamspaceSettingsResult.settings.baseUrl); + setTeamspaceCredentialsUserId(teamspaceSettingsResult.settings.credentialsUserId ?? ""); + setTeamspaceProjectExclusions(teamspaceProjectExclusionsResult.exclusions); } setForms((current) => @@ -222,9 +236,11 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) { try { const result = await saveTeamspaceSettings({ - baseUrl: teamspaceBaseUrl + baseUrl: teamspaceBaseUrl, + credentialsUserId: teamspaceCredentialsUserId || null }); setTeamspaceBaseUrl(result.settings.baseUrl); + setTeamspaceCredentialsUserId(result.settings.credentialsUserId ?? ""); toast.success("Teamspace-Zugang gespeichert", { description: "URL ist hinterlegt." }); @@ -237,6 +253,56 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) { } } + function addTeamspaceExclusion() { + const project = newTeamspaceProjectExclusion.trim(); + + if (!project) { + return; + } + + const exists = teamspaceProjectExclusions.some((entry) => entry.teamspace_project.toLocaleLowerCase("de") === project.toLocaleLowerCase("de")); + + if (exists) { + toast.info("Teamspace-Projekt ist bereits in der Liste"); + setNewTeamspaceProjectExclusion(""); + return; + } + + setTeamspaceProjectExclusions((current) => [ + ...current, + { + teamspace_project: project, + teamspace_name: null, + customer_name: null, + note: "" + } + ]); + setNewTeamspaceProjectExclusion(""); + } + + function removeTeamspaceExclusion(project: string) { + setTeamspaceProjectExclusions((current) => current.filter((entry) => entry.teamspace_project !== project)); + } + + async function saveTeamspaceExclusions() { + setSavingTeamspaceProjectExclusions(true); + + try { + const projects = teamspaceProjectExclusions.map((entry) => entry.teamspace_project.trim()).filter(Boolean); + const result = await saveTeamspaceProjectExclusions(projects); + setTeamspaceProjectExclusions(result.exclusions); + toast.success("Teamspace-Ausnahmen gespeichert", { + description: `${result.exclusions.length} Projektnummer(n) geprüft und gespeichert.` + }); + } catch (error) { + toast.error("Teamspace-Ausnahmen konnten nicht gespeichert werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } finally { + setSavingTeamspaceProjectExclusions(false); + } + } + async function exportDatabase() { setExportingDatabase(true); @@ -400,7 +466,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {

Teamspace API

-
+
setTeamspaceBaseUrl(event.currentTarget.value)} />
+
+ + +

Dieser Benutzer wird für Admin-Checks gegen Teamspace verwendet.

+
+
+
+
+

TS-Projekte ohne Differenz-/Budgetprüfung

+

+ Sync und Gesamtverbrauch laufen weiter, aber TS/TT-Differenzen, Budgetbalken und Budgetverbrauch werden für diese TS-Projekte ausgeblendet. Beim Speichern werden Name und Kunde aus Teamspace geladen. +

+
+
+ setNewTeamspaceProjectExclusion(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + addTeamspaceExclusion(); + } + }} + /> + +
+
+ {teamspaceProjectExclusions.length === 0 ? ( +

Noch keine Teamspace-Projekte ausgenommen.

+ ) : ( +
+ {teamspaceProjectExclusions.map((entry) => ( +
+
{entry.teamspace_project}
+
+

{entry.teamspace_name ?? "Name wird beim Speichern aus Teamspace geladen"}

+

{entry.customer_name ?? "Kunde wird beim Speichern aus Teamspace geladen"}

+
+
+ {entry.customer_name ?? "Kunde wird beim Speichern aus Teamspace geladen"} +
+ +
+ ))} +
+ )} +
+
diff --git a/frontend/src/views/AnalysisPage.tsx b/frontend/src/views/AnalysisPage.tsx index 6d6f3ec..37f45b6 100644 --- a/frontend/src/views/AnalysisPage.tsx +++ b/frontend/src/views/AnalysisPage.tsx @@ -1,4 +1,4 @@ -import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, Clock3, Download, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react"; +import { AlertCircle, ArrowDown, ArrowUp, ChevronLeft, ChevronRight, Clock3, Download, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; @@ -168,6 +168,41 @@ function TicketStatus({ ticket }: { ticket: TicketSummary }) { return bewertet; } +function formatSignedMinutes(minutes: number) { + if (minutes === 0) { + return formatMinutes(0); + } + + return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`; +} + +function teamspaceMonthDeltaMinutes(ticket: TicketSummary) { + const actuals = ticket.teamspace_month_actuals ?? []; + + if (actuals.length === 0) { + return 0; + } + + const tsMinutes = ticket.teamspace_month_billable_minutes ?? actuals.reduce((sum, actual) => sum + actual.teamspace_billable_minutes, 0); + return ticket.teamspace_month_delta_minutes ?? tsMinutes - ticket.crm_billed_minutes; +} + +function TeamspaceDeviationIcon({ ticket }: { ticket: TicketSummary }) { + const deltaMinutes = teamspaceMonthDeltaMinutes(ticket); + + if (deltaMinutes === 0) { + return