This commit is contained in:
michael committed 2026-09-30 16:59:14 +02:00
1 parent 35cb578a96
commit d5952d82bb
11 files changed
+1081 -119

No files matched your search

+106 -8
View File
@@ -31,6 +31,11 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled);
const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes);
const [zammadArticleTypeId, setZammadArticleTypeId] = useState<ZammadArticleTypeId>(currentUser.zammad_article_type_id ?? 6);
const [projectFollowupEnabled, setProjectFollowupEnabled] = useState(currentUser.project_followup_enabled ?? true);
const [projectFollowupWeeks, setProjectFollowupWeeks] = useState(currentUser.project_followup_weeks ?? 4);
const [projectFollowupSecondEnabled, setProjectFollowupSecondEnabled] = useState(currentUser.project_followup_second_enabled ?? true);
const [projectFollowupSecondWeeks, setProjectFollowupSecondWeeks] = useState(currentUser.project_followup_second_weeks ?? 2);
const [projectFollowupCompleteOnSession, setProjectFollowupCompleteOnSession] = useState(currentUser.project_followup_complete_on_session ?? true);
const [zammadQueue, setZammadQueue] = useState<ZammadSessionArticleQueueItem[]>([]);
const [loadingZammadQueue, setLoadingZammadQueue] = useState(false);
const [retryingZammadQueue, setRetryingZammadQueue] = useState(false);
@@ -48,6 +53,11 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes);
setZammadArticleTypeId(currentUser.zammad_article_type_id ?? 6);
setProjectFollowupEnabled(currentUser.project_followup_enabled ?? true);
setProjectFollowupWeeks(currentUser.project_followup_weeks ?? 4);
setProjectFollowupSecondEnabled(currentUser.project_followup_second_enabled ?? true);
setProjectFollowupSecondWeeks(currentUser.project_followup_second_weeks ?? 2);
setProjectFollowupCompleteOnSession(currentUser.project_followup_complete_on_session ?? true);
}, [currentUser]);
async function loadZammadQueue() {
@@ -126,14 +136,19 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {}),
...(currentUser.role === "user"
? {
zammadEmailSessionWebhookEnabled,
zammadAtossEmailSyncEnabled,
sessionBillingPromptEnabled,
zammadSessionNoteDefaultEnabled,
zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes,
zammadArticleTypeId
}
zammadEmailSessionWebhookEnabled,
zammadAtossEmailSyncEnabled,
sessionBillingPromptEnabled,
zammadSessionNoteDefaultEnabled,
zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes,
zammadArticleTypeId,
projectFollowupEnabled,
projectFollowupWeeks,
projectFollowupSecondEnabled,
projectFollowupSecondWeeks,
projectFollowupCompleteOnSession
}
: {})
};
@@ -334,6 +349,89 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
onCheckedChange={setSessionBillingPromptEnabled}
/>
</div>
<div className="mt-3 space-y-3 rounded-md border bg-background p-3">
<div className="flex items-center justify-between gap-3">
<div>
<label className="text-sm font-medium" htmlFor="profile-project-followup-enabled">
Automatische Wiedervorlagen
</label>
<p className="text-xs text-muted-foreground">Consulting-Projekte automatisch auf Wiedervorlage setzen.</p>
</div>
<Switch
id="profile-project-followup-enabled"
checked={projectFollowupEnabled}
onCheckedChange={setProjectFollowupEnabled}
/>
</div>
{projectFollowupEnabled ? (
<div className="space-y-3 border-t pt-3">
<div>
<p className="text-sm font-medium">1. Wiedervorlage aktiv</p>
<p className="text-xs text-muted-foreground">Wenn automatische Wiedervorlagen aktiv sind, wird die erste WVL immer geprüft.</p>
</div>
<div className="grid gap-2 sm:max-w-xs">
<label className="text-sm font-medium" htmlFor="profile-project-followup-weeks">
1. Wiedervorlage nach Wochen
</label>
<Input
id="profile-project-followup-weeks"
type="number"
min={1}
max={52}
value={projectFollowupWeeks}
onChange={(event) => setProjectFollowupWeeks(Math.max(1, Math.min(52, Number(event.currentTarget.value) || 1)))}
/>
</div>
<div className="flex items-center justify-between gap-3">
<div>
<label className="text-sm font-medium" htmlFor="profile-project-followup-second-enabled">
2. Wiedervorlage
</label>
<p className="text-xs text-muted-foreground">Nur anlegen, wenn die erste WVL erledigt oder gelöscht wurde und keine Session entstanden ist.</p>
</div>
<Switch
id="profile-project-followup-second-enabled"
checked={projectFollowupSecondEnabled}
onCheckedChange={setProjectFollowupSecondEnabled}
/>
</div>
{projectFollowupSecondEnabled ? (
<div className="grid gap-2 sm:max-w-xs">
<label className="text-sm font-medium" htmlFor="profile-project-followup-second-weeks">
2. Wiedervorlage nach weiteren Wochen
</label>
<Input
id="profile-project-followup-second-weeks"
type="number"
min={1}
max={52}
value={projectFollowupSecondWeeks}
onChange={(event) => setProjectFollowupSecondWeeks(Math.max(1, Math.min(52, Number(event.currentTarget.value) || 1)))}
/>
</div>
) : null}
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<div>
<label className="text-sm font-medium" htmlFor="profile-project-followup-complete-on-session">
Automatische Wiedervorlagen durch Session erledigen
</label>
<p className="text-xs text-muted-foreground">Gilt für beide Zeiträume. Betrifft nur automatisch erzeugte Wiedervorlagen, niemals manuelle ToDos.</p>
</div>
<Switch
id="profile-project-followup-complete-on-session"
checked={projectFollowupCompleteOnSession}
onCheckedChange={setProjectFollowupCompleteOnSession}
/>
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
<div className="flex items-center gap-2">
<FileText className="size-4 text-muted-foreground" />
+161 -3
View File
@@ -1,9 +1,11 @@
import {
ChevronLeft,
ChevronRight,
BellPlus,
CircleAlert,
Clock3,
ExternalLink,
ListChecks,
RotateCcw,
Ticket,
TrendingUp,
@@ -20,11 +22,12 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input";
import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { HelpLink } from "@/components/HelpLink";
import { getStatisticsOverview } from "../api";
import { currentMonth, formatDate, formatMinutes } from "../format";
import type { StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsTicket, WorkType } from "../types";
import { getProjectFollowups, getStatisticsOverview } from "../api";
import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format";
import type { StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsProjectFollowup, StatisticsProjectFollowupOverview, StatisticsTicket, WorkType } from "../types";
type StatisticsPageProps = {
onNavigate: (to: string) => void;
@@ -361,12 +364,72 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
);
}
function daysSince(value: string) {
const then = new Date(value).getTime();
if (!Number.isFinite(then)) {
return null;
}
return Math.max(0, Math.floor((Date.now() - then) / 86_400_000));
}
function ProjectFollowupRow({ project, onNavigate }: { project: StatisticsProjectFollowup; onNavigate: (to: string) => void }) {
const inactiveDays = daysSince(project.latest_tt_update);
const hasBudget = project.hour_budget_minutes !== null;
return (
<div className="grid gap-3 rounded-md border bg-background p-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold">{project.ticket_number}</span>
{hasBudget ? (
<Badge variant={project.remaining_minutes !== null && project.remaining_minutes > 0 ? "warning" : "secondary"}>
{project.remaining_minutes !== null && project.remaining_minutes > 0 ? `${formatMinutes(project.remaining_minutes)} offen` : "Budget verbraucht"}
</Badge>
) : (
<Badge variant="info">ohne Budget</Badge>
)}
{project.open_followup_todo_id ? <Badge variant="secondary">ToDo erstellt</Badge> : null}
{project.followup_state === "done" ? <Badge variant="success">erledigt</Badge> : null}
{project.followup_state === "dismissed" ? <Badge variant="outline">gelöscht</Badge> : null}
{project.overdue_todos > 0 ? <Badge variant="destructive">{project.overdue_todos} überfällig</Badge> : null}
</div>
<div className="min-w-0">
<p className="truncate text-sm">{project.title ?? project.teamspace_project ?? "Projekt ohne Titel"}</p>
<p className="truncate text-xs text-muted-foreground">{project.organization_name ?? "Keine Organisation"}</p>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>Letztes TT-Update {formatDateTime(project.latest_tt_update)}</span>
{inactiveDays !== null ? <span>{inactiveDays} Tag(e) inaktiv</span> : null}
<span>Budget {hasBudget ? formatMinutes(project.hour_budget_minutes!) : "nicht gesetzt"}</span>
<span>Verbraucht {formatMinutes(project.consumption_minutes)}</span>
<span>{project.open_todos} offene ToDo(s)</span>
</div>
</div>
<div className="flex flex-wrap gap-2 lg:justify-end">
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}>
Ticket
<ExternalLink className="size-4" />
</Button>
<Button variant="secondary" size="sm" onClick={() => onNavigate("/projects")}>
Projekte
<ExternalLink className="size-4" />
</Button>
</div>
</div>
);
}
export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
const [month, setMonth] = useState(currentMonth());
const [stats, setStats] = useState<StatisticsOverview | null>(null);
const [followups, setFollowups] = useState<StatisticsProjectFollowupOverview | null>(null);
const [loading, setLoading] = useState(false);
const [loadingFollowups, setLoadingFollowups] = useState(false);
const [organizationPage, setOrganizationPage] = useState(1);
const loadRequestId = useRef(0);
const followupRequestId = useRef(0);
async function load() {
const requestId = loadRequestId.current + 1;
@@ -396,6 +459,44 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
void load();
}, [month]);
async function loadFollowups() {
const requestId = followupRequestId.current + 1;
followupRequestId.current = requestId;
setLoadingFollowups(true);
try {
const result = await getProjectFollowups();
if (requestId === followupRequestId.current) {
setFollowups(result);
if (result.createdTodos > 0 || result.updatedTodos > 0) {
window.dispatchEvent(new Event("tickettracker:todos-changed"));
}
if (result.createdTodos > 0) {
toast.success("Wiedervorlagen erstellt", {
description: `${result.createdTodos} ToDo(s) automatisch angelegt.`
});
}
}
} catch (error) {
if (requestId === followupRequestId.current) {
toast.error("Wiedervorlagen konnten nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
} finally {
if (requestId === followupRequestId.current) {
setLoadingFollowups(false);
}
}
}
useEffect(() => {
void loadFollowups();
}, []);
useEffect(() => {
function refreshVisible() {
if (document.visibilityState === "visible") {
@@ -476,6 +577,15 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</div>
</div>
<Tabs defaultValue="overview" className="space-y-5">
<TabsList className="grid h-auto w-full grid-cols-2 gap-1 sm:grid-cols-4">
<TabsTrigger value="overview">Überblick</TabsTrigger>
<TabsTrigger value="groups">Kunden & Typen</TabsTrigger>
<TabsTrigger value="tickets">Tickets</TabsTrigger>
<TabsTrigger value="followups">Wiedervorlagen</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-5">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard
label="Abgerechnet"
@@ -597,6 +707,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</CardContent>
</Card>
</TabsContent>
<TabsContent value="groups" className="space-y-5">
<div className="grid gap-3 xl:grid-cols-[1.45fr_0.8fr]">
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
@@ -731,6 +844,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</Card>
</div>
</TabsContent>
<TabsContent value="tickets" className="space-y-5">
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
@@ -768,6 +884,48 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</CardContent>
</Card>
) : null}
</TabsContent>
<TabsContent value="followups" className="space-y-5">
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
<CardTitle>Automatische Wiedervorlagen</CardTitle>
<CardDescription>
{followups?.enabled === false
? "Automatische Wiedervorlagen sind im Profil deaktiviert."
: followups?.secondEnabled === false
? `Erste Wiedervorlage nach ${followups?.weeks ?? "-"} Woche(n).`
: `Erste Wiedervorlage nach ${followups?.weeks ?? "-"} Woche(n), zweite nach weiteren ${followups?.secondWeeks ?? "-"} Woche(n) ohne Session.`}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{followups?.projects.length ?? 0} Projekt(e)</Badge>
{loadingFollowups ? <Badge variant="secondary">lädt...</Badge> : null}
<Button type="button" size="sm" variant="secondary" onClick={() => void loadFollowups()} disabled={loadingFollowups}>
<BellPlus className="size-4" />
Prüfen
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
{(followups?.projects ?? []).map((project) => (
<ProjectFollowupRow key={project.project_id} project={project} onNavigate={onNavigate} />
))}
{!loadingFollowups && followups && followups.projects.length === 0 ? (
<div className="grid place-items-center gap-2 rounded-md border bg-muted/20 p-8 text-center">
<ListChecks className="size-8 text-muted-foreground" />
<div>
<p className="font-medium">Keine Wiedervorlagen nötig</p>
<p className="text-sm text-muted-foreground">Alle offenen Projektbudgets wurden innerhalb des eingestellten Zeitraums aktualisiert.</p>
</div>
</div>
) : null}
{loadingFollowups && !followups ? <p className="py-8 text-center text-sm text-muted-foreground">Wiedervorlagen werden geprüft...</p> : null}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
+26 -4
View File
@@ -129,6 +129,10 @@ function notifyZammadArticleResult(result: CreateSessionResult) {
}
}
function notifyTodosChanged() {
window.dispatchEvent(new Event("tickettracker:todos-changed"));
}
function zammadSyncStorageKey(userId: string) {
return `tickettracker.zammad-sync.${userId}`;
}
@@ -259,6 +263,18 @@ function teamspaceBillingKey(day: string, projectTeamspaceEntryId: string | null
return `${projectTeamspaceEntryId ?? "ticket"}::${day}`;
}
function dayBillingProjectEntryIdForView(
projectTeamspaceEntryId: string | null | undefined,
hasMultipleProjectTeamspaceEntries: boolean,
defaultProjectEntryId: string | null
) {
if (!hasMultipleProjectTeamspaceEntries) {
return null;
}
return projectTeamspaceEntryId ?? defaultProjectEntryId;
}
function projectEditForm(project: ProjectSummary): ProjectEditFormState {
const entries = projectTeamspaceEntries(project).map((entry, index) => ({
clientId: entry.id || projectEntryId(),
@@ -824,10 +840,12 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
}, [periodType, period, ticketId]);
useEffect(() => {
const defaultEntryId = data?.project ? projectTeamspaceEntries(data.project).find((entry) => entry.is_default)?.id ?? null : null;
const loadedProjectEntries = data?.project ? projectTeamspaceEntries(data.project) : [];
const hasMultipleEntries = loadedProjectEntries.length > 1;
const defaultEntryId = loadedProjectEntries.find((entry) => entry.is_default)?.id ?? null;
const nextInputs = Object.fromEntries(
(data?.dayBillings ?? []).map((billing) => [
teamspaceBillingKey(billing.day, billing.project_teamspace_entry_id ?? defaultEntryId),
teamspaceBillingKey(billing.day, dayBillingProjectEntryIdForView(billing.project_teamspace_entry_id, hasMultipleEntries, defaultEntryId)),
formatHoursInput(billing.billed_minutes)
])
);
@@ -920,11 +938,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
() =>
new Map(
(data?.dayBillings ?? []).map((billing) => [
teamspaceBillingKey(billing.day, billing.project_teamspace_entry_id ?? defaultProjectEntryId),
teamspaceBillingKey(billing.day, dayBillingProjectEntryIdForView(billing.project_teamspace_entry_id, requiresProjectTeamspaceEntry, defaultProjectEntryId)),
billing.billed_minutes
])
),
[data?.dayBillings, defaultProjectEntryId]
[data?.dayBillings, defaultProjectEntryId, requiresProjectTeamspaceEntry]
);
const savedDayBillingInput = (key: string) => formatHoursInput(dayBillingMap.get(key));
const isDayBillingDirty = (key: string) => (dayBillingInputs[key] ?? "") !== savedDayBillingInput(key);
@@ -1186,6 +1204,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
: `${result.created} angelegt, ${result.skipped} ohne Transfer.`;
const notify = result.created === 0 && result.errors.length > 0 ? toast.error : toast.success;
notify("Mail-Sessions importiert", { description });
if (result.created > 0) {
notifyTodosChanged();
}
await load();
} catch (error) {
toast.error("Mail-Sessions konnten nicht importiert werden", {
@@ -1514,6 +1535,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet."
});
notifyZammadArticleResult(result);
notifyTodosChanged();
setTicketSession({
...emptyTicketSessionForm(),
organizationId: ticket.organization_id ?? "",
+6
View File
@@ -141,6 +141,10 @@ function notifyZammadArticleResult(result: CreateSessionResult) {
}
}
function notifyTodosChanged() {
window.dispatchEvent(new Event("tickettracker:todos-changed"));
}
function escapeHtml(value: string) {
return value
.replace(/&/g, "&amp;")
@@ -773,6 +777,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
description: `${finished.ticketNumber} wurde mit ${finished.roundedMinutes} Minute(n) erfasst.`
});
notifyZammadArticleResult(result);
notifyTodosChanged();
try {
const timerResult = await deleteActiveTimer(finished.timerId);
removeTimerActivityDraft(currentUser.id, finished.timerId);
@@ -868,6 +873,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen."
});
notifyZammadArticleResult(result);
notifyTodosChanged();
setManualSession({
...emptyManualSession(),
day: manualSession.day,
+57 -34
View File
@@ -21,6 +21,7 @@ type TodosPageProps = {
type TodoSortMode = "custom" | "priority" | "title" | "ticket" | "organization" | "date" | "updated";
type TodoGroupMode = "ticket" | "priority" | "organization" | "status";
type CalendarDayMode = "current";
type TodoPreferences = {
status: TodoStatus | "all";
@@ -277,6 +278,23 @@ function sortTodos(todos: TicketTodo[], sortMode: TodoSortMode) {
return [...todos].sort((left, right) => compareTodos(left, right, sortMode));
}
function uniqueTodos(todos: TicketTodo[]) {
return todos.filter((todo, index, list) => list.findIndex((item) => item.id === todo.id) === index);
}
function sortCurrentTodos(todos: TicketTodo[], today: string) {
return [...todos].sort((left, right) => {
const leftOverdue = isTodoOverdue(left, today);
const rightOverdue = isTodoOverdue(right, today);
if (leftOverdue !== rightOverdue) {
return leftOverdue ? -1 : 1;
}
return priorityOrder[left.priority] - priorityOrder[right.priority] || left.sort_order - right.sort_order || compareText(left.title, right.title);
});
}
function matchesTicketSearch(todo: TicketTodo, search: string) {
const normalizedSearch = normalizeSearch(search);
@@ -400,9 +418,9 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
const [modalEndsOn, setModalEndsOn] = useState("");
const [modalBusy, setModalBusy] = useState(false);
const [selectedCalendarDay, setSelectedCalendarDay] = useState<string | null>(null);
const [selectedCalendarDayMode, setSelectedCalendarDayMode] = useState<"summarized" | "overdue">("summarized");
const [selectedCalendarDayMode, setSelectedCalendarDayMode] = useState<CalendarDayMode>("current");
const [returnCalendarDay, setReturnCalendarDay] = useState<string | null>(null);
const [returnCalendarDayMode, setReturnCalendarDayMode] = useState<"summarized" | "overdue">("summarized");
const [returnCalendarDayMode, setReturnCalendarDayMode] = useState<CalendarDayMode>("current");
async function load() {
setLoading(true);
@@ -422,6 +440,13 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
void load();
}, []);
useEffect(() => {
const onTodosChanged = () => void load();
window.addEventListener("tickettracker:todos-changed", onTodosChanged);
return () => window.removeEventListener("tickettracker:todos-changed", onTodosChanged);
}, []);
useEffect(() => {
window.localStorage.setItem(todoPreferencesKey, JSON.stringify(preferences));
}, [preferences]);
@@ -432,7 +457,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
if (selectedTodoId && !selectedTodo) {
setSelectedTodoId(null);
setReturnCalendarDay(null);
setReturnCalendarDayMode("summarized");
setReturnCalendarDayMode("current");
}
}, [selectedTodo, selectedTodoId]);
@@ -473,7 +498,19 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
return entries;
}, [calendarDays, todos]);
const selectedCalendarDayTodos = selectedCalendarDay ? calendarTodosByDay.get(selectedCalendarDay)?.[selectedCalendarDayMode] ?? [] : [];
const selectedCalendarDayTodos = useMemo(() => {
if (!selectedCalendarDay) {
return [];
}
const dayTodos = calendarTodosByDay.get(selectedCalendarDay);
if (!dayTodos) {
return [];
}
return sortCurrentTodos(uniqueTodos([...dayTodos.overdue, ...dayTodos.single, ...dayTodos.summarized]), currentDay());
}, [calendarTodosByDay, selectedCalendarDay]);
const counts = useMemo(
() => ({
open: todos.filter((todo) => todo.status === "open").length,
@@ -531,7 +568,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
});
}
function openTodoModal(todo: TicketTodo, returnToDay: string | null = null, returnToDayMode: "summarized" | "overdue" = "summarized") {
function openTodoModal(todo: TicketTodo, returnToDay: string | null = null, returnToDayMode: CalendarDayMode = "current") {
setReturnCalendarDay(returnToDay);
setReturnCalendarDayMode(returnToDayMode);
setSelectedTodoId(todo.id);
@@ -545,7 +582,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
function closeTodoModal() {
setSelectedTodoId(null);
setReturnCalendarDay(null);
setReturnCalendarDayMode("summarized");
setReturnCalendarDayMode("current");
}
function backToCalendarDay() {
@@ -557,7 +594,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
const mode = returnCalendarDayMode;
setSelectedTodoId(null);
setReturnCalendarDay(null);
setReturnCalendarDayMode("summarized");
setReturnCalendarDayMode("current");
setSelectedCalendarDayMode(mode);
setSelectedCalendarDay(day);
}
@@ -904,6 +941,9 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
const summarizedTodos = dayTodos.summarized;
const overdueTodos = dayTodos.overdue;
const totalTodos = singleTodos.length + summarizedTodos.length + overdueTodos.length;
const currentTodos = sortCurrentTodos(uniqueTodos([...overdueTodos, ...singleTodos, ...summarizedTodos]), currentDay());
const visibleCalendarTodos = currentTodos.slice(0, 2);
const additionalTodosCount = Math.max(0, currentTodos.length - visibleCalendarTodos.length);
return (
<div
@@ -918,7 +958,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
</div>
{!day.workingDay ? <div className="text-[10px] leading-tight text-muted-foreground">{day.holidayName ?? "Wochenende"}</div> : null}
<div className="space-y-1">
{singleTodos.slice(0, 3).map((todo) => (
{visibleCalendarTodos.map((todo) => (
<button
key={todo.id}
type="button"
@@ -929,31 +969,18 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
<span className="block truncate text-muted-foreground">{todo.ticket_number ?? "Ticket"}</span>
</button>
))}
{singleTodos.length > 3 ? <span className="block px-1 text-[11px] text-muted-foreground">+{singleTodos.length - 3} weitere fixe</span> : null}
</div>
<div className="mt-auto flex flex-wrap gap-1 pt-1">
{summarizedTodos.length > 0 ? (
<div className="mt-auto pt-1">
{additionalTodosCount > 0 ? (
<button
type="button"
className="min-w-0 flex-1 rounded-md border border-muted-foreground/20 bg-background/60 px-2 py-1 text-left text-[11px] leading-tight text-muted-foreground hover:bg-muted"
className="block w-full rounded-md border border-muted-foreground/20 bg-background/60 px-2 py-1 text-left text-[11px] leading-tight text-muted-foreground hover:bg-muted"
onClick={() => {
setSelectedCalendarDayMode("summarized");
setSelectedCalendarDayMode("current");
setSelectedCalendarDay(day.iso);
}}
>
+{summarizedTodos.length} ToDo{summarizedTodos.length === 1 ? "" : "s"}
</button>
) : null}
{overdueTodos.length > 0 ? (
<button
type="button"
className="shrink-0 rounded-md border border-red-500/50 bg-red-500/10 px-2 py-1 text-left text-[11px] font-semibold leading-tight text-red-200 hover:bg-red-500/20"
onClick={() => {
setSelectedCalendarDayMode("overdue");
setSelectedCalendarDay(day.iso);
}}
>
+{overdueTodos.length} üf
+{additionalTodosCount} weitere ToDo{additionalTodosCount === 1 ? "" : "s"}
</button>
) : null}
</div>
@@ -1073,13 +1100,11 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
<Dialog open={Boolean(selectedCalendarDay)} onOpenChange={(open) => !open && setSelectedCalendarDay(null)}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{selectedCalendarDayMode === "overdue" ? "Überfällige ToDos" : "ToDos am Tag"}</DialogTitle>
<DialogTitle>Aktuelle ToDos</DialogTitle>
<DialogDescription>
{selectedCalendarDay
? `${formatTodoDate(selectedCalendarDay)} · ${selectedCalendarDayTodos.length} ${selectedCalendarDayMode === "overdue" ? "überfällige" : "laufende"} ToDo(s)`
: selectedCalendarDayMode === "overdue"
? "Überfällige ToDos dieses Tages"
: "Laufende ToDos dieses Tages"}
? `${formatTodoDate(selectedCalendarDay)} · ${selectedCalendarDayTodos.length} aktuelle ToDo(s)`
: "Aktuelle ToDos dieses Tages"}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
@@ -1142,9 +1167,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
</div>
))}
{selectedCalendarDayTodos.length === 0 ? (
<p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">
{selectedCalendarDayMode === "overdue" ? "Keine überfälligen ToDos an diesem Tag." : "Keine laufenden ToDos an diesem Tag."}
</p>
<p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine aktuellen ToDos an diesem Tag.</p>
) : null}
</div>
</DialogContent>