2962 lines
113 KiB
TypeScript
2962 lines
113 KiB
TypeScript
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<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>();
|
|
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<DashboardWidget[]>((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<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 [dayControlTickets, setDayControlTickets] = useState<DashboardDayTicket[]>([]);
|
|
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, 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<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 ?? [],
|
|
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<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 || 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<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, 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 (
|
|
<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={definition.fixed ? "Festes Widget" : 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" /> : definition.type === "dayControl" ? <CalendarCheck 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[],
|
|
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 (
|
|
<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>
|
|
{!fixed ? (
|
|
<Button type="button" size="icon" variant="ghost" className="size-6" onClick={onRemove}>
|
|
<X className="size-3.5" />
|
|
</Button>
|
|
) : null}
|
|
</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 && !fixed ? (
|
|
<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>
|
|
{!fixed ? (
|
|
<Button type="button" size="icon" variant="ghost" className="size-6" onClick={onRemove}>
|
|
<X className="size-3.5" />
|
|
</Button>
|
|
) : null}
|
|
</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 === "dayControl") {
|
|
return <DayControlWidget {...props} />;
|
|
}
|
|
|
|
if (widget.type === "today") {
|
|
return (
|
|
<MetricGrid
|
|
items={[
|
|
{ label: "Abgerechnet 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: "Abgerechnet 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: "Abgerechnet - 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="Keine offenen Bewertungen."
|
|
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;
|
|
}
|
|
|
|
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 (
|
|
<div className="flex h-full min-h-0 flex-col gap-2">
|
|
<div className="grid gap-2 lg:grid-cols-[0.95fr_1.45fr]">
|
|
<div className="rounded-md border bg-background/60 px-2 py-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={cn(
|
|
"size-3 shrink-0 rounded-full",
|
|
trafficLight === "danger" && "bg-red-500 shadow-[0_0_0_3px_rgba(239,68,68,0.18)]",
|
|
trafficLight === "warning" && "bg-amber-400 shadow-[0_0_0_3px_rgba(251,191,36,0.18)]",
|
|
trafficLight === "good" && "bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.18)]"
|
|
)}
|
|
/>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-xs font-semibold">Ampel {trafficLightLabel}</p>
|
|
<p className="truncate text-[11px] text-muted-foreground">{restParts.length > 0 ? restParts.join(" · ") : "Alles erledigt."}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DayControlChecklist items={checklistItems} />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-4 gap-1.5">
|
|
<DayControlMetric label="Heute" value={formatMinutes(stats.dayTotals?.minutes ?? 0)} icon={Timer} />
|
|
<DayControlMetric label="Offen" value={todayOpenRatings.length} icon={ClipboardCheck} tone={todayOpenRatings.length > 0 ? "warning" : "good"} />
|
|
<DayControlMetric label="ToDos" value={overdueTodos.length + dueTodos.length} icon={ClipboardCheck} tone={overdueTodos.length > 0 ? "danger" : dueTodos.length > 0 ? "warning" : "good"} />
|
|
<DayControlMetric label="Warten" value={waitingTickets.length} icon={Bell} tone={waitingTickets.length > 0 ? "info" : "good"} />
|
|
</div>
|
|
|
|
<div className="grid min-h-0 flex-1 gap-2 md:grid-cols-[1.25fr_1fr]">
|
|
<DayControlSection title="Heute finalisieren" empty="Tag sauber: alle heutigen Punkte sind erledigt." items={finalizationItems} />
|
|
<div className="min-h-0 space-y-2 overflow-y-auto">
|
|
<DayControlMiniSection
|
|
title="Nacharbeit"
|
|
icon={CalendarCheck}
|
|
empty={todayTickets.length === 0 ? "Heute noch keine Sessions." : "Keine Nacharbeit offen."}
|
|
items={afterworkTickets.slice(0, 4).map((ticket) => ({
|
|
key: `today-ticket-${ticket.id}`,
|
|
title: ticket.title || ticket.ticket_number,
|
|
detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`,
|
|
note: dayControlTodaySessionReason(ticket),
|
|
onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`)
|
|
}))}
|
|
/>
|
|
<DayControlMiniSection
|
|
title="Wiedervorlagen"
|
|
icon={Bell}
|
|
empty="Keine fälligen Wiedervorlagen."
|
|
items={waitingTickets.slice(0, 4).map((ticket) => ({
|
|
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()}`)
|
|
}))}
|
|
/>
|
|
<DayControlMiniSection
|
|
title="Budget / Aufräumen"
|
|
icon={WalletCards}
|
|
empty="Keine Budget-Hinweise."
|
|
items={budgetAttention.slice(0, 4).map((project) => {
|
|
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()}`)
|
|
};
|
|
})}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="rounded-md border bg-background/50 px-2 py-1.5">
|
|
<p className="mb-1 text-[11px] font-semibold text-muted-foreground">Tagesabschluss</p>
|
|
<div className="grid gap-x-2 gap-y-1 sm:grid-cols-2">
|
|
{items.map((item) => (
|
|
<div key={item.key} className="flex min-w-0 items-start gap-1.5">
|
|
<CheckCircle2 className={cn("mt-0.5 size-3 shrink-0", item.done ? "text-emerald-500" : "text-amber-500")} />
|
|
<div className="min-w-0">
|
|
<p className="truncate text-[11px] font-medium">{item.label}</p>
|
|
<p className="truncate text-[10px] text-muted-foreground">{item.action}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DayControlMetric({ label, value, icon: Icon, tone = "normal" }: { label: string; value: string | number; icon: typeof Timer; tone?: DayControlTone }) {
|
|
return (
|
|
<div className="min-w-0 rounded-md border bg-background/60 px-2 py-1.5">
|
|
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
|
<Icon className="size-3" />
|
|
<span className="truncate">{label}</span>
|
|
</div>
|
|
<p
|
|
className={cn(
|
|
"truncate text-sm font-semibold",
|
|
tone === "good" && "text-emerald-500",
|
|
tone === "warning" && "text-amber-500",
|
|
tone === "danger" && "text-red-500",
|
|
tone === "info" && "text-blue-500"
|
|
)}
|
|
>
|
|
{value}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DayControlSection({ title, empty, items }: { title: string; empty: string; items: DayControlListItem[] }) {
|
|
return (
|
|
<div className="min-h-0 overflow-y-auto rounded-md border bg-background/40 p-2">
|
|
<p className="mb-1.5 text-[11px] font-semibold text-muted-foreground">{title}</p>
|
|
{items.length === 0 ? (
|
|
<p className="py-4 text-center text-xs text-emerald-500">{empty}</p>
|
|
) : (
|
|
<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={cn(
|
|
"block truncate text-xs font-medium",
|
|
item.tone === "danger" && "text-red-500",
|
|
item.tone === "warning" && "text-amber-500",
|
|
item.tone === "info" && "text-blue-500"
|
|
)}
|
|
>
|
|
{item.title}
|
|
</span>
|
|
<span className="block truncate text-[11px] text-muted-foreground">{item.detail}</span>
|
|
{item.note ? <span className="block truncate text-[10px] text-muted-foreground/80">{item.note}</span> : null}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DayControlMiniSection({
|
|
title,
|
|
icon: Icon,
|
|
empty,
|
|
items
|
|
}: {
|
|
title: string;
|
|
icon: typeof Timer;
|
|
empty: string;
|
|
items: DayControlListItem[];
|
|
}) {
|
|
return (
|
|
<div>
|
|
<div className="mb-1 flex items-center gap-1 text-[11px] font-semibold text-muted-foreground">
|
|
<Icon className="size-3" />
|
|
<span className="truncate">{title}</span>
|
|
</div>
|
|
{items.length === 0 ? (
|
|
<p className="rounded-md border bg-background/30 px-2 py-2 text-xs text-muted-foreground">{empty}</p>
|
|
) : (
|
|
<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 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>
|
|
{item.note ? <span className="block truncate text-[10px] text-muted-foreground/80">{item.note}</span> : null}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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: "Abgerechnet", 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: "Abgerechnet", value: statistics.totals.billedMinutes, detail: `${statistics.totals.billedSessions} Session(s)` },
|
|
{ key: "billable", title: "Abrechenbar", value: statistics.totals.billableMinutes, detail: `${statistics.totals.billableSessions} 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" ? `Abgerechnet ${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>
|
|
);
|
|
}
|