diff --git a/frontend/src/components/MultiFilterDropdown.tsx b/frontend/src/components/MultiFilterDropdown.tsx new file mode 100644 index 0000000..ef85e85 --- /dev/null +++ b/frontend/src/components/MultiFilterDropdown.tsx @@ -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 ( + + + + + {activeLabels.length === 0 ? placeholder : activeLabels.length === 1 ? activeLabels[0] : `${activeLabels.length} ${countLabel}`} + + + + + + setSearch(event.currentTarget.value)} /> + + {visibleOptions.length === 0 ? ( + {emptyLabel} + ) : ( + + {visibleOptions.map((option) => { + const checked = values.includes(option.value); + + return ( + toggle(option.value)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggle(option.value); + } + }} + > + + {option.label} + + ); + })} + + )} + + {values.length > 0 ? ( + onChange([])}> + Zurücksetzen + + ) : null} + + + ); +} diff --git a/frontend/src/views/AnalysisPage.tsx b/frontend/src/views/AnalysisPage.tsx index f684372..60e01f3 100644 --- a/frontend/src/views/AnalysisPage.tsx +++ b/frontend/src/views/AnalysisPage.tsx @@ -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 { Fragment, useEffect, useMemo, useRef, useState } from "react"; 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 { CopyTicketButton } from "@/components/CopyTicketButton"; import { HelpLink } from "@/components/HelpLink"; +import { MultiFilterDropdown, type MultiFilterOption } from "@/components/MultiFilterDropdown"; import { downloadMonthExport, getPeriodOverview } from "../api"; import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format"; import type { PeriodOverview, PeriodType, TicketSummary } from "../types"; @@ -22,17 +23,15 @@ type AnalysisPageProps = { type TicketGroupBy = "none" | "workType" | "organization" | "status"; type TicketSortBy = "ticket" | "workType" | "organization" | "status"; type TicketSortDirection = "asc" | "desc"; -type TicketStatusFilter = "all" | "open" | "evaluated"; -type TicketWorkTypeFilter = "all" | "support" | "consulting"; +type TicketFilterOption = MultiFilterOption; type AnalysisTicketViewSettings = { groupBy: TicketGroupBy; sortBy: TicketSortBy; sortDirection: TicketSortDirection; query: string; - workType: TicketWorkTypeFilter; - organization: string; - status: TicketStatusFilter; + filterValues: string[]; + organizationValues: string[]; }; type TicketSection = { @@ -51,11 +50,19 @@ const defaultTicketViewSettings: AnalysisTicketViewSettings = { sortBy: "ticket", sortDirection: "asc", query: "", - workType: "all", - organization: "all", - status: "all" + filterValues: [], + organizationValues: [] }; +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 }> = [ { value: "none", label: "Keine" }, { value: "workType", label: "Typ" }, @@ -70,6 +77,56 @@ const sortByOptions: Array<{ value: TicketSortBy; label: string }> = [ { 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 & Record) { + 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 & Record) { + 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 { if (typeof window === "undefined") { return defaultTicketViewSettings; @@ -83,15 +140,17 @@ function readStoredTicketViewSettings(): AnalysisTicketViewSettings { } const parsed = JSON.parse(raw) as Partial; + const filterValues = Array.isArray(parsed.filterValues) + ? normalizedTicketFilterValues(parsed.filterValues) + : legacyTicketFilterValues(parsed as Partial & Record); return { groupBy: groupByOptions.find((option) => option.value === parsed.groupBy)?.value ?? defaultTicketViewSettings.groupBy, sortBy: sortByOptions.find((option) => option.value === parsed.sortBy)?.value ?? defaultTicketViewSettings.sortBy, sortDirection: parsed.sortDirection === "desc" ? "desc" : defaultTicketViewSettings.sortDirection, query: typeof parsed.query === "string" ? parsed.query : defaultTicketViewSettings.query, - workType: parsed.workType === "support" || parsed.workType === "consulting" ? parsed.workType : defaultTicketViewSettings.workType, - organization: typeof parsed.organization === "string" ? parsed.organization : defaultTicketViewSettings.organization, - status: parsed.status === "open" || parsed.status === "evaluated" ? parsed.status : defaultTicketViewSettings.status + filterValues, + organizationValues: legacyTicketOrganizationValues(parsed as Partial & Record) }; } catch { return defaultTicketViewSettings; @@ -136,6 +195,57 @@ function organizationKey(ticket: TicketSummary) { 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(); + + 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) { 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.title ?? "").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); }, [tickets, ticketViewSettings]); const ticketSections = useMemo(() => groupTicketSections(visibleTickets, ticketViewSettings.groupBy), [visibleTickets, ticketViewSettings.groupBy]); const activeFilterCount = [ - ticketViewSettings.query.trim(), - ticketViewSettings.workType !== "all", - ticketViewSettings.organization !== "all", - ticketViewSettings.status !== "all", - ticketViewSettings.groupBy !== "none", - ticketViewSettings.sortBy !== "ticket", - ticketViewSettings.sortDirection !== "asc" + ticketViewSettings.query.trim() !== "", + JSON.stringify(ticketViewSettings.filterValues) !== JSON.stringify(defaultTicketViewSettings.filterValues), + ticketViewSettings.organizationValues.length > 0, + ticketViewSettings.groupBy !== defaultTicketViewSettings.groupBy, + ticketViewSettings.sortBy !== defaultTicketViewSettings.sortBy, + ticketViewSettings.sortDirection !== defaultTicketViewSettings.sortDirection ].filter(Boolean).length; return ( @@ -448,6 +554,119 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) { ))} + + + + + + + Filter und Sortierung + {activeFilterCount > 0 ? {activeFilterCount} : null} + + {visibleTickets.length} von {tickets.length} Ticket(s) sichtbar. + + + + setTicketViewSettings(defaultTicketViewSettings)}> + + Zurücksetzen + + + + + + + + Suche + + { + const query = event.currentTarget.value; + setTicketViewSettings((current) => ({ ...current, query })); + }} + /> + + + Filter + setTicketViewSettings((current) => ({ ...current, filterValues }))} + /> + + + Organisationen + setTicketViewSettings((current) => ({ ...current, organizationValues }))} + placeholder="Alle Org." + countLabel="Org." + emptyLabel="Keine Org." + /> + + + Gruppierung + setTicketViewSettings((current) => ({ ...current, groupBy: value as TicketGroupBy }))}> + + + + + {groupByOptions.map((option) => ( + + {option.label} + + ))} + + + + + Sortierung + setTicketViewSettings((current) => ({ ...current, sortBy: value as TicketSortBy }))}> + + + + + {sortByOptions.map((option) => ( + + {option.label} + + ))} + + + + + Richtung + + setTicketViewSettings((current) => ({ ...current, sortDirection: "asc" }))} + > + + Auf + + setTicketViewSettings((current) => ({ ...current, sortDirection: "desc" }))} + > + + Ab + + + + + + @@ -459,138 +678,6 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) { - - - - - - Ansicht - - {visibleTickets.length} von {tickets.length} Ticket(s) - - - {activeFilterCount > 0 ? {activeFilterCount} aktiv : null} - - - setTicketViewSettings(defaultTicketViewSettings)} - > - - Zurücksetzen - - - - - - Suche - { - const query = event.currentTarget.value; - setTicketViewSettings((current) => ({ ...current, query })); - }} - /> - - - - Typ - setTicketViewSettings((current) => ({ ...current, workType: value as TicketWorkTypeFilter }))}> - - - - - Alle Typen - Support - Consulting - - - - - - Organisation - setTicketViewSettings((current) => ({ ...current, organization: value }))}> - - - - - Alle Organisationen - {organizationOptions.map((organization) => ( - - {organization.label} - - ))} - - - - - - Status - setTicketViewSettings((current) => ({ ...current, status: value as TicketStatusFilter }))}> - - - - - Alle Status - Offen - Bewertet - - - - - - Gruppierung - setTicketViewSettings((current) => ({ ...current, groupBy: value as TicketGroupBy }))}> - - - - - {groupByOptions.map((option) => ( - - {option.label} - - ))} - - - - - - Sortierung - setTicketViewSettings((current) => ({ ...current, sortBy: value as TicketSortBy }))}> - - - - - {sortByOptions.map((option) => ( - - {option.label} - - ))} - - - - - - Richtung - setTicketViewSettings((current) => ({ ...current, sortDirection: value as TicketSortDirection }))}> - - - - - Aufsteigend - Absteigend - - - - - - diff --git a/frontend/src/views/ProjectsPage.tsx b/frontend/src/views/ProjectsPage.tsx index 67ebc47..a3df0ee 100644 --- a/frontend/src/views/ProjectsPage.tsx +++ b/frontend/src/views/ProjectsPage.tsx @@ -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 { 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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; 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 { Textarea } from "@/components/ui/textarea"; import { HelpLink } from "@/components/HelpLink"; +import { MultiFilterDropdown, type MultiFilterOption } from "@/components/MultiFilterDropdown"; import { OrganizationSelect } from "@/components/OrganizationSelect"; import { cn } from "@/lib/utils"; 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 ProjectSortDirection = "asc" | "desc"; -type ProjectFilterOption = { value: string; label: string }; +type ProjectFilterOption = MultiFilterOption; type ProjectViewSettings = { query: string; @@ -356,86 +356,6 @@ function SortDirectionIcon({ direction }: { direction: ProjectSortDirection }) { return direction === "asc" ? : ; } -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 ( - - - - - {activeLabels.length === 0 ? placeholder : activeLabels.length === 1 ? activeLabels[0] : `${activeLabels.length} ${countLabel}`} - - - - - - setSearch(event.currentTarget.value)} /> - - {visibleOptions.length === 0 ? ( - {emptyLabel} - ) : ( - - {visibleOptions.map((option) => { - const checked = values.includes(option.value); - - return ( - toggle(option.value)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - toggle(option.value); - } - }} - > - - {option.label} - - ); - })} - - )} - - {values.length > 0 ? ( - onChange([])}> - Zurücksetzen - - ) : null} - - - ); -} - function parseHoursInput(value: string) { const trimmed = value.trim(); @@ -1384,7 +1304,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) { Filter - setViewSettings((current) => ({ ...current, filterValues }))} @@ -1392,7 +1312,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) { Organisationen - setViewSettings((current) => ({ ...current, organizationValues }))} diff --git a/frontend/src/views/TicketDetailPage.tsx b/frontend/src/views/TicketDetailPage.tsx index d03003b..a38b660 100644 --- a/frontend/src/views/TicketDetailPage.tsx +++ b/frontend/src/views/TicketDetailPage.tsx @@ -1628,20 +1628,23 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO {sessionGroups.map((group) => ( - + {group.label} {group.sessions.length} Session(s) - - openTeamspacePreparation(group)}> + + openTeamspacePreparation(group)}> TS Aufbereitung - Abgerechnet {formatMinutes(group.totalMinutes)} - {group.nonBillableMinutes > 0 ? Nicht {formatMinutes(group.nonBillableMinutes)} : null} - {group.openMinutes > 0 ? Offen {formatMinutes(group.openMinutes)} : null} - + + Abgerechnet {formatMinutes(group.totalMinutes)} + + 0 ? "warning" : "success"} className="flex h-7 w-full justify-center whitespace-nowrap"> + Offen {formatMinutes(group.openMinutes)} + + Teamspace + {group.nonBillableMinutes > 0 ? ( + + Nicht {formatMinutes(group.nonBillableMinutes)} + + ) : null}
{emptyLabel}
Ansicht
- {visibleTickets.length} von {tickets.length} Ticket(s) -
{group.label}
{group.sessions.length} Session(s)