This commit is contained in:
michael committed 2026-09-15 14:11:23 +00:00
1 parent 62d8f537a8
commit 8a4cf3fc9b
4 files changed
+313 -206

No files matched your search

@@ -0,0 +1,92 @@
import { ChevronsUpDown } from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
export type MultiFilterOption = { value: string; label: string };
type MultiFilterDropdownProps = {
values: string[];
options: MultiFilterOption[];
onChange: (values: string[]) => void;
placeholder?: string;
countLabel?: string;
emptyLabel?: string;
};
export function MultiFilterDropdown({
values,
options,
onChange,
placeholder = "Alle Filter",
countLabel = "Filter",
emptyLabel = "Keine Filter."
}: MultiFilterDropdownProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const normalizedSearch = search.trim().toLocaleLowerCase("de-DE");
const visibleOptions = normalizedSearch
? options.filter((option) => option.label.toLocaleLowerCase("de-DE").includes(normalizedSearch) || option.value.toLocaleLowerCase("de-DE").includes(normalizedSearch))
: options;
const activeLabels = values.map((value) => options.find((option) => option.value === value)?.label).filter(Boolean);
function toggle(value: string) {
onChange(values.includes(value) ? values.filter((filter) => filter !== value) : [...values, value]);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" role="combobox" aria-expanded={open} className="h-8 w-full justify-between px-2 text-xs font-normal">
<span className={cn("truncate", values.length === 0 && "text-muted-foreground")}>
{activeLabels.length === 0 ? placeholder : activeLabels.length === 1 ? activeLabels[0] : `${activeLabels.length} ${countLabel}`}
</span>
<ChevronsUpDown className="size-3.5 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[--radix-popover-trigger-width] gap-1 p-1.5">
<Input className="h-7 text-xs" value={search} placeholder="Suchen..." onChange={(event) => setSearch(event.currentTarget.value)} />
<div className="max-h-64 overflow-y-auto pr-1">
{visibleOptions.length === 0 ? (
<p className="px-2 py-3 text-center text-xs text-muted-foreground">{emptyLabel}</p>
) : (
<div className="space-y-0.5">
{visibleOptions.map((option) => {
const checked = values.includes(option.value);
return (
<div
key={option.value}
role="button"
tabIndex={0}
aria-pressed={checked}
className="flex h-7 w-full min-w-0 cursor-default items-center gap-2 rounded-md px-2 text-left text-xs outline-none hover:bg-muted focus-visible:bg-muted"
onClick={() => toggle(option.value)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggle(option.value);
}
}}
>
<Checkbox checked={checked} tabIndex={-1} className="pointer-events-none" />
<span className="truncate">{option.label}</span>
</div>
);
})}
</div>
)}
</div>
{values.length > 0 ? (
<Button type="button" size="sm" variant="ghost" className="h-6 justify-start px-2 text-[11px]" onClick={() => onChange([])}>
Zurücksetzen
</Button>
) : null}
</PopoverContent>
</Popover>
);
}
+201 -114
View File
@@ -1,4 +1,4 @@
import { ChevronLeft, ChevronRight, Clock3, Download, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react"; import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, Clock3, Download, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -11,6 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { CopyTicketButton } from "@/components/CopyTicketButton"; import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink"; import { HelpLink } from "@/components/HelpLink";
import { MultiFilterDropdown, type MultiFilterOption } from "@/components/MultiFilterDropdown";
import { downloadMonthExport, getPeriodOverview } from "../api"; import { downloadMonthExport, getPeriodOverview } from "../api";
import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format"; import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format";
import type { PeriodOverview, PeriodType, TicketSummary } from "../types"; import type { PeriodOverview, PeriodType, TicketSummary } from "../types";
@@ -22,17 +23,15 @@ type AnalysisPageProps = {
type TicketGroupBy = "none" | "workType" | "organization" | "status"; type TicketGroupBy = "none" | "workType" | "organization" | "status";
type TicketSortBy = "ticket" | "workType" | "organization" | "status"; type TicketSortBy = "ticket" | "workType" | "organization" | "status";
type TicketSortDirection = "asc" | "desc"; type TicketSortDirection = "asc" | "desc";
type TicketStatusFilter = "all" | "open" | "evaluated"; type TicketFilterOption = MultiFilterOption;
type TicketWorkTypeFilter = "all" | "support" | "consulting";
type AnalysisTicketViewSettings = { type AnalysisTicketViewSettings = {
groupBy: TicketGroupBy; groupBy: TicketGroupBy;
sortBy: TicketSortBy; sortBy: TicketSortBy;
sortDirection: TicketSortDirection; sortDirection: TicketSortDirection;
query: string; query: string;
workType: TicketWorkTypeFilter; filterValues: string[];
organization: string; organizationValues: string[];
status: TicketStatusFilter;
}; };
type TicketSection = { type TicketSection = {
@@ -51,11 +50,19 @@ const defaultTicketViewSettings: AnalysisTicketViewSettings = {
sortBy: "ticket", sortBy: "ticket",
sortDirection: "asc", sortDirection: "asc",
query: "", query: "",
workType: "all", filterValues: [],
organization: "all", organizationValues: []
status: "all"
}; };
const ticketStaticFilterOptions: TicketFilterOption[] = [
{ value: "workType:support", label: "Support" },
{ value: "workType:consulting", label: "Consulting" },
{ value: "status:open", label: "Offen" },
{ value: "status:evaluated", label: "Bewertet" }
];
const ticketStaticFilterValues = new Set(ticketStaticFilterOptions.map((option) => option.value));
const groupByOptions: Array<{ value: TicketGroupBy; label: string }> = [ const groupByOptions: Array<{ value: TicketGroupBy; label: string }> = [
{ value: "none", label: "Keine" }, { value: "none", label: "Keine" },
{ value: "workType", label: "Typ" }, { value: "workType", label: "Typ" },
@@ -70,6 +77,56 @@ const sortByOptions: Array<{ value: TicketSortBy; label: string }> = [
{ value: "status", label: "Status" } { value: "status", label: "Status" }
]; ];
function isKnownTicketFilterValue(value: unknown): value is string {
if (typeof value !== "string") {
return false;
}
return ticketStaticFilterValues.has(value);
}
function normalizedTicketFilterValues(values: unknown) {
if (!Array.isArray(values)) {
return defaultTicketViewSettings.filterValues;
}
return Array.from(new Set(values.filter(isKnownTicketFilterValue)));
}
function normalizedTicketOrganizationValues(values: unknown) {
if (!Array.isArray(values)) {
return defaultTicketViewSettings.organizationValues;
}
return Array.from(new Set(values.filter((value): value is string => typeof value === "string" && value.trim() !== "")));
}
function legacyTicketFilterValues(parsed: Partial<AnalysisTicketViewSettings> & Record<string, unknown>) {
const filters: string[] = [];
if (parsed.workType === "support" || parsed.workType === "consulting") {
filters.push(`workType:${parsed.workType}`);
}
if (parsed.status === "open" || parsed.status === "evaluated") {
filters.push(`status:${parsed.status}`);
}
return filters;
}
function legacyTicketOrganizationValues(parsed: Partial<AnalysisTicketViewSettings> & Record<string, unknown>) {
if (Array.isArray(parsed.organizationValues)) {
return normalizedTicketOrganizationValues(parsed.organizationValues);
}
if (typeof parsed.organization === "string" && parsed.organization !== "all") {
return [parsed.organization];
}
return defaultTicketViewSettings.organizationValues;
}
function readStoredTicketViewSettings(): AnalysisTicketViewSettings { function readStoredTicketViewSettings(): AnalysisTicketViewSettings {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return defaultTicketViewSettings; return defaultTicketViewSettings;
@@ -83,15 +140,17 @@ function readStoredTicketViewSettings(): AnalysisTicketViewSettings {
} }
const parsed = JSON.parse(raw) as Partial<AnalysisTicketViewSettings>; const parsed = JSON.parse(raw) as Partial<AnalysisTicketViewSettings>;
const filterValues = Array.isArray(parsed.filterValues)
? normalizedTicketFilterValues(parsed.filterValues)
: legacyTicketFilterValues(parsed as Partial<AnalysisTicketViewSettings> & Record<string, unknown>);
return { return {
groupBy: groupByOptions.find((option) => option.value === parsed.groupBy)?.value ?? defaultTicketViewSettings.groupBy, groupBy: groupByOptions.find((option) => option.value === parsed.groupBy)?.value ?? defaultTicketViewSettings.groupBy,
sortBy: sortByOptions.find((option) => option.value === parsed.sortBy)?.value ?? defaultTicketViewSettings.sortBy, sortBy: sortByOptions.find((option) => option.value === parsed.sortBy)?.value ?? defaultTicketViewSettings.sortBy,
sortDirection: parsed.sortDirection === "desc" ? "desc" : defaultTicketViewSettings.sortDirection, sortDirection: parsed.sortDirection === "desc" ? "desc" : defaultTicketViewSettings.sortDirection,
query: typeof parsed.query === "string" ? parsed.query : defaultTicketViewSettings.query, query: typeof parsed.query === "string" ? parsed.query : defaultTicketViewSettings.query,
workType: parsed.workType === "support" || parsed.workType === "consulting" ? parsed.workType : defaultTicketViewSettings.workType, filterValues,
organization: typeof parsed.organization === "string" ? parsed.organization : defaultTicketViewSettings.organization, organizationValues: legacyTicketOrganizationValues(parsed as Partial<AnalysisTicketViewSettings> & Record<string, unknown>)
status: parsed.status === "open" || parsed.status === "evaluated" ? parsed.status : defaultTicketViewSettings.status
}; };
} catch { } catch {
return defaultTicketViewSettings; return defaultTicketViewSettings;
@@ -136,6 +195,57 @@ function organizationKey(ticket: TicketSummary) {
return ticket.organization_id ? `id:${ticket.organization_id}` : `name:${organizationLabel(ticket)}`; return ticket.organization_id ? `id:${ticket.organization_id}` : `name:${organizationLabel(ticket)}`;
} }
function groupedTicketFilterMatch(filters: string[], groupForFilter: (filter: string) => string, matchesFilter: (filter: string) => boolean) {
if (filters.length === 0) {
return true;
}
const groups = new Map<string, string[]>();
for (const filter of filters) {
const group = groupForFilter(filter);
groups.set(group, [...(groups.get(group) ?? []), filter]);
}
return Array.from(groups.values()).every((groupFilters) => groupFilters.some((filter) => matchesFilter(filter)));
}
function ticketFilterGroup(filter: string) {
if (filter.startsWith("workType:")) {
return "workType";
}
if (filter.startsWith("status:")) {
return "status";
}
return "misc";
}
function matchesSingleTicketFilter(ticket: TicketSummary, filter: string) {
if (filter.startsWith("workType:")) {
const workType = filter.slice("workType:".length);
return ticket.work_type === workType;
}
if (filter.startsWith("status:")) {
const status = filter.slice("status:".length);
return ticketStatusKey(ticket) === status;
}
return true;
}
function matchesTicketFilters(ticket: TicketSummary, filters: string[], organizationValues: string[]) {
if (organizationValues.length > 0 && !organizationValues.includes(organizationKey(ticket))) {
return false;
}
return groupedTicketFilterMatch(filters, ticketFilterGroup, (filter) => matchesSingleTicketFilter(ticket, filter));
}
function compareText(left: string, right: string) { function compareText(left: string, right: string) {
return left.localeCompare(right, "de", { numeric: true, sensitivity: "base" }); return left.localeCompare(right, "de", { numeric: true, sensitivity: "base" });
} }
@@ -356,24 +466,20 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
ticket.ticket_number.toLocaleLowerCase("de").includes(normalizedQuery) || ticket.ticket_number.toLocaleLowerCase("de").includes(normalizedQuery) ||
(ticket.title ?? "").toLocaleLowerCase("de").includes(normalizedQuery) || (ticket.title ?? "").toLocaleLowerCase("de").includes(normalizedQuery) ||
organizationLabel(ticket).toLocaleLowerCase("de").includes(normalizedQuery); organizationLabel(ticket).toLocaleLowerCase("de").includes(normalizedQuery);
const matchesWorkType = ticketViewSettings.workType === "all" || ticket.work_type === ticketViewSettings.workType;
const matchesOrganization = ticketViewSettings.organization === "all" || organizationKey(ticket) === ticketViewSettings.organization;
const matchesStatus = ticketViewSettings.status === "all" || ticketStatusKey(ticket) === ticketViewSettings.status;
return matchesQuery && matchesWorkType && matchesOrganization && matchesStatus; return matchesQuery && matchesTicketFilters(ticket, ticketViewSettings.filterValues, ticketViewSettings.organizationValues);
}); });
return sortTickets(filtered, ticketViewSettings.sortBy, ticketViewSettings.sortDirection); return sortTickets(filtered, ticketViewSettings.sortBy, ticketViewSettings.sortDirection);
}, [tickets, ticketViewSettings]); }, [tickets, ticketViewSettings]);
const ticketSections = useMemo(() => groupTicketSections(visibleTickets, ticketViewSettings.groupBy), [visibleTickets, ticketViewSettings.groupBy]); const ticketSections = useMemo(() => groupTicketSections(visibleTickets, ticketViewSettings.groupBy), [visibleTickets, ticketViewSettings.groupBy]);
const activeFilterCount = [ const activeFilterCount = [
ticketViewSettings.query.trim(), ticketViewSettings.query.trim() !== "",
ticketViewSettings.workType !== "all", JSON.stringify(ticketViewSettings.filterValues) !== JSON.stringify(defaultTicketViewSettings.filterValues),
ticketViewSettings.organization !== "all", ticketViewSettings.organizationValues.length > 0,
ticketViewSettings.status !== "all", ticketViewSettings.groupBy !== defaultTicketViewSettings.groupBy,
ticketViewSettings.groupBy !== "none", ticketViewSettings.sortBy !== defaultTicketViewSettings.sortBy,
ticketViewSettings.sortBy !== "ticket", ticketViewSettings.sortDirection !== defaultTicketViewSettings.sortDirection
ticketViewSettings.sortDirection !== "asc"
].filter(Boolean).length; ].filter(Boolean).length;
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@@ -449,102 +555,61 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
</div> </div>
<Card> <Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0"> <CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div> <div>
<CardTitle>Tickets im Zeitraum</CardTitle> <CardTitle className="flex items-center gap-2 text-base">
<CardDescription>
{periodType === "month" ? "Sessions prüfen, bewerten und bei Bedarf Tickets öffnen." : "Tagesansicht ohne eigenen Abschluss."}
</CardDescription>
</div>
<HelpLink anchor="auswertung" label="Hilfe zu Tickets im Zeitraum" />
</CardHeader>
<CardContent className="px-4 pb-4">
<div className="mb-4 rounded-md border bg-muted/20 p-3">
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<SlidersHorizontal className="size-4 text-muted-foreground" /> <SlidersHorizontal className="size-4 text-muted-foreground" />
<div> Filter und Sortierung
<p className="text-sm font-medium">Ansicht</p> {activeFilterCount > 0 ? <Badge variant="secondary">{activeFilterCount}</Badge> : null}
<p className="text-xs text-muted-foreground"> </CardTitle>
{visibleTickets.length} von {tickets.length} Ticket(s) <CardDescription>{visibleTickets.length} von {tickets.length} Ticket(s) sichtbar.</CardDescription>
</p>
</div> </div>
{activeFilterCount > 0 ? <Badge variant="outline">{activeFilterCount} aktiv</Badge> : null} <div className="flex items-center gap-2">
<HelpLink anchor="auswertung" label="Hilfe zu Filtern und Sortierung" /> <HelpLink anchor="auswertung" label="Hilfe zu Filtern und Sortierung" />
</div> <Button type="button" size="sm" variant="ghost" disabled={activeFilterCount === 0} onClick={() => setTicketViewSettings(defaultTicketViewSettings)}>
<Button <RotateCcw className="size-4" />
type="button"
size="sm"
variant="secondary"
disabled={activeFilterCount === 0}
onClick={() => setTicketViewSettings(defaultTicketViewSettings)}
>
<RotateCcw className="size-3.5" />
Zurücksetzen Zurücksetzen
</Button> </Button>
</div> </div>
</div>
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-[minmax(180px,1fr)_150px_190px_140px_140px_140px_140px]"> </CardHeader>
<div className="space-y-1"> <CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
<label className="text-xs font-medium text-muted-foreground" htmlFor="ticket-filter-query">Suche</label> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor="ticket-filter-query">
Suche
</label>
<Input <Input
id="ticket-filter-query" id="ticket-filter-query"
className="h-8" className="h-8"
value={ticketViewSettings.query} value={ticketViewSettings.query}
placeholder="Ticket oder Organisation" placeholder="Ticket, Titel, Organisation..."
onChange={(event) => { onChange={(event) => {
const query = event.currentTarget.value; const query = event.currentTarget.value;
setTicketViewSettings((current) => ({ ...current, query })); setTicketViewSettings((current) => ({ ...current, query }));
}} }}
/> />
</div> </div>
<div className="space-y-1.5">
<div className="space-y-1"> <label className="text-xs font-medium text-muted-foreground">Filter</label>
<label className="text-xs font-medium text-muted-foreground">Typ</label> <MultiFilterDropdown
<Select value={ticketViewSettings.workType} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, workType: value as TicketWorkTypeFilter }))}> values={ticketViewSettings.filterValues}
<SelectTrigger size="sm" className="w-full"> options={ticketStaticFilterOptions}
<SelectValue /> onChange={(filterValues) => setTicketViewSettings((current) => ({ ...current, filterValues }))}
</SelectTrigger> />
<SelectContent>
<SelectItem value="all">Alle Typen</SelectItem>
<SelectItem value="support">Support</SelectItem>
<SelectItem value="consulting">Consulting</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="space-y-1.5">
<div className="space-y-1"> <label className="text-xs font-medium text-muted-foreground">Organisationen</label>
<label className="text-xs font-medium text-muted-foreground">Organisation</label> <MultiFilterDropdown
<Select value={ticketViewSettings.organization} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, organization: value }))}> values={ticketViewSettings.organizationValues}
<SelectTrigger size="sm" className="w-full"> options={organizationOptions}
<SelectValue /> onChange={(organizationValues) => setTicketViewSettings((current) => ({ ...current, organizationValues }))}
</SelectTrigger> placeholder="Alle Org."
<SelectContent> countLabel="Org."
<SelectItem value="all">Alle Organisationen</SelectItem> emptyLabel="Keine Org."
{organizationOptions.map((organization) => ( />
<SelectItem key={organization.value} value={organization.value}>
{organization.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<div className="space-y-1.5">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Status</label>
<Select value={ticketViewSettings.status} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, status: value as TicketStatusFilter }))}>
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle Status</SelectItem>
<SelectItem value="open">Offen</SelectItem>
<SelectItem value="evaluated">Bewertet</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Gruppierung</label> <label className="text-xs font-medium text-muted-foreground">Gruppierung</label>
<Select value={ticketViewSettings.groupBy} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, groupBy: value as TicketGroupBy }))}> <Select value={ticketViewSettings.groupBy} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, groupBy: value as TicketGroupBy }))}>
<SelectTrigger size="sm" className="w-full"> <SelectTrigger size="sm" className="w-full">
@@ -559,8 +624,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-1.5">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Sortierung</label> <label className="text-xs font-medium text-muted-foreground">Sortierung</label>
<Select value={ticketViewSettings.sortBy} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, sortBy: value as TicketSortBy }))}> <Select value={ticketViewSettings.sortBy} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, sortBy: value as TicketSortBy }))}>
<SelectTrigger size="sm" className="w-full"> <SelectTrigger size="sm" className="w-full">
@@ -575,22 +639,45 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-1.5">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Richtung</label> <label className="text-xs font-medium text-muted-foreground">Richtung</label>
<Select value={ticketViewSettings.sortDirection} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, sortDirection: value as TicketSortDirection }))}> <div className="grid grid-cols-2 rounded-md border bg-background p-0.5">
<SelectTrigger size="sm" className="w-full"> <Button
<SelectValue /> type="button"
</SelectTrigger> size="sm"
<SelectContent> variant={ticketViewSettings.sortDirection === "asc" ? "secondary" : "ghost"}
<SelectItem value="asc">Aufsteigend</SelectItem> className="h-7 px-2"
<SelectItem value="desc">Absteigend</SelectItem> onClick={() => setTicketViewSettings((current) => ({ ...current, sortDirection: "asc" }))}
</SelectContent> >
</Select> <ArrowUp className="size-3.5" />
</div> Auf
</Button>
<Button
type="button"
size="sm"
variant={ticketViewSettings.sortDirection === "desc" ? "secondary" : "ghost"}
className="h-7 px-2"
onClick={() => setTicketViewSettings((current) => ({ ...current, sortDirection: "desc" }))}
>
<ArrowDown className="size-3.5" />
Ab
</Button>
</div> </div>
</div> </div>
</CardContent>
</Card>
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<CardTitle>Tickets im Zeitraum</CardTitle>
<CardDescription>
{periodType === "month" ? "Sessions prüfen, bewerten und bei Bedarf Tickets öffnen." : "Tagesansicht ohne eigenen Abschluss."}
</CardDescription>
</div>
<HelpLink anchor="auswertung" label="Hilfe zu Tickets im Zeitraum" />
</CardHeader>
<CardContent className="px-4 pb-4">
<div className="hidden md:block"> <div className="hidden md:block">
<Table> <Table>
<TableHeader> <TableHeader>
+5 -85
View File
@@ -1,4 +1,4 @@
import { ArrowDown, ArrowUp, CheckCircle2, ChevronsUpDown, Eye, EyeOff, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Save, Search, SlidersHorizontal, Star, Trash2 } from "lucide-react"; import { ArrowDown, ArrowUp, CheckCircle2, Eye, EyeOff, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Save, Search, SlidersHorizontal, Star, Trash2 } from "lucide-react";
import { FormEvent, useEffect, useMemo, useState } from "react"; import { FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -9,10 +9,10 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { HelpLink } from "@/components/HelpLink"; import { HelpLink } from "@/components/HelpLink";
import { MultiFilterDropdown, type MultiFilterOption } from "@/components/MultiFilterDropdown";
import { OrganizationSelect } from "@/components/OrganizationSelect"; import { OrganizationSelect } from "@/components/OrganizationSelect";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { addFavoriteTicket, createProject, getProjects, hideProject, lookupTicket, unhideProject, updateProject, updateProjectStatus } from "../api"; import { addFavoriteTicket, createProject, getProjects, hideProject, lookupTicket, unhideProject, updateProject, updateProjectStatus } from "../api";
@@ -60,7 +60,7 @@ type ProjectTeamspaceEntryForm = {
type ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "consumption" | "consumptionPercent" | "remaining" | "budget"; type ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "consumption" | "consumptionPercent" | "remaining" | "budget";
type ProjectSortDirection = "asc" | "desc"; type ProjectSortDirection = "asc" | "desc";
type ProjectFilterOption = { value: string; label: string }; type ProjectFilterOption = MultiFilterOption;
type ProjectViewSettings = { type ProjectViewSettings = {
query: string; query: string;
@@ -356,86 +356,6 @@ function SortDirectionIcon({ direction }: { direction: ProjectSortDirection }) {
return direction === "asc" ? <ArrowUp className="size-3.5" /> : <ArrowDown className="size-3.5" />; return direction === "asc" ? <ArrowUp className="size-3.5" /> : <ArrowDown className="size-3.5" />;
} }
function ProjectFilterDropdown({
values,
options,
onChange,
placeholder = "Alle Filter",
countLabel = "Filter",
emptyLabel = "Keine Filter."
}: {
values: string[];
options: ProjectFilterOption[];
onChange: (values: string[]) => void;
placeholder?: string;
countLabel?: string;
emptyLabel?: string;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const normalizedSearch = search.trim().toLocaleLowerCase("de-DE");
const visibleOptions = normalizedSearch
? options.filter((option) => option.label.toLocaleLowerCase("de-DE").includes(normalizedSearch) || option.value.toLocaleLowerCase("de-DE").includes(normalizedSearch))
: options;
const activeLabels = values.map((value) => options.find((option) => option.value === value)?.label).filter(Boolean);
function toggle(value: string) {
onChange(values.includes(value) ? values.filter((filter) => filter !== value) : [...values, value]);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" role="combobox" aria-expanded={open} className="h-8 w-full justify-between px-2 text-xs font-normal">
<span className={cn("truncate", values.length === 0 && "text-muted-foreground")}>
{activeLabels.length === 0 ? placeholder : activeLabels.length === 1 ? activeLabels[0] : `${activeLabels.length} ${countLabel}`}
</span>
<ChevronsUpDown className="size-3.5 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[--radix-popover-trigger-width] gap-1 p-1.5">
<Input className="h-7 text-xs" value={search} placeholder="Suchen..." onChange={(event) => setSearch(event.currentTarget.value)} />
<div className="max-h-64 overflow-y-auto pr-1">
{visibleOptions.length === 0 ? (
<p className="px-2 py-3 text-center text-xs text-muted-foreground">{emptyLabel}</p>
) : (
<div className="space-y-0.5">
{visibleOptions.map((option) => {
const checked = values.includes(option.value);
return (
<div
key={option.value}
role="button"
tabIndex={0}
aria-pressed={checked}
className="flex h-7 w-full min-w-0 cursor-default items-center gap-2 rounded-md px-2 text-left text-xs outline-none hover:bg-muted focus-visible:bg-muted"
onClick={() => toggle(option.value)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggle(option.value);
}
}}
>
<Checkbox checked={checked} tabIndex={-1} className="pointer-events-none" />
<span className="truncate">{option.label}</span>
</div>
);
})}
</div>
)}
</div>
{values.length > 0 ? (
<Button type="button" size="sm" variant="ghost" className="h-6 justify-start px-2 text-[11px]" onClick={() => onChange([])}>
Zurücksetzen
</Button>
) : null}
</PopoverContent>
</Popover>
);
}
function parseHoursInput(value: string) { function parseHoursInput(value: string) {
const trimmed = value.trim(); const trimmed = value.trim();
@@ -1384,7 +1304,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Filter</label> <label className="text-xs font-medium text-muted-foreground">Filter</label>
<ProjectFilterDropdown <MultiFilterDropdown
values={viewSettings.filterValues} values={viewSettings.filterValues}
options={projectStaticFilterOptions} options={projectStaticFilterOptions}
onChange={(filterValues) => setViewSettings((current) => ({ ...current, filterValues }))} onChange={(filterValues) => setViewSettings((current) => ({ ...current, filterValues }))}
@@ -1392,7 +1312,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Organisationen</label> <label className="text-xs font-medium text-muted-foreground">Organisationen</label>
<ProjectFilterDropdown <MultiFilterDropdown
values={viewSettings.organizationValues} values={viewSettings.organizationValues}
options={organizationOptions} options={organizationOptions}
onChange={(organizationValues) => setViewSettings((current) => ({ ...current, organizationValues }))} onChange={(organizationValues) => setViewSettings((current) => ({ ...current, organizationValues }))}
+15 -7
View File
@@ -1628,20 +1628,23 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<div className="space-y-2"> <div className="space-y-2">
{sessionGroups.map((group) => ( {sessionGroups.map((group) => (
<div key={group.key} className="rounded-md border bg-background"> <div key={group.key} className="rounded-md border bg-background">
<div className="flex min-w-0 flex-col gap-2 border-b bg-muted/25 px-3 py-2 lg:flex-row lg:items-center lg:justify-between"> <div className="grid min-w-0 gap-2 border-b bg-muted/25 px-3 py-2 lg:grid-cols-[minmax(180px,1fr)_auto] lg:items-center">
<div className="min-w-0 text-left"> <div className="min-w-0 text-left">
<p className="truncate font-medium">{group.label}</p> <p className="truncate font-medium">{group.label}</p>
<p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p> <p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="grid w-full gap-2 sm:grid-cols-[13rem_10rem_8rem] xl:w-auto xl:grid-cols-[13rem_10rem_8rem_auto] xl:items-center xl:justify-end">
<Button type="button" size="sm" variant="secondary" className="h-7" onClick={() => openTeamspacePreparation(group)}> <Button type="button" size="sm" variant="secondary" className="h-7 w-full justify-center" onClick={() => openTeamspacePreparation(group)}>
<ClipboardList className="size-3.5" /> <ClipboardList className="size-3.5" />
TS Aufbereitung TS Aufbereitung
</Button> </Button>
<Badge variant="outline">Abgerechnet {formatMinutes(group.totalMinutes)}</Badge> <Badge variant="outline" className="flex h-7 w-full justify-center whitespace-nowrap">
{group.nonBillableMinutes > 0 ? <Badge variant="secondary">Nicht {formatMinutes(group.nonBillableMinutes)}</Badge> : null} Abgerechnet {formatMinutes(group.totalMinutes)}
{group.openMinutes > 0 ? <Badge variant="warning">Offen {formatMinutes(group.openMinutes)}</Badge> : null} </Badge>
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <Badge variant={group.openMinutes > 0 ? "warning" : "success"} className="flex h-7 w-full justify-center whitespace-nowrap">
Offen {formatMinutes(group.openMinutes)}
</Badge>
<div className="flex items-center gap-2 text-xs text-muted-foreground sm:col-span-3 xl:col-span-1">
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label> <label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
<Input <Input
id={`crm-billing-${group.key}`} id={`crm-billing-${group.key}`}
@@ -1683,6 +1686,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
{savingDayBillingKey === group.key ? "Speichert..." : "Speichern"} {savingDayBillingKey === group.key ? "Speichert..." : "Speichern"}
</Button> </Button>
</div> </div>
{group.nonBillableMinutes > 0 ? (
<Badge variant="secondary" className="flex h-7 w-full justify-center whitespace-nowrap sm:col-span-3 xl:col-span-1 xl:col-start-2">
Nicht {formatMinutes(group.nonBillableMinutes)}
</Badge>
) : null}
</div> </div>
</div> </div>
<div className="space-y-1.5 p-2 sm:p-3"> <div className="space-y-1.5 p-2 sm:p-3">