diff --git a/.env_dev b/.env_dev index 88f4e7a..b03b95d 100644 --- a/.env_dev +++ b/.env_dev @@ -1,5 +1,5 @@ -PP_PASSWORD='rabenNest7$' -DATABASE_URL=postgresql://tickettracker:${PP_PASSWORD}@172.22.0.2:5432/tickettracker +PP_PASSWORD='$ouzs4D$@RAi9' +DATABASE_URL=postgresql://tickettracker:${PP_PASSWORD}@172.22.0.2:5432/tickettracker_dev APP_PORT=3911 TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest COOKIE_SECURE=false diff --git a/backend/src/index.ts b/backend/src/index.ts index 3767554..5f8d074 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -299,6 +299,7 @@ type ProjectRow = { work_type: "support" | "consulting" | null; zammad_priority_id?: string | null; zammad_state_id?: string | null; + zammad_pending_time?: string | null; teamspace_project: string | null; description: string; goals: string; @@ -457,6 +458,11 @@ function numberOrNull(value: unknown) { return Number.isInteger(number) && number > 0 ? number : null; } +function zammadStateIdOrNull(value: unknown) { + const stateId = numberOrNull(value); + return stateId === 1 ? 2 : stateId; +} + const zammadConsultingGroupId = 2; const zammadSupportGroupIds = [3, 4]; @@ -699,22 +705,24 @@ function zammadTicketImportFromRaw(rawTicket: Record, fallbackT const preferences = isPlainObject(rawTicket.preferences) ? rawTicket.preferences : {}; const teamspaceProjects = parseZammadTeamspaceProjects(zammadPreferenceValue(rawTicket, "project")); + const pendingTime = parseZammadPendingTime(zammadPreferenceValue(rawTicket, "pending_time")); + const storedPreferences = pendingTime ? { ...preferences, pending_time: pendingTime } : preferences; return { zammadTicketId, ticketNumber, zammadGroupId: groupId, zammadPriorityId: numberOrNull(rawTicket.priority_id), - zammadStateId: numberOrNull(rawTicket.state_id), + zammadStateId: zammadStateIdOrNull(rawTicket.state_id), organizationId, title: typeof rawTicket.title === "string" && rawTicket.title.trim() ? rawTicket.title.trim() : null, zammadOwnerId: numberOrNull(rawTicket.owner_id), workType, - preferences, + preferences: storedPreferences, teamspaceBudgetMinutes: parseZammadBudgetMinutes(zammadPreferenceValue(rawTicket, "gesamtbudget")), teamspaceProject: teamspaceProjects[0] ?? null, teamspaceProjects, - pendingTime: parseZammadPendingTime(zammadPreferenceValue(rawTicket, "pending_time")) + pendingTime }; } @@ -1622,7 +1630,8 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, t.zammad_priority_id::text AS zammad_priority_id, - t.zammad_state_id::text AS zammad_state_id, + CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id, + t.zammad_preferences ->> 'pending_time' AS zammad_pending_time, p.teamspace_project, p.description, p.goals, @@ -2020,7 +2029,12 @@ function syncIdComparableValue(value: unknown) { } function syncComparableValueForField(field: string, value: unknown) { - if (field === "Organisation" || field === "Zammad-Priorität" || field === "Zammad-Status") { + if (field === "Zammad-Status") { + const stateId = zammadStateIdOrNull(value); + return stateId === null ? null : String(stateId); + } + + if (field === "Organisation" || field === "Zammad-Priorität") { return syncIdComparableValue(value); } @@ -2072,7 +2086,7 @@ function zammadTicketFromSyncSnapshot(snapshot: Record | null): const ticketNumber = typeof snapshot.ticketNumber === "string" ? snapshot.ticketNumber : null; const zammadGroupId = numberOrNull(snapshot.groupId); const zammadPriorityId = numberOrNull(snapshot.priorityId); - const zammadStateId = numberOrNull(snapshot.stateId); + const zammadStateId = zammadStateIdOrNull(snapshot.stateId); const organizationId = numberOrNull(snapshot.organizationId); const zammadOwnerId = numberOrNull(snapshot.ownerId); const workType = zammadWorkType(zammadGroupId); @@ -2870,6 +2884,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use COUNT(*) FILTER (WHERE s.recurring_billing_id IS NOT NULL)::int AS recurring_session_count, 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(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), 0)::int AS open_minutes, COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS open_count, @@ -2883,6 +2898,14 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use JOIN sessions s ON s.ticket_id = t.id LEFT JOIN organizations o ON o.zammad_id = t.organization_id LEFT JOIN projects p ON p.ticket_id = t.id AND p.user_id = $3 + LEFT JOIN LATERAL ( + SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS crm_billed_minutes + FROM ticket_day_billings tdb + WHERE tdb.ticket_id = t.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 + ) ticket_crm ON true WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz AND s.user_id = $3 GROUP BY @@ -2893,7 +2916,8 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use o.name, t.customer_name, t.work_type, - p.id + p.id, + ticket_crm.crm_billed_minutes ORDER BY t.ticket_number ASC; `, [period.startIso, period.endIso, userId] @@ -3390,7 +3414,7 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, t.zammad_priority_id::text AS zammad_priority_id, - t.zammad_state_id::text AS zammad_state_id, + CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id, NULL::timestamptz AS closed_at, pc.closed_at AS period_closed_at, COALESCE(period_sessions.session_count, 0)::int AS ticket_session_count, @@ -3527,7 +3551,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, t.zammad_priority_id::text AS zammad_priority_id, - t.zammad_state_id::text AS zammad_state_id, + CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id, NULL::timestamptz AS closed_at, NULL::timestamptz AS period_closed_at, COALESCE(all_sessions.session_count, 0)::int AS ticket_session_count, @@ -3670,7 +3694,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, t.zammad_priority_id::text AS zammad_priority_id, - t.zammad_state_id::text AS zammad_state_id, + CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id, p.teamspace_project, p.description, p.goals, @@ -3995,6 +4019,103 @@ app.put("/api/dashboard/preferences", requireUser, async (req, res) => { res.json({ preference: saved }); }); +app.get("/api/dashboard/day-control", requireUser, async (req, res) => { + const userId = currentUser(req).id; + const result = await query( + ` + SELECT + t.id::text AS ticket_id, + t.ticket_number, + t.title, + COALESCE(o.name, t.customer_name) AS organization_name, + t.work_type, + t.zammad_priority_id::text AS zammad_priority_id, + CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id, + t.zammad_preferences ->> 'pending_time' AS zammad_pending_time, + MAX(s.started_at) FILTER (WHERE s.user_id = $1)::text AS latest_started_at, + EXISTS ( + SELECT 1 + FROM projects own_projects + WHERE own_projects.ticket_id = t.id + AND own_projects.user_id = $1 + ) AS has_project, + COUNT(s.id) FILTER (WHERE s.user_id = $1)::int AS own_session_count, + COUNT(s.id) FILTER (WHERE s.user_id = $1 AND s.billing_status IS NULL)::int AS open_session_count, + COALESCE(todo_stats.open_todo_count, 0)::int AS open_todo_count, + COALESCE(todo_stats.due_todo_count, 0)::int AS due_todo_count + FROM tickets t + LEFT JOIN organizations o ON o.zammad_id = t.organization_id + LEFT JOIN sessions s ON s.ticket_id = t.id + LEFT JOIN LATERAL ( + SELECT + COUNT(*) FILTER (WHERE tt.status = 'open')::int AS open_todo_count, + COUNT(*) FILTER ( + WHERE tt.status = 'open' + AND ( + tt.ends_on < (now() AT TIME ZONE 'Europe/Berlin')::date + OR tt.ends_on = (now() AT TIME ZONE 'Europe/Berlin')::date + OR tt.starts_on = (now() AT TIME ZONE 'Europe/Berlin')::date + OR (tt.starts_on <= (now() AT TIME ZONE 'Europe/Berlin')::date AND tt.ends_on >= (now() AT TIME ZONE 'Europe/Berlin')::date) + ) + )::int AS due_todo_count + FROM ticket_todos tt + WHERE tt.ticket_id = t.id + AND tt.user_id = $1 + ) todo_stats ON true + WHERE ( + EXISTS ( + SELECT 1 + FROM sessions own_sessions + WHERE own_sessions.ticket_id = t.id + AND own_sessions.user_id = $1 + ) + OR EXISTS ( + SELECT 1 + FROM projects own_projects + WHERE own_projects.ticket_id = t.id + AND own_projects.user_id = $1 + ) + OR EXISTS ( + SELECT 1 + FROM ticket_todos own_todos + WHERE own_todos.ticket_id = t.id + AND own_todos.user_id = $1 + ) + ) + AND ( + t.zammad_state_id IN (1, 2, 3, 7) + OR EXISTS ( + SELECT 1 + FROM sessions open_sessions + WHERE open_sessions.ticket_id = t.id + AND open_sessions.user_id = $1 + AND open_sessions.billing_status IS NULL + ) + ) + GROUP BY + t.id, + t.ticket_number, + t.title, + o.name, + t.customer_name, + t.work_type, + t.zammad_priority_id, + t.zammad_state_id, + t.zammad_preferences, + todo_stats.open_todo_count, + todo_stats.due_todo_count + ORDER BY + CASE t.zammad_priority_id WHEN 3 THEN 0 WHEN 2 THEN 1 WHEN 1 THEN 2 ELSE 3 END, + MAX(s.started_at) FILTER (WHERE s.user_id = $1) DESC NULLS LAST, + t.ticket_number ASC + LIMIT 200; + `, + [userId] + ); + + res.json({ tickets: result.rows }); +}); + app.post("/api/dashboard/favorites", requireUser, async (req, res) => { const userId = currentUser(req).id; const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber); @@ -5360,7 +5481,7 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => { t.zammad_ticket_id::text AS zammad_ticket_id, t.zammad_group_id::text AS zammad_group_id, t.zammad_priority_id::text AS zammad_priority_id, - t.zammad_state_id::text AS zammad_state_id, + CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id, t.zammad_owner_id::text AS zammad_owner_id, p.id::text AS project_id, (p.id IS NOT NULL) AS is_project, @@ -6074,7 +6195,8 @@ app.get("/api/projects", requireUser, async (req, res) => { COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, t.zammad_priority_id::text AS zammad_priority_id, - t.zammad_state_id::text AS zammad_state_id, + CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id, + t.zammad_preferences ->> 'pending_time' AS zammad_pending_time, p.teamspace_project, p.description, p.goals, @@ -6234,7 +6356,8 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, t.zammad_priority_id::text AS zammad_priority_id, - t.zammad_state_id::text AS zammad_state_id, + CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id, + t.zammad_preferences ->> 'pending_time' AS zammad_pending_time, p.teamspace_project, p.description, p.goals, diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index 2a9be63..0c0fff5 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -169,6 +169,7 @@ export async function migrate() { await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_group_id BIGINT;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_priority_id BIGINT;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_state_id BIGINT;"); + await query("UPDATE tickets SET zammad_state_id = 2 WHERE zammad_state_id = 1;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_owner_id BIGINT;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_preferences JSONB NOT NULL DEFAULT '{}'::jsonb;"); await query(` diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ff6994e..b482cfb 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -2,6 +2,7 @@ import type { AdminUser, AuthUser, BillingStatus, + DashboardDayTicket, DashboardPreference, Organization, PeriodOverview, @@ -207,6 +208,10 @@ export function getDashboardPreference() { return request<{ preference: Partial }>("/api/dashboard/preferences"); } +export function getDashboardDayControl() { + return request<{ tickets: DashboardDayTicket[] }>("/api/dashboard/day-control"); +} + export function saveDashboardPreference(preference: DashboardPreference) { return request<{ preference: Partial }>("/api/dashboard/preferences", { method: "PUT", diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 7c14727..8762c01 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -14,7 +14,7 @@ export type AuthUser = { has_zammad_api_token: boolean; }; -export type DashboardWidgetType = "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes"; +export type DashboardWidgetType = "dayControl" | "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes"; export type DashboardSortDirection = "asc" | "desc"; export type DashboardFavorite = { @@ -40,6 +40,23 @@ export type DashboardPreference = { favorites: DashboardFavorite[]; }; +export type DashboardDayTicket = { + ticket_id: string; + ticket_number: string; + title: string | null; + organization_name: string | null; + work_type: WorkType | null; + zammad_priority_id: string | null; + zammad_state_id: string | null; + zammad_pending_time: string | null; + latest_started_at: string | null; + has_project: boolean; + own_session_count: number; + open_session_count: number; + open_todo_count: number; + due_todo_count: number; +}; + export type AdminUser = AuthUser & { created_at: string; updated_at: string; @@ -138,6 +155,7 @@ export type ProjectSummary = { work_type: WorkType | null; zammad_priority_id?: string | null; zammad_state_id?: string | null; + zammad_pending_time?: string | null; teamspace_project: string | null; description: string; goals: string; @@ -211,6 +229,7 @@ export type TicketSummary = { project_id: string | null; session_count: number; total_minutes: number; + crm_billed_minutes: number; non_billable_minutes: number; open_minutes: number; open_count: number; diff --git a/frontend/src/views/DashboardPage.tsx b/frontend/src/views/DashboardPage.tsx index 7b22479..14a00e3 100644 --- a/frontend/src/views/DashboardPage.tsx +++ b/frontend/src/views/DashboardPage.tsx @@ -1,6 +1,8 @@ import { ArrowDown, ArrowUp, + Bell, + CalendarCheck, CheckCircle2, ChevronsUpDown, ChevronLeft, @@ -18,6 +20,7 @@ import { Star, Timer, Trash2, + WalletCards, X } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -35,6 +38,7 @@ import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { addFavoriteTicket, + getDashboardDayControl, getDashboardPreference, getPeriodOverview, getProjects, @@ -58,6 +62,7 @@ import { zammadStateSortRank } from "../zammad-meta"; import type { + DashboardDayTicket, DashboardFavorite, DashboardPreference, DashboardSortDirection, @@ -82,6 +87,7 @@ type WidgetDefinition = { defaultSize: { w: number; h: number }; minSize?: { w: number; h: number }; multiple?: boolean; + fixed?: boolean; }; type DashboardNote = { @@ -95,6 +101,7 @@ const rowHeight = 48; const gridColumns = 12; const widgetDefinitions: WidgetDefinition[] = [ + { type: "dayControl", title: "Tagessteuerung", defaultSize: { w: 6, h: 5 }, minSize: { w: 4, h: 4 }, fixed: true }, { type: "today", title: "Heute", defaultSize: { w: 2, h: 2 }, multiple: true }, { type: "month", title: "Monat", defaultSize: { w: 2, h: 2 }, multiple: true }, { type: "openRatings", title: "Bewertungen", defaultSize: { w: 3, h: 3 }, multiple: true }, @@ -108,14 +115,15 @@ const widgetDefinitions: WidgetDefinition[] = [ ]; const defaultWidgets: DashboardWidget[] = [ - { id: "today", type: "today", x: 0, y: 0, w: 2, h: 2 }, - { id: "month", type: "month", x: 2, y: 0, w: 2, h: 2 }, - { id: "teamspace", type: "teamspace", x: 4, y: 0, w: 2, h: 2 }, - { id: "open-ratings", type: "openRatings", x: 6, y: 0, w: 3, h: 3 }, - { id: "todos", type: "todos", x: 9, y: 0, w: 3, h: 3, config: { sortBy: "due", sortDirection: "asc" } }, - { id: "project-budgets", type: "projectBudgets", x: 0, y: 2, w: 6, h: 3, config: { sortBy: "remaining", sortDirection: "asc" } }, - { id: "favorites", type: "favorites", x: 6, y: 3, w: 3, h: 3, config: { sortBy: "custom", sortDirection: "asc" } }, - { id: "statistic", type: "statistic", x: 9, y: 3, w: 3, h: 3, config: { view: "dailyBars", sortDirection: "desc" } } + { id: "day-control", type: "dayControl", x: 0, y: 0, w: 6, h: 5 }, + { id: "today", type: "today", x: 6, y: 0, w: 2, h: 2 }, + { id: "month", type: "month", x: 8, y: 0, w: 2, h: 2 }, + { id: "teamspace", type: "teamspace", x: 10, y: 0, w: 2, h: 2 }, + { id: "open-ratings", type: "openRatings", x: 6, y: 2, w: 3, h: 3 }, + { id: "todos", type: "todos", x: 9, y: 2, w: 3, h: 3, config: { sortBy: "due", sortDirection: "asc" } }, + { id: "project-budgets", type: "projectBudgets", x: 0, y: 5, w: 6, h: 3, config: { sortBy: "remaining", sortDirection: "asc" } }, + { id: "favorites", type: "favorites", x: 6, y: 5, w: 3, h: 3, config: { sortBy: "custom", sortDirection: "asc" } }, + { id: "statistic", type: "statistic", x: 9, y: 5, w: 3, h: 3, config: { view: "dailyBars", sortDirection: "desc" } } ]; function widgetTitle(type: DashboardWidgetType) { @@ -134,6 +142,10 @@ function widgetSupportsMultiple(type: DashboardWidgetType) { return widgetDefinition(type)?.multiple === true; } +function widgetIsFixed(type: DashboardWidgetType) { + return widgetDefinition(type)?.fixed === true; +} + function defaultWidgetConfig(type: DashboardWidgetType): Record { if (type === "todos") { return { filterValues: ["open"], sortBy: "due", sortDirection: "asc" }; @@ -247,34 +259,52 @@ function normalizePreference(preference: Partial): Dashboar const widgets = preferenceVersion === dashboardPreferenceVersion && Array.isArray(preference.widgets) ? preference.widgets : cloneDefaultWidgets(); const favorites = Array.isArray(preference.favorites) ? preference.favorites : []; const seenTypes = new Set(); + const normalizedWidgets = widgets + .filter((widget) => { + const known = widgetDefinitions.some((definition) => definition.type === widget.type); + + if (!known || (!widgetSupportsMultiple(widget.type) && seenTypes.has(widget.type))) { + return false; + } + + if (!widgetSupportsMultiple(widget.type)) { + seenTypes.add(widget.type); + } + + return true; + }) + .map((widget, index) => ({ + id: widget.id || `${widget.type}-${index}`, + type: widget.type, + title: typeof widget.title === "string" ? widget.title : widgetTitle(widget.type), + x: clampNumber(widget.x, 0, gridColumns - 1), + y: clampNumber(widget.y, 0, 30), + w: clampNumber(widget.w, widgetMinSize(widget.type).w, gridColumns), + h: clampNumber(widget.h, widgetMinSize(widget.type).h, 8), + config: normalizeWidgetConfig(widget.type, widget.config) + })) + .reduce((placed, widget) => [...placed, resolveWidgetPlacement(placed, widget)], []); + + for (const definition of widgetDefinitions.filter((item) => item.fixed)) { + if (!normalizedWidgets.some((widget) => widget.type === definition.type)) { + normalizedWidgets.push( + resolveWidgetPlacement(normalizedWidgets, { + id: definition.type, + type: definition.type, + title: definition.title, + x: 0, + y: Math.max(0, ...normalizedWidgets.map((widget) => widget.y + widget.h)), + w: definition.defaultSize.w, + h: definition.defaultSize.h, + config: defaultWidgetConfig(definition.type) + }) + ); + } + } return { version: dashboardPreferenceVersion, - widgets: widgets - .filter((widget) => { - const known = widgetDefinitions.some((definition) => definition.type === widget.type); - - if (!known || (!widgetSupportsMultiple(widget.type) && seenTypes.has(widget.type))) { - return false; - } - - if (!widgetSupportsMultiple(widget.type)) { - seenTypes.add(widget.type); - } - - return true; - }) - .map((widget, index) => ({ - id: widget.id || `${widget.type}-${index}`, - type: widget.type, - title: typeof widget.title === "string" ? widget.title : widgetTitle(widget.type), - x: clampNumber(widget.x, 0, gridColumns - 1), - y: clampNumber(widget.y, 0, 30), - w: clampNumber(widget.w, widgetMinSize(widget.type).w, gridColumns), - h: clampNumber(widget.h, widgetMinSize(widget.type).h, 8), - config: normalizeWidgetConfig(widget.type, widget.config) - })) - .reduce((placed, widget) => [...placed, resolveWidgetPlacement(placed, widget)], []), + widgets: normalizedWidgets, favorites: favorites.map((favorite, index) => ({ ticketNumber: favorite.ticketNumber, addedAt: favorite.addedAt || new Date().toISOString(), @@ -295,30 +325,38 @@ function overlaps(left: DashboardWidget, right: DashboardWidget) { return left.x < right.x + right.w && left.x + left.w > right.x && left.y < right.y + right.h && left.y + left.h > right.y; } -function resolveWidgetPlacement(widgets: DashboardWidget[], changed: DashboardWidget) { +function columnsOverlap(left: DashboardWidget, right: DashboardWidget) { + return left.x < right.x + right.w && left.x + left.w > right.x; +} + +function normalizeWidgetPlacement(changed: DashboardWidget) { const minSize = widgetMinSize(changed.type); const w = clampNumber(changed.w, minSize.w, gridColumns); const h = clampNumber(changed.h, minSize.h, 8); - const desiredY = clampNumber(changed.y, 0, 80); - const next = { + + return { ...changed, w, h, x: clampNumber(changed.x, 0, gridColumns - w), - y: desiredY + y: clampNumber(changed.y, 0, 80) }; +} + +function resolveWidgetPlacement(widgets: DashboardWidget[], changed: DashboardWidget) { + const next = normalizeWidgetPlacement(changed); const hasCollision = (y: number) => widgets.some((widget) => widget.id !== next.id && overlaps(widget, { ...next, y })); - if (!hasCollision(desiredY)) { + if (!hasCollision(next.y)) { return next; } - const maxExistingRow = Math.max(80, ...widgets.filter((widget) => widget.id !== next.id).map((widget) => widget.y + widget.h + h)); + const maxExistingRow = Math.max(80, ...widgets.filter((widget) => widget.id !== next.id).map((widget) => widget.y + widget.h + next.h)); - for (let distance = 1; distance <= maxExistingRow + h; distance += 1) { - const yAbove = desiredY - distance; - const yBelow = desiredY + distance; + for (let distance = 1; distance <= maxExistingRow + next.h; distance += 1) { + const yAbove = next.y - distance; + const yBelow = next.y + distance; if (yAbove >= 0 && !hasCollision(yAbove)) { return { ...next, y: yAbove }; @@ -332,9 +370,96 @@ function resolveWidgetPlacement(widgets: DashboardWidget[], changed: DashboardWi return { ...next, y: maxExistingRow + h }; } +function moveCollidingWidgetsDown(widgets: DashboardWidget[], lockedWidgetId: string) { + const next = widgets.map((widget) => ({ ...widget })); + + for (let guard = 0; guard < 500; guard += 1) { + let moved = false; + const sorted = [...next].sort((left, right) => { + if (left.id === lockedWidgetId && right.id !== lockedWidgetId) { + return -1; + } + + if (right.id === lockedWidgetId && left.id !== lockedWidgetId) { + return 1; + } + + return compareNumber(left.y, right.y) || compareNumber(left.x, right.x) || compareText(left.id, right.id); + }); + + for (let index = 0; index < sorted.length; index += 1) { + for (let otherIndex = index + 1; otherIndex < sorted.length; otherIndex += 1) { + const left = sorted[index]; + const right = sorted[otherIndex]; + + if (!overlaps(left, right)) { + continue; + } + + const anchor = + left.id === lockedWidgetId + ? left + : right.id === lockedWidgetId + ? right + : left.y < right.y || (left.y === right.y && left.x <= right.x) + ? left + : right; + const mover = anchor.id === left.id ? right : left; + const movedWidget = next.find((widget) => widget.id === mover.id); + + if (movedWidget && movedWidget.y < anchor.y + anchor.h) { + movedWidget.y = anchor.y + anchor.h; + moved = true; + } + } + } + + if (!moved) { + return next; + } + } + + return next; +} + +function compactWidgetsUp(widgets: DashboardWidget[], lockedWidgetId: string) { + const next = widgets.map((widget) => ({ ...widget })); + const lockedWidget = next.find((widget) => widget.id === lockedWidgetId) ?? null; + const movingWidgets = next + .filter((widget) => widget.id !== lockedWidgetId) + .sort((left, right) => compareNumber(left.y, right.y) || compareNumber(left.x, right.x) || compareText(left.id, right.id)); + + for (const widget of movingWidgets) { + const minY = lockedWidget && columnsOverlap(widget, lockedWidget) && widget.y >= lockedWidget.y + lockedWidget.h ? lockedWidget.y + lockedWidget.h : 0; + + for (let y = minY; y < widget.y; y += 1) { + const candidate = { ...widget, y }; + const collides = next.some((other) => other.id !== widget.id && overlaps(other, candidate)); + + if (!collides) { + widget.y = y; + break; + } + } + } + + return next; +} + +function layoutWidgetsWithPlacement(widgets: DashboardWidget[], changed: DashboardWidget) { + const next = normalizeWidgetPlacement(changed); + const hasWidget = widgets.some((widget) => widget.id === next.id); + const withChanged = hasWidget + ? widgets.map((widget) => (widget.id === next.id ? next : normalizeWidgetPlacement(widget))) + : [...widgets.map((widget) => normalizeWidgetPlacement(widget)), next]; + const pushed = moveCollidingWidgetsDown(withChanged, next.id); + const compacted = compactWidgetsUp(pushed, next.id); + + return moveCollidingWidgetsDown(compacted, next.id).map((widget) => ({ ...widget, y: clampNumber(widget.y, 0, 120) })); +} + function placeWidget(widgets: DashboardWidget[], changed: DashboardWidget) { - const next = resolveWidgetPlacement(widgets, changed); - return widgets.map((widget) => (widget.id === next.id ? next : widget)); + return layoutWidgetsWithPlacement(widgets, changed); } function widgetPlacementFromPoint(gridElement: HTMLDivElement, widgets: DashboardWidget[], widget: DashboardWidget, clientX: number, clientY: number) { @@ -345,7 +470,7 @@ function widgetPlacementFromPoint(gridElement: HTMLDivElement, widgets: Dashboar const columnStep = (rect.width + columnGap) / gridColumns; const rowStep = rowHeight + rowGap; - return resolveWidgetPlacement(widgets, { + return normalizeWidgetPlacement({ ...widget, x: clampNumber(Math.floor((clientX - rect.left) / columnStep), 0, gridColumns - widget.w), y: clampNumber(Math.floor((clientY - rect.top) / rowStep), 0, 80) @@ -403,6 +528,20 @@ function compareOptionalTimestamp(left: string | null | undefined, right: string return compareNumber(leftTime, rightTime) * directionFactor(direction); } +function pendingDay(value: string | null | undefined) { + if (!value) { + return null; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return null; + } + + return value.slice(0, 10); +} + function stableSort(items: T[], compare: (left: T, right: T) => number) { return items .map((item, index) => ({ item, index })) @@ -753,6 +892,7 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) { const [statisticsOverview, setStatisticsOverview] = useState(null); const [projects, setProjects] = useState([]); const [todos, setTodos] = useState([]); + const [dayControlTickets, setDayControlTickets] = useState([]); const [recurringBillings, setRecurringBillings] = useState([]); const [favoriteMetas, setFavoriteMetas] = useState>({}); const [favoriteInputs, setFavoriteInputs] = useState>({}); @@ -764,8 +904,9 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) { setLoading(true); try { - const [dashboardResult, monthResult, todayResult, statisticsResult, projectsResult, todosResult, recurringResult] = await Promise.all([ + const [dashboardResult, dayControlResult, monthResult, todayResult, statisticsResult, projectsResult, todosResult, recurringResult] = await Promise.all([ getDashboardPreference(), + getDashboardDayControl(), getPeriodOverview("month", currentMonth()), getPeriodOverview("day", currentDay()), getStatisticsOverview(currentMonth()), @@ -779,6 +920,7 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) { } setPreference(normalizePreference(dashboardResult.preference)); + setDayControlTickets(dayControlResult.tickets); setMonthOverview(monthResult); setTodayOverview(todayResult); setStatisticsOverview(statisticsResult); @@ -899,6 +1041,9 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) { dayTotals, statistics: statisticsOverview, openRatings: monthOverview?.openSessions ?? [], + todayTickets: todayOverview?.tickets ?? [], + todayOpenRatings: todayOverview?.openSessions ?? [], + dayControlTickets, fixedBillingTickets, teamspaceDelta: (monthTotals?.minutes ?? 0) - (monthTotals?.crmBilledMinutes ?? 0), overBudget, @@ -906,7 +1051,7 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) { openTodos: todos.filter((todo) => todo.status === "open"), overdueTodos: todos.filter((todo) => todo.status === "open" && todo.ends_on && todo.ends_on < currentDay()) }; - }, [monthOverview, projects, recurringBillings, statisticsOverview, todayOverview, todos]); + }, [dayControlTickets, monthOverview, projects, recurringBillings, statisticsOverview, todayOverview, todos]); function updateWidget(widgetId: string, changes: Partial) { setPreference((current) => ({ @@ -960,7 +1105,7 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) { function removeWidget(widgetId: string) { setPreference((current) => ({ ...current, - widgets: current.widgets.filter((widget) => widget.id !== widgetId) + widgets: current.widgets.filter((widget) => widget.id !== widgetId || widgetIsFixed(widget.type)) })); } @@ -1110,10 +1255,10 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) { size="sm" variant={active ? "outline" : "secondary"} disabled={active} - title={active ? "Bereits im Dashboard" : "Widget hinzufügen"} + title={definition.fixed ? "Festes Widget" : active ? "Bereits im Dashboard" : "Widget hinzufügen"} onClick={() => addWidget(definition.type)} > - {definition.type === "divider" ? : definition.type === "notes" ? : } + {definition.type === "divider" ? : definition.type === "notes" ? : definition.type === "dayControl" ? : } {definition.title} ); @@ -1237,6 +1382,9 @@ function useDashboardStatsPlaceholder() { teamspaceDelta: 0, overBudget: [] as ProjectSummary[], nearlyFull: [] as ProjectSummary[], + todayTickets: [] as PeriodOverview["tickets"], + todayOpenRatings: [] as PeriodOverview["openSessions"], + dayControlTickets: [] as DashboardDayTicket[], openTodos: [] as TicketTodo[], overdueTodos: [] as TicketTodo[] }; @@ -1264,6 +1412,8 @@ function DashboardWidgetCard({ onDragEnd, onResizeStart }: DashboardWidgetCardProps) { + const fixed = widgetIsFixed(widget.type); + if (widget.type === "divider") { return (
- + {!fixed ? ( + + ) : null}
- + {!fixed ? ( + + ) : null} ) : null} @@ -1372,6 +1526,10 @@ type WidgetContentProps = { function WidgetContent(props: WidgetContentProps) { const { widget, stats, onNavigate } = props; + if (widget.type === "dayControl") { + return ; + } + if (widget.type === "today") { return ( void; +}; + +function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentProps) { + const today = currentDay(); + const overdueTodos = stableSort( + todos.filter((todo) => todo.status === "open" && todo.ends_on && todo.ends_on < today), + (left, right) => compareOptionalDate(left.ends_on, right.ends_on, "asc") || compareText(left.title, right.title) + ); + const dueTodos = stableSort( + todos.filter((todo) => todo.status === "open" && (todo.ends_on === today || todo.starts_on === today || Boolean(todo.starts_on && todo.starts_on <= today && todo.ends_on && todo.ends_on >= today))), + (left, right) => compareOptionalDate(left.ends_on ?? left.starts_on, right.ends_on ?? right.starts_on, "asc") || compareText(left.title, right.title) + ); + const waitingTickets = stableSort( + stats.dayControlTickets.filter((ticket) => { + const day = pendingDay(ticket.zammad_pending_time); + return (ticket.zammad_state_id === "3" || ticket.zammad_state_id === "7") && Boolean(day && day <= today); + }), + (left, right) => compareOptionalDate(pendingDay(left.zammad_pending_time), pendingDay(right.zammad_pending_time), "asc") || compareNumber(zammadStateSortRank(left.zammad_state_id), zammadStateSortRank(right.zammad_state_id)) + ); + const todayTickets = stableSort(stats.todayTickets, (left, right) => compareText(left.ticket_number, right.ticket_number)); + const todayOpenRatings = stableSort(stats.todayOpenRatings, (left, right) => compareOptionalDate(left.started_at, right.started_at, "asc") || compareText(left.ticket_number, right.ticket_number)); + const teamspaceReviewTickets = todayTickets.filter((ticket) => ticket.open_count === 0 && teamspaceDeltaMinutes(ticket) !== 0); + const budgetAttention = stableSort( + projects.filter((project) => { + const percent = projectConsumptionPercent(project); + return !project.closed_at && (Boolean(percent && percent >= 90) || Number(project.unassigned_teamspace_session_count ?? 0) > 0 || Boolean(project.close_review_required)); + }), + (left, right) => (compareNumber(projectConsumptionPercent(right) ?? 0, projectConsumptionPercent(left) ?? 0)) || compareText(left.ticket_number, right.ticket_number) + ); + + const criticalCount = todayOpenRatings.length + overdueTodos.length + waitingTickets.length; + const warningCount = dueTodos.length + teamspaceReviewTickets.length + budgetAttention.length; + const trafficLight = criticalCount > 0 ? "danger" : warningCount > 0 ? "warning" : "good"; + const trafficLightLabel = trafficLight === "danger" ? "Rot" : trafficLight === "warning" ? "Gelb" : "Grün"; + const restParts = [ + todayOpenRatings.length > 0 ? `${todayOpenRatings.length} Bewertung(en) setzen` : null, + overdueTodos.length > 0 ? `${overdueTodos.length} überfällige ToDo(s)` : null, + dueTodos.length > 0 ? `${dueTodos.length} heutige ToDo(s)` : null, + waitingTickets.length > 0 ? `${waitingTickets.length} Wiedervorlage(n)` : null, + teamspaceReviewTickets.length > 0 ? `${teamspaceReviewTickets.length} Teamspace-Abgleich(e)` : null, + budgetAttention.length > 0 ? `${budgetAttention.length} Budget-Hinweis(e)` : null + ].filter(Boolean); + + const checklistItems = [ + { + key: "ratings", + label: "Sessions bewertet", + done: todayOpenRatings.length === 0, + action: todayOpenRatings.length === 0 ? "Keine offenen Bewertungen." : `${todayOpenRatings.length} heutige Bewertung(en) setzen.` + }, + { + key: "todos", + label: "ToDos geprüft", + done: overdueTodos.length + dueTodos.length === 0, + action: + overdueTodos.length + dueTodos.length === 0 + ? "Keine fälligen ToDos." + : overdueTodos.length > 0 + ? `${overdueTodos.length} überfällige ToDo(s) erledigen oder neu terminieren.` + : `${dueTodos.length} heutige ToDo(s) erledigen.` + }, + { + key: "waiting", + label: "Wiedervorlagen geprüft", + done: waitingTickets.length === 0, + action: waitingTickets.length === 0 ? "Keine fälligen Wiedervorlagen." : `${waitingTickets.length} Wiedervorlage(n) bearbeiten oder neues Datum setzen.` + }, + { + key: "teamspace", + label: "Teamspace geprüft", + done: teamspaceReviewTickets.length === 0, + action: teamspaceReviewTickets.length === 0 ? "Teamspace passt für heute." : `${teamspaceReviewTickets.length} Teamspace-Abgleich(e) korrigieren.` + }, + { + key: "budget", + label: "Budget geprüft", + done: budgetAttention.length === 0, + action: budgetAttention.length === 0 ? "Keine Budget-Hinweise." : `${budgetAttention.length} Budget-Hinweis(e) klären.` + } + ]; + + const finalizationItems: DayControlListItem[] = [ + ...overdueTodos.slice(0, 4).map((todo) => ({ + key: `overdue-${todo.id}`, + tone: "danger" as const, + title: todo.title, + detail: `${todo.ticket_number ?? "Ticket"} · überfällig seit ${todo.ends_on ? formatDate(`${todo.ends_on}T00:00:00`) : "ohne Datum"}`, + note: "ToDo erledigen oder Datum neu setzen.", + onClick: () => onNavigate(todo.ticket_id ? `/tickets/${todo.ticket_id}?month=${currentMonth()}` : "/todos") + })), + ...dueTodos.slice(0, 4).map((todo) => ({ + key: `due-${todo.id}`, + tone: "warning" as const, + title: todo.title, + detail: `${todo.ticket_number ?? "Ticket"} · fällig ${todo.ends_on ? formatDate(`${todo.ends_on}T00:00:00`) : "heute"}`, + note: "Heutiges ToDo erledigen.", + onClick: () => onNavigate(todo.ticket_id ? `/tickets/${todo.ticket_id}?month=${currentMonth()}` : "/todos") + })), + ...todayOpenRatings.slice(0, 4).map((session) => ({ + key: `open-session-${session.id}`, + tone: "warning" as const, + title: session.ticket_number, + detail: `${formatMinutes(session.rounded_minutes)} offen · ${session.organization_name ?? "Keine Organisation"}`, + note: "Bewertung setzen: Abgerechnet oder Nicht abrechenbar.", + onClick: () => onNavigate(`/tickets/${session.ticket_id}?month=${currentMonth()}&status=open`) + })), + ...teamspaceReviewTickets.slice(0, 4).map((ticket) => ({ + key: `teamspace-review-${ticket.id}`, + tone: "info" as const, + title: ticket.title || ticket.ticket_number, + detail: `${ticket.ticket_number} · TT ${formatMinutes(ticket.total_minutes)} · Teamspace ${formatMinutes(ticket.crm_billed_minutes ?? 0)}`, + note: dayControlTodaySessionReason(ticket), + onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`) + })) + ].slice(0, 8); + + return ( +
+
+
+
+ +
+

Ampel {trafficLightLabel}

+

{restParts.length > 0 ? restParts.join(" · ") : "Alles erledigt."}

+
+
+
+ +
+ +
+ + 0 ? "warning" : "good"} /> + 0 ? "danger" : dueTodos.length > 0 ? "warning" : "good"} /> + 0 ? "info" : "good"} /> +
+ +
+ +
+ ({ + key: `today-ticket-${ticket.id}`, + title: ticket.title || ticket.ticket_number, + detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`, + note: dayControlTodaySessionReason(ticket), + onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`) + }))} + /> + ({ + key: `pending-${ticket.ticket_id}`, + title: ticket.title || ticket.ticket_number, + detail: `${ticket.ticket_number} · ${ticket.zammad_state_id === "7" ? "Abschluss" : "Wartend"} · ${pendingDay(ticket.zammad_pending_time) ? formatDate(`${pendingDay(ticket.zammad_pending_time)}T00:00:00`) : "ohne Datum"}`, + note: ticket.zammad_state_id === "7" ? "Abschluss-Wiedervorlage bearbeiten oder neues Datum setzen." : "Erinnerungs-Wiedervorlage bearbeiten oder neues Datum setzen.", + onClick: () => onNavigate(`/tickets/${ticket.ticket_id}?month=${currentMonth()}`) + }))} + /> + { + const percent = projectConsumptionPercent(project); + const hasUnassignedSessions = Number(project.unassigned_teamspace_session_count ?? 0) > 0; + const detail = project.close_review_required + ? "Abschluss prüfen" + : hasUnassignedSessions + ? `${project.unassigned_teamspace_session_count} Session(s) ohne TS-Zuordnung` + : `${percent ?? 0}% Kontingent verbraucht`; + const note = project.close_review_required + ? "Abschluss in TT/Teamspace prüfen und ggf. Umbuchungen klären." + : hasUnassignedSessions + ? "Sessions einem Teamspace-Projekt zuordnen." + : "Kontingent kritisch: Budget, Verbrauch und weitere Arbeit prüfen."; + + return { + key: `budget-${project.id}`, + title: project.title || project.ticket_number, + detail: `${project.ticket_number} · ${detail}`, + note, + onClick: () => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`) + }; + })} + /> +
+
+
+ ); +} + +function dayControlTodaySessionReason(ticket: PeriodOverview["tickets"][number]) { + if (ticket.open_count > 0) { + return `${ticket.open_count} offene Bewertung(en): Bewertungsstatus setzen.`; + } + + const crmMinutes = ticket.crm_billed_minutes ?? 0; + const deltaMinutes = teamspaceDeltaMinutes(ticket); + if (ticket.total_minutes > 0 && deltaMinutes > 0) { + return `Teamspace nachtragen: ${formatMinutes(deltaMinutes)} fehlen.`; + } + + if (deltaMinutes < 0) { + return `Teamspace prüfen: ${formatMinutes(Math.abs(deltaMinutes))} zu viel erfasst.`; + } + + if (ticket.total_minutes > 0) { + return `Teamspace ok: ${formatMinutes(crmMinutes)} erfasst.`; + } + + if (ticket.non_billable_count > 0) { + return `Nicht abrechenbar gesetzt: Zammad-Dokumentation kontrollieren.`; + } + + return "Heute erfasst: Bewertung kontrollieren."; +} + +function teamspaceDeltaMinutes(ticket: PeriodOverview["tickets"][number]) { + return (ticket.total_minutes ?? 0) - (ticket.crm_billed_minutes ?? 0); +} + +function DayControlChecklist({ items }: { items: Array<{ key: string; label: string; done: boolean; action: string }> }) { + return ( +
+

Tagesabschluss

+
+ {items.map((item) => ( +
+ +
+

{item.label}

+

{item.action}

+
+
+ ))} +
+
+ ); +} + +function DayControlMetric({ label, value, icon: Icon, tone = "normal" }: { label: string; value: string | number; icon: typeof Timer; tone?: DayControlTone }) { + return ( +
+
+ + {label} +
+

+ {value} +

+
+ ); +} + +function DayControlSection({ title, empty, items }: { title: string; empty: string; items: DayControlListItem[] }) { + return ( +
+

{title}

+ {items.length === 0 ? ( +

{empty}

+ ) : ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ); +} + +function DayControlMiniSection({ + title, + icon: Icon, + empty, + items +}: { + title: string; + icon: typeof Timer; + empty: string; + items: DayControlListItem[]; +}) { + return ( +
+
+ + {title} +
+ {items.length === 0 ? ( +

{empty}

+ ) : ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ); +} + function MetricGrid({ items }: { items: Array<{ label: string; value: string | number; icon: typeof Timer; tone?: "normal" | "good" | "warning" }> }) { return (
diff --git a/frontend/src/views/ZammadSyncPage.tsx b/frontend/src/views/ZammadSyncPage.tsx index 0f3d5f3..371d6d7 100644 --- a/frontend/src/views/ZammadSyncPage.tsx +++ b/frontend/src/views/ZammadSyncPage.tsx @@ -23,7 +23,7 @@ import { } from "../api"; import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client"; import type { AuthUser } from "../types"; -import { zammadPriorityLabel, zammadStateLabel } from "../zammad-meta"; +import { zammadPriorityLabel, zammadStateId, zammadStateLabel } from "../zammad-meta"; type ZammadSyncPageProps = { currentUser: AuthUser; @@ -289,7 +289,8 @@ function formatZammadPriority(value: unknown) { } function formatZammadState(value: unknown) { - const stateId = numericValue(value); + const normalizedStateId = zammadStateId(value); + const stateId = normalizedStateId ? numericValue(normalizedStateId) : null; if (stateId !== null) { return `${stateId} (${zammadStateLabel(stateId)})`; @@ -512,7 +513,11 @@ function comparablePreviewIdValue(value: unknown) { } function comparablePreviewValueForField(field: string, value: unknown) { - if (field === "Organisation" || field === "Zammad-Priorität" || field === "Zammad-Status") { + if (field === "Zammad-Status") { + return zammadStateId(value); + } + + if (field === "Organisation" || field === "Zammad-Priorität") { return comparablePreviewIdValue(value); } diff --git a/frontend/src/zammad-meta.tsx b/frontend/src/zammad-meta.tsx index 81d8cda..af87007 100644 --- a/frontend/src/zammad-meta.tsx +++ b/frontend/src/zammad-meta.tsx @@ -80,8 +80,13 @@ export function zammadId(value: unknown) { return /^\d+$/.test(normalized) ? normalized.replace(/^0+(?=\d)/, "") : normalized || null; } -export function zammadStateLabel(value: unknown) { +export function zammadStateId(value: unknown) { const id = zammadId(value); + return id === "1" ? "2" : id; +} + +export function zammadStateLabel(value: unknown) { + const id = zammadStateId(value); return id ? zammadStateMeta[id as keyof typeof zammadStateMeta]?.label ?? `Status ${id}` : "-"; } @@ -91,7 +96,7 @@ export function zammadPriorityLabel(value: unknown) { } export function zammadStateSortRank(value: unknown) { - const id = zammadId(value); + const id = zammadStateId(value); const fallback = id ? Number(id) : Number.NaN; return id ? zammadStateMeta[id as keyof typeof zammadStateMeta]?.order ?? (Number.isFinite(fallback) ? 900 + fallback : 999) : 999; } @@ -103,7 +108,7 @@ export function zammadPrioritySortRank(value: unknown) { } export function matchesZammadStateFilter(value: unknown, filter: ZammadStateFilter) { - const id = zammadId(value); + const id = zammadStateId(value); return filter === "all" || (filter === "none" ? !id : id === filter); } @@ -113,7 +118,7 @@ export function matchesZammadPriorityFilter(value: unknown, filter: ZammadPriori } export function ZammadStateBadge({ stateId, className }: { stateId: unknown; className?: string }) { - const id = zammadId(stateId); + const id = zammadStateId(stateId); if (!id) { return null;