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

No files matched your search

+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 { 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" ? <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) {
const trimmed = value.trim();
@@ -1384,7 +1304,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Filter</label>
<ProjectFilterDropdown
<MultiFilterDropdown
values={viewSettings.filterValues}
options={projectStaticFilterOptions}
onChange={(filterValues) => setViewSettings((current) => ({ ...current, filterValues }))}
@@ -1392,7 +1312,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Organisationen</label>
<ProjectFilterDropdown
<MultiFilterDropdown
values={viewSettings.organizationValues}
options={organizationOptions}
onChange={(organizationValues) => setViewSettings((current) => ({ ...current, organizationValues }))}