Update
This commit is contained in:
1 parent
35cb578a96
commit
d5952d82bb
11 files changed
+1081
-119
No files matched your search
+60
-34
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
@@ -591,20 +590,57 @@ const sidebarTodoCalendarClasses: Record<TicketTodo["priority"], string> = {
|
||||
};
|
||||
|
||||
const sidebarOverdueTodoClass = "border-red-500/60 bg-red-500/15 text-red-100";
|
||||
const sidebarTodoPriorityOrder: Record<TicketTodo["priority"], number> = {
|
||||
urgent: 0,
|
||||
high: 1,
|
||||
medium: 2,
|
||||
low: 3
|
||||
};
|
||||
|
||||
function sortSidebarCurrentTodos(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 sidebarTodoPriorityOrder[left.priority] - sidebarTodoPriorityOrder[right.priority] || left.sort_order - right.sort_order || left.title.localeCompare(right.title, "de-DE", { sensitivity: "base" });
|
||||
});
|
||||
}
|
||||
|
||||
function notifyTodosChanged() {
|
||||
window.dispatchEvent(new Event("tickettracker:todos-changed"));
|
||||
}
|
||||
|
||||
function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
|
||||
const [runningTodosOpen, setRunningTodosOpen] = useState(false);
|
||||
const [todosModalOpen, setTodosModalOpen] = 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 currentTodos = sortSidebarCurrentTodos(
|
||||
[...overdueTodos, ...fixedTodos, ...summarizedTodos].filter((todo, index, list) => list.findIndex((item) => item.id === todo.id) === index),
|
||||
today
|
||||
);
|
||||
const visibleCurrentTodos = currentTodos.slice(0, 6);
|
||||
const additionalTodosCount = Math.max(0, currentTodos.length - visibleCurrentTodos.length);
|
||||
const hasTodos = fixedTodos.length > 0 || summarizedTodos.length > 0 || overdueTodos.length > 0;
|
||||
|
||||
function todoStateLabel(todo: TicketTodo) {
|
||||
if (isTodoOverdue(todo, today)) {
|
||||
return "Überfällig";
|
||||
}
|
||||
|
||||
if (isTodoFixedOnDay(todo, today)) {
|
||||
return "Fester Termin";
|
||||
}
|
||||
|
||||
return "Laufend";
|
||||
}
|
||||
|
||||
async function toggleTodo(todo: TicketTodo) {
|
||||
setModalBusy(true);
|
||||
|
||||
@@ -666,42 +702,30 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
|
||||
|
||||
{hasTodos ? (
|
||||
<div className="space-y-1">
|
||||
{overdueTodos.slice(0, 3).map((todo) => (
|
||||
{visibleCurrentTodos.map((todo) => (
|
||||
<button
|
||||
key={`overdue-${todo.id}`}
|
||||
key={`current-${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 px-2 py-1.5 text-left hover:brightness-110 ${sidebarTodoCalendarClasses[todo.priority]}`}
|
||||
className={`block w-full rounded-md border px-2 py-1.5 text-left hover:brightness-110 ${isTodoOverdue(todo, today) ? sidebarOverdueTodoClass : sidebarTodoCalendarClasses[todo.priority]}`}
|
||||
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>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">
|
||||
{todo.ticket_number ?? "Ticket"} · {todoStateLabel(todo)}
|
||||
</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 ? (
|
||||
{additionalTodosCount > 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)}
|
||||
onClick={() => setTodosModalOpen(true)}
|
||||
>
|
||||
+{summarizedTodos.length} laufende ToDo{summarizedTodos.length === 1 ? "" : "s"}
|
||||
<span className="block">+{additionalTodosCount} weitere aktuelle ToDo{additionalTodosCount === 1 ? "" : "s"}</span>
|
||||
<span className="block truncate font-normal">
|
||||
{overdueTodos.length} überfällig · {summarizedTodos.length} laufend · {fixedTodos.length} feste
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -712,22 +736,24 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<Dialog open={runningTodosOpen} onOpenChange={setRunningTodosOpen}>
|
||||
<Dialog open={todosModalOpen} onOpenChange={setTodosModalOpen}>
|
||||
<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>
|
||||
<DialogTitle>Aktuelle ToDos</DialogTitle>
|
||||
<DialogDescription>
|
||||
{overdueTodos.length} überfällig · {fixedTodos.length} feste Termine · {summarizedTodos.length} laufende ToDo(s)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
{summarizedTodos.map((todo) => (
|
||||
{currentTodos.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>
|
||||
<Badge variant={isTodoOverdue(todo, today) ? "destructive" : isTodoFixedOnDay(todo, today) ? "outline" : "secondary"}>{todoStateLabel(todo)}</Badge>
|
||||
<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"}
|
||||
@@ -749,7 +775,7 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setRunningTodosOpen(false);
|
||||
setTodosModalOpen(false);
|
||||
onNavigate(`/tickets/${todo.ticket_id}`);
|
||||
}}
|
||||
>
|
||||
@@ -764,8 +790,8 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
|
||||
</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>
|
||||
{currentTodos.length === 0 ? (
|
||||
<p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine aktuellen ToDos.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ProjectSummary,
|
||||
RecurringBilling,
|
||||
StatisticsOverview,
|
||||
StatisticsProjectFollowupOverview,
|
||||
TicketMeta,
|
||||
TicketPeriod,
|
||||
TicketTodo,
|
||||
@@ -254,6 +255,11 @@ export function updateCurrentUser(payload: {
|
||||
zammadTimeUnitRoundingMinutes?: number;
|
||||
zammadAtossEmailSyncEnabled?: boolean;
|
||||
zammadArticleTypeId?: number;
|
||||
projectFollowupEnabled?: boolean;
|
||||
projectFollowupWeeks?: number;
|
||||
projectFollowupSecondEnabled?: boolean;
|
||||
projectFollowupSecondWeeks?: number;
|
||||
projectFollowupCompleteOnSession?: boolean;
|
||||
}) {
|
||||
return request<{ user: AuthUser }>("/api/auth/me", {
|
||||
method: "PATCH",
|
||||
@@ -889,6 +895,10 @@ export function getStatisticsOverview(period: string, type: "month" | "week" = "
|
||||
return request<StatisticsOverview>(`/api/statistics/${type === "week" ? "weeks" : "months"}/${period}`);
|
||||
}
|
||||
|
||||
export function getProjectFollowups() {
|
||||
return request<StatisticsProjectFollowupOverview>("/api/statistics/project-followups");
|
||||
}
|
||||
|
||||
export function getTicketPeriod(type: PeriodType, period: string, ticketId: string) {
|
||||
return request<TicketPeriod>(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}`);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ export type AuthUser = {
|
||||
zammad_time_unit_rounding_minutes: number;
|
||||
zammad_atoss_email_sync_enabled: boolean;
|
||||
zammad_article_type_id: number;
|
||||
project_followup_enabled: boolean;
|
||||
project_followup_weeks: number;
|
||||
project_followup_second_enabled: boolean;
|
||||
project_followup_second_weeks: number;
|
||||
project_followup_complete_on_session: boolean;
|
||||
};
|
||||
|
||||
export type DashboardWidgetType = "dayControl" | "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes";
|
||||
@@ -479,6 +484,35 @@ export type StatisticsTeamspaceDay = {
|
||||
project_teamspace_project: string | null;
|
||||
};
|
||||
|
||||
export type StatisticsProjectFollowup = {
|
||||
project_id: string;
|
||||
ticket_id: string;
|
||||
ticket_number: string;
|
||||
title: string | null;
|
||||
organization_name: string | null;
|
||||
teamspace_project: string | null;
|
||||
hour_budget_minutes: number | null;
|
||||
consumption_minutes: number;
|
||||
remaining_minutes: number | null;
|
||||
latest_tt_update: string;
|
||||
last_session_at: string | null;
|
||||
project_updated_at: string;
|
||||
open_followup_todo_id: string | null;
|
||||
followup_state: "open" | "done" | "dismissed" | null;
|
||||
open_todos: number;
|
||||
overdue_todos: number;
|
||||
};
|
||||
|
||||
export type StatisticsProjectFollowupOverview = {
|
||||
enabled: boolean;
|
||||
weeks: number;
|
||||
secondEnabled: boolean;
|
||||
secondWeeks: number;
|
||||
createdTodos: number;
|
||||
updatedTodos: number;
|
||||
projects: StatisticsProjectFollowup[];
|
||||
};
|
||||
|
||||
export type StatisticsOverview = {
|
||||
periodType: "month" | "week";
|
||||
period: string;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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, "&")
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user