Update
This commit is contained in:
1 parent
3d9674bd5a
commit
d1da86beab
20 files changed
+6109
-923
No files matched your search
+324
-14
@@ -1,13 +1,16 @@
|
||||
import {
|
||||
BarChart3,
|
||||
AlertCircle,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
Command,
|
||||
ChartNoAxesCombined,
|
||||
CircleHelp,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
FolderKanban,
|
||||
GripVertical,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
ListTodo,
|
||||
LogOut,
|
||||
@@ -19,7 +22,9 @@ import {
|
||||
Search,
|
||||
Shield,
|
||||
Sun,
|
||||
Ticket,
|
||||
Timer,
|
||||
Trash2,
|
||||
UserCog,
|
||||
UserCircle,
|
||||
X,
|
||||
@@ -28,7 +33,9 @@ import { FormEvent, MouseEvent, useCallback, useEffect, useMemo, useState } from
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -58,11 +65,14 @@ import {
|
||||
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
|
||||
|
||||
import { cn } from "./lib/utils";
|
||||
import { ApiError, getCurrentUser, getSetupStatus, logout, lookupTicket, searchTickets } from "./api";
|
||||
import { formatDateTime, formatMinutes, formatTimer } from "./format";
|
||||
import { ApiError, deleteTodo, getCurrentUser, getRecurringBillings, getSetupStatus, getTodos, logout, lookupTicket, searchTickets, updateTodo } from "./api";
|
||||
import { currentDay, formatDateTime, formatMinutes, formatTimer } from "./format";
|
||||
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "./recurring-billings";
|
||||
import { activeElapsedMs, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
|
||||
import { formatTodoPeriod, isTodoActiveOnDay, isTodoFixedOnDay, isTodoOverdue } from "./todos";
|
||||
import { AdminUsersPage } from "./views/AdminUsersPage";
|
||||
import { AnalysisPage } from "./views/AnalysisPage";
|
||||
import { DashboardPage } from "./views/DashboardPage";
|
||||
import { FaqPage } from "./views/FaqPage";
|
||||
import { LoginPage } from "./views/LoginPage";
|
||||
import { MonthlyClosePage } from "./views/MonthlyClosePage";
|
||||
@@ -74,7 +84,7 @@ import { SetupPage } from "./views/SetupPage";
|
||||
import { TicketDetailPage } from "./views/TicketDetailPage";
|
||||
import { TimerPage } from "./views/TimerPage";
|
||||
import { TodosPage } from "./views/TodosPage";
|
||||
import type { AuthUser, PeriodType, TicketDetailPeriodType, TicketSearchResult } from "./types";
|
||||
import type { AuthUser, PeriodType, TicketDetailPeriodType, TicketSearchResult, TicketTodo } from "./types";
|
||||
import type { Dispatch, DragEvent, SetStateAction } from "react";
|
||||
|
||||
type NavMenuEntry =
|
||||
@@ -97,6 +107,7 @@ type OpenTicketNavItem = {
|
||||
periodType: TicketDetailPeriodType;
|
||||
period: string;
|
||||
path: string;
|
||||
returnPath?: string;
|
||||
dirty: boolean;
|
||||
};
|
||||
|
||||
@@ -127,6 +138,7 @@ function readOpenTickets(userId: string): OpenTicketNavItem[] {
|
||||
periodType: item.periodType === "day" ? "day" : item.periodType === "month" ? "month" : "all",
|
||||
period: item.period ? String(item.period) : "all",
|
||||
path: String(item.path),
|
||||
returnPath: typeof item.returnPath === "string" ? item.returnPath : undefined,
|
||||
dirty: false
|
||||
}));
|
||||
} catch {
|
||||
@@ -177,6 +189,10 @@ function routeFromPath(pathWithSearch: string) {
|
||||
return { page: "analysis" as const };
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/dashboard")) {
|
||||
return { page: "dashboard" as const };
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/statistics")) {
|
||||
return { page: "statistics" as const };
|
||||
}
|
||||
@@ -535,8 +551,218 @@ function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers,
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarTodayTodosProps = {
|
||||
todos: TicketTodo[];
|
||||
onNavigate: (to: string) => void;
|
||||
};
|
||||
|
||||
const sidebarTodoPriorityLabels: Record<TicketTodo["priority"], string> = {
|
||||
urgent: "Dringend",
|
||||
high: "Hoch",
|
||||
medium: "Normal",
|
||||
low: "Niedrig"
|
||||
};
|
||||
|
||||
const sidebarTodoPriorityClasses: Record<TicketTodo["priority"], string> = {
|
||||
low: "border-sky-500/30 bg-sky-500/10 text-sky-300",
|
||||
medium: "border-muted-foreground/25 bg-muted/50 text-muted-foreground",
|
||||
high: "border-amber-500/30 bg-amber-500/10 text-amber-300",
|
||||
urgent: "border-red-500/30 bg-red-500/10 text-red-300"
|
||||
};
|
||||
|
||||
const sidebarTodoCalendarClasses: Record<TicketTodo["priority"], string> = {
|
||||
low: "border-sky-500/40 bg-sky-500/15 text-sky-200",
|
||||
medium: "border-zinc-500/40 bg-zinc-500/15 text-zinc-200",
|
||||
high: "border-amber-500/45 bg-amber-500/15 text-amber-200",
|
||||
urgent: "border-red-500/45 bg-red-500/15 text-red-200"
|
||||
};
|
||||
|
||||
const sidebarOverdueTodoClass = "border-red-500/60 bg-red-500/15 text-red-100";
|
||||
|
||||
function notifyTodosChanged() {
|
||||
window.dispatchEvent(new Event("tickettracker:todos-changed"));
|
||||
}
|
||||
|
||||
function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
|
||||
const [runningTodosOpen, setRunningTodosOpen] = useState(false);
|
||||
const [modalBusy, setModalBusy] = useState(false);
|
||||
const today = currentDay();
|
||||
const fixedTodos = todos.filter((todo) => isTodoFixedOnDay(todo, today));
|
||||
const summarizedTodos = todos.filter((todo) => todo.status === "open" && isTodoActiveOnDay(todo, today) && !isTodoFixedOnDay(todo, today));
|
||||
const overdueTodos = todos.filter((todo) => isTodoOverdue(todo, today));
|
||||
const hasTodos = fixedTodos.length > 0 || summarizedTodos.length > 0 || overdueTodos.length > 0;
|
||||
|
||||
async function toggleTodo(todo: TicketTodo) {
|
||||
setModalBusy(true);
|
||||
|
||||
try {
|
||||
await updateTodo(todo.id, {
|
||||
title: todo.title,
|
||||
description: todo.description,
|
||||
priority: todo.priority,
|
||||
status: todo.status === "done" ? "open" : "done",
|
||||
startsOn: todo.starts_on,
|
||||
endsOn: todo.ends_on
|
||||
});
|
||||
toast.success(todo.status === "done" ? "ToDo wieder geöffnet" : "ToDo erledigt");
|
||||
notifyTodosChanged();
|
||||
} catch (error) {
|
||||
toast.error("ToDo konnte nicht aktualisiert werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setModalBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTodo(todo: TicketTodo) {
|
||||
const confirmed = window.confirm(`ToDo "${todo.title}" wirklich löschen?`);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setModalBusy(true);
|
||||
|
||||
try {
|
||||
await deleteTodo(todo.id);
|
||||
toast.success("ToDo gelöscht");
|
||||
notifyTodosChanged();
|
||||
} catch (error) {
|
||||
toast.error("ToDo konnte nicht gelöscht werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setModalBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup className="pt-0 group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel className="h-6 px-2 text-xs">Termine</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="mx-2 space-y-2 rounded-md border bg-card/70 p-2 shadow-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<CalendarDays className="size-3.5" />
|
||||
Heute
|
||||
</span>
|
||||
{overdueTodos.length > 0 ? <Badge variant="destructive">{overdueTodos.length} überfällig</Badge> : null}
|
||||
</div>
|
||||
|
||||
{hasTodos ? (
|
||||
<div className="space-y-1">
|
||||
{overdueTodos.slice(0, 3).map((todo) => (
|
||||
<button
|
||||
key={`overdue-${todo.id}`}
|
||||
type="button"
|
||||
className="block w-full rounded-md border border-red-500/45 bg-red-500/10 px-2 py-1.5 text-left hover:bg-red-500/15"
|
||||
onClick={() => onNavigate(`/tickets/${todo.ticket_id}`)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-[11px] font-semibold text-red-200">
|
||||
<AlertCircle className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{todo.title}</span>
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">{todo.ticket_number ?? "Ticket"} · {formatTodoPeriod(todo)}</span>
|
||||
</button>
|
||||
))}
|
||||
{overdueTodos.length > 3 ? <p className="px-1 text-[11px] text-red-200">+{overdueTodos.length - 3} weitere überfällig</p> : null}
|
||||
|
||||
{fixedTodos.slice(0, 3).map((todo) => (
|
||||
<button
|
||||
key={`fixed-${todo.id}`}
|
||||
type="button"
|
||||
className="block w-full rounded-md border bg-background/70 px-2 py-1.5 text-left hover:bg-muted"
|
||||
onClick={() => onNavigate(`/tickets/${todo.ticket_id}`)}
|
||||
>
|
||||
<span className="block truncate text-[11px] font-semibold">{todo.title}</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">{todo.ticket_number ?? "Ticket"} · {todo.organization_name ?? "Keine Organisation"}</span>
|
||||
</button>
|
||||
))}
|
||||
{fixedTodos.length > 3 ? <p className="px-1 text-[11px] text-muted-foreground">+{fixedTodos.length - 3} weitere feste Termine</p> : null}
|
||||
|
||||
{summarizedTodos.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full rounded-md border border-muted-foreground/20 bg-background/60 px-2 py-1.5 text-left text-[11px] font-medium text-muted-foreground hover:bg-muted"
|
||||
onClick={() => setRunningTodosOpen(true)}
|
||||
>
|
||||
+{summarizedTodos.length} laufende ToDo{summarizedTodos.length === 1 ? "" : "s"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-1 py-1 text-xs text-muted-foreground">Heute keine Termine.</p>
|
||||
)}
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<Dialog open={runningTodosOpen} onOpenChange={setRunningTodosOpen}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>ToDos am Tag</DialogTitle>
|
||||
<DialogDescription>Heute · {summarizedTodos.length} laufende ToDo(s)</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
{summarizedTodos.map((todo) => (
|
||||
<div key={todo.id} className={`rounded-md border p-2.5 ${isTodoOverdue(todo, today) ? sidebarOverdueTodoClass : sidebarTodoCalendarClasses[todo.priority]}`}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 truncate text-sm font-semibold">{todo.title}</span>
|
||||
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium ${sidebarTodoPriorityClasses[todo.priority]}`}>{sidebarTodoPriorityLabels[todo.priority]}</span>
|
||||
{isTodoOverdue(todo, today) ? <Badge variant="destructive">überfällig</Badge> : null}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground" title={todo.organization_name ?? "Keine Organisation"}>
|
||||
{todo.organization_name ?? "Keine Organisation"}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 pt-0.5 text-xs font-medium text-muted-foreground">{todo.ticket_number ?? "Ticket"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
{formatTodoPeriod(todo) ? <p className="text-xs text-muted-foreground">{formatTodoPeriod(todo)}</p> : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-1">
|
||||
<Button type="button" size="sm" variant="secondary" disabled={modalBusy} onClick={() => void toggleTodo(todo)}>
|
||||
<CheckCircle2 className="size-4" />
|
||||
Erledigt
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setRunningTodosOpen(false);
|
||||
onNavigate(`/tickets/${todo.ticket_id}`);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Ticket
|
||||
</Button>
|
||||
<Button type="button" size="icon" variant="ghost" className="size-8 text-destructive hover:text-destructive" disabled={modalBusy} onClick={() => void removeTodo(todo)}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{summarizedTodos.length === 0 ? (
|
||||
<p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine laufenden ToDos an diesem Tag.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const { path, route, navHandler, navigate } = useAppRouter();
|
||||
const { path, route, navigate: routerNavigate } = useAppRouter();
|
||||
const [currentUser, setCurrentUser] = useState<AuthUser | null>(null);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
const [setupRequired, setSetupRequired] = useState(false);
|
||||
@@ -546,7 +772,37 @@ export function App() {
|
||||
const [selectedTimerId, setSelectedTimerId] = useState<string | null>(null);
|
||||
const [tick, setTick] = useState(Date.now());
|
||||
const [openTickets, setOpenTickets] = useState<OpenTicketNavItem[]>([]);
|
||||
const [ticketReturnPaths, setTicketReturnPaths] = useState<Record<string, string>>({});
|
||||
const [draggedOpenTicketKey, setDraggedOpenTicketKey] = useState<string | null>(null);
|
||||
const [sidebarTodos, setSidebarTodos] = useState<TicketTodo[]>([]);
|
||||
|
||||
const navigate = useCallback(
|
||||
(to: string) => {
|
||||
const targetRoute = routeFromPath(to);
|
||||
|
||||
if (targetRoute.page === "ticket") {
|
||||
const currentRoute = routeFromPath(path);
|
||||
|
||||
if (!(currentRoute.page === "ticket" && currentRoute.ticketId === targetRoute.ticketId)) {
|
||||
setTicketReturnPaths((current) => ({
|
||||
...current,
|
||||
[targetRoute.ticketId]: path
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
routerNavigate(to);
|
||||
},
|
||||
[path, routerNavigate]
|
||||
);
|
||||
|
||||
const navHandler = useCallback(
|
||||
(to: string) => (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
navigate(to);
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadUser() {
|
||||
@@ -582,6 +838,7 @@ export function App() {
|
||||
setTimerOwnerId(null);
|
||||
setTimersLoaded(false);
|
||||
setOpenTickets([]);
|
||||
setSidebarTodos([]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -591,6 +848,7 @@ export function App() {
|
||||
setTimerOwnerId(currentUser.id);
|
||||
setTimersLoaded(true);
|
||||
setOpenTickets([]);
|
||||
setSidebarTodos([]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -628,6 +886,40 @@ export function App() {
|
||||
}
|
||||
}, [currentUser, timers, selectedTimerId, timersLoaded, timerOwnerId]);
|
||||
|
||||
const loadSidebarTodos = useCallback(async () => {
|
||||
if (!currentUser || currentUser.role === "admin") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getTodos("open");
|
||||
setSidebarTodos(result.todos);
|
||||
} catch {
|
||||
setSidebarTodos([]);
|
||||
}
|
||||
}, [currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser || currentUser.role === "admin") {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadSidebarTodos();
|
||||
|
||||
const interval = window.setInterval(() => void loadSidebarTodos(), 60_000);
|
||||
const onFocus = () => void loadSidebarTodos();
|
||||
const onTodosChanged = () => void loadSidebarTodos();
|
||||
|
||||
window.addEventListener("focus", onFocus);
|
||||
window.addEventListener("tickettracker:todos-changed", onTodosChanged);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
window.removeEventListener("tickettracker:todos-changed", onTodosChanged);
|
||||
};
|
||||
}, [currentUser, loadSidebarTodos]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser || currentUser.role === "admin" || !timersLoaded || timerOwnerId !== currentUser.id) {
|
||||
return;
|
||||
@@ -721,12 +1013,25 @@ export function App() {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ticketNumber = rawTicketNumber.trim();
|
||||
const ticketNumber = rawTicketNumber.trim();
|
||||
|
||||
if (!ticketPattern.test(ticketNumber)) {
|
||||
if (!ticketPattern.test(ticketNumber)) {
|
||||
toast.error("Ticketnummer prüfen", {
|
||||
description: "Das Format muss Ticket#XXXXXX sein."
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const [ticketResult, recurringResult] = await Promise.all([
|
||||
lookupTicket(ticketNumber).catch(() => ({ ticket: null })),
|
||||
getRecurringBillings().catch(() => ({ recurringBillings: [] }))
|
||||
]);
|
||||
|
||||
if (ticketHasRecurringBilling(ticketResult.ticket, recurringResult.recurringBillings) || isRecurringBillingTicketNumber(ticketNumber, recurringResult.recurringBillings)) {
|
||||
setTimers((current) => current.filter((timer) => timer.ticketNumber !== ticketNumber));
|
||||
toast.error("Fixe Abrechnung", {
|
||||
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gestartet werden. Bitte die Definition bearbeiten."
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -744,8 +1049,6 @@ export function App() {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ticketResult = await lookupTicket(ticketNumber).catch(() => ({ ticket: null }));
|
||||
|
||||
const newTimer: TimerEntry = {
|
||||
id: crypto.randomUUID(),
|
||||
ticketNumber,
|
||||
@@ -847,7 +1150,8 @@ export function App() {
|
||||
setOpenTickets((items) => items.filter((item) => item.key !== ticket.key));
|
||||
|
||||
if (pathWithoutSearch(path) === pathWithoutSearch(ticket.path)) {
|
||||
navigate("/analysis");
|
||||
const returnPath = current.returnPath && pathWithoutSearch(current.returnPath) !== pathWithoutSearch(ticket.path) ? current.returnPath : "/analysis";
|
||||
routerNavigate(returnPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -890,8 +1194,10 @@ export function App() {
|
||||
{ type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") }
|
||||
]
|
||||
: [
|
||||
{ type: "item" as const, href: "/dashboard", label: "Dashboard", icon: LayoutDashboard, active: path.startsWith("/dashboard") },
|
||||
{ type: "separator" as const, key: "after-dashboard" },
|
||||
{ type: "item" as const, href: "/timer", label: "Session starten", icon: Timer, active: path.startsWith("/timer") || path === "/" },
|
||||
{ type: "item" as const, href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") },
|
||||
{ type: "item" as const, href: "/analysis", label: "Ticket-Sessions", icon: Ticket, active: path.startsWith("/analysis") },
|
||||
{ type: "separator" as const, key: "after-analysis" },
|
||||
{ type: "item" as const, href: "/projects", label: "Projekte", icon: FolderKanban, active: path.startsWith("/projects") },
|
||||
{ type: "item" as const, href: "/todos", label: "ToDos", icon: ListTodo, active: path.startsWith("/todos") },
|
||||
@@ -988,10 +1294,12 @@ export function App() {
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarGroup>
|
||||
|
||||
{!isAdmin && openTickets.length > 0 ? (
|
||||
<SidebarGroup className="pt-0 group-data-[collapsible=icon]:hidden">
|
||||
{!isAdmin ? <SidebarTodayTodos todos={sidebarTodos} onNavigate={navigate} /> : null}
|
||||
|
||||
{!isAdmin && openTickets.length > 0 ? (
|
||||
<SidebarGroup className="pt-0 group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel className="h-6 px-2 text-xs">Geöffnete Tickets</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
@@ -1156,6 +1464,7 @@ export function App() {
|
||||
tick={tick}
|
||||
/>
|
||||
) : null}
|
||||
{!isAdmin && route.page === "dashboard" ? <DashboardPage onNavigate={navigate} /> : null}
|
||||
{!isAdmin && route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
|
||||
{!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null}
|
||||
{!isAdmin && route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null}
|
||||
@@ -1181,6 +1490,7 @@ export function App() {
|
||||
ticketId={route.ticketId}
|
||||
onNavigate={navigate}
|
||||
onOpenTicketChange={updateOpenTicket}
|
||||
returnPath={ticketReturnPaths[route.ticketId] ?? openTickets.find((ticket) => ticket.ticketId === route.ticketId)?.returnPath ?? "/analysis"}
|
||||
/>
|
||||
) : null}
|
||||
{isAdmin && route.page !== "admin-users" && route.page !== "profile" && route.page !== "faq" ? <AdminUsersPage currentUser={currentUser} /> : null}
|
||||
|
||||
+61
-2
@@ -2,6 +2,7 @@ import type {
|
||||
AdminUser,
|
||||
AuthUser,
|
||||
BillingStatus,
|
||||
DashboardPreference,
|
||||
Organization,
|
||||
PeriodOverview,
|
||||
PeriodType,
|
||||
@@ -100,6 +101,30 @@ export function updateCurrentUser(payload: {
|
||||
});
|
||||
}
|
||||
|
||||
export function getDashboardPreference() {
|
||||
return request<{ preference: Partial<DashboardPreference> }>("/api/dashboard/preferences");
|
||||
}
|
||||
|
||||
export function saveDashboardPreference(preference: DashboardPreference) {
|
||||
return request<{ preference: Partial<DashboardPreference> }>("/api/dashboard/preferences", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ preference })
|
||||
});
|
||||
}
|
||||
|
||||
export function addFavoriteTicket(ticketNumber: string) {
|
||||
return request<{ preference: Partial<DashboardPreference> }>("/api/dashboard/favorites", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ticketNumber })
|
||||
});
|
||||
}
|
||||
|
||||
export function removeFavoriteTicket(ticketNumber: string) {
|
||||
return request<{ preference: Partial<DashboardPreference> }>(`/api/dashboard/favorites/${encodeURIComponent(ticketNumber)}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
}
|
||||
|
||||
export function getSetupStatus() {
|
||||
return request<{ required: boolean }>("/api/setup/status");
|
||||
}
|
||||
@@ -294,8 +319,14 @@ export function deleteRecurringBilling(billingId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function getProjects() {
|
||||
return request<{ projects: ProjectSummary[] }>("/api/projects");
|
||||
export function getProjects(includeClosed = false) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (includeClosed) {
|
||||
params.set("includeClosed", "true");
|
||||
}
|
||||
|
||||
return request<{ projects: ProjectSummary[] }>(`/api/projects${params.size ? `?${params}` : ""}`);
|
||||
}
|
||||
|
||||
export function getProject(projectId: string) {
|
||||
@@ -312,6 +343,7 @@ export function createProject(payload: {
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
hourBudgetMinutes?: number | null;
|
||||
manualConsumptionMinutes?: number | null;
|
||||
}) {
|
||||
return request<{ project: ProjectSummary }>("/api/projects", {
|
||||
method: "POST",
|
||||
@@ -331,6 +363,7 @@ export function updateProject(
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
hourBudgetMinutes?: number | null;
|
||||
manualConsumptionMinutes?: number | null;
|
||||
}
|
||||
) {
|
||||
return request<{ project: ProjectSummary }>(`/api/projects/${projectId}`, {
|
||||
@@ -339,6 +372,19 @@ export function updateProject(
|
||||
});
|
||||
}
|
||||
|
||||
export function updateProjectStatus(projectId: string, closed: boolean) {
|
||||
return request<{ project: { id: string; ticket_id: string; closed_at: string | null } }>(`/api/projects/${projectId}/status`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ closed })
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteProject(projectId: string) {
|
||||
return request<{ deleted: { id: string; ticket_id: string } }>(`/api/projects/${projectId}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
}
|
||||
|
||||
export function getTodos(status?: TodoStatus | "all") {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -355,6 +401,8 @@ export function createTicketTodo(
|
||||
title: string;
|
||||
description: string;
|
||||
priority: TodoPriority;
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
}
|
||||
) {
|
||||
return request<{ todo: TicketTodo }>(`/api/tickets/${ticketId}/todos`, {
|
||||
@@ -370,6 +418,8 @@ export function updateTodo(
|
||||
description: string;
|
||||
priority: TodoPriority;
|
||||
status: TodoStatus;
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
}
|
||||
) {
|
||||
return request<{ todo: TicketTodo }>(`/api/todos/${todoId}`, {
|
||||
@@ -378,6 +428,13 @@ export function updateTodo(
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTodoOrder(todoIds: string[]) {
|
||||
return request<{ ok: boolean }>("/api/todos/order", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ todoIds })
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTodo(todoId: string) {
|
||||
return request<{ deleted: { id: string } }>(`/api/todos/${todoId}`, {
|
||||
method: "DELETE"
|
||||
@@ -406,6 +463,7 @@ export function updateTicket(
|
||||
title?: string | null;
|
||||
organizationId: string;
|
||||
workType: WorkType;
|
||||
removeProjectData?: boolean;
|
||||
}
|
||||
) {
|
||||
return request<{ ticket: TicketMeta }>(`/api/tickets/${ticketId}`, {
|
||||
@@ -420,6 +478,7 @@ export function updateSessionDetails(
|
||||
organizationId: string;
|
||||
activity: string;
|
||||
workType: WorkType;
|
||||
removeProjectData?: boolean;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
@import "../styles/presets/soft-pop.css";
|
||||
@import "../styles/presets/tangerine.css";
|
||||
|
||||
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
@@ -50,6 +52,8 @@
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: var(--font-sans);
|
||||
--breakpoint-xxl: 108.125rem;
|
||||
|
||||
}
|
||||
|
||||
/* Default theme styles (used when no data-theme-preset is set or when 'default' is selected).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CheckCircle2, Circle, Pencil, Plus, Save, Trash2 } from "lucide-react";
|
||||
import { CalendarDays, CheckCircle2, Circle, GripVertical, Pencil, Plus, Save, Trash2, X } from "lucide-react";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -9,6 +9,8 @@ import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createTicketTodo, deleteTodo, updateTodo } from "../api";
|
||||
import { currentDay } from "../format";
|
||||
import { formatTodoPeriod, isTodoOverdue } from "../todos";
|
||||
import type { TicketTodo, TodoPriority, TodoStatus } from "../types";
|
||||
|
||||
const priorityLabels: Record<TodoPriority, string> = {
|
||||
@@ -25,6 +27,10 @@ const priorityClasses: Record<TodoPriority, string> = {
|
||||
urgent: "border-red-500/30 bg-red-500/10 text-red-300"
|
||||
};
|
||||
|
||||
function notifyTodosChanged() {
|
||||
window.dispatchEvent(new Event("tickettracker:todos-changed"));
|
||||
}
|
||||
|
||||
type TodoPanelProps = {
|
||||
ticketId?: string;
|
||||
todos: TicketTodo[];
|
||||
@@ -32,19 +38,26 @@ type TodoPanelProps = {
|
||||
showCreate?: boolean;
|
||||
showTicketMeta?: boolean;
|
||||
onNavigate?: (to: string) => void;
|
||||
reorderEnabled?: boolean;
|
||||
onReorder?: (draggedTodoId: string, targetTodoId: string) => void;
|
||||
};
|
||||
|
||||
export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate }: TodoPanelProps) {
|
||||
export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate, reorderEnabled = false, onReorder }: TodoPanelProps) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [priority, setPriority] = useState<TodoPriority>("medium");
|
||||
const [startsOn, setStartsOn] = useState("");
|
||||
const [endsOn, setEndsOn] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editPriority, setEditPriority] = useState<TodoPriority>("medium");
|
||||
const [editStartsOn, setEditStartsOn] = useState("");
|
||||
const [editEndsOn, setEditEndsOn] = useState("");
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
|
||||
async function submitTodo(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
@@ -53,15 +66,25 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
||||
return;
|
||||
}
|
||||
|
||||
if (startsOn && endsOn && endsOn < startsOn) {
|
||||
toast.error("Zeitraum prüfen", {
|
||||
description: "Das Bis-Datum darf nicht vor dem Von-Datum liegen."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await createTicketTodo(ticketId, { title, description, priority });
|
||||
await createTicketTodo(ticketId, { title, description, priority, startsOn: startsOn || null, endsOn: endsOn || null });
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setPriority("medium");
|
||||
setStartsOn("");
|
||||
setEndsOn("");
|
||||
setCreateOpen(false);
|
||||
toast.success("ToDo angelegt");
|
||||
await onChanged();
|
||||
notifyTodosChanged();
|
||||
} catch (error) {
|
||||
toast.error("ToDo konnte nicht angelegt werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
@@ -76,19 +99,31 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
||||
setEditTitle(todo.title);
|
||||
setEditDescription(todo.description);
|
||||
setEditPriority(todo.priority);
|
||||
setEditStartsOn(todo.starts_on ?? "");
|
||||
setEditEndsOn(todo.ends_on ?? "");
|
||||
}
|
||||
|
||||
async function saveTodo(todo: TicketTodo, status: TodoStatus = todo.status) {
|
||||
if (editingId === todo.id && editStartsOn && editEndsOn && editEndsOn < editStartsOn) {
|
||||
toast.error("Zeitraum prüfen", {
|
||||
description: "Das Bis-Datum darf nicht vor dem Von-Datum liegen."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setBusyId(todo.id);
|
||||
try {
|
||||
await updateTodo(todo.id, {
|
||||
title: editingId === todo.id ? editTitle : todo.title,
|
||||
description: editingId === todo.id ? editDescription : todo.description,
|
||||
priority: editingId === todo.id ? editPriority : todo.priority,
|
||||
status
|
||||
status,
|
||||
startsOn: editingId === todo.id ? editStartsOn || null : todo.starts_on,
|
||||
endsOn: editingId === todo.id ? editEndsOn || null : todo.ends_on
|
||||
});
|
||||
setEditingId(null);
|
||||
await onChanged();
|
||||
notifyTodosChanged();
|
||||
} catch (error) {
|
||||
toast.error("ToDo konnte nicht gespeichert werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
@@ -110,6 +145,7 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
||||
await deleteTodo(todo.id);
|
||||
toast.success("ToDo gelöscht");
|
||||
await onChanged();
|
||||
notifyTodosChanged();
|
||||
} catch (error) {
|
||||
toast.error("ToDo konnte nicht gelöscht werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
@@ -159,6 +195,34 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor={`todo-starts-on-${ticketId}`}>
|
||||
Umsetzung von
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input id={`todo-starts-on-${ticketId}`} type="date" value={startsOn} onChange={(event) => setStartsOn(event.currentTarget.value)} />
|
||||
{startsOn ? (
|
||||
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setStartsOn("")} aria-label="Von-Datum entfernen">
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor={`todo-ends-on-${ticketId}`}>
|
||||
Umsetzung bis
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input id={`todo-ends-on-${ticketId}`} type="date" value={endsOn} onChange={(event) => setEndsOn(event.currentTarget.value)} />
|
||||
{endsOn ? (
|
||||
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setEndsOn("")} aria-label="Bis-Datum entfernen">
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor={`todo-description-${ticketId}`}>
|
||||
Details
|
||||
@@ -185,9 +249,40 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
||||
<div className="space-y-2">
|
||||
{todos.map((todo) => {
|
||||
const isEditing = editingId === todo.id;
|
||||
const periodLabel = formatTodoPeriod(todo);
|
||||
const overdue = isTodoOverdue(todo, currentDay());
|
||||
|
||||
return (
|
||||
<div key={todo.id} className={cn("rounded-md border bg-card/50 p-3", todo.status === "done" && "opacity-70")}>
|
||||
<div
|
||||
key={todo.id}
|
||||
className={cn(
|
||||
"rounded-md border bg-card/50 p-3 transition-colors",
|
||||
overdue && "border-red-500/50 bg-red-500/10",
|
||||
todo.status === "done" && "opacity-70",
|
||||
reorderEnabled && draggingId === todo.id && "border-primary/50 bg-primary/5"
|
||||
)}
|
||||
onDragOver={
|
||||
reorderEnabled
|
||||
? (event) => {
|
||||
event.preventDefault();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDrop={
|
||||
reorderEnabled
|
||||
? (event) => {
|
||||
event.preventDefault();
|
||||
const draggedTodoId = event.dataTransfer.getData("text/todo-id") || draggingId;
|
||||
|
||||
if (draggedTodoId && draggedTodoId !== todo.id) {
|
||||
onReorder?.(draggedTodoId, todo.id);
|
||||
}
|
||||
|
||||
setDraggingId(null);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{showTicketMeta ? (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<button type="button" className="font-medium text-foreground hover:underline" onClick={() => onNavigate?.(`/tickets/${todo.ticket_id}`)}>
|
||||
@@ -202,6 +297,24 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
||||
<div className="space-y-2">
|
||||
<Input value={editTitle} onChange={(event) => setEditTitle(event.currentTarget.value)} />
|
||||
<Textarea className="min-h-16" value={editDescription} onChange={(event) => setEditDescription(event.currentTarget.value)} />
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="flex gap-2">
|
||||
<Input type="date" value={editStartsOn} onChange={(event) => setEditStartsOn(event.currentTarget.value)} />
|
||||
{editStartsOn ? (
|
||||
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setEditStartsOn("")} aria-label="Von-Datum entfernen">
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input type="date" value={editEndsOn} onChange={(event) => setEditEndsOn(event.currentTarget.value)} />
|
||||
{editEndsOn ? (
|
||||
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setEditEndsOn("")} aria-label="Bis-Datum entfernen">
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<select
|
||||
className="h-8 rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
@@ -227,19 +340,44 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-start gap-2 text-left"
|
||||
disabled={busyId === todo.id}
|
||||
onClick={() => void saveTodo(todo, todo.status === "done" ? "open" : "done")}
|
||||
>
|
||||
{todo.status === "done" ? <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-400" /> : <Circle className="mt-0.5 size-4 shrink-0 text-muted-foreground" />}
|
||||
<span className="min-w-0">
|
||||
<span className={cn("block break-words text-sm font-medium", todo.status === "done" && "line-through")}>{todo.title}</span>
|
||||
{todo.description ? <span className="mt-1 block whitespace-pre-wrap break-words text-xs text-muted-foreground">{todo.description}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||
{reorderEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 flex size-6 shrink-0 cursor-grab items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground active:cursor-grabbing"
|
||||
aria-label="ToDo verschieben"
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/todo-id", todo.id);
|
||||
setDraggingId(todo.id);
|
||||
}}
|
||||
onDragEnd={() => setDraggingId(null)}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-start gap-2 text-left"
|
||||
disabled={busyId === todo.id}
|
||||
onClick={() => void saveTodo(todo, todo.status === "done" ? "open" : "done")}
|
||||
>
|
||||
{todo.status === "done" ? <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-400" /> : <Circle className="mt-0.5 size-4 shrink-0 text-muted-foreground" />}
|
||||
<span className="min-w-0">
|
||||
<span className={cn("block break-words text-sm font-medium", todo.status === "done" && "line-through")}>{todo.title}</span>
|
||||
{periodLabel ? (
|
||||
<span className={cn("mt-1 flex items-center gap-1 text-xs", overdue ? "text-red-300" : "text-muted-foreground")}>
|
||||
<CalendarDays className="size-3.5" />
|
||||
{periodLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{todo.description ? <span className="mt-1 block whitespace-pre-wrap break-words text-xs text-muted-foreground">{todo.description}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{overdue ? <Badge variant="destructive">überfällig</Badge> : null}
|
||||
<span className={cn("rounded-full border px-2 py-0.5 text-xs font-medium", priorityClasses[todo.priority])}>{priorityLabels[todo.priority]}</span>
|
||||
<Button type="button" size="icon" variant="ghost" className="size-8" onClick={() => startEdit(todo)}>
|
||||
<Pencil className="size-4" />
|
||||
|
||||
@@ -41,8 +41,8 @@ export const helpSections: HelpSection[] = [
|
||||
"Während der Arbeit läuft genau ein Timer aktiv. Wechselst du zu einem anderen Timer, pausiert TicketTracker die übrigen Timer automatisch.",
|
||||
"Wenn die Arbeit erledigt ist, beendest du den Timer. Die Zeit stoppt sofort beim Klick auf Beenden; das anschließende Ausfüllen der Tätigkeit zählt nicht mehr zur Sessionzeit.",
|
||||
"Im Beenden-Dialog trägst du die Tätigkeit ein. Wenn das Ticket bereits bekannt ist, übernimmt TicketTracker Organisation und Art automatisch. Bei neuen Tickets wählst du Organisation und Art zusätzlich aus.",
|
||||
"Vergessene Sessions trägst du auf der Seite Session starten im Bereich Session nachtragen nach. Wenn das Ticket bereits in der Auswertung geöffnet ist, nutzt du in der Ticketdetailansicht ebenfalls Session nachtragen; dann ist die Zuordnung zum Ticket bereits vorbelegt.",
|
||||
"Unter Auswertung wählst du Monat oder Tag, öffnest ein Ticket über Öffnen und siehst dort alle Sessions nach Tag gruppiert.",
|
||||
"Vergessene Sessions trägst du auf der Seite Session starten im Bereich Session nachtragen nach. Wenn das Ticket bereits in der Ticketliste geöffnet ist, nutzt du in der Ticketdetailansicht ebenfalls Session nachtragen; dann ist die Zuordnung zum Ticket bereits vorbelegt.",
|
||||
"Unter Tickets wählst du Monat oder Tag, öffnest ein Ticket über Öffnen und siehst dort alle Sessions nach Tag gruppiert.",
|
||||
"Im Ticket bewertest du jede Session mit Abgerechnet oder Nicht abrechenbar. Ein erneuter Klick auf den aktiven Status nimmt die Bewertung zurück.",
|
||||
"Im Ticket steht pro Tag das Feld Teamspace. Dort trägst du die Stunden ein, die du für genau dieses Ticket an genau diesem Tag wirklich im CRM beziehungsweise Teamspace erfasst hast. Nach einer Änderung erscheint der Speichern-Button.",
|
||||
"Die Teamspace-Zeit ist ein Tageswert pro Ticket und Benutzer. Monats- und Tagesansicht zeigen denselben Wert; er muss also nur einmal gepflegt werden.",
|
||||
@@ -58,7 +58,7 @@ export const helpSections: HelpSection[] = [
|
||||
"Arbeit erledigen; bei Bedarf zwischen vorbereiteten Timern wechseln.",
|
||||
"Timer beenden und Tätigkeit speichern.",
|
||||
"Vergessene Zeiten über Session nachtragen ergänzen.",
|
||||
"Auswertung öffnen, Monat wählen und Tickets prüfen.",
|
||||
"Tickets öffnen, Monat wählen und Einträge prüfen.",
|
||||
"Ticket öffnen, Sessions pro Tag bewerten und Teamspace-Tageswerte speichern.",
|
||||
"Monatsabschluss öffnen, offene Bewertungen und Teamspace-Prüfpunkte abarbeiten.",
|
||||
"Teamspace-Summe mit dem CRM vergleichen.",
|
||||
@@ -72,7 +72,7 @@ export const helpSections: HelpSection[] = [
|
||||
"Tätigkeit eintragen",
|
||||
"Session wird dem Ticket und Benutzer gespeichert",
|
||||
"Optional: vergessene Session nachtragen",
|
||||
"Auswertung öffnen und Ticket prüfen",
|
||||
"Tickets öffnen und Ticket prüfen",
|
||||
"Sessions bewerten",
|
||||
"Teamspace pro Ticket-Tag eintragen",
|
||||
"Monatsabschluss öffnen",
|
||||
@@ -176,8 +176,8 @@ export const helpSections: HelpSection[] = [
|
||||
},
|
||||
{
|
||||
id: "auswertung",
|
||||
title: "Auswertung",
|
||||
description: "Die Auswertung ist die Arbeitsliste für Monat und Tag: Tickets öffnen, Sessions prüfen, sortieren und bewerten.",
|
||||
title: "Tickets",
|
||||
description: "Tickets ist die Arbeitsliste für Monat und Tag: Tickets öffnen, Sessions prüfen, sortieren und bewerten.",
|
||||
quickItems: [
|
||||
"Monat oder Tag wählen; die Ansicht aktualisiert automatisch.",
|
||||
"Tickets können gefiltert, gruppiert und sortiert werden.",
|
||||
@@ -250,8 +250,14 @@ export const helpSections: HelpSection[] = [
|
||||
"Der Navipunkt Projekte ist für Consulting-Arbeiten gedacht, die länger laufen oder ein Stundenkontingent haben.",
|
||||
"Beim Anlegen gibst du die Ticketnummer ein. Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Stammdaten automatisch.",
|
||||
"Existiert das Ticket noch nicht, muss eine Organisation aus der synchronisierten Organisationsliste gewählt werden.",
|
||||
"Ein Projekt-Ticket bleibt ein normales Ticket. Es kann über Suche, Auswertung, geöffnete Tickets und ToDos geöffnet werden.",
|
||||
"Zusätzlich speicherst du Teamspace-Projekt, Beschreibung, Ziele, ungefähren Zeitraum und gesamtes Stundenkontingent.",
|
||||
"Ein Projekt-Ticket bleibt ein normales Ticket. Es kann über Suche, Tickets, geöffnete Tickets und ToDos geöffnet werden.",
|
||||
"Zusätzlich speicherst du Teamspace-Projekt, Beschreibung/Ziele, ungefähren Zeitraum und gesamtes Stundenkontingent.",
|
||||
"Der Projektverbrauch wird nur aus gepflegten Teamspace-Tageswerten und einem optionalen manuellen Projektverbrauch berechnet. Getrackte Sessionzeiten zählen nicht in den Projektverbrauch.",
|
||||
"Der manuelle Projektverbrauch ist für Altbestände oder einmalige Vorbuchungen gedacht und wirkt ausschließlich im Projektverbrauch. Er erscheint nicht in Auswertungen, Statistiken, CSV-Export oder Monatsabschluss.",
|
||||
"Projekte können offen oder geschlossen sein. Standardmäßig zeigt die Projektseite offene Projekte; geschlossene können zusätzlich eingeblendet werden.",
|
||||
"Auf der Projektseite kannst du Projekte nach Freitext, Status, Organisation, Kontingentstatus und ToDos filtern sowie nach Ticket, Titel, Organisation, Teamspace-ID, Verbrauch, Reststunden, Kontingent oder ToDos sortieren. Diese Einstellungen bleiben im Browser gespeichert.",
|
||||
"Reststunden werden aus dem Stundenkontingent minus Projektverbrauch berechnet. Gibt es kein Kontingent, kann TicketTracker keinen offenen Rest berechnen.",
|
||||
"Offene Projekte stehen immer vor geschlossenen Projekten. Der Projektstatus ändert nicht die Anzeige der Sessions im Ticket.",
|
||||
"Projekt-Tickets sind immer Consulting. Auch wenn Sessions später über den normalen Timer oder das Nachtragen erfasst werden, erkennt TicketTracker das Projekt und übernimmt Consulting.",
|
||||
"Aus einem Projekt heraus kann direkt eine Session gestartet werden. Der Timer verhält sich wie jeder andere Timer und pausiert andere Timer automatisch."
|
||||
]
|
||||
@@ -263,11 +269,22 @@ export const helpSections: HelpSection[] = [
|
||||
quickItems: [
|
||||
"ToDos werden direkt im Ticket angelegt.",
|
||||
"Die zentrale ToDo-Seite sammelt alle offenen Aufgaben.",
|
||||
"Prioritäten helfen beim Sortieren der nächsten Schritte."
|
||||
"Prioritäten und optionale Umsetzungszeiträume helfen beim Sortieren der nächsten Schritte."
|
||||
],
|
||||
details: [
|
||||
"Jedes Ticket kann ToDos enthalten, egal ob es ein Projekt oder ein normales Einzelticket ist.",
|
||||
"In der Ticketdetailansicht legst du neue Aufgaben mit Titel, optionaler Beschreibung und Priorität an.",
|
||||
"In der Ticketdetailansicht legst du neue Aufgaben mit Titel, optionaler Beschreibung, Priorität und optionalem Umsetzungzeitraum an.",
|
||||
"Wenn Umsetzung von und Umsetzung bis gleich sind, ist das ToDo ein fester Termin an diesem Tag.",
|
||||
"Die zentrale Seite ToDos zeigt zusätzlich Heutige ToDos. Dort erscheinen nur offene Aufgaben, bei denen Umsetzung von und Umsetzung bis beide auf heute stehen.",
|
||||
"Die Aufgabenliste kann nach Status und Ticketnummer gefiltert, nach Kriterien sortiert oder per Drag and Drop frei sortiert werden.",
|
||||
"Wenn du eine Aufgabe ziehst, wird die Sortierung automatisch auf Benutzerdefiniert gestellt und dauerhaft gespeichert.",
|
||||
"Optional kannst du bis zu drei Gruppierungen in Reihenfolge setzen, zum Beispiel erst Ticket, dann Priorität, dann Kunde. Filter, Sortierung und Gruppierung bleiben im Browser gespeichert.",
|
||||
"In der linken Navigation zeigt die Kachel Termine die festen heutigen ToDos, laufende ToDos als Summe und überfällige Aufgaben.",
|
||||
"Unter der Aufgabenliste zeigt der ToDo-Kalender offene ToDos im Monatsüberblick. Erledigte ToDos werden dort ausgeblendet; Wochenenden und bayerische Feiertage bleiben ohne ToDo-Einträge.",
|
||||
"Ein-Tages-ToDos werden einzeln und farblich nach Priorität angezeigt. Zeiträume, offene Von-Daten und Bis-Daten werden pro Tag nur als Summe angezeigt.",
|
||||
"Bei Nur-Von läuft die Summe ab diesem Datum weiter, bis das ToDo erledigt, gelöscht oder mit einem Bis-Datum begrenzt wird. Bei Nur-Bis zählt der Kalender ab heute bis zum Bis-Datum.",
|
||||
"Ein offenes ToDo ist überfällig, wenn Umsetzung bis vor heute liegt. Im Kalender bleibt es ab dem Tag nach der Deadline an jedem Arbeitstag als roter +X üf-Badge sichtbar, bis es erledigt oder gelöscht wird.",
|
||||
"Ein Klick auf ein einzelnes ToDo öffnet die Bearbeitung. Eine Tagessumme öffnet eine kompakte Tagesliste; daraus kannst du ein ToDo separat öffnen, erledigen, löschen oder das Ticket öffnen.",
|
||||
"Die Prioritäten Niedrig, Normal, Hoch und Dringend helfen dabei, wichtige Punkte nicht zwischen Sessions zu verlieren.",
|
||||
"ToDos lassen sich als erledigt markieren, wieder öffnen, bearbeiten oder löschen.",
|
||||
"Die zentrale Seite ToDos zeigt Aufgaben aus allen deinen Tickets. Von dort öffnest du direkt das zugehörige Ticket.",
|
||||
@@ -288,7 +305,7 @@ export const helpSections: HelpSection[] = [
|
||||
"Die Art ist bei manuellen Sessions änderbar. Bei wiederkehrenden Sessions ist sie gesperrt und kommt aus der Regel.",
|
||||
"Die Dauer wird aus Von und Bis neu berechnet und wieder auf Minuten gerundet.",
|
||||
"Beim Löschen wird geprüft, ob dadurch ein Ticket für dich oder komplett leer wird. Leere Tickets werden entfernt.",
|
||||
"Wenn die letzte Session eines Tickets gelöscht wird, springt die Ansicht zurück zur Auswertung.",
|
||||
"Wenn die letzte Session eines Tickets gelöscht wird, springt die Ansicht zurück zur Ticketliste.",
|
||||
"Wiederkehrend erzeugte Sessions werden beim Löschen als Ausnahme gespeichert. Dadurch erscheinen sie beim nächsten Öffnen des Monats nicht wieder automatisch.",
|
||||
"Wenn eine wiederkehrende Leistung nur an einem anderen Tag erledigt wurde, wird die erzeugte Session bearbeitet beziehungsweise verschoben."
|
||||
]
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ProjectSummary, RecurringBilling, TicketMeta, TicketPeriod } from "./types";
|
||||
|
||||
type RecurringAwareTicket = (TicketMeta | TicketPeriod["ticket"]) & {
|
||||
recurring_session_count?: number;
|
||||
};
|
||||
|
||||
export function isGeneratedRecurringTicketNumber(ticketNumber: string | null | undefined) {
|
||||
return Boolean(ticketNumber?.startsWith("Fix#"));
|
||||
}
|
||||
|
||||
export function recurringBillingTicketNumbers(recurringBillings: RecurringBilling[]) {
|
||||
const numbers = new Set<string>();
|
||||
|
||||
for (const billing of recurringBillings) {
|
||||
numbers.add(billing.ticket_number);
|
||||
|
||||
if (billing.configured_ticket_number) {
|
||||
numbers.add(billing.configured_ticket_number);
|
||||
}
|
||||
}
|
||||
|
||||
return numbers;
|
||||
}
|
||||
|
||||
export function isRecurringBillingTicketNumber(ticketNumber: string | null | undefined, recurringBillings: RecurringBilling[] = []) {
|
||||
if (!ticketNumber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isGeneratedRecurringTicketNumber(ticketNumber) || recurringBillingTicketNumbers(recurringBillings).has(ticketNumber);
|
||||
}
|
||||
|
||||
export function ticketHasRecurringBilling(ticket: RecurringAwareTicket | null | undefined, recurringBillings: RecurringBilling[] = []) {
|
||||
return Boolean(ticket?.has_recurring_billing || Number(ticket?.recurring_session_count ?? 0) > 0 || isRecurringBillingTicketNumber(ticket?.ticket_number, recurringBillings));
|
||||
}
|
||||
|
||||
export function projectHasRecurringBilling(project: ProjectSummary) {
|
||||
return Boolean(project.has_recurring_billing || Number(project.recurring_session_count ?? 0) > 0 || isGeneratedRecurringTicketNumber(project.ticket_number));
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { TicketTodo } from "./types";
|
||||
|
||||
function parseDateOnly(value: string) {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
export function formatDateOnly(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function formatTodoDate(value: string) {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric"
|
||||
}).format(parseDateOnly(value));
|
||||
}
|
||||
|
||||
export function formatTodoPeriod(todo: Pick<TicketTodo, "starts_on" | "ends_on">) {
|
||||
if (!todo.starts_on && !todo.ends_on) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (todo.starts_on && todo.ends_on && todo.starts_on === todo.ends_on) {
|
||||
return `am ${formatTodoDate(todo.starts_on)}`;
|
||||
}
|
||||
|
||||
if (todo.starts_on && todo.ends_on) {
|
||||
return `${formatTodoDate(todo.starts_on)} - ${formatTodoDate(todo.ends_on)}`;
|
||||
}
|
||||
|
||||
if (todo.starts_on) {
|
||||
return `ab ${formatTodoDate(todo.starts_on)}`;
|
||||
}
|
||||
|
||||
return `bis ${formatTodoDate(todo.ends_on!)}`;
|
||||
}
|
||||
|
||||
export function isTodoActiveOnDay(todo: Pick<TicketTodo, "starts_on" | "ends_on" | "status">, day: string) {
|
||||
if (todo.status !== "open") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!todo.starts_on && !todo.ends_on) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (!todo.starts_on || todo.starts_on <= day) && (!todo.ends_on || todo.ends_on >= day);
|
||||
}
|
||||
|
||||
export function isTodoFixedOnDay(todo: Pick<TicketTodo, "starts_on" | "ends_on" | "status">, day: string) {
|
||||
return todo.status === "open" && todo.starts_on === day && todo.ends_on === day;
|
||||
}
|
||||
|
||||
export function isTodoOverdue(todo: Pick<TicketTodo, "ends_on" | "status">, today: string) {
|
||||
return todo.status === "open" && Boolean(todo.ends_on) && todo.ends_on! < today;
|
||||
}
|
||||
@@ -12,6 +12,32 @@ export type AuthUser = {
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export type DashboardWidgetType = "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes";
|
||||
export type DashboardSortDirection = "asc" | "desc";
|
||||
|
||||
export type DashboardFavorite = {
|
||||
ticketNumber: string;
|
||||
addedAt: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type DashboardWidget = {
|
||||
id: string;
|
||||
type: DashboardWidgetType;
|
||||
title?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DashboardPreference = {
|
||||
version: number;
|
||||
widgets: DashboardWidget[];
|
||||
favorites: DashboardFavorite[];
|
||||
};
|
||||
|
||||
export type AdminUser = AuthUser & {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -64,6 +90,7 @@ export type TicketMeta = {
|
||||
project_id?: string | null;
|
||||
is_project?: boolean;
|
||||
project_name?: string | null;
|
||||
has_recurring_billing?: boolean;
|
||||
};
|
||||
|
||||
export type TicketSearchResult = {
|
||||
@@ -98,11 +125,19 @@ export type ProjectSummary = {
|
||||
starts_on: string | null;
|
||||
ends_on: string | null;
|
||||
hour_budget_minutes: number | null;
|
||||
manual_consumption_minutes: number;
|
||||
teamspace_consumption_minutes?: number;
|
||||
consumption_minutes?: number;
|
||||
closed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
session_count?: number;
|
||||
recurring_session_count?: number;
|
||||
manual_session_count?: number;
|
||||
has_recurring_billing?: boolean;
|
||||
billed_minutes?: number;
|
||||
open_todos?: number;
|
||||
overdue_todos?: number;
|
||||
done_todos?: number;
|
||||
};
|
||||
|
||||
@@ -120,6 +155,9 @@ export type TicketTodo = {
|
||||
description: string;
|
||||
priority: TodoPriority;
|
||||
status: TodoStatus;
|
||||
sort_order: number;
|
||||
starts_on: string | null;
|
||||
ends_on: string | null;
|
||||
completed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -133,6 +171,7 @@ export type TicketSummary = {
|
||||
organization_name: string | null;
|
||||
customer_name: string | null;
|
||||
work_type: WorkType | null;
|
||||
project_id: string | null;
|
||||
session_count: number;
|
||||
total_minutes: number;
|
||||
non_billable_minutes: number;
|
||||
@@ -159,6 +198,7 @@ export type OpenSession = {
|
||||
work_type: WorkType;
|
||||
started_at: string;
|
||||
rounded_minutes: number;
|
||||
recurring_billing_id?: string | null;
|
||||
};
|
||||
|
||||
export type ActivityBucket = {
|
||||
@@ -239,6 +279,7 @@ export type TicketPeriod = {
|
||||
requires_closure: boolean;
|
||||
recurring_session_count: number;
|
||||
manual_session_count: number;
|
||||
has_recurring_billing?: boolean;
|
||||
};
|
||||
sessions: SessionEntry[];
|
||||
dayBillings: TicketDayBilling[];
|
||||
|
||||
@@ -19,13 +19,6 @@ type AnalysisPageProps = {
|
||||
onNavigate: (to: string) => void;
|
||||
};
|
||||
|
||||
type TimelineBucket = {
|
||||
key: string;
|
||||
label: string;
|
||||
totalMinutes: number;
|
||||
sessionCount: number;
|
||||
};
|
||||
|
||||
type TicketGroupBy = "none" | "workType" | "organization" | "status";
|
||||
type TicketSortBy = "ticket" | "workType" | "organization" | "status";
|
||||
type TicketSortDirection = "asc" | "desc";
|
||||
@@ -224,82 +217,6 @@ function groupTicketSections(tickets: TicketSummary[], groupBy: TicketGroupBy):
|
||||
});
|
||||
}
|
||||
|
||||
function buildChartPath(points: Array<{ x: number; y: number }>) {
|
||||
if (points.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (points.length === 1) {
|
||||
return `M ${points[0].x - 18} ${points[0].y} L ${points[0].x + 18} ${points[0].y}`;
|
||||
}
|
||||
|
||||
return points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" ");
|
||||
}
|
||||
|
||||
function niceCeilMinutes(value: number) {
|
||||
if (value <= 30) {
|
||||
return Math.max(5, Math.ceil(value / 5) * 5);
|
||||
}
|
||||
|
||||
if (value <= 120) {
|
||||
return Math.ceil(value / 15) * 15;
|
||||
}
|
||||
|
||||
if (value <= 480) {
|
||||
return Math.ceil(value / 30) * 30;
|
||||
}
|
||||
|
||||
return Math.ceil(value / 60) * 60;
|
||||
}
|
||||
|
||||
function formatAxisMinutes(value: number) {
|
||||
const minutes = Math.round(value);
|
||||
|
||||
if (minutes === 0) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
if (minutes >= 60) {
|
||||
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: minutes < 600 ? 1 : 0 }).format(minutes / 60)} h`;
|
||||
}
|
||||
|
||||
return `${minutes} min`;
|
||||
}
|
||||
|
||||
function buildTimeline(periodType: PeriodType, period: string, overview: PeriodOverview | null): TimelineBucket[] {
|
||||
const series = new Map((overview?.activitySeries ?? []).map((bucket) => [bucket.bucket_key, bucket]));
|
||||
|
||||
if (periodType === "day") {
|
||||
return Array.from({ length: 24 }).map((_, hour) => {
|
||||
const key = String(hour).padStart(2, "0");
|
||||
const bucket = series.get(key);
|
||||
|
||||
return {
|
||||
key,
|
||||
label: `${key}:00`,
|
||||
totalMinutes: bucket?.total_minutes ?? 0,
|
||||
sessionCount: bucket?.session_count ?? 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const [year, month] = period.split("-").map(Number);
|
||||
const daysInMonth = Number.isFinite(year) && Number.isFinite(month) ? new Date(year, month, 0).getDate() : 31;
|
||||
|
||||
return Array.from({ length: daysInMonth }).map((_, index) => {
|
||||
const day = String(index + 1).padStart(2, "0");
|
||||
const key = `${period}-${day}`;
|
||||
const bucket = series.get(key);
|
||||
|
||||
return {
|
||||
key,
|
||||
label: day,
|
||||
totalMinutes: bucket?.total_minutes ?? 0,
|
||||
sessionCount: bucket?.session_count ?? 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
const [periodType, setPeriodType] = useState<PeriodType>("month");
|
||||
const [period, setPeriod] = useState(currentMonth());
|
||||
@@ -325,7 +242,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId === loadRequestId.current) {
|
||||
toast.error("Auswertung konnte nicht geladen werden", {
|
||||
toast.error("Tickets konnten nicht geladen werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
}
|
||||
@@ -458,37 +375,11 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
ticketViewSettings.sortBy !== "ticket",
|
||||
ticketViewSettings.sortDirection !== "asc"
|
||||
].filter(Boolean).length;
|
||||
const timeline = buildTimeline(periodType, period, overview);
|
||||
const chartWidth = 900;
|
||||
const chartHeight = 180;
|
||||
const chartPadding = { top: 18, right: 18, bottom: 34, left: 64 };
|
||||
const chartInnerWidth = chartWidth - chartPadding.left - chartPadding.right;
|
||||
const chartInnerHeight = chartHeight - chartPadding.top - chartPadding.bottom;
|
||||
const maxMinutes = Math.max(...timeline.map((bucket) => bucket.totalMinutes), 1);
|
||||
const chartMaxMinutes = niceCeilMinutes(maxMinutes);
|
||||
const yAxisTicks = Array.from({ length: 5 }).map((_, index) => chartMaxMinutes - (index * chartMaxMinutes) / 4);
|
||||
const barWidth = Math.max(4, Math.min(18, chartInnerWidth / Math.max(timeline.length, 1) / 1.8));
|
||||
const chartPoints = timeline.map((bucket, index) => {
|
||||
const x =
|
||||
timeline.length === 1
|
||||
? chartPadding.left + chartInnerWidth / 2
|
||||
: chartPadding.left + (index * chartInnerWidth) / (timeline.length - 1);
|
||||
|
||||
return {
|
||||
bucket,
|
||||
x,
|
||||
barHeight: bucket.totalMinutes > 0 ? Math.max(2, (bucket.totalMinutes / chartMaxMinutes) * chartInnerHeight) : 0,
|
||||
y: chartPadding.top + (1 - bucket.totalMinutes / chartMaxMinutes) * chartInnerHeight
|
||||
};
|
||||
});
|
||||
const effortPath = buildChartPath(chartPoints.filter((point) => point.bucket.totalMinutes > 0).map((point) => ({ x: point.x, y: point.y })));
|
||||
const labelStep = periodType === "month" ? Math.max(1, Math.ceil(timeline.length / 8)) : 3;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-normal">Auswertung</h2>
|
||||
<h2 className="text-2xl font-semibold tracking-normal">Tickets</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{periodType === "month" ? "Tickets prüfen, Sessions bewerten und Zeiten kontrollieren." : "Sessions des Tages prüfen und bewerten."}
|
||||
</p>
|
||||
@@ -557,95 +448,6 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<CardTitle>Abgerechneter Aufwand</CardTitle>
|
||||
<CardDescription>
|
||||
{periodType === "month" ? "Abrechenbare Zeit pro Tag im ausgewählten Monat." : "Abrechenbare Zeit pro Stunde am ausgewählten Tag."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{formatMinutes(totals?.minutes ?? 0)}</Badge>
|
||||
<HelpLink anchor="auswertung" label="Hilfe zur Auswertung" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<div className="h-44 w-full overflow-hidden rounded-md border bg-background">
|
||||
{(totals?.minutes ?? 0) > 0 ? (
|
||||
<svg viewBox={`0 0 ${chartWidth} ${chartHeight}`} className="h-full w-full">
|
||||
{yAxisTicks.map((tick) => {
|
||||
const y = chartPadding.top + (1 - tick / chartMaxMinutes) * chartInnerHeight;
|
||||
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line x1={chartPadding.left} x2={chartWidth - chartPadding.right} y1={y} y2={y} stroke="currentColor" className="text-muted/70" />
|
||||
<line x1={chartPadding.left - 5} x2={chartPadding.left} y1={y} y2={y} stroke="currentColor" className="text-muted-foreground/70" />
|
||||
<text x={chartPadding.left - 9} y={y + 4} textAnchor="end" className="fill-muted-foreground text-[11px]">
|
||||
{formatAxisMinutes(tick)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<line
|
||||
x1={chartPadding.left}
|
||||
x2={chartPadding.left}
|
||||
y1={chartPadding.top}
|
||||
y2={chartPadding.top + chartInnerHeight}
|
||||
stroke="currentColor"
|
||||
className="text-muted-foreground/70"
|
||||
/>
|
||||
|
||||
{chartPoints.map((point) => {
|
||||
const barY = chartPadding.top + chartInnerHeight - point.barHeight;
|
||||
|
||||
return (
|
||||
<g key={point.bucket.key}>
|
||||
{point.bucket.totalMinutes > 0 ? (
|
||||
<rect
|
||||
x={point.x - barWidth / 2}
|
||||
y={barY}
|
||||
width={barWidth}
|
||||
height={point.barHeight}
|
||||
rx="4"
|
||||
className="fill-neutral-300 dark:fill-neutral-700"
|
||||
/>
|
||||
) : null}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{effortPath ? <path d={effortPath} fill="none" stroke="currentColor" strokeWidth="2.5" className="text-foreground" /> : null}
|
||||
|
||||
{chartPoints.filter((point) => point.bucket.totalMinutes > 0).map((point) => (
|
||||
<circle key={`${point.bucket.key}-effort`} cx={point.x} cy={point.y} r="3" className="fill-background stroke-foreground" strokeWidth="2" />
|
||||
))}
|
||||
|
||||
{chartPoints.map((point, index) =>
|
||||
index % labelStep === 0 || index === chartPoints.length - 1 ? (
|
||||
<text key={`${point.bucket.key}-label`} x={point.x} y={chartHeight - 10} textAnchor="middle" className="fill-muted-foreground text-[11px]">
|
||||
{point.bucket.label}
|
||||
</text>
|
||||
) : null
|
||||
)}
|
||||
</svg>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center text-sm text-muted-foreground">Keine Daten für den gewählten Zeitraum.</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-sm bg-neutral-300 dark:bg-neutral-700" />
|
||||
Abgerechnet
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="h-0.5 w-4 bg-foreground" />
|
||||
Verlauf
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
@@ -690,7 +492,10 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
className="h-8"
|
||||
value={ticketViewSettings.query}
|
||||
placeholder="Ticket oder Organisation"
|
||||
onChange={(event) => setTicketViewSettings((current) => ({ ...current, query: event.currentTarget.value }))}
|
||||
onChange={(event) => {
|
||||
const query = event.currentTarget.value;
|
||||
setTicketViewSettings((current) => ({ ...current, query }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -823,11 +628,14 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
{section.tickets.map((ticket) => (
|
||||
<TableRow key={ticket.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-semibold">{ticket.title || ticket.ticket_number}</span>
|
||||
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="truncate font-semibold">{ticket.ticket_number}</span>
|
||||
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
||||
</div>
|
||||
{ticket.project_id ? <Badge variant="outline" className="shrink-0">Projekt</Badge> : null}
|
||||
</div>
|
||||
{ticket.title ? <p className="text-xs text-muted-foreground">{ticket.ticket_number}</p> : null}
|
||||
{ticket.title ? <p className="max-w-[220px] truncate text-xs text-muted-foreground">{ticket.title}</p> : null}
|
||||
</TableCell>
|
||||
<TableCell>{ticket.customer_name ?? "-"}</TableCell>
|
||||
<TableCell>{workTypeLabel(ticket.work_type)}</TableCell>
|
||||
@@ -875,11 +683,14 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
<div key={ticket.id} className="space-y-2 rounded-md border bg-background p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="truncate font-semibold">{ticket.title || ticket.ticket_number}</p>
|
||||
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<p className="truncate font-semibold">{ticket.ticket_number}</p>
|
||||
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
||||
</div>
|
||||
{ticket.project_id ? <Badge variant="outline" className="shrink-0">Projekt</Badge> : null}
|
||||
</div>
|
||||
{ticket.title ? <p className="text-xs text-muted-foreground">{ticket.ticket_number}</p> : null}
|
||||
{ticket.title ? <p className="truncate text-xs text-muted-foreground">{ticket.title}</p> : null}
|
||||
<p className="text-sm text-muted-foreground">{ticket.customer_name ?? "Keine Organisation"} · {workTypeLabel(ticket.work_type)} · {ticket.session_count} Session(s)</p>
|
||||
</div>
|
||||
<TicketStatus ticket={ticket} />
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -46,7 +46,7 @@ export function FaqPage() {
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-normal">FAQ und Anleitung</h2>
|
||||
<p className="text-sm text-muted-foreground">Ausführliche Anleitung für Timer, Auswertung, Teamspace, fixe Abrechnungen, Monatsabschluss und Adminfunktionen.</p>
|
||||
<p className="text-sm text-muted-foreground">Ausführliche Anleitung für Timer, Tickets, Teamspace, fixe Abrechnungen, Monatsabschluss und Adminfunktionen.</p>
|
||||
</div>
|
||||
<Badge variant="outline">{helpSections.length} Abschnitt(e)</Badge>
|
||||
</div>
|
||||
|
||||
+676
-179
File diff suppressed because it is too large.
Load diff
@@ -204,7 +204,7 @@ export function RecurringBillingsPage() {
|
||||
setRecurringForm(emptyRecurringForm());
|
||||
setEditingBillingId(null);
|
||||
toast.success(editingBillingId ? "Fixe Abrechnung gespeichert" : "Fixe Abrechnung angelegt", {
|
||||
description: "Die Sessions erscheinen automatisch in der Auswertung, sobald du den Zeitraum öffnest. Nicht mehr gültige erzeugte Sessions werden entfernt."
|
||||
description: "Die Sessions erscheinen automatisch in Tickets, sobald du den Zeitraum öffnest. Nicht mehr gültige erzeugte Sessions werden entfernt."
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Fixe Abrechnung konnte nicht angelegt werden", {
|
||||
@@ -454,7 +454,7 @@ export function RecurringBillingsPage() {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Regeln</CardTitle>
|
||||
<CardDescription>Erzeugte Einträge können in der Auswertung verschoben oder gelöscht werden.</CardDescription>
|
||||
<CardDescription>Erzeugte Einträge können in Tickets verschoben oder gelöscht werden.</CardDescription>
|
||||
</div>
|
||||
<HelpLink anchor="fixe-abrechnungen" label="Hilfe zu erzeugten fixen Sessions" />
|
||||
</div>
|
||||
|
||||
@@ -712,7 +712,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => onNavigate("/analysis")}>
|
||||
Auswertung öffnen
|
||||
Tickets öffnen
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
<HelpLink anchor="statistik-hotspots" label="Hilfe zu Ticket-Hotspots" />
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -10,10 +10,11 @@ import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { HelpLink } from "@/components/HelpLink";
|
||||
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
||||
import { createSession, lookupTicket } from "../api";
|
||||
import { createSession, getRecurringBillings, lookupTicket } from "../api";
|
||||
import { formatTimer } from "../format";
|
||||
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
|
||||
import { activeElapsedMs, pauseEntry, resumeEntry, ticketPattern, type TimerEntry } from "../timers";
|
||||
import type { TicketMeta, WorkType } from "../types";
|
||||
import type { RecurringBilling, TicketMeta, WorkType } from "../types";
|
||||
|
||||
type ManualSessionFormState = {
|
||||
ticketNumber: string;
|
||||
@@ -75,14 +76,41 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
const [savingManualSession, setSavingManualSession] = useState(false);
|
||||
const [manualLookupStatus, setManualLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
|
||||
const [manualLookupTicket, setManualLookupTicket] = useState<TicketMeta | null>(null);
|
||||
const [recurringBillings, setRecurringBillings] = useState<RecurringBilling[]>([]);
|
||||
|
||||
const selectedTimer = timers.find((timer) => timer.id === selectedTimerId) ?? timers[0] ?? null;
|
||||
const elapsedSeconds = useMemo(() => Math.floor(activeElapsedMs(selectedTimer, tick) / 1000), [selectedTimer, tick]);
|
||||
const isValidTicket = ticketPattern.test(ticketNumber);
|
||||
const isManualTicketValid = ticketPattern.test(manualSession.ticketNumber.trim());
|
||||
const manualTicketDefaultsLocked = Boolean(manualLookupTicket?.organization_id && manualLookupTicket.work_type);
|
||||
const manualTicketIsRecurringBilling =
|
||||
ticketHasRecurringBilling(manualLookupTicket, recurringBillings) || isRecurringBillingTicketNumber(manualSession.ticketNumber.trim(), recurringBillings);
|
||||
const runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadRecurringBillings() {
|
||||
try {
|
||||
const result = await getRecurringBillings();
|
||||
|
||||
if (!cancelled) {
|
||||
setRecurringBillings(result.recurringBillings);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setRecurringBillings([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadRecurringBillings();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedTicketNumber = manualSession.ticketNumber.trim();
|
||||
|
||||
@@ -104,7 +132,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
|
||||
const timeout = window.setTimeout(async () => {
|
||||
try {
|
||||
const result = await lookupTicket(normalizedTicketNumber);
|
||||
const result = await lookupTicket(normalizedTicketNumber);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -149,6 +177,16 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
return;
|
||||
}
|
||||
|
||||
const ticketResult = await lookupTicket(ticketNumber).catch(() => ({ ticket: null }));
|
||||
|
||||
if (ticketHasRecurringBilling(ticketResult.ticket, recurringBillings) || isRecurringBillingTicketNumber(ticketNumber, recurringBillings)) {
|
||||
setTimers((current) => current.filter((timer) => timer.ticketNumber !== ticketNumber));
|
||||
toast.error("Fixe Abrechnung", {
|
||||
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gestartet werden. Bitte die Definition bearbeiten."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (timers.some((timer) => timer.ticketNumber === ticketNumber)) {
|
||||
toast.error("Timer existiert bereits", {
|
||||
description: "Für dieses Ticket läuft oder pausiert schon ein Timer."
|
||||
@@ -157,7 +195,6 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const ticketResult = await lookupTicket(ticketNumber).catch(() => ({ ticket: null }));
|
||||
const newTimer: TimerEntry = {
|
||||
id: crypto.randomUUID(),
|
||||
ticketNumber,
|
||||
@@ -221,6 +258,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
const result = await lookupTicket(timer.ticketNumber);
|
||||
const ticket = result.ticket;
|
||||
|
||||
if (ticketHasRecurringBilling(ticket, recurringBillings) || isRecurringBillingTicketNumber(timer.ticketNumber, recurringBillings)) {
|
||||
setTimers((current) => current.filter((entry) => entry.id !== timer.id));
|
||||
toast.error("Fixe Abrechnung", {
|
||||
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gespeichert werden. Bitte die Definition bearbeiten."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setOrganizationId(ticket?.organization_id ?? "");
|
||||
setOrganizationName(ticket?.organization_name ?? null);
|
||||
setWorkType(ticket?.work_type ?? "support");
|
||||
@@ -250,6 +295,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
return;
|
||||
}
|
||||
|
||||
if (ticketHasRecurringBilling(finished.ticket, recurringBillings) || isRecurringBillingTicketNumber(finished.ticketNumber, recurringBillings)) {
|
||||
toast.error("Fixe Abrechnung", {
|
||||
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gespeichert werden. Bitte die Definition bearbeiten."
|
||||
});
|
||||
setFinished(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingTicketHasDefaults = Boolean(finished.ticket?.organization_id && finished.ticket?.work_type);
|
||||
const effectiveOrganizationId = existingTicketHasDefaults ? finished.ticket!.organization_id! : organizationId;
|
||||
const effectiveWorkType = existingTicketHasDefaults ? finished.ticket!.work_type! : workType;
|
||||
@@ -307,6 +360,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
return;
|
||||
}
|
||||
|
||||
if (manualTicketIsRecurringBilling) {
|
||||
toast.error("Fixe Abrechnung", {
|
||||
description: "Für fixe Abrechnungen dürfen keine Sessions nachgetragen werden. Bitte die Definition bearbeiten."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = new Date(`${manualSession.day}T${manualSession.startTime}:00`);
|
||||
const endedAt = new Date(`${manualSession.day}T${manualSession.endTime}:00`);
|
||||
|
||||
@@ -335,7 +395,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
durationSeconds
|
||||
});
|
||||
toast.success("Session nachgetragen", {
|
||||
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Auswertung offen."
|
||||
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen."
|
||||
});
|
||||
setManualSession({
|
||||
...emptyManualSession(),
|
||||
@@ -488,6 +548,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
{manualLookupStatus === "found" && !manualTicketDefaultsLocked ? (
|
||||
<p className="text-xs text-muted-foreground">Ticket gefunden, aber Stammdaten sind unvollständig. Bitte ergänzen.</p>
|
||||
) : null}
|
||||
{manualTicketIsRecurringBilling ? (
|
||||
<p className="text-xs text-destructive">Fixe Abrechnung: Sessions kommen ausschließlich aus der Definition.</p>
|
||||
) : null}
|
||||
{manualLookupStatus === "not-found" ? <p className="text-xs text-muted-foreground">Neues Ticket. Organisation und Art bitte wählen.</p> : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -562,7 +625,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={savingManualSession}>
|
||||
<Button type="submit" disabled={savingManualSession || manualTicketIsRecurringBilling}>
|
||||
<Upload className="size-4" />
|
||||
{savingManualSession ? "Speichert..." : "Nachtragen"}
|
||||
</Button>
|
||||
|
||||
+1074
-22
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user