Update
This commit is contained in:
1 parent
62d8f537a8
commit
8a4cf3fc9b
4 files changed
+354
-247
No files matched your search
+242
-155
@@ -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<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 {
|
||||
if (typeof window === "undefined") {
|
||||
return defaultTicketViewSettings;
|
||||
@@ -83,15 +140,17 @@ function readStoredTicketViewSettings(): 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 {
|
||||
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<AnalysisTicketViewSettings> & Record<string, unknown>)
|
||||
};
|
||||
} 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<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) {
|
||||
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 (
|
||||
<div className="space-y-5">
|
||||
@@ -448,6 +554,119 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<SlidersHorizontal className="size-4 text-muted-foreground" />
|
||||
Filter und Sortierung
|
||||
{activeFilterCount > 0 ? <Badge variant="secondary">{activeFilterCount}</Badge> : null}
|
||||
</CardTitle>
|
||||
<CardDescription>{visibleTickets.length} von {tickets.length} Ticket(s) sichtbar.</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<HelpLink anchor="auswertung" label="Hilfe zu Filtern und Sortierung" />
|
||||
<Button type="button" size="sm" variant="ghost" disabled={activeFilterCount === 0} onClick={() => setTicketViewSettings(defaultTicketViewSettings)}>
|
||||
<RotateCcw className="size-4" />
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="ticket-filter-query">
|
||||
Suche
|
||||
</label>
|
||||
<Input
|
||||
id="ticket-filter-query"
|
||||
className="h-8"
|
||||
value={ticketViewSettings.query}
|
||||
placeholder="Ticket, Titel, Organisation..."
|
||||
onChange={(event) => {
|
||||
const query = event.currentTarget.value;
|
||||
setTicketViewSettings((current) => ({ ...current, query }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Filter</label>
|
||||
<MultiFilterDropdown
|
||||
values={ticketViewSettings.filterValues}
|
||||
options={ticketStaticFilterOptions}
|
||||
onChange={(filterValues) => setTicketViewSettings((current) => ({ ...current, filterValues }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Organisationen</label>
|
||||
<MultiFilterDropdown
|
||||
values={ticketViewSettings.organizationValues}
|
||||
options={organizationOptions}
|
||||
onChange={(organizationValues) => setTicketViewSettings((current) => ({ ...current, organizationValues }))}
|
||||
placeholder="Alle Org."
|
||||
countLabel="Org."
|
||||
emptyLabel="Keine Org."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Gruppierung</label>
|
||||
<Select value={ticketViewSettings.groupBy} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, groupBy: value as TicketGroupBy }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupByOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Sortierung</label>
|
||||
<Select value={ticketViewSettings.sortBy} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, sortBy: value as TicketSortBy }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortByOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Richtung</label>
|
||||
<div className="grid grid-cols-2 rounded-md border bg-background p-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={ticketViewSettings.sortDirection === "asc" ? "secondary" : "ghost"}
|
||||
className="h-7 px-2"
|
||||
onClick={() => setTicketViewSettings((current) => ({ ...current, sortDirection: "asc" }))}
|
||||
>
|
||||
<ArrowUp className="size-3.5" />
|
||||
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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
@@ -459,138 +678,6 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
<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" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Ansicht</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{visibleTickets.length} von {tickets.length} Ticket(s)
|
||||
</p>
|
||||
</div>
|
||||
{activeFilterCount > 0 ? <Badge variant="outline">{activeFilterCount} aktiv</Badge> : null}
|
||||
<HelpLink anchor="auswertung" label="Hilfe zu Filtern und Sortierung" />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={activeFilterCount === 0}
|
||||
onClick={() => setTicketViewSettings(defaultTicketViewSettings)}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
</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]">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="ticket-filter-query">Suche</label>
|
||||
<Input
|
||||
id="ticket-filter-query"
|
||||
className="h-8"
|
||||
value={ticketViewSettings.query}
|
||||
placeholder="Ticket oder Organisation"
|
||||
onChange={(event) => {
|
||||
const query = event.currentTarget.value;
|
||||
setTicketViewSettings((current) => ({ ...current, query }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Typ</label>
|
||||
<Select value={ticketViewSettings.workType} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, workType: value as TicketWorkTypeFilter }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Alle Typen</SelectItem>
|
||||
<SelectItem value="support">Support</SelectItem>
|
||||
<SelectItem value="consulting">Consulting</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Organisation</label>
|
||||
<Select value={ticketViewSettings.organization} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, organization: value }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Alle Organisationen</SelectItem>
|
||||
{organizationOptions.map((organization) => (
|
||||
<SelectItem key={organization.value} value={organization.value}>
|
||||
{organization.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<Select value={ticketViewSettings.groupBy} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, groupBy: value as TicketGroupBy }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupByOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Sortierung</label>
|
||||
<Select value={ticketViewSettings.sortBy} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, sortBy: value as TicketSortBy }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortByOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Richtung</label>
|
||||
<Select value={ticketViewSettings.sortDirection} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, sortDirection: value as TicketSortDirection }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="asc">Aufsteigend</SelectItem>
|
||||
<SelectItem value="desc">Absteigend</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
||||
Reference in new issue
Block a user