import { ArrowDown, ArrowUp, Bell, CalendarCheck, CheckCircle2, ChevronsUpDown, ChevronLeft, ChevronRight, ClipboardCheck, FileText, GripVertical, LayoutDashboard, Maximize2, Minus, Pencil, Plus, RotateCcw, Save, Star, Timer, Trash2, WalletCards, X } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import type { DragEvent, PointerEvent } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { addFavoriteTicket, getDashboardDayControl, getDashboardPreference, getPeriodOverview, getProjects, getRecurringBillings, getStatisticsOverview, getTodos, lookupTicket, removeFavoriteTicket, saveDashboardPreference } from "../api"; import { currentDay, currentMonth, formatDate, formatMinutes } from "../format"; import { ticketPattern } from "../timers"; import { matchesZammadPriorityFilter, matchesZammadStateFilter, ZammadPriorityBadge, zammadPriorityFilterOptions, zammadPrioritySortRank, ZammadStateBadge, zammadStateFilterOptions, zammadStateSortRank } from "../zammad-meta"; import type { DashboardDayTicket, DashboardFavorite, DashboardPreference, DashboardSortDirection, DashboardWidget, DashboardWidgetType, PeriodOverview, ProjectTeamspaceEntry, ProjectSummary, RecurringBilling, StatisticsOverview, TicketMeta, TicketTodo } from "../types"; type DashboardPageProps = { onNavigate: (to: string) => void; }; type WidgetDefinition = { type: DashboardWidgetType; title: string; defaultSize: { w: number; h: number }; minSize?: { w: number; h: number }; multiple?: boolean; fixed?: boolean; }; type DashboardNote = { id: string; title: string; content: string; }; const dashboardPreferenceVersion = 3; const rowHeight = 48; const gridColumns = 12; const maxWidgetHeight = 40; const maxWidgetY = 120; 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 }, { type: "projectBudgets", title: "Projekte", defaultSize: { w: 6, h: 3 }, multiple: true }, { type: "todos", title: "ToDos", defaultSize: { w: 3, h: 3 }, multiple: true }, { type: "teamspace", title: "Teamspace", defaultSize: { w: 2, h: 2 }, multiple: true }, { type: "favorites", title: "Favoriten", defaultSize: { w: 3, h: 3 }, multiple: true }, { type: "statistic", title: "Statistik", defaultSize: { w: 4, h: 3 }, multiple: true }, { type: "notes", title: "Notizen", defaultSize: { w: 4, h: 4 }, minSize: { w: 3, h: 3 } }, { type: "divider", title: "Trennlinie", defaultSize: { w: 6, h: 1 }, minSize: { w: 1, h: 1 }, multiple: true } ]; const defaultWidgets: DashboardWidget[] = [ { 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) { return widgetDefinitions.find((definition) => definition.type === type)?.title ?? "Widget"; } function widgetDefinition(type: DashboardWidgetType) { return widgetDefinitions.find((definition) => definition.type === type); } function widgetMinSize(type: DashboardWidgetType) { return widgetDefinition(type)?.minSize ?? { w: 2, h: 2 }; } 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" }; } if (type === "projectBudgets") { return { filterValues: ["budgeted"], sortBy: "remaining", sortDirection: "asc" }; } if (type === "openRatings") { return { filterValues: [], sortBy: "date", sortDirection: "asc" }; } if (type === "favorites") { return { filterValues: [], sortBy: "custom", sortDirection: "asc" }; } if (type === "statistic") { return { view: "dailyBars", sortBy: "minutes", sortDirection: "desc" }; } if (type === "notes") { return { activeNoteId: "note-1", notes: [{ id: "note-1", title: "Notiz 1", content: "" }] }; } return {}; } function widgetAllowedFilters(type: DashboardWidgetType) { if (type === "todos") { return ["open", "overdue", "today", "next7", "undated", "done", "fixedBilling", "manualBilling"]; } if (type === "projectBudgets") { return [ "budgeted", "withoutBudget", "critical90", "overBudget", "open", "closed", "zammadState:2", "zammadState:3", "zammadState:4", "zammadState:7", "zammadState:none", "zammadPriority:1", "zammadPriority:2", "zammadPriority:3", "zammadPriority:none", "openTodos", "overdueTodos", "fixedBilling", "manualBilling" ]; } if (type === "openRatings") { return ["today", "older3", "fixedBilling", "manualBilling"]; } if (type === "favorites") { return [ "projects", "openTodos", "overdueTodos", "zammadState:2", "zammadState:3", "zammadState:4", "zammadState:7", "zammadState:none", "zammadPriority:1", "zammadPriority:2", "zammadPriority:3", "zammadPriority:none", "missingMeta", "fixedBilling", "manualBilling" ]; } return []; } function normalizeWidgetConfig(type: DashboardWidgetType, config: Record | undefined) { const nextConfig = { ...defaultWidgetConfig(type), ...(config ?? {}) }; if (!Array.isArray(config?.filterValues)) { const allowed = widgetAllowedFilters(type); const legacyFilter = typeof config?.filterBy === "string" ? config.filterBy : ""; if (legacyFilter === "all") { nextConfig.filterValues = []; } else if (allowed.includes(legacyFilter)) { nextConfig.filterValues = [legacyFilter]; } } return nextConfig; } function cloneDefaultWidgets() { return defaultWidgets.map((widget) => ({ ...widget, config: widget.config ? { ...widget.config } : undefined })); } function normalizePreference(preference: Partial): DashboardPreference { const preferenceVersion = typeof preference.version === "number" ? preference.version : 0; 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, maxWidgetY), w: clampNumber(widget.w, widgetMinSize(widget.type).w, gridColumns), h: clampNumber(widget.h, widgetMinSize(widget.type).h, maxWidgetHeight), 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: normalizedWidgets, favorites: favorites.map((favorite, index) => ({ ticketNumber: favorite.ticketNumber, addedAt: favorite.addedAt || new Date().toISOString(), sortOrder: Number.isInteger(favorite.sortOrder) ? favorite.sortOrder : index })) }; } function clampNumber(value: number, min: number, max: number) { if (!Number.isFinite(value)) { return min; } return Math.min(max, Math.max(min, Math.round(value))); } 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 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, maxWidgetHeight); return { ...changed, w, h, x: clampNumber(changed.x, 0, gridColumns - w), y: clampNumber(changed.y, 0, maxWidgetY) }; } 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(next.y)) { return next; } const maxExistingRow = Math.max(maxWidgetY, ...widgets.filter((widget) => widget.id !== next.id).map((widget) => widget.y + widget.h + next.h)); 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 }; } if (!hasCollision(yBelow)) { return { ...next, y: yBelow }; } } return { ...next, y: maxExistingRow + next.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) { return layoutWidgetsWithPlacement(widgets, changed); } function widgetPlacementFromPoint(gridElement: HTMLDivElement, widget: DashboardWidget, clientX: number, clientY: number) { const rect = gridElement.getBoundingClientRect(); const styles = window.getComputedStyle(gridElement); const columnGap = Number.parseFloat(styles.columnGap) || 0; const rowGap = Number.parseFloat(styles.rowGap) || 0; const columnStep = (rect.width + columnGap) / gridColumns; const rowStep = rowHeight + rowGap; return normalizeWidgetPlacement({ ...widget, x: clampNumber(Math.floor((clientX - rect.left) / columnStep), 0, gridColumns - widget.w), y: clampNumber(Math.floor((clientY - rect.top) / rowStep), 0, maxWidgetY) }); } function decimalHours(minutes: number) { return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(minutes / 60)} h`; } function consumptionBarClass(percent: number) { if (percent >= 100) { return "bg-blue-500"; } if (percent >= 90) { return "bg-red-500"; } return "bg-emerald-500"; } const collator = new Intl.Collator("de", { numeric: true, sensitivity: "base" }); function directionFactor(direction: DashboardSortDirection) { return direction === "asc" ? 1 : -1; } function compareText(left: string | null | undefined, right: string | null | undefined) { return collator.compare(left?.trim() || "", right?.trim() || ""); } function compareNumber(left: number, right: number) { return left - right; } function compareOptionalDate(left: string | null | undefined, right: string | null | undefined, direction: DashboardSortDirection) { if (!left || !right) { return !left && !right ? 0 : !left ? 1 : -1; } return compareText(left, right) * directionFactor(direction); } function compareOptionalTimestamp(left: string | null | undefined, right: string | null | undefined, direction: DashboardSortDirection) { const leftTime = left ? new Date(left).getTime() : Number.NaN; const rightTime = right ? new Date(right).getTime() : Number.NaN; const leftMissing = Number.isNaN(leftTime); const rightMissing = Number.isNaN(rightTime); if (leftMissing || rightMissing) { return leftMissing === rightMissing ? 0 : leftMissing ? 1 : -1; } 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 })) .sort((left, right) => compare(left.item, right.item) || left.index - right.index) .map(({ item }) => item); } function createNote(title: string): DashboardNote { return { id: typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, title, content: "" }; } function readWidgetNotes(config: Record | undefined) { const rawNotes = Array.isArray(config?.notes) ? config.notes : []; const notes = rawNotes .map((entry, index) => { if (!entry || typeof entry !== "object" || Array.isArray(entry)) { return null; } const note = entry as Record; const id = typeof note.id === "string" && note.id ? note.id : `note-${index + 1}`; return { id, title: typeof note.title === "string" && note.title.trim() ? note.title : `Notiz ${index + 1}`, content: typeof note.content === "string" ? note.content : "" }; }) .filter((note): note is DashboardNote => Boolean(note)); const safeNotes = notes.length > 0 ? notes : [{ id: "note-1", title: "Notiz 1", content: "" }]; const configuredActiveId = typeof config?.activeNoteId === "string" ? config.activeNoteId : ""; const activeNoteId = safeNotes.some((note) => note.id === configuredActiveId) ? configuredActiveId : safeNotes[0].id; return { notes: safeNotes, activeNoteId }; } function addDays(day: string, amount: number) { const date = new Date(`${day}T00:00:00`); date.setDate(date.getDate() + amount); return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; } function todoRelevantDate(todo: TicketTodo) { return todo.ends_on ?? todo.starts_on ?? null; } function readFilterValues(config: Record | undefined, allowedValues: string[], fallbackValues: string[] = []) { const allowed = new Set(allowedValues); if (Array.isArray(config?.filterValues)) { const configuredValues = config.filterValues.filter((value): value is string => typeof value === "string" && allowed.has(value)); return Array.from(new Set(configuredValues)); } const legacyValue = typeof config?.filterBy === "string" && allowed.has(config.filterBy) ? config.filterBy : ""; if (legacyValue && legacyValue !== "all") { return [legacyValue]; } return fallbackValues.filter((value) => allowed.has(value)); } function groupedFilterMatch(filters: string[], groupForFilter: (filter: string) => string, matchesFilter: (filter: string) => boolean) { if (filters.length === 0) { return true; } const groups = new Map(); for (const filter of filters) { const group = groupForFilter(filter); groups.set(group, [...(groups.get(group) ?? []), filter]); } return Array.from(groups.values()).every((groupFilters) => groupFilters.some((filter) => matchesFilter(filter))); } function todoFilterGroup(filter: string) { if (filter === "fixedBilling" || filter === "manualBilling") { return "source"; } if (filter === "open" || filter === "done") { return "status"; } return "date"; } function ticketIdentifiers(ticketId: string | null | undefined, ticketNumber: string | null | undefined) { return [ticketId, ticketNumber].filter((value): value is string => Boolean(value)); } function hasFixedBillingForTicket(fixedBillingTickets: Set, ticketId: string | null | undefined, ticketNumber: string | null | undefined) { return ticketIdentifiers(ticketId, ticketNumber).some((identifier) => fixedBillingTickets.has(identifier)); } function matchesSingleTodoFilter(todo: TicketTodo, filterBy: string, fixedBillingTickets: Set) { const today = currentDay(); const nextWeek = addDays(today, 7); if (filterBy === "all") { return true; } if (filterBy === "fixedBilling") { return hasFixedBillingForTicket(fixedBillingTickets, todo.ticket_id, todo.ticket_number); } if (filterBy === "manualBilling") { return !hasFixedBillingForTicket(fixedBillingTickets, todo.ticket_id, todo.ticket_number); } if (filterBy === "done") { return todo.status === "done"; } if (todo.status !== "open") { return false; } if (filterBy === "overdue") { return Boolean(todo.ends_on && todo.ends_on < today); } if (filterBy === "today") { return todo.ends_on === today || todo.starts_on === today || Boolean(todo.starts_on && todo.starts_on <= today && todo.ends_on && todo.ends_on >= today); } if (filterBy === "next7") { const relevantDate = todoRelevantDate(todo); return Boolean(relevantDate && relevantDate >= today && relevantDate <= nextWeek); } if (filterBy === "undated") { return !todo.starts_on && !todo.ends_on; } return true; } function matchesTodoFilters(todo: TicketTodo, filters: string[], fixedBillingTickets: Set) { return groupedFilterMatch(filters, todoFilterGroup, (filter) => matchesSingleTodoFilter(todo, filter, fixedBillingTickets)); } function projectFilterGroup(filter: string) { if (filter === "fixedBilling" || filter === "manualBilling") { return "source"; } if (filter === "open" || filter === "closed") { return "status"; } if (filter.startsWith("zammadState:")) { return "zammadState"; } if (filter.startsWith("zammadPriority:")) { return "zammadPriority"; } if (filter === "openTodos" || filter === "overdueTodos") { return "todos"; } return "budget"; } function matchesSingleProjectFilter(project: ProjectSummary, filterBy: string, fixedBillingTickets: Set) { const consumptionMinutes = project.consumption_minutes ?? 0; const budgetMinutes = project.hour_budget_minutes ?? 0; const percent = budgetMinutes > 0 ? consumptionMinutes / budgetMinutes : 0; const hasFixedBilling = Number(project.recurring_session_count ?? 0) > 0 || hasFixedBillingForTicket(fixedBillingTickets, project.ticket_id, project.ticket_number); if (filterBy === "all") { return true; } if (filterBy === "fixedBilling") { return hasFixedBilling; } if (filterBy === "manualBilling") { return !hasFixedBilling; } if (filterBy === "budgeted") { return budgetMinutes > 0; } if (filterBy === "withoutBudget") { return budgetMinutes <= 0; } if (filterBy === "standardBudgetOnly") { const budgetedEntries = budgetedProjectTeamspaceEntries(project); return budgetedEntries.length > 0 && budgetedEntries.every((entry) => entry.is_default); } if (filterBy === "critical90") { return budgetMinutes > 0 && percent >= 0.9; } if (filterBy === "overBudget") { return budgetMinutes > 0 && consumptionMinutes >= budgetMinutes; } if (filterBy === "open") { return !project.closed_at; } if (filterBy === "closed") { return Boolean(project.closed_at); } if (filterBy.startsWith("zammadState:")) { return matchesZammadStateFilter(project.zammad_state_id, filterBy.slice("zammadState:".length) as Parameters[1]); } if (filterBy.startsWith("zammadPriority:")) { return matchesZammadPriorityFilter(project.zammad_priority_id, filterBy.slice("zammadPriority:".length) as Parameters[1]); } if (filterBy === "openTodos") { return Number(project.open_todos ?? 0) > 0; } if (filterBy === "overdueTodos") { return Number(project.overdue_todos ?? 0) > 0; } return true; } function matchesProjectFilters(project: ProjectSummary, filters: string[], fixedBillingTickets: Set) { return groupedFilterMatch(filters, projectFilterGroup, (filter) => matchesSingleProjectFilter(project, filter, fixedBillingTickets)); } function projectConsumptionPercent(project: ProjectSummary) { const budgetMinutes = project.hour_budget_minutes ?? 0; if (budgetMinutes <= 0) { return null; } return Math.round(((project.consumption_minutes ?? 0) / budgetMinutes) * 100); } function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry[] { if (project.teamspace_entries?.length) { return project.teamspace_entries; } return [ { id: `${project.id}-default`, teamspace_project: project.teamspace_project, is_default: true, hour_budget_minutes: project.hour_budget_minutes, manual_consumption_minutes: project.manual_consumption_minutes ?? 0, teamspace_consumption_minutes: project.teamspace_consumption_minutes ?? 0, consumption_minutes: project.consumption_minutes ?? project.teamspace_consumption_minutes ?? 0, display_order: 0 } ]; } function budgetedProjectTeamspaceEntries(project: ProjectSummary) { return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0); } function openRatingFilterGroup(filter: string) { if (filter === "fixedBilling" || filter === "manualBilling") { return "source"; } return "date"; } function favoriteFilterGroup(filter: string) { if (filter === "fixedBilling" || filter === "manualBilling") { return "source"; } if (filter === "openTodos" || filter === "overdueTodos") { return "todos"; } if (filter.startsWith("zammadState:")) { return "zammadState"; } if (filter.startsWith("zammadPriority:")) { return "zammadPriority"; } return filter; } function sortedFavorites(favorites: DashboardFavorite[], metas: Record, config: Record) { const sortBy = config.sortBy === "ticket" || config.sortBy === "title" || config.sortBy === "recent" || config.sortBy === "zammadStatus" || config.sortBy === "zammadPriority" ? config.sortBy : "custom"; const direction: DashboardSortDirection = config.sortDirection === "desc" ? "desc" : "asc"; const factor = directionFactor(direction); return stableSort(favorites, (left, right) => { if (sortBy === "ticket") { return compareText(left.ticketNumber, right.ticketNumber) * factor; } if (sortBy === "title") { return (compareText(metas[left.ticketNumber]?.title || left.ticketNumber, metas[right.ticketNumber]?.title || right.ticketNumber) * factor) || compareText(left.ticketNumber, right.ticketNumber); } if (sortBy === "recent") { return compareOptionalTimestamp(left.addedAt, right.addedAt, direction) || compareText(left.ticketNumber, right.ticketNumber); } if (sortBy === "zammadStatus") { return (compareNumber(zammadStateSortRank(metas[left.ticketNumber]?.zammad_state_id), zammadStateSortRank(metas[right.ticketNumber]?.zammad_state_id)) * factor) || compareText(left.ticketNumber, right.ticketNumber); } if (sortBy === "zammadPriority") { return (compareNumber(zammadPrioritySortRank(metas[left.ticketNumber]?.zammad_priority_id), zammadPrioritySortRank(metas[right.ticketNumber]?.zammad_priority_id)) * factor) || compareText(left.ticketNumber, right.ticketNumber); } return (compareNumber(left.sortOrder, right.sortOrder) * factor) || compareText(left.ticketNumber, right.ticketNumber); }); } export function DashboardPage({ onNavigate }: DashboardPageProps) { const gridRef = useRef(null); const [preference, setPreference] = useState(() => normalizePreference({})); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [loaded, setLoaded] = useState(false); const [editing, setEditing] = useState(false); const [draggingWidgetId, setDraggingWidgetId] = useState(null); const [dragPreview, setDragPreview] = useState(null); const [monthOverview, setMonthOverview] = useState(null); const [todayOverview, setTodayOverview] = useState(null); 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>({}); useEffect(() => { let cancelled = false; async function load() { setLoading(true); try { const [dashboardResult, dayControlResult, monthResult, todayResult, statisticsResult, projectsResult, todosResult, recurringResult] = await Promise.all([ getDashboardPreference(), getDashboardDayControl(), getPeriodOverview("month", currentMonth()), getPeriodOverview("day", currentDay()), getStatisticsOverview(currentMonth()), getProjects(true), getTodos("all"), getRecurringBillings() ]); if (cancelled) { return; } setPreference(normalizePreference(dashboardResult.preference)); setDayControlTickets(dayControlResult.tickets); setMonthOverview(monthResult); setTodayOverview(todayResult); setStatisticsOverview(statisticsResult); setProjects(projectsResult.projects); setTodos(todosResult.todos); setRecurringBillings(recurringResult.recurringBillings); setLoaded(true); } catch (error) { toast.error("Dashboard konnte nicht geladen werden", { description: error instanceof Error ? error.message : "Unbekannter Fehler" }); } finally { if (!cancelled) { setLoading(false); } } } void load(); return () => { cancelled = true; }; }, []); useEffect(() => { if (!loaded) { return; } const timeout = window.setTimeout(async () => { setSaving(true); try { await saveDashboardPreference(preference); } catch (error) { toast.error("Dashboard konnte nicht gespeichert werden", { description: error instanceof Error ? error.message : "Unbekannter Fehler" }); } finally { setSaving(false); } }, 500); return () => window.clearTimeout(timeout); }, [loaded, preference]); useEffect(() => { let cancelled = false; const missing = preference.favorites.filter((favorite) => !(favorite.ticketNumber in favoriteMetas)); if (missing.length === 0) { return; } async function loadFavorites() { const entries = await Promise.all( missing.map(async (favorite) => { try { const result = await lookupTicket(favorite.ticketNumber); return [favorite.ticketNumber, result.ticket] as const; } catch { return [favorite.ticketNumber, null] as const; } }) ); if (!cancelled) { setFavoriteMetas((current) => ({ ...current, ...Object.fromEntries(entries) })); } } void loadFavorites(); return () => { cancelled = true; }; }, [favoriteMetas, preference.favorites]); const maxGridRow = useMemo( () => Math.max(6, dragPreview ? dragPreview.y + dragPreview.h + 1 : 0, ...preference.widgets.map((widget) => widget.y + widget.h + 1)), [dragPreview, preference.widgets] ); const dashboardStats = useMemo(() => { const monthTotals = monthOverview?.totals; const dayTotals = todayOverview?.totals; const overBudget = projects.filter((project) => project.hour_budget_minutes !== null && project.hour_budget_minutes !== undefined && (project.consumption_minutes ?? 0) > project.hour_budget_minutes); const nearlyFull = projects.filter((project) => { if (!project.hour_budget_minutes || project.hour_budget_minutes <= 0) { return false; } return (project.consumption_minutes ?? 0) / project.hour_budget_minutes >= 0.8; }); const fixedBillingTickets = new Set(); for (const ticket of monthOverview?.tickets ?? []) { if (ticket.recurring_session_count > 0) { ticketIdentifiers(ticket.id, ticket.ticket_number).forEach((identifier) => fixedBillingTickets.add(identifier)); } } for (const project of projects) { if (Number(project.recurring_session_count ?? 0) > 0) { ticketIdentifiers(project.ticket_id, project.ticket_number).forEach((identifier) => fixedBillingTickets.add(identifier)); } } for (const billing of recurringBillings) { if (billing.active) { ticketIdentifiers(null, billing.ticket_number).forEach((identifier) => fixedBillingTickets.add(identifier)); ticketIdentifiers(null, billing.configured_ticket_number).forEach((identifier) => fixedBillingTickets.add(identifier)); } } return { monthTotals, dayTotals, statistics: statisticsOverview, openRatings: monthOverview?.openSessions ?? [], todayTickets: todayOverview?.tickets ?? [], todayOpenRatings: todayOverview?.openSessions ?? [], dayControlTickets, fixedBillingTickets, teamspaceDelta: (monthTotals?.minutes ?? 0) - (monthTotals?.crmBilledMinutes ?? 0), overBudget, nearlyFull, openTodos: todos.filter((todo) => todo.status === "open"), overdueTodos: todos.filter((todo) => todo.status === "open" && todo.ends_on && todo.ends_on < currentDay()) }; }, [dayControlTickets, monthOverview, projects, recurringBillings, statisticsOverview, todayOverview, todos]); function updateWidget(widgetId: string, changes: Partial) { setPreference((current) => ({ ...current, widgets: placeWidget(current.widgets, { ...current.widgets.find((widget) => widget.id === widgetId)!, ...changes }) })); } function updateWidgetConfig(widgetId: string, config: Record) { setPreference((current) => ({ ...current, widgets: current.widgets.map((widget) => (widget.id === widgetId ? { ...widget, config: { ...widget.config, ...config } } : widget)) })); } function updateWidgetTitle(widgetId: string, title: string) { setPreference((current) => ({ ...current, widgets: current.widgets.map((widget) => (widget.id === widgetId ? { ...widget, title } : widget)) })); } function addWidget(type: DashboardWidgetType) { const definition = widgetDefinitions.find((entry) => entry.type === type)!; if (!widgetSupportsMultiple(type) && preference.widgets.some((widget) => widget.type === type)) { toast.info("Widget ist bereits im Dashboard"); return; } setPreference((current) => ({ ...current, widgets: !widgetSupportsMultiple(type) && current.widgets.some((widget) => widget.type === type) ? current.widgets : [ ...current.widgets, resolveWidgetPlacement(current.widgets, { id: widgetSupportsMultiple(type) ? `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` : type, type, title: definition.title, x: 0, y: Math.max(0, ...current.widgets.map((widget) => widget.y + widget.h)), w: definition.defaultSize.w, h: definition.defaultSize.h, config: defaultWidgetConfig(type) }) ] })); } function removeWidget(widgetId: string) { setPreference((current) => ({ ...current, widgets: current.widgets.filter((widget) => widget.id !== widgetId || widgetIsFixed(widget.type)) })); } function moveDroppedWidget(clientX: number, clientY: number) { if (!draggingWidgetId || !gridRef.current) { return; } const widget = preference.widgets.find((item) => item.id === draggingWidgetId); if (!widget) { return; } const next = dragPreview?.id === widget.id ? dragPreview : widgetPlacementFromPoint(gridRef.current, widget, clientX, clientY); setPreference((current) => ({ ...current, widgets: placeWidget(current.widgets, next) })); setDraggingWidgetId(null); setDragPreview(null); } function previewDroppedWidget(clientX: number, clientY: number) { if (!draggingWidgetId || !gridRef.current) { return; } const widget = preference.widgets.find((item) => item.id === draggingWidgetId); if (!widget) { return; } setDragPreview(widgetPlacementFromPoint(gridRef.current, widget, clientX, clientY)); } function startResize(event: PointerEvent, widget: DashboardWidget) { event.preventDefault(); event.stopPropagation(); const startX = event.clientX; const startY = event.clientY; const startW = widget.w; const startH = widget.h; const minSize = widgetMinSize(widget.type); const rect = gridRef.current?.getBoundingClientRect(); const columnWidth = rect ? rect.width / gridColumns : 80; function onMove(moveEvent: globalThis.PointerEvent) { updateWidget(widget.id, { w: clampNumber(startW + Math.round((moveEvent.clientX - startX) / columnWidth), minSize.w, gridColumns - widget.x), h: clampNumber(startH + Math.round((moveEvent.clientY - startY) / rowHeight), minSize.h, maxWidgetHeight) }); } function onUp() { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); } window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); } async function addFavorite(widgetId: string, ticketNumber: string) { const normalized = ticketNumber.trim(); if (!ticketPattern.test(normalized)) { toast.error("Ticketnummer prüfen", { description: "Das Format muss Ticket#XXXXXX sein." }); return; } try { const result = await addFavoriteTicket(normalized); setPreference(normalizePreference(result.preference)); setFavoriteInputs((current) => ({ ...current, [widgetId]: "" })); toast.success("Favorit gespeichert", { description: normalized }); } catch (error) { toast.error("Favorit konnte nicht gespeichert werden", { description: error instanceof Error ? error.message : "Unbekannter Fehler" }); } } async function removeFavorite(ticketNumber: string) { try { const result = await removeFavoriteTicket(ticketNumber); setPreference(normalizePreference(result.preference)); } catch (error) { toast.error("Favorit konnte nicht entfernt werden", { description: error instanceof Error ? error.message : "Unbekannter Fehler" }); } } function moveFavorite(ticketNumber: string, delta: number) { setPreference((current) => { const favorites = [...current.favorites].sort((left, right) => left.sortOrder - right.sortOrder); const index = favorites.findIndex((favorite) => favorite.ticketNumber === ticketNumber); const target = index + delta; if (index < 0 || target < 0 || target >= favorites.length) { return current; } [favorites[index], favorites[target]] = [favorites[target], favorites[index]]; return { ...current, favorites: favorites.map((favorite, sortOrder) => ({ ...favorite, sortOrder })) }; }); } return (

Dashboard

{loading ? "Lädt..." : `${preference.widgets.length} Widget(s)`}

{saving ? Speichert... : Gespeichert}
{editing ? ( {widgetDefinitions.map((definition) => { const active = !definition.multiple && preference.widgets.some((widget) => widget.type === definition.type); return ( ); })} ) : null}
{ event.preventDefault(); event.dataTransfer.dropEffect = "move"; previewDroppedWidget(event.clientX, event.clientY); }} onDragLeave={(event) => { const nextTarget = event.relatedTarget; if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) { setDragPreview(null); } }} onDrop={(event) => { event.preventDefault(); moveDroppedWidget(event.clientX, event.clientY); }} > {preference.widgets.map((widget) => ( setFavoriteInputs((current) => ({ ...current, [widget.id]: value }))} onNavigate={onNavigate} onRemove={() => removeWidget(widget.id)} onUpdateTitle={(title) => updateWidgetTitle(widget.id, title)} onUpdateConfig={(config) => updateWidgetConfig(widget.id, config)} onAddFavorite={(ticketNumber) => addFavorite(widget.id, ticketNumber)} onRemoveFavorite={removeFavorite} onMoveFavorite={moveFavorite} dragging={draggingWidgetId === widget.id} onDragStart={(event) => { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", widget.id); setDraggingWidgetId(widget.id); setDragPreview(widget); }} onDragEnd={() => { setDraggingWidgetId(null); setDragPreview(null); }} onResizeStart={(event) => startResize(event, widget)} /> ))} {editing && dragPreview ? (
Ziel
) : null}
); } type DashboardWidgetCardProps = { widget: DashboardWidget; editing: boolean; monthOverview: PeriodOverview | null; todayOverview: PeriodOverview | null; projects: ProjectSummary[]; todos: TicketTodo[]; favorites: DashboardFavorite[]; favoriteMetas: Record; stats: ReturnType; favoriteInput: string; setFavoriteInput: (value: string) => void; onNavigate: (to: string) => void; onRemove: () => void; onUpdateTitle: (title: string) => void; onUpdateConfig: (config: Record) => void; onAddFavorite: (ticketNumber: string) => void; onRemoveFavorite: (ticketNumber: string) => void; onMoveFavorite: (ticketNumber: string, delta: number) => void; dragging: boolean; onDragStart: (event: DragEvent) => void; onDragEnd: () => void; onResizeStart: (event: PointerEvent) => void; }; function useDashboardStatsPlaceholder() { return { monthTotals: null as PeriodOverview["totals"] | null | undefined, dayTotals: null as PeriodOverview["totals"] | null | undefined, statistics: null as StatisticsOverview | null, openRatings: [] as PeriodOverview["openSessions"], fixedBillingTickets: new Set(), teamspaceDelta: 0, overBudget: [] as ProjectSummary[], nearlyFull: [] as ProjectSummary[], todayTickets: [] as PeriodOverview["tickets"], todayOpenRatings: [] as PeriodOverview["openSessions"], dayControlTickets: [] as DashboardDayTicket[], openTodos: [] as TicketTodo[], overdueTodos: [] as TicketTodo[] }; } function DashboardWidgetCard({ widget, editing, projects, todos, stats, favorites, favoriteMetas, favoriteInput, setFavoriteInput, onNavigate, onRemove, onUpdateTitle, onUpdateConfig, onAddFavorite, onRemoveFavorite, onMoveFavorite, dragging, onDragStart, onDragEnd, onResizeStart }: DashboardWidgetCardProps) { const fixed = widgetIsFixed(widget.type); if (widget.type === "divider") { return (
{editing ? ( <>
{!fixed ? ( ) : null}
) : null}
); } return (
{editing && !fixed ? ( onUpdateTitle(event.currentTarget.value)} /> ) : ( {widget.title?.trim() || widgetTitle(widget.type)} )} {editing ? (
{!fixed ? ( ) : null}
) : null}
{editing ? ( ) : null}
); } type WidgetContentProps = { widget: DashboardWidget; editing: boolean; projects: ProjectSummary[]; todos: TicketTodo[]; favorites: DashboardFavorite[]; favoriteMetas: Record; stats: ReturnType; favoriteInput: string; setFavoriteInput: (value: string) => void; onNavigate: (to: string) => void; onUpdateConfig: (config: Record) => void; onAddFavorite: (ticketNumber: string) => void; onRemoveFavorite: (ticketNumber: string) => void; onMoveFavorite: (ticketNumber: string, delta: number) => void; }; function WidgetContent(props: WidgetContentProps) { const { widget, stats, onNavigate } = props; if (widget.type === "dayControl") { return ; } if (widget.type === "today") { return ( ); } if (widget.type === "month") { return ( ); } if (widget.type === "teamspace") { const delta = stats.teamspaceDelta; return ( = 0 ? "+" : "-"}${formatMinutes(Math.abs(delta))}`, icon: ArrowUp, tone: delta === 0 ? "normal" : delta > 0 ? "warning" : "good" } ]} /> ); } if (widget.type === "openRatings") { const filterValues = readFilterValues(widget.config, ["today", "older3", "fixedBilling", "manualBilling"]); const sortBy = widget.config?.sortBy === "ticket" || widget.config?.sortBy === "duration" ? widget.config.sortBy : "date"; const sortDirection: DashboardSortDirection = widget.config?.sortDirection === "desc" ? "desc" : "asc"; const factor = directionFactor(sortDirection); const today = currentDay(); const olderThan = addDays(today, -3); const sessions = stableSort( stats.openRatings.filter((session) => { const day = session.started_at.slice(0, 10); return groupedFilterMatch(filterValues, openRatingFilterGroup, (filter) => { if (filter === "today") { return day === today; } if (filter === "older3") { return day <= olderThan; } if (filter === "fixedBilling") { return Boolean(session.recurring_billing_id); } if (filter === "manualBilling") { return !session.recurring_billing_id; } return true; }); }), (left, right) => { if (sortBy === "ticket") { return (compareText(left.ticket_number, right.ticket_number) * factor) || compareText(left.started_at, right.started_at); } if (sortBy === "duration") { return (compareNumber(left.rounded_minutes, right.rounded_minutes) * factor) || compareText(left.ticket_number, right.ticket_number); } return (compareText(left.started_at, right.started_at) * factor) || compareText(left.ticket_number, right.ticket_number); } ); return ( <> {props.editing ? ( ) : null} ({ key: session.id, title: session.ticket_number, detail: `${formatDate(session.started_at)} · ${formatMinutes(session.rounded_minutes)}`, onClick: () => onNavigate(`/tickets/${session.ticket_id}?month=${currentMonth()}&status=open`) }))} /> ); } if (widget.type === "projectBudgets") { return ; } if (widget.type === "todos") { return ; } if (widget.type === "favorites") { return ; } if (widget.type === "statistic") { return ; } if (widget.type === "notes") { return ; } return null; } type DayControlTone = "normal" | "good" | "warning" | "danger" | "info"; type DayControlListItem = { key: string; title: string; detail: string; note?: string; tone?: DayControlTone; onClick?: () => 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 afterworkTickets = todayTickets.filter((ticket) => ticket.open_count > 0 || teamspaceDeltaMinutes(ticket) > 0 || (ticket.total_minutes === 0 && (ticket.billable_count > 0 || ticket.non_billable_count > 0))); const 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: "Bewertungen final", done: todayOpenRatings.length === 0, action: todayOpenRatings.length === 0 ? "Keine offenen Bewertungen." : `${todayOpenRatings.length} heutige Bewertung(en) finalisieren.` }, { 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: Abrechenbar 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 (ticket.total_minutes > 0 && deltaMinutes === 0) { return `Teamspace ok: ${formatMinutes(crmMinutes)} erfasst.`; } if (ticket.billable_count > 0) { return `Abrechenbar gesetzt: später abrechnen oder Status finalisieren.`; } if (ticket.non_billable_count > 0) { return `Nicht abrechenbar gesetzt: Zammad-Dokumentation kontrollieren.`; } return ticket.total_minutes > 0 ? "Abgerechnet gesetzt." : "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 (
{items.map((item) => { const Icon = item.icon; return (
{item.label}

{item.value}

); })}
); } function CompactList({ items, empty }: { items: Array<{ key: string; title: string; detail: string; onClick?: () => void }>; empty: string }) { if (items.length === 0) { return

{empty}

; } return (
{items.map((item) => ( ))}
); } type WidgetOption = { value: string; label: string }; function MultiFilterDropdown({ values, options, onChange }: { values: string[]; options: WidgetOption[]; onChange: (values: string[]) => void; }) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const normalizedSearch = search.trim().toLocaleLowerCase("de-DE"); const visibleOptions = normalizedSearch ? options.filter((option) => option.label.toLocaleLowerCase("de-DE").includes(normalizedSearch) || option.value.toLocaleLowerCase("de-DE").includes(normalizedSearch)) : options; function toggle(value: string) { onChange(values.includes(value) ? values.filter((filter) => filter !== value) : [...values, value]); } return ( setSearch(event.currentTarget.value)} />
{visibleOptions.length === 0 ? (

Keine Filter.

) : (
{visibleOptions.map((option) => { const checked = values.includes(option.value); return (
toggle(option.value)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); toggle(option.value); } }} > {option.label}
); })}
)}
{values.length > 0 ? ( ) : null}
); } function WidgetSettings({ filterValues, filterOptions = [], view, viewOptions = [], sortBy, sortDirection, options, onChange }: { filterValues?: string[]; filterOptions?: WidgetOption[]; view?: string; viewOptions?: WidgetOption[]; sortBy?: string; sortDirection?: DashboardSortDirection; options?: WidgetOption[]; onChange: (config: Record) => void; }) { const activeFilters = filterValues ?? []; return (
{view && viewOptions.length > 0 ? ( ) : null} {filterOptions.length > 0 ? ( onChange({ filterValues: nextFilters, filterBy: nextFilters[0] ?? "all" })} /> ) : null} {sortBy && sortDirection && options && options.length > 0 ? (
) : null}
); } function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onUpdateConfig }: WidgetContentProps) { const filterValues = readFilterValues( widget.config, [ "budgeted", "withoutBudget", "standardBudgetOnly", "critical90", "overBudget", "open", "closed", "zammadState:2", "zammadState:3", "zammadState:4", "zammadState:7", "zammadState:none", "zammadPriority:1", "zammadPriority:2", "zammadPriority:3", "zammadPriority:none", "openTodos", "overdueTodos", "fixedBilling", "manualBilling" ], ["budgeted"] ); const sortBy = widget.config?.sortBy === "consumption" || widget.config?.sortBy === "consumptionPercent" || widget.config?.sortBy === "zammadStatus" || widget.config?.sortBy === "zammadPriority" || widget.config?.sortBy === "title" || widget.config?.sortBy === "ticket" || widget.config?.sortBy === "organization" || widget.config?.sortBy === "budget" || widget.config?.sortBy === "todos" ? widget.config.sortBy : "remaining"; const sortDirection: DashboardSortDirection = widget.config?.sortDirection === "desc" ? "desc" : "asc"; const factor = directionFactor(sortDirection); const visible = stableSort( projects.filter((project) => matchesProjectFilters(project, filterValues, stats.fixedBillingTickets)), (left, right) => { if (sortBy === "title") { return (compareText(left.title || left.ticket_number, right.title || right.ticket_number) * factor) || compareText(left.ticket_number, right.ticket_number); } if (sortBy === "ticket") { return compareText(left.ticket_number, right.ticket_number) * factor; } if (sortBy === "organization") { return (compareText(left.organization_name || left.customer_name, right.organization_name || right.customer_name) * factor) || compareText(left.ticket_number, right.ticket_number); } if (sortBy === "zammadStatus") { return (compareNumber(zammadStateSortRank(left.zammad_state_id), zammadStateSortRank(right.zammad_state_id)) * factor) || compareText(left.ticket_number, right.ticket_number); } if (sortBy === "zammadPriority") { return (compareNumber(zammadPrioritySortRank(left.zammad_priority_id), zammadPrioritySortRank(right.zammad_priority_id)) * factor) || compareText(left.ticket_number, right.ticket_number); } if (sortBy === "consumption") { return (compareNumber(left.consumption_minutes ?? 0, right.consumption_minutes ?? 0) * factor) || compareText(left.ticket_number, right.ticket_number); } if (sortBy === "consumptionPercent") { const leftPercent = projectConsumptionPercent(left); const rightPercent = projectConsumptionPercent(right); if (leftPercent === null || rightPercent === null) { return leftPercent === rightPercent ? compareText(left.ticket_number, right.ticket_number) : leftPercent === null ? 1 : -1; } return (compareNumber(leftPercent, rightPercent) * factor) || compareText(left.ticket_number, right.ticket_number); } if (sortBy === "budget") { return (compareNumber(left.hour_budget_minutes ?? 0, right.hour_budget_minutes ?? 0) * factor) || compareText(left.ticket_number, right.ticket_number); } if (sortBy === "todos") { return (compareNumber(left.open_todos ?? 0, right.open_todos ?? 0) * factor) || compareText(left.ticket_number, right.ticket_number); } const leftRemaining = (left.hour_budget_minutes ?? 0) - (left.consumption_minutes ?? 0); const rightRemaining = (right.hour_budget_minutes ?? 0) - (right.consumption_minutes ?? 0); return (compareNumber(leftRemaining, rightRemaining) * factor) || compareText(left.ticket_number, right.ticket_number); } ); return ( <> {editing ? ( option.value !== "all").map((option) => ({ value: `zammadState:${option.value}`, label: `ZA ${option.label}` })), ...zammadPriorityFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadPriority:${option.value}`, label: `ZA Prio ${option.label}` })), { value: "openTodos", label: "Mit offenen ToDos" }, { value: "overdueTodos", label: "Mit überfälligen ToDos" }, { value: "fixedBilling", label: "Fixe Abrechnungen" }, { value: "manualBilling", label: "Ohne fixe Abrechnung" } ]} sortBy={String(sortBy)} sortDirection={sortDirection} options={[ { value: "remaining", label: "Rest" }, { value: "consumption", label: "Verbrauch" }, { value: "consumptionPercent", label: "Verbrauch %" }, { value: "budget", label: "Kontingent" }, { value: "todos", label: "ToDos" }, { value: "title", label: "Titel" }, { value: "ticket", label: "Ticket" }, { value: "organization", label: "Organisation" }, { value: "zammadStatus", label: "ZA Status" }, { value: "zammadPriority", label: "ZA Prio" } ]} onChange={onUpdateConfig} /> ) : null} {visible.length === 0 ? (

Keine Kontingente.

) : (
{visible.map((project) => { const consumptionMinutes = project.consumption_minutes ?? 0; const budgetMinutes = project.hour_budget_minutes ?? 0; const percent = projectConsumptionPercent(project); const budgetedEntries = budgetedProjectTeamspaceEntries(project); const showBudgetedTeamspaceEntries = budgetedEntries.length > 1; return ( ); })}
)} ); } function TodoWidget({ widget, editing, todos, stats, onNavigate, onUpdateConfig }: WidgetContentProps) { const filterValues = readFilterValues(widget.config, ["open", "overdue", "today", "next7", "undated", "done", "fixedBilling", "manualBilling"], ["open"]); const sortBy = widget.config?.sortBy === "priority" || widget.config?.sortBy === "title" || widget.config?.sortBy === "ticket" || widget.config?.sortBy === "organization" ? widget.config.sortBy : "due"; const sortDirection: DashboardSortDirection = widget.config?.sortDirection === "desc" ? "desc" : "asc"; const factor = directionFactor(sortDirection); const priorityWeight = { urgent: 0, high: 1, medium: 2, low: 3 }; const visible = stableSort(todos.filter((todo) => matchesTodoFilters(todo, filterValues, stats.fixedBillingTickets)), (left, right) => { if (sortBy === "priority") { return (compareNumber(priorityWeight[left.priority], priorityWeight[right.priority]) * factor) || compareOptionalDate(left.ends_on, right.ends_on, "asc") || compareText(left.title, right.title); } if (sortBy === "title") { return (compareText(left.title, right.title) * factor) || compareText(left.ticket_number, right.ticket_number); } if (sortBy === "ticket") { return (compareText(left.ticket_number, right.ticket_number) * factor) || compareText(left.title, right.title); } if (sortBy === "organization") { return (compareText(left.organization_name, right.organization_name) * factor) || compareText(left.title, right.title); } return compareOptionalDate(left.ends_on, right.ends_on, sortDirection) || compareText(left.title, right.title) || compareText(left.ticket_number, right.ticket_number); }); return ( <> {editing ? ( ) : null} ({ key: todo.id, title: todo.title, detail: `${todo.ticket_number ?? "Ticket"} · ${todo.organization_name ?? "Keine Organisation"} · ${todo.ends_on ? formatDate(`${todo.ends_on}T00:00:00`) : "ohne Datum"}`, onClick: () => onNavigate(todo.ticket_id ? `/tickets/${todo.ticket_id}?month=${currentMonth()}` : "/todos") }))} /> ); } function StatisticWidget({ widget, editing, stats, onNavigate, onUpdateConfig }: WidgetContentProps) { const statistics = stats.statistics; const view = widget.config?.view === "totals" || widget.config?.view === "statusSplit" || widget.config?.view === "dailyBars" || widget.config?.view === "teamspaceDaily" || widget.config?.view === "workTypeSplit" || widget.config?.view === "topOrganizations" || widget.config?.view === "topTickets" ? widget.config.view : "dailyBars"; const sortBy = widget.config?.sortBy === "sessions" || widget.config?.sortBy === "open" || widget.config?.sortBy === "delta" ? widget.config.sortBy : "minutes"; const sortDirection: DashboardSortDirection = widget.config?.sortDirection === "asc" ? "asc" : "desc"; const factor = directionFactor(sortDirection); const sortableStatistic = view === "topOrganizations" || view === "topTickets"; function topListValue(item: { total_minutes: number; sessions: number; open_minutes: number; open_sessions: number; crm_delta_minutes: number }) { if (sortBy === "sessions") { return { value: item.sessions, valueLabel: `${item.sessions}` }; } if (sortBy === "open") { return { value: item.open_minutes, valueLabel: formatMinutes(item.open_minutes) }; } if (sortBy === "delta") { const delta = item.crm_delta_minutes; return { value: Math.abs(delta), valueLabel: `${delta >= 0 ? "+" : "-"}${formatMinutes(Math.abs(delta))}` }; } return { value: item.total_minutes, valueLabel: formatMinutes(item.total_minutes) }; } if (!statistics) { return

Statistik lädt...

; } const settings = editing ? ( ) : null; if (view === "totals") { return ( <> {settings} ); } if (view === "statusSplit") { return ( <> {settings} ); } if (view === "workTypeSplit") { const items = stableSort(statistics.workTypes, (left, right) => (compareNumber(left.total_minutes, right.total_minutes) * -1) || compareText(left.work_type, right.work_type)); return ( <> {settings} ({ key: item.work_type ?? "unknown", title: item.work_type === "consulting" ? "Consulting" : item.work_type === "support" ? "Support" : "Unbekannt", value: item.total_minutes, detail: `${item.sessions} Session(s)` }))} /> ); } if (view === "topOrganizations") { const items = stableSort(statistics.organizations, (left, right) => { if (sortBy === "sessions") { return (compareNumber(left.sessions, right.sessions) * factor) || compareText(left.organization_name, right.organization_name); } if (sortBy === "open") { return (compareNumber(left.open_sessions, right.open_sessions) * factor) || compareText(left.organization_name, right.organization_name); } if (sortBy === "delta") { return (compareNumber(left.crm_delta_minutes, right.crm_delta_minutes) * factor) || compareText(left.organization_name, right.organization_name); } return (compareNumber(left.total_minutes, right.total_minutes) * factor) || compareText(left.organization_name, right.organization_name); }); return ( <> {settings} ({ key: item.organization_id ?? `${item.organization_name ?? "org"}-${index}`, title: item.organization_name ?? "Keine Organisation", ...topListValue(item), detail: `${formatMinutes(item.total_minutes)} · ${item.sessions} Session(s) · offen ${item.open_sessions}` }))} /> ); } if (view === "topTickets") { const items = stableSort(statistics.tickets, (left, right) => { if (sortBy === "sessions") { return (compareNumber(left.sessions, right.sessions) * factor) || compareText(left.ticket_number, right.ticket_number); } if (sortBy === "open") { return (compareNumber(left.open_sessions, right.open_sessions) * factor) || compareText(left.ticket_number, right.ticket_number); } if (sortBy === "delta") { return (compareNumber(left.crm_delta_minutes, right.crm_delta_minutes) * factor) || compareText(left.ticket_number, right.ticket_number); } return (compareNumber(left.total_minutes, right.total_minutes) * factor) || compareText(left.ticket_number, right.ticket_number); }); return ( <> {settings} ({ key: item.ticket_id, title: item.ticket_number, ...topListValue(item), detail: `${item.organization_name} · ${item.sessions} Session(s)`, onClick: () => onNavigate(`/tickets/${item.ticket_id}?month=${currentMonth()}`) }))} /> ); } const dailyItems = statistics.dailySeries.map((day) => ({ key: day.day, title: formatDate(`${day.day}T00:00:00`), value: view === "teamspaceDaily" ? day.crm_billed_minutes : day.total_minutes, detail: view === "teamspaceDaily" ? `Abgerechnet ${formatMinutes(day.total_minutes)}` : `${day.sessions} Session(s)` })); return ( <> {settings} ); } function BarList({ items, compact = false }: { items: Array<{ key: string; title: string; value: number; valueLabel?: string; detail?: string; onClick?: () => void }>; compact?: boolean; }) { if (items.length === 0) { return

Keine Daten.

; } const maxValue = Math.max(1, ...items.map((item) => Math.abs(item.value))); return (
{items.map((item) => { const content = ( <>
{item.title} {item.valueLabel ?? formatMinutes(item.value)}
{!compact && item.detail ?

{item.detail}

: null} ); return item.onClick ? ( ) : (
{content}
); })}
); } function NotesWidget({ widget, editing, onUpdateConfig }: WidgetContentProps) { const { notes, activeNoteId } = readWidgetNotes(widget.config); const activeNote = notes.find((note) => note.id === activeNoteId) ?? notes[0]; const tabsScrollerRef = useRef(null); const [tabScrollState, setTabScrollState] = useState({ canScrollLeft: false, canScrollRight: false }); function updateTabScrollState() { const scroller = tabsScrollerRef.current; if (!scroller) { setTabScrollState({ canScrollLeft: false, canScrollRight: false }); return; } const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth; setTabScrollState({ canScrollLeft: scroller.scrollLeft > 1, canScrollRight: scroller.scrollLeft < maxScrollLeft - 1 }); } useEffect(() => { updateTabScrollState(); const scroller = tabsScrollerRef.current; if (!scroller) { return; } const handleScroll = () => updateTabScrollState(); const handleResize = () => updateTabScrollState(); scroller.addEventListener("scroll", handleScroll, { passive: true }); window.addEventListener("resize", handleResize); return () => { scroller.removeEventListener("scroll", handleScroll); window.removeEventListener("resize", handleResize); }; }, [notes.length]); useEffect(() => { updateTabScrollState(); }, [activeNoteId, notes.length]); function saveNotes(nextNotes: DashboardNote[], nextActiveNoteId = activeNoteId) { const safeActiveNoteId = nextNotes.some((note) => note.id === nextActiveNoteId) ? nextActiveNoteId : nextNotes[0]?.id; onUpdateConfig({ notes: nextNotes, activeNoteId: safeActiveNoteId }); } function addNote() { const note = createNote(`Notiz ${notes.length + 1}`); saveNotes([...notes, note], note.id); } function updateNote(noteId: string, changes: Partial) { saveNotes(notes.map((note) => (note.id === noteId ? { ...note, ...changes } : note))); } function removeNote(noteId: string) { if (notes.length <= 1) { updateNote(noteId, { content: "", title: "Notiz 1" }); return; } const nextNotes = notes.filter((note) => note.id !== noteId); saveNotes(nextNotes, activeNoteId === noteId ? nextNotes[0].id : activeNoteId); } function scrollTabs(direction: -1 | 1) { tabsScrollerRef.current?.scrollBy({ left: direction * 160, behavior: "smooth" }); } return ( onUpdateConfig({ activeNoteId: value })} className="flex h-full min-h-0 overflow-hidden gap-1">
{notes.map((note) => ( {note.title} ))}
{notes.map((note) => (
{editing ? ( updateNote(note.id, { title: event.currentTarget.value })} /> ) : null}