Update
This commit is contained in:
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
@@ -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>
|
||||||
|
|||||||
@@ -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 }))}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in new issue
Block a user