Files
tickettracker/frontend/src/views/DashboardPage.tsx
T
2026-09-15 13:02:28 +00:00

2452 lines
91 KiB
TypeScript

import {
ArrowDown,
ArrowUp,
CheckCircle2,
ChevronsUpDown,
ChevronLeft,
ChevronRight,
ClipboardCheck,
FileText,
GripVertical,
LayoutDashboard,
Maximize2,
Minus,
Pencil,
Plus,
RotateCcw,
Save,
Star,
Timer,
Trash2,
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,
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 {
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;
};
type DashboardNote = {
id: string;
title: string;
content: string;
};
const dashboardPreferenceVersion = 3;
const rowHeight = 48;
const gridColumns = 12;
const widgetDefinitions: WidgetDefinition[] = [
{ 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: "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" } }
];
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 defaultWidgetConfig(type: DashboardWidgetType): Record<string, unknown> {
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<string, unknown> | 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>): 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<DashboardWidgetType>();
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<DashboardWidget[]>((placed, widget) => [...placed, resolveWidgetPlacement(placed, widget)], []),
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 resolveWidgetPlacement(widgets: DashboardWidget[], 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 = {
...changed,
w,
h,
x: clampNumber(changed.x, 0, gridColumns - w),
y: desiredY
};
const hasCollision = (y: number) => widgets.some((widget) => widget.id !== next.id && overlaps(widget, { ...next, y }));
if (!hasCollision(desiredY)) {
return next;
}
const maxExistingRow = Math.max(80, ...widgets.filter((widget) => widget.id !== next.id).map((widget) => widget.y + widget.h + h));
for (let distance = 1; distance <= maxExistingRow + h; distance += 1) {
const yAbove = desiredY - distance;
const yBelow = desiredY + distance;
if (yAbove >= 0 && !hasCollision(yAbove)) {
return { ...next, y: yAbove };
}
if (!hasCollision(yBelow)) {
return { ...next, y: yBelow };
}
}
return { ...next, y: maxExistingRow + h };
}
function placeWidget(widgets: DashboardWidget[], changed: DashboardWidget) {
const next = resolveWidgetPlacement(widgets, changed);
return widgets.map((widget) => (widget.id === next.id ? next : widget));
}
function widgetPlacementFromPoint(gridElement: HTMLDivElement, widgets: DashboardWidget[], 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 resolveWidgetPlacement(widgets, {
...widget,
x: clampNumber(Math.floor((clientX - rect.left) / columnStep), 0, gridColumns - widget.w),
y: clampNumber(Math.floor((clientY - rect.top) / rowStep), 0, 80)
});
}
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 stableSort<T>(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<string, unknown> | 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<string, unknown>;
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<string, unknown> | 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<string, string[]>();
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<string>, 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<string>) {
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<string>) {
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<string>) {
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<typeof matchesZammadStateFilter>[1]);
}
if (filterBy.startsWith("zammadPriority:")) {
return matchesZammadPriorityFilter(project.zammad_priority_id, filterBy.slice("zammadPriority:".length) as Parameters<typeof matchesZammadPriorityFilter>[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<string>) {
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<string, TicketMeta | null>, config: Record<string, unknown>) {
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<HTMLDivElement | null>(null);
const [preference, setPreference] = useState<DashboardPreference>(() => 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<string | null>(null);
const [dragPreview, setDragPreview] = useState<DashboardWidget | null>(null);
const [monthOverview, setMonthOverview] = useState<PeriodOverview | null>(null);
const [todayOverview, setTodayOverview] = useState<PeriodOverview | null>(null);
const [statisticsOverview, setStatisticsOverview] = useState<StatisticsOverview | null>(null);
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [todos, setTodos] = useState<TicketTodo[]>([]);
const [recurringBillings, setRecurringBillings] = useState<RecurringBilling[]>([]);
const [favoriteMetas, setFavoriteMetas] = useState<Record<string, TicketMeta | null>>({});
const [favoriteInputs, setFavoriteInputs] = useState<Record<string, string>>({});
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
try {
const [dashboardResult, monthResult, todayResult, statisticsResult, projectsResult, todosResult, recurringResult] = await Promise.all([
getDashboardPreference(),
getPeriodOverview("month", currentMonth()),
getPeriodOverview("day", currentDay()),
getStatisticsOverview(currentMonth()),
getProjects(true),
getTodos("all"),
getRecurringBillings()
]);
if (cancelled) {
return;
}
setPreference(normalizePreference(dashboardResult.preference));
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<string>();
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 ?? [],
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())
};
}, [monthOverview, projects, recurringBillings, statisticsOverview, todayOverview, todos]);
function updateWidget(widgetId: string, changes: Partial<DashboardWidget>) {
setPreference((current) => ({
...current,
widgets: placeWidget(current.widgets, { ...current.widgets.find((widget) => widget.id === widgetId)!, ...changes })
}));
}
function updateWidgetConfig(widgetId: string, config: Record<string, unknown>) {
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)
}));
}
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, preference.widgets, 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, preference.widgets, widget, clientX, clientY));
}
function startResize(event: PointerEvent<HTMLButtonElement>, 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, 8)
});
}
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 (
<div className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="flex items-center gap-2 text-xl font-semibold tracking-normal sm:text-2xl">
<LayoutDashboard className="size-5 text-muted-foreground" />
Dashboard
</h2>
<p className="text-sm text-muted-foreground">{loading ? "Lädt..." : `${preference.widgets.length} Widget(s)`}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{saving ? <Badge variant="outline">Speichert...</Badge> : <Badge variant="secondary">Gespeichert</Badge>}
<Button type="button" size="sm" variant={editing ? "default" : "secondary"} onClick={() => setEditing((current) => !current)}>
{editing ? <Save className="size-4" /> : <Pencil className="size-4" />}
{editing ? "Fertig" : "Bearbeiten"}
</Button>
</div>
</div>
{editing ? (
<Card>
<CardContent className="flex flex-wrap gap-2 p-3">
{widgetDefinitions.map((definition) => {
const active = !definition.multiple && preference.widgets.some((widget) => widget.type === definition.type);
return (
<Button
key={definition.type}
type="button"
size="sm"
variant={active ? "outline" : "secondary"}
disabled={active}
title={active ? "Bereits im Dashboard" : "Widget hinzufügen"}
onClick={() => addWidget(definition.type)}
>
{definition.type === "divider" ? <Minus className="size-3.5" /> : definition.type === "notes" ? <FileText className="size-3.5" /> : <Plus className="size-3.5" />}
{definition.title}
</Button>
);
})}
<Button type="button" size="sm" variant="ghost" className="ml-auto" onClick={() => setPreference({ version: dashboardPreferenceVersion, widgets: cloneDefaultWidgets(), favorites: preference.favorites })}>
<RotateCcw className="size-3.5" />
Layout zurücksetzen
</Button>
</CardContent>
</Card>
) : null}
<div
ref={gridRef}
className={cn("grid gap-2", editing ? "rounded-md border border-dashed bg-muted/15 p-2" : undefined)}
style={{
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
gridAutoRows: `${rowHeight}px`,
minHeight: `${maxGridRow * rowHeight}px`
}}
onDragOver={(event) => {
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) => (
<DashboardWidgetCard
key={widget.id}
widget={widget}
editing={editing}
monthOverview={monthOverview}
todayOverview={todayOverview}
projects={projects}
todos={todos}
favorites={preference.favorites}
favoriteMetas={favoriteMetas}
stats={dashboardStats}
favoriteInput={favoriteInputs[widget.id] ?? ""}
setFavoriteInput={(value) => 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 ? (
<div
className="pointer-events-none z-30 flex items-center justify-center rounded-md border-2 border-primary bg-primary/15 ring-2 ring-primary/30"
style={{
gridColumn: `${dragPreview.x + 1} / span ${dragPreview.w}`,
gridRow: `${dragPreview.y + 1} / span ${dragPreview.h}`
}}
>
<span className="rounded bg-background/90 px-2 py-0.5 text-[11px] font-medium text-primary shadow-sm">Ziel</span>
</div>
) : null}
</div>
</div>
);
}
type DashboardWidgetCardProps = {
widget: DashboardWidget;
editing: boolean;
monthOverview: PeriodOverview | null;
todayOverview: PeriodOverview | null;
projects: ProjectSummary[];
todos: TicketTodo[];
favorites: DashboardFavorite[];
favoriteMetas: Record<string, TicketMeta | null>;
stats: ReturnType<typeof useDashboardStatsPlaceholder>;
favoriteInput: string;
setFavoriteInput: (value: string) => void;
onNavigate: (to: string) => void;
onRemove: () => void;
onUpdateTitle: (title: string) => void;
onUpdateConfig: (config: Record<string, unknown>) => void;
onAddFavorite: (ticketNumber: string) => void;
onRemoveFavorite: (ticketNumber: string) => void;
onMoveFavorite: (ticketNumber: string, delta: number) => void;
dragging: boolean;
onDragStart: (event: DragEvent<HTMLButtonElement>) => void;
onDragEnd: () => void;
onResizeStart: (event: PointerEvent<HTMLButtonElement>) => 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<string>(),
teamspaceDelta: 0,
overBudget: [] as ProjectSummary[],
nearlyFull: [] as ProjectSummary[],
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) {
if (widget.type === "divider") {
return (
<div
className={cn("relative min-w-0", editing ? "rounded-md border border-dashed bg-muted/10 ring-1 ring-primary/20" : undefined, dragging ? "opacity-60" : undefined)}
style={{
gridColumn: `${widget.x + 1} / span ${widget.w}`,
gridRow: `${widget.y + 1} / span ${widget.h}`
}}
>
<div className="flex h-full items-center px-1">
<span className={cn("block h-px w-full", editing ? "bg-primary/70" : "bg-border")} />
</div>
{editing ? (
<>
<div className="absolute right-1 top-1 flex items-center gap-1 rounded bg-background/90 shadow-sm">
<button type="button" draggable className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground" onDragStart={onDragStart} onDragEnd={onDragEnd}>
<GripVertical className="size-3.5" />
</button>
<Button type="button" size="icon" variant="ghost" className="size-6" onClick={onRemove}>
<X className="size-3.5" />
</Button>
</div>
<button type="button" className="absolute bottom-1 right-1 rounded bg-background/90 p-1 text-muted-foreground shadow-sm hover:bg-muted hover:text-foreground" onPointerDown={onResizeStart}>
<Maximize2 className="size-3.5" />
</button>
</>
) : null}
</div>
);
}
return (
<Card
size="sm"
className={cn("relative min-w-0 gap-0 overflow-hidden rounded-md py-0", editing ? "ring-1 ring-primary/20" : undefined, dragging ? "opacity-60" : undefined)}
style={{
gridColumn: `${widget.x + 1} / span ${widget.w}`,
gridRow: `${widget.y + 1} / span ${widget.h}`
}}
>
<div className="flex h-7 shrink-0 items-center justify-between gap-1 border-b bg-muted/20 px-2">
{editing ? (
<Input
className="h-6 min-w-0 flex-1 rounded-sm border-0 bg-background/70 px-1.5 text-xs font-semibold shadow-none"
value={widget.title ?? widgetTitle(widget.type)}
onChange={(event) => onUpdateTitle(event.currentTarget.value)}
/>
) : (
<span className="truncate text-xs font-semibold">{widget.title?.trim() || widgetTitle(widget.type)}</span>
)}
{editing ? (
<div className="flex shrink-0 items-center gap-1">
<button type="button" draggable className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground" onDragStart={onDragStart} onDragEnd={onDragEnd}>
<GripVertical className="size-3.5" />
</button>
<Button type="button" size="icon" variant="ghost" className="size-6" onClick={onRemove}>
<X className="size-3.5" />
</Button>
</div>
) : null}
</div>
<CardContent className={cn("min-h-0 flex-1 p-2 text-sm", widget.type === "notes" ? "overflow-hidden" : "overflow-y-auto")}>
<WidgetContent
widget={widget}
editing={editing}
projects={projects}
todos={todos}
favorites={favorites}
favoriteMetas={favoriteMetas}
stats={stats}
favoriteInput={favoriteInput}
setFavoriteInput={setFavoriteInput}
onNavigate={onNavigate}
onUpdateConfig={onUpdateConfig}
onAddFavorite={onAddFavorite}
onRemoveFavorite={onRemoveFavorite}
onMoveFavorite={onMoveFavorite}
/>
</CardContent>
{editing ? (
<button type="button" className="absolute bottom-1 right-1 rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" onPointerDown={onResizeStart}>
<Maximize2 className="size-3.5" />
</button>
) : null}
</Card>
);
}
type WidgetContentProps = {
widget: DashboardWidget;
editing: boolean;
projects: ProjectSummary[];
todos: TicketTodo[];
favorites: DashboardFavorite[];
favoriteMetas: Record<string, TicketMeta | null>;
stats: ReturnType<typeof useDashboardStatsPlaceholder>;
favoriteInput: string;
setFavoriteInput: (value: string) => void;
onNavigate: (to: string) => void;
onUpdateConfig: (config: Record<string, unknown>) => 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 === "today") {
return (
<MetricGrid
items={[
{ label: "Bewertet heute", value: formatMinutes(stats.dayTotals?.minutes ?? 0), icon: Timer },
{ label: "Offene Bewertungen", value: stats.dayTotals?.openSessions ?? 0, icon: ClipboardCheck }
]}
/>
);
}
if (widget.type === "month") {
return (
<MetricGrid
items={[
{ label: "Bewertet Monat", value: formatMinutes(stats.monthTotals?.minutes ?? 0), icon: CheckCircle2 },
{ label: "Offene Bewertungen", value: stats.monthTotals?.openSessions ?? 0, icon: ClipboardCheck }
]}
/>
);
}
if (widget.type === "teamspace") {
const delta = stats.teamspaceDelta;
return (
<MetricGrid
items={[
{ label: "TS-Zeit Monat", value: formatMinutes(stats.monthTotals?.crmBilledMinutes ?? 0), icon: LayoutDashboard },
{ label: "Bewertet - TS", value: `${delta >= 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 ? (
<WidgetSettings
filterValues={filterValues}
filterOptions={[
{ value: "today", label: "Heute offen" },
{ value: "older3", label: "Älter als 3 Tage" },
{ value: "fixedBilling", label: "Fixe Abrechnungen" },
{ value: "manualBilling", label: "Ohne fixe Abrechnung" }
]}
sortBy={String(sortBy)}
sortDirection={sortDirection}
options={[
{ value: "date", label: "Datum" },
{ value: "ticket", label: "Ticket" },
{ value: "duration", label: "Dauer" }
]}
onChange={props.onUpdateConfig}
/>
) : null}
<CompactList
empty="Alles bewertet."
items={sessions.map((session) => ({
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 <ProjectBudgetWidget {...props} />;
}
if (widget.type === "todos") {
return <TodoWidget {...props} />;
}
if (widget.type === "favorites") {
return <FavoritesWidget {...props} />;
}
if (widget.type === "statistic") {
return <StatisticWidget {...props} />;
}
if (widget.type === "notes") {
return <NotesWidget {...props} />;
}
return null;
}
function MetricGrid({ items }: { items: Array<{ label: string; value: string | number; icon: typeof Timer; tone?: "normal" | "good" | "warning" }> }) {
return (
<div className="grid h-full grid-cols-2 gap-1.5">
{items.map((item) => {
const Icon = item.icon;
return (
<div key={item.label} className="min-w-0 rounded-md border bg-background/60 p-1.5">
<div className="mb-1 flex items-center gap-1 text-[11px] text-muted-foreground">
<Icon className="size-3" />
<span className="truncate">{item.label}</span>
</div>
<p className={cn("truncate text-sm font-semibold", item.tone === "good" ? "text-emerald-500" : item.tone === "warning" ? "text-amber-500" : undefined)}>{item.value}</p>
</div>
);
})}
</div>
);
}
function CompactList({ items, empty }: { items: Array<{ key: string; title: string; detail: string; onClick?: () => void }>; empty: string }) {
if (items.length === 0) {
return <p className="py-3 text-center text-xs text-muted-foreground">{empty}</p>;
}
return (
<div className="space-y-1">
{items.map((item) => (
<button key={item.key} type="button" className="block w-full min-w-0 rounded-md px-2 py-1.5 text-left hover:bg-muted" onClick={item.onClick}>
<span className="block truncate text-xs font-medium">{item.title}</span>
<span className="block truncate text-[11px] text-muted-foreground">{item.detail}</span>
</button>
))}
</div>
);
}
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" role="combobox" aria-expanded={open} className="h-7 w-full justify-between px-2 text-xs font-normal">
<span className={cn("truncate", values.length === 0 && "text-muted-foreground")}>
{values.length === 0 ? "Alle Filter" : `${values.length} ausgewählt`}
</span>
<ChevronsUpDown className="size-3.5 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[--radix-popover-trigger-width] gap-1 p-1.5">
<Input className="h-7 text-xs" value={search} placeholder="Suchen..." onChange={(event) => setSearch(event.currentTarget.value)} />
<div className="max-h-48 overflow-y-auto pr-1">
{visibleOptions.length === 0 ? (
<p className="px-2 py-3 text-center text-xs text-muted-foreground">Keine Filter.</p>
) : (
<div className="space-y-0.5">
{visibleOptions.map((option) => {
const checked = values.includes(option.value);
return (
<div
key={option.value}
role="button"
tabIndex={0}
aria-pressed={checked}
className="flex h-7 w-full min-w-0 cursor-default items-center gap-2 rounded-md px-2 text-left text-xs outline-none hover:bg-muted focus-visible:bg-muted"
onClick={() => toggle(option.value)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggle(option.value);
}
}}
>
<Checkbox checked={checked} tabIndex={-1} className="pointer-events-none" />
<span className="truncate">{option.label}</span>
</div>
);
})}
</div>
)}
</div>
{values.length > 0 ? (
<Button type="button" size="sm" variant="ghost" className="h-6 justify-start px-2 text-[11px]" onClick={() => onChange([])}>
Zurücksetzen
</Button>
) : null}
</PopoverContent>
</Popover>
);
}
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<string, unknown>) => void;
}) {
const activeFilters = filterValues ?? [];
return (
<div className="mb-2 space-y-1">
{view && viewOptions.length > 0 ? (
<select className="h-7 w-full min-w-0 rounded-md border bg-background px-2 text-xs" value={view} onChange={(event) => onChange({ view: event.currentTarget.value })}>
{viewOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
) : null}
{filterOptions.length > 0 ? (
<MultiFilterDropdown values={activeFilters} options={filterOptions} onChange={(nextFilters) => onChange({ filterValues: nextFilters, filterBy: nextFilters[0] ?? "all" })} />
) : null}
{sortBy && sortDirection && options && options.length > 0 ? (
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] gap-1">
<select className="h-7 min-w-0 rounded-md border bg-background px-2 text-xs" value={sortBy} onChange={(event) => onChange({ sortBy: event.currentTarget.value })}>
{options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<Button type="button" size="icon" variant={sortDirection === "asc" ? "secondary" : "ghost"} className="size-7" onClick={() => onChange({ sortDirection: "asc" })}>
<ArrowUp className="size-3.5" />
</Button>
<Button type="button" size="icon" variant={sortDirection === "desc" ? "secondary" : "ghost"} className="size-7" onClick={() => onChange({ sortDirection: "desc" })}>
<ArrowDown className="size-3.5" />
</Button>
</div>
) : null}
</div>
);
}
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 ? (
<WidgetSettings
filterValues={filterValues}
filterOptions={[
{ value: "budgeted", label: "Mit Kontingent" },
{ value: "critical90", label: "Ab 90%" },
{ value: "overBudget", label: "Ab 100%" },
{ value: "withoutBudget", label: "Ohne Kontingent" },
{ value: "standardBudgetOnly", label: "Nur Std.-Budget" },
{ value: "open", label: "TT Offen" },
{ value: "closed", label: "TT Geschl." },
...zammadStateFilterOptions.filter((option) => 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 ? (
<p className="py-3 text-center text-xs text-muted-foreground">Keine Kontingente.</p>
) : (
<div className="space-y-1">
{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 (
<button key={project.id} type="button" className="block w-full min-w-0 rounded-md px-2 py-1.5 text-left hover:bg-muted" onClick={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}>
<span className="block truncate text-xs font-medium">{project.title || project.ticket_number}</span>
<span className="block truncate text-[11px] text-muted-foreground">
{project.ticket_number} · {showBudgetedTeamspaceEntries ? `${budgetedEntries.length} Budgets · ` : ""}{budgetMinutes > 0 ? `${decimalHours(consumptionMinutes)} / ${decimalHours(budgetMinutes)} · ${percent ?? 0}%` : "ohne Kontingent"}
</span>
<span className="mt-1 flex flex-wrap gap-1">
<ZammadStateBadge stateId={project.zammad_state_id} />
<ZammadPriorityBadge priorityId={project.zammad_priority_id} />
</span>
{percent !== null ? (
<span className="mt-1 block h-1.5 overflow-hidden rounded-full bg-muted" title={`${percent}% Verbrauch`} aria-label={`${percent}% Verbrauch`}>
<span className={cn("block h-full rounded-full", consumptionBarClass(percent))} style={{ width: `${Math.min(100, percent)}%` }} />
</span>
) : null}
{showBudgetedTeamspaceEntries ? (
<span className="mt-1 grid gap-1">
{budgetedEntries.map((entry) => {
const entryBudgetMinutes = entry.hour_budget_minutes ?? 0;
const entryConsumptionMinutes = entry.consumption_minutes ?? 0;
const entryPercent = entryBudgetMinutes > 0 ? Math.round((entryConsumptionMinutes / entryBudgetMinutes) * 100) : null;
return (
<span key={entry.id} className="block">
<span className="flex min-w-0 items-center justify-between gap-2 text-[10px] text-muted-foreground">
<span className="truncate">
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
{entry.is_default ? " · Standard" : ""}
</span>
<span className="shrink-0">
{entryBudgetMinutes > 0 ? `${decimalHours(entryConsumptionMinutes)} / ${decimalHours(entryBudgetMinutes)}` : decimalHours(entryConsumptionMinutes)}
</span>
</span>
{entryPercent !== null ? (
<span className="mt-0.5 block h-1 overflow-hidden rounded-full bg-muted">
<span className={cn("block h-full rounded-full", consumptionBarClass(entryPercent))} style={{ width: `${Math.min(100, entryPercent)}%` }} />
</span>
) : null}
</span>
);
})}
</span>
) : null}
</button>
);
})}
</div>
)}
</>
);
}
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 ? (
<WidgetSettings
filterValues={filterValues}
filterOptions={[
{ value: "open", label: "Alle offenen" },
{ value: "overdue", label: "Überfällig" },
{ value: "today", label: "Heute fällig/aktiv" },
{ value: "next7", label: "Nächste 7 Tage" },
{ value: "undated", label: "Ohne Datum" },
{ value: "done", label: "Erledigt" },
{ value: "fixedBilling", label: "Fixe Abrechnungen" },
{ value: "manualBilling", label: "Ohne fixe Abrechnung" }
]}
sortBy={String(sortBy)}
sortDirection={sortDirection}
options={[
{ value: "due", label: "Fällig" },
{ value: "priority", label: "Prio" },
{ value: "title", label: "Titel" },
{ value: "ticket", label: "Ticket" },
{ value: "organization", label: "Organisation" }
]}
onChange={onUpdateConfig}
/>
) : null}
<CompactList
empty="Keine offenen ToDos."
items={visible.map((todo) => ({
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 <p className="py-3 text-center text-xs text-muted-foreground">Statistik lädt...</p>;
}
const settings = editing ? (
<WidgetSettings
view={String(view)}
viewOptions={[
{ value: "dailyBars", label: "Tagesverlauf Arbeitszeit" },
{ value: "teamspaceDaily", label: "Tagesverlauf Teamspace" },
{ value: "statusSplit", label: "Status-Verteilung" },
{ value: "workTypeSplit", label: "Support / Consulting" },
{ value: "topOrganizations", label: "Top Organisationen" },
{ value: "topTickets", label: "Top Tickets" },
{ value: "totals", label: "Kennzahlen" }
]}
sortBy={sortableStatistic ? String(sortBy) : undefined}
sortDirection={sortableStatistic ? sortDirection : undefined}
options={sortableStatistic ? [
{ value: "minutes", label: "Zeit" },
{ value: "sessions", label: "Sessions" },
{ value: "open", label: "Offen" },
{ value: "delta", label: "TS-Differenz" }
] : undefined}
onChange={onUpdateConfig}
/>
) : null;
if (view === "totals") {
return (
<>
{settings}
<MetricGrid
items={[
{ label: "Bewertet", value: formatMinutes(statistics.totals.minutes), icon: CheckCircle2 },
{ label: "Offen", value: statistics.totals.openSessions, icon: ClipboardCheck },
{ label: "Ø Session", value: formatMinutes(statistics.totals.averageSessionMinutes), icon: Timer },
{ label: "Aktive Tage", value: statistics.totals.activeDays, icon: LayoutDashboard }
]}
/>
</>
);
}
if (view === "statusSplit") {
return (
<>
{settings}
<BarList
items={[
{ key: "billed", title: "Bewertet", value: statistics.totals.billedMinutes, detail: `${statistics.totals.billedSessions} Session(s)` },
{ key: "non", title: "Nicht abrechenbar", value: statistics.totals.nonBillableMinutes, detail: `${statistics.totals.nonBillableSessions} Session(s)` },
{ key: "open", title: "Offen", value: statistics.totals.openMinutes, detail: `${statistics.totals.openSessions} Session(s)` }
]}
/>
</>
);
}
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}
<BarList
items={items.map((item) => ({
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}
<BarList
items={items.map((item, index) => ({
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}
<BarList
items={items.map((item) => ({
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" ? `Bewertet ${formatMinutes(day.total_minutes)}` : `${day.sessions} Session(s)`
}));
return (
<>
{settings}
<BarList items={dailyItems} compact />
</>
);
}
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 <p className="py-3 text-center text-xs text-muted-foreground">Keine Daten.</p>;
}
const maxValue = Math.max(1, ...items.map((item) => Math.abs(item.value)));
return (
<div className="space-y-1">
{items.map((item) => {
const content = (
<>
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="truncate text-xs font-medium">{item.title}</span>
<span className="shrink-0 text-[11px] text-muted-foreground">{item.valueLabel ?? formatMinutes(item.value)}</span>
</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary" style={{ width: `${Math.max(3, Math.round((Math.abs(item.value) / maxValue) * 100))}%` }} />
</div>
{!compact && item.detail ? <p className="mt-0.5 truncate text-[11px] text-muted-foreground">{item.detail}</p> : null}
</>
);
return item.onClick ? (
<button key={item.key} type="button" className="block w-full rounded-md px-2 py-1.5 text-left hover:bg-muted" onClick={item.onClick}>
{content}
</button>
) : (
<div key={item.key} className="rounded-md px-2 py-1.5">
{content}
</div>
);
})}
</div>
);
}
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<HTMLDivElement | null>(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<DashboardNote>) {
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 (
<Tabs value={activeNoteId} onValueChange={(value) => onUpdateConfig({ activeNoteId: value })} className="flex h-full min-h-0 overflow-hidden gap-1">
<div className="flex h-8 min-w-0 shrink-0 items-center gap-1 overflow-hidden">
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" disabled={!tabScrollState.canScrollLeft} onClick={() => scrollTabs(-1)}>
<ChevronLeft className="size-3.5" />
</Button>
<div className="relative min-w-0 flex-1">
<div
className={cn(
"pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-gradient-to-r from-card to-transparent transition-opacity",
tabScrollState.canScrollLeft ? "opacity-100" : "opacity-0"
)}
/>
<div
className={cn(
"pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-gradient-to-l from-card to-transparent transition-opacity",
tabScrollState.canScrollRight ? "opacity-100" : "opacity-0"
)}
/>
<div
ref={tabsScrollerRef}
className="min-w-0 flex-1 overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
onScroll={updateTabScrollState}
>
<TabsList variant="line" className="h-8 w-max min-w-max justify-start rounded-none p-0 pb-1">
{notes.map((note) => (
<TabsTrigger key={note.id} value={note.id} className="h-7 w-32 shrink-0 flex-none px-2 text-xs">
<span className="truncate">{note.title}</span>
</TabsTrigger>
))}
</TabsList>
</div>
</div>
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" disabled={!tabScrollState.canScrollRight} onClick={() => scrollTabs(1)}>
<ChevronRight className="size-3.5" />
</Button>
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" onClick={addNote}>
<Plus className="size-3.5" />
</Button>
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" disabled={!activeNote} onClick={() => activeNote && removeNote(activeNote.id)}>
<Trash2 className="size-3.5" />
</Button>
</div>
{notes.map((note) => (
<TabsContent key={note.id} value={note.id} className="min-h-0 flex-1 overflow-hidden">
<div className="flex h-full min-h-0 flex-col gap-2">
{editing ? (
<Input
className="h-7 text-xs"
value={note.title}
onChange={(event) => updateNote(note.id, { title: event.currentTarget.value })}
/>
) : null}
<Textarea
className="min-h-0 flex-1 resize-none overflow-auto text-sm [field-sizing:fixed]"
value={note.content}
placeholder="Notiz..."
onChange={(event) => updateNote(note.id, { content: event.currentTarget.value })}
/>
</div>
</TabsContent>
))}
{!activeNote ? <p className="py-3 text-center text-xs text-muted-foreground">Keine Notiz.</p> : null}
</Tabs>
);
}
function FavoritesWidget({
widget,
editing,
projects,
todos,
stats,
favorites,
favoriteMetas,
favoriteInput,
setFavoriteInput,
onNavigate,
onUpdateConfig,
onAddFavorite,
onRemoveFavorite,
onMoveFavorite
}: WidgetContentProps) {
const filterValues = readFilterValues(widget.config, ["projects", "openTodos", "overdueTodos", "missingMeta", "fixedBilling", "manualBilling"]);
const sortBy = widget.config?.sortBy === "ticket" || widget.config?.sortBy === "title" || widget.config?.sortBy === "recent" ? widget.config.sortBy : "custom";
const sortDirection: DashboardSortDirection = widget.config?.sortDirection === "desc" ? "desc" : "asc";
const today = currentDay();
const visible = sortedFavorites(favorites, favoriteMetas, { sortBy, sortDirection }).filter((favorite) => {
const meta = favoriteMetas[favorite.ticketNumber];
return groupedFilterMatch(filterValues, favoriteFilterGroup, (filter) => {
if (filter === "projects") {
return projects.some((project) => project.ticket_number === favorite.ticketNumber);
}
if (filter === "openTodos") {
return todos.some((todo) => todo.ticket_number === favorite.ticketNumber && todo.status === "open");
}
if (filter === "overdueTodos") {
return todos.some((todo) => todo.ticket_number === favorite.ticketNumber && todo.status === "open" && todo.ends_on && todo.ends_on < today);
}
if (filter.startsWith("zammadState:")) {
return matchesZammadStateFilter(meta?.zammad_state_id, filter.slice("zammadState:".length) as Parameters<typeof matchesZammadStateFilter>[1]);
}
if (filter.startsWith("zammadPriority:")) {
return matchesZammadPriorityFilter(meta?.zammad_priority_id, filter.slice("zammadPriority:".length) as Parameters<typeof matchesZammadPriorityFilter>[1]);
}
if (filter === "missingMeta") {
return favoriteMetas[favorite.ticketNumber] === null;
}
if (filter === "fixedBilling") {
return hasFixedBillingForTicket(stats.fixedBillingTickets, meta?.id, favorite.ticketNumber);
}
if (filter === "manualBilling") {
return !hasFixedBillingForTicket(stats.fixedBillingTickets, meta?.id, favorite.ticketNumber);
}
return true;
});
});
return (
<div className="space-y-2">
{editing ? (
<>
<div className="flex gap-1">
<Input
className="h-7 text-xs"
value={favoriteInput}
placeholder="Ticket#123456"
onChange={(event) => setFavoriteInput(event.currentTarget.value)}
/>
<Button type="button" size="icon" className="size-7" onClick={() => void onAddFavorite(favoriteInput)}>
<Plus className="size-3.5" />
</Button>
</div>
<WidgetSettings
filterValues={filterValues}
filterOptions={[
{ value: "projects", label: "Nur Projekte" },
{ value: "openTodos", label: "Mit offenen ToDos" },
{ value: "overdueTodos", label: "Mit überfälligen ToDos" },
...zammadStateFilterOptions.filter((option) => 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: "missingMeta", label: "Nicht gefunden" },
{ value: "fixedBilling", label: "Fixe Abrechnungen" },
{ value: "manualBilling", label: "Ohne fixe Abrechnung" }
]}
sortBy={String(sortBy)}
sortDirection={sortDirection}
options={[
{ value: "custom", label: "Frei" },
{ value: "ticket", label: "Ticket" },
{ value: "title", label: "Titel" },
{ value: "recent", label: "Neu" },
{ value: "zammadStatus", label: "ZA Status" },
{ value: "zammadPriority", label: "ZA Prio" }
]}
onChange={onUpdateConfig}
/>
</>
) : null}
{visible.length === 0 ? (
<p className="py-3 text-center text-xs text-muted-foreground">Keine Favoriten.</p>
) : (
<div className="space-y-1">
{visible.map((favorite) => {
const meta = favoriteMetas[favorite.ticketNumber];
return (
<div key={favorite.ticketNumber} className="flex min-w-0 items-center gap-1 rounded-md px-1.5 py-1 hover:bg-muted">
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => meta?.id && onNavigate(`/tickets/${meta.id}?month=${currentMonth()}`)}>
<span className="block truncate text-xs font-medium">{favorite.ticketNumber}</span>
<span className="block truncate text-[11px] text-muted-foreground">{meta?.title || meta?.organization_name || "Favorit"}</span>
<span className="mt-1 flex flex-wrap gap-1">
<ZammadStateBadge stateId={meta?.zammad_state_id} />
<ZammadPriorityBadge priorityId={meta?.zammad_priority_id} />
</span>
</button>
{editing && sortBy === "custom" ? (
<>
<Button type="button" size="icon" variant="ghost" className="size-6" onClick={() => onMoveFavorite(favorite.ticketNumber, -1)}>
<ArrowUp className="size-3" />
</Button>
<Button type="button" size="icon" variant="ghost" className="size-6" onClick={() => onMoveFavorite(favorite.ticketNumber, 1)}>
<ArrowDown className="size-3" />
</Button>
</>
) : null}
{editing ? (
<Button type="button" size="icon" variant="ghost" className="size-6" onClick={() => void onRemoveFavorite(favorite.ticketNumber)}>
<Trash2 className="size-3" />
</Button>
) : (
<Star className="size-3.5 fill-amber-400 text-amber-500" />
)}
</div>
);
})}
</div>
)}
</div>
);
}