This commit is contained in:
michael committed 2026-09-15 11:49:47 +00:00
1 parent 235c19e51b
commit 227644cfeb
8 files changed
+1041 -309

No files matched your search

+406 -178
View File
@@ -1,4 +1,4 @@
import { ArrowDown, ArrowUp, CheckCircle2, Eye, EyeOff, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Save, Search, SlidersHorizontal, Star, Trash2 } from "lucide-react";
import { ArrowDown, ArrowUp, CheckCircle2, ChevronsUpDown, 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,6 +9,7 @@ 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";
@@ -31,7 +32,6 @@ import {
zammadStateLabel,
zammadStateSortRank
} from "../zammad-meta";
import type { ZammadPriorityFilter, ZammadStateFilter } from "../zammad-meta";
type ProjectsPageProps = {
onNavigate: (to: string) => void;
@@ -58,18 +58,15 @@ type ProjectTeamspaceEntryForm = {
isDefault: boolean;
};
type ProjectStatusFilter = "open" | "all" | "closed" | "hidden";
type ProjectBudgetFilter = "all" | "with-budget" | "without-budget" | "remaining" | "over-budget";
type ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "consumption" | "consumptionPercent" | "remaining" | "budget";
type ProjectSortDirection = "asc" | "desc";
type ProjectFilterOption = { value: string; label: string };
type ProjectViewSettings = {
query: string;
status: ProjectStatusFilter;
zammadStatus: ZammadStateFilter;
zammadPriority: ZammadPriorityFilter;
organization: string;
budget: ProjectBudgetFilter;
filterValues: string[];
organizationValues: string[];
standardTeamspaceOnly: boolean;
sortBy: ProjectSortBy;
sortDirection: ProjectSortDirection;
};
@@ -78,29 +75,26 @@ const projectViewSettingsKey = "tickettracker.projects.viewSettings";
const defaultProjectViewSettings: ProjectViewSettings = {
query: "",
status: "open",
zammadStatus: "all",
zammadPriority: "all",
organization: "all",
budget: "all",
filterValues: ["open"],
organizationValues: [],
standardTeamspaceOnly: false,
sortBy: "updated",
sortDirection: "desc"
};
const projectStatusOptions: Array<{ value: ProjectStatusFilter; label: string }> = [
{ value: "open", label: "Offen" },
{ value: "all", label: "Alle" },
{ value: "closed", label: "Geschlossen" },
{ value: "hidden", label: "Ausgeblendet" }
const projectStaticFilterOptions: ProjectFilterOption[] = [
{ value: "open", label: "TT Offen" },
{ value: "closed", label: "TT Geschl." },
{ value: "hidden", label: "Ausgebl." },
{ value: "budgeted", label: "Mit Kontingent" },
{ value: "withoutBudget", label: "Ohne Kontingent" },
{ value: "remaining", label: "Mit Rest" },
{ value: "overBudget", label: "Überzogen" },
...zammadStateFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadState:${option.value}`, label: `ZA ${option.label}` })),
...zammadPriorityFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadPriority:${option.value}`, label: `ZA Prio ${option.label}` }))
];
const projectBudgetOptions: Array<{ value: ProjectBudgetFilter; label: string }> = [
{ value: "all", label: "Alle Kontingente" },
{ value: "with-budget", label: "Mit Kontingent" },
{ value: "without-budget", label: "Ohne Kontingent" },
{ value: "remaining", label: "Mit Reststunden" },
{ value: "over-budget", label: "Überzogen" }
];
const projectStaticFilterValues = new Set(projectStaticFilterOptions.map((option) => option.value));
const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "zammadStatus", "zammadPriority", "consumption", "consumptionPercent", "remaining", "budget"];
@@ -110,8 +104,8 @@ const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [
{ value: "title", label: "Titel" },
{ value: "organization", label: "Organisation" },
{ value: "status", label: "Status" },
{ value: "zammadStatus", label: "Zammad-Status" },
{ value: "zammadPriority", label: "Zammad-Priorität" },
{ value: "zammadStatus", label: "ZA Status" },
{ value: "zammadPriority", label: "ZA Prio" },
{ value: "consumption", label: "Verbrauch" },
{ value: "consumptionPercent", label: "Verbrauch %" },
{ value: "remaining", label: "Reststunden" },
@@ -137,6 +131,115 @@ function emptyProjectTeamspaceEntry(isDefault = false): ProjectTeamspaceEntryFor
};
}
function isKnownProjectFilterValue(value: unknown): value is string {
if (typeof value !== "string") {
return false;
}
return projectStaticFilterValues.has(value);
}
function normalizedProjectFilterValues(values: unknown, fallback = defaultProjectViewSettings.filterValues) {
if (!Array.isArray(values)) {
return fallback;
}
return Array.from(new Set(values.filter(isKnownProjectFilterValue)));
}
function legacyProjectBudgetFilter(value: unknown) {
if (value === "with-budget") {
return "budgeted";
}
if (value === "without-budget") {
return "withoutBudget";
}
if (value === "remaining") {
return "remaining";
}
if (value === "over-budget") {
return "overBudget";
}
return null;
}
function legacyProjectFilters(parsed: Partial<ProjectViewSettings> & Record<string, unknown>) {
const filters: string[] = [];
const hasLegacyFilter = ["status", "zammadStatus", "zammadPriority", "organization", "budget"].some((key) => Object.prototype.hasOwnProperty.call(parsed, key));
if (parsed.status === "open" || parsed.status === "closed" || parsed.status === "hidden") {
filters.push(parsed.status);
}
if (typeof parsed.zammadStatus === "string" && parsed.zammadStatus !== "all" && zammadStateFilterOptions.some((option) => option.value === parsed.zammadStatus)) {
filters.push(`zammadState:${parsed.zammadStatus}`);
}
if (typeof parsed.zammadPriority === "string" && parsed.zammadPriority !== "all" && zammadPriorityFilterOptions.some((option) => option.value === parsed.zammadPriority)) {
filters.push(`zammadPriority:${parsed.zammadPriority}`);
}
const budgetFilter = legacyProjectBudgetFilter(parsed.budget);
if (budgetFilter) {
filters.push(budgetFilter);
}
if (hasLegacyFilter) {
return Array.from(new Set(filters));
}
return filters.length > 0 ? Array.from(new Set(filters)) : defaultProjectViewSettings.filterValues;
}
function normalizedProjectOrganizationValues(values: unknown) {
if (!Array.isArray(values)) {
return [];
}
return Array.from(
new Set(
values
.map((value) => {
if (typeof value !== "string") {
return "";
}
return value.startsWith("organization:") ? value.slice("organization:".length).trim() : value.trim();
})
.filter(Boolean)
)
);
}
function legacyProjectOrganizationValues(parsed: Partial<ProjectViewSettings> & Record<string, unknown>) {
if (Array.isArray(parsed.organizationValues)) {
return normalizedProjectOrganizationValues(parsed.organizationValues);
}
if (Array.isArray(parsed.filterValues)) {
return normalizedProjectOrganizationValues(parsed.filterValues.filter((value) => typeof value === "string" && value.startsWith("organization:")));
}
if (typeof parsed.organization === "string" && parsed.organization !== "all") {
return [parsed.organization];
}
return defaultProjectViewSettings.organizationValues;
}
function legacyStandardTeamspaceOnly(parsed: Partial<ProjectViewSettings> & Record<string, unknown>) {
if (typeof parsed.standardTeamspaceOnly === "boolean") {
return parsed.standardTeamspaceOnly;
}
return Array.isArray(parsed.filterValues) && parsed.filterValues.includes("standardBudgetOnly");
}
function readStoredProjectViewSettings(): ProjectViewSettings {
if (typeof window === "undefined") {
return defaultProjectViewSettings;
@@ -150,14 +253,15 @@ function readStoredProjectViewSettings(): ProjectViewSettings {
}
const parsed = JSON.parse(raw) as Partial<ProjectViewSettings>;
const filterValues = Array.isArray(parsed.filterValues)
? normalizedProjectFilterValues(parsed.filterValues)
: legacyProjectFilters(parsed as Partial<ProjectViewSettings> & Record<string, unknown>);
return {
query: typeof parsed.query === "string" ? parsed.query : defaultProjectViewSettings.query,
status: projectStatusOptions.find((option) => option.value === parsed.status)?.value ?? defaultProjectViewSettings.status,
zammadStatus: zammadStateFilterOptions.find((option) => option.value === parsed.zammadStatus)?.value ?? defaultProjectViewSettings.zammadStatus,
zammadPriority: zammadPriorityFilterOptions.find((option) => option.value === parsed.zammadPriority)?.value ?? defaultProjectViewSettings.zammadPriority,
organization: typeof parsed.organization === "string" ? parsed.organization : defaultProjectViewSettings.organization,
budget: projectBudgetOptions.find((option) => option.value === parsed.budget)?.value ?? defaultProjectViewSettings.budget,
filterValues,
organizationValues: legacyProjectOrganizationValues(parsed as Partial<ProjectViewSettings> & Record<string, unknown>),
standardTeamspaceOnly: legacyStandardTeamspaceOnly(parsed as Partial<ProjectViewSettings> & Record<string, unknown>),
sortBy: projectSortValues.find((value) => value === parsed.sortBy) ?? defaultProjectViewSettings.sortBy,
sortDirection: parsed.sortDirection === "asc" ? "asc" : defaultProjectViewSettings.sortDirection
};
@@ -214,14 +318,20 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
];
}
function projectTeamspaceEntryCount(project: ProjectSummary) {
return Number(project.teamspace_entry_count ?? projectTeamspaceEntries(project).length);
function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0);
}
function projectEntryConsumption(entry: ProjectTeamspaceEntry) {
return Number(entry.consumption_minutes ?? 0);
}
function defaultProjectTeamspaceEntry(project: ProjectSummary) {
const entries = projectTeamspaceEntries(project);
return entries.find((entry) => entry.is_default) ?? entries[0] ?? null;
}
function projectEntryPercent(entry: ProjectTeamspaceEntry) {
if (!entry.hour_budget_minutes || entry.hour_budget_minutes <= 0) {
return null;
@@ -246,6 +356,86 @@ 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();
@@ -274,24 +464,42 @@ function projectOrganizationKey(project: ProjectSummary) {
return project.organization_id ? `id:${project.organization_id}` : `name:${projectOrganizationLabel(project)}`;
}
function projectConsumption(project: ProjectSummary) {
function projectBudget(project: ProjectSummary, standardTeamspaceOnly = false) {
if (standardTeamspaceOnly) {
return defaultProjectTeamspaceEntry(project)?.hour_budget_minutes ?? null;
}
return project.hour_budget_minutes ?? null;
}
function projectConsumption(project: ProjectSummary, standardTeamspaceOnly = false) {
if (standardTeamspaceOnly) {
const defaultEntry = defaultProjectTeamspaceEntry(project);
return defaultEntry ? projectEntryConsumption(defaultEntry) : 0;
}
return Number(project.consumption_minutes ?? 0);
}
function projectRemaining(project: ProjectSummary) {
if (project.hour_budget_minutes === null || project.hour_budget_minutes === undefined) {
function projectRemaining(project: ProjectSummary, standardTeamspaceOnly = false) {
const budgetMinutes = projectBudget(project, standardTeamspaceOnly);
if (budgetMinutes === null || budgetMinutes === undefined) {
return null;
}
return project.hour_budget_minutes - projectConsumption(project);
return budgetMinutes - projectConsumption(project, standardTeamspaceOnly);
}
function projectConsumptionPercent(project: ProjectSummary) {
if (!project.hour_budget_minutes || project.hour_budget_minutes <= 0) {
function projectConsumptionPercent(project: ProjectSummary, standardTeamspaceOnly = false) {
const budgetMinutes = projectBudget(project, standardTeamspaceOnly);
if (!budgetMinutes || budgetMinutes <= 0) {
return null;
}
return Math.round((projectConsumption(project) / project.hour_budget_minutes) * 100);
return Math.round((projectConsumption(project, standardTeamspaceOnly) / budgetMinutes) * 100);
}
function projectMatchesQuery(project: ProjectSummary, query: string) {
@@ -317,7 +525,103 @@ function projectMatchesQuery(project: ProjectSummary, query: string) {
return values.some((value) => value.toLocaleLowerCase("de-DE").includes(normalized));
}
function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: ProjectSortBy, direction: ProjectSortDirection) {
function groupedProjectFilterMatch(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 projectFilterGroup(filter: string) {
if (filter === "open" || filter === "closed") {
return "status";
}
if (filter === "hidden") {
return "visibility";
}
if (filter.startsWith("zammadState:")) {
return "zammadState";
}
if (filter.startsWith("zammadPriority:")) {
return "zammadPriority";
}
return "budget";
}
function matchesSingleProjectFilter(project: ProjectSummary, filter: string, standardTeamspaceOnly = false) {
if (filter === "open") {
return !project.closed_at;
}
if (filter === "closed") {
return Boolean(project.closed_at);
}
if (filter === "hidden") {
return Boolean(project.hidden_for_user);
}
if (filter.startsWith("zammadState:")) {
return matchesZammadStateFilter(project.zammad_state_id, filter.slice("zammadState:".length) as Parameters<typeof matchesZammadStateFilter>[1]);
}
if (filter.startsWith("zammadPriority:")) {
return matchesZammadPriorityFilter(project.zammad_priority_id, filter.slice("zammadPriority:".length) as Parameters<typeof matchesZammadPriorityFilter>[1]);
}
const remaining = projectRemaining(project, standardTeamspaceOnly);
const budgetMinutes = projectBudget(project, standardTeamspaceOnly) ?? 0;
if (filter === "budgeted") {
return budgetMinutes > 0;
}
if (filter === "withoutBudget") {
return budgetMinutes <= 0;
}
if (filter === "remaining") {
return budgetMinutes > 0 && remaining !== null && remaining > 0;
}
if (filter === "overBudget") {
return budgetMinutes > 0 && remaining !== null && remaining < 0;
}
return true;
}
function matchesProjectFilters(project: ProjectSummary, filters: string[], organizationValues: string[], standardTeamspaceOnly = false) {
if (organizationValues.length > 0 && !organizationValues.includes(projectOrganizationKey(project))) {
return false;
}
return groupedProjectFilterMatch(filters, projectFilterGroup, (filter) => matchesSingleProjectFilter(project, filter, standardTeamspaceOnly));
}
function projectShouldLoadClosed(filters: string[]) {
const statusFilters = filters.filter((filter) => filter === "open" || filter === "closed");
return statusFilters.length === 0 || statusFilters.includes("closed");
}
function projectShouldLoadHidden(filters: string[]) {
return filters.includes("hidden");
}
function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: ProjectSortBy, direction: ProjectSortDirection, standardTeamspaceOnly = false) {
const factor = direction === "asc" ? 1 : -1;
let comparison = 0;
@@ -334,10 +638,10 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr
} else if (sortBy === "zammadPriority") {
comparison = zammadPrioritySortRank(left.zammad_priority_id) - zammadPrioritySortRank(right.zammad_priority_id);
} else if (sortBy === "consumption") {
comparison = projectConsumption(left) - projectConsumption(right);
comparison = projectConsumption(left, standardTeamspaceOnly) - projectConsumption(right, standardTeamspaceOnly);
} else if (sortBy === "consumptionPercent") {
const leftPercent = projectConsumptionPercent(left);
const rightPercent = projectConsumptionPercent(right);
const leftPercent = projectConsumptionPercent(left, standardTeamspaceOnly);
const rightPercent = projectConsumptionPercent(right, standardTeamspaceOnly);
if (leftPercent === null || rightPercent === null) {
return leftPercent === rightPercent ? compareText(left.ticket_number, right.ticket_number) : leftPercent === null ? 1 : -1;
@@ -345,9 +649,9 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr
comparison = leftPercent - rightPercent;
} else if (sortBy === "remaining") {
comparison = (projectRemaining(left) ?? Number.POSITIVE_INFINITY) - (projectRemaining(right) ?? Number.POSITIVE_INFINITY);
comparison = (projectRemaining(left, standardTeamspaceOnly) ?? Number.POSITIVE_INFINITY) - (projectRemaining(right, standardTeamspaceOnly) ?? Number.POSITIVE_INFINITY);
} else if (sortBy === "budget") {
comparison = (left.hour_budget_minutes ?? Number.POSITIVE_INFINITY) - (right.hour_budget_minutes ?? Number.POSITIVE_INFINITY);
comparison = (projectBudget(left, standardTeamspaceOnly) ?? Number.POSITIVE_INFINITY) - (projectBudget(right, standardTeamspaceOnly) ?? Number.POSITIVE_INFINITY);
} else {
comparison = new Date(left.updated_at).getTime() - new Date(right.updated_at).getTime();
}
@@ -638,7 +942,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
async function load() {
setLoading(true);
try {
const result = await getProjects(viewSettings.status !== "open", viewSettings.status === "hidden");
const result = await getProjects(projectShouldLoadClosed(viewSettings.filterValues), projectShouldLoadHidden(viewSettings.filterValues));
setProjects(result.projects);
setHiddenProjectCount(result.hiddenCount ?? 0);
setTotalProjectCount(result.totalCount ?? result.projects.length);
@@ -653,7 +957,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
useEffect(() => {
void load();
}, [viewSettings.status]);
}, [viewSettings.filterValues]);
useEffect(() => {
window.localStorage.setItem(projectViewSettingsKey, JSON.stringify(viewSettings));
@@ -727,68 +1031,28 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
const filteredProjects = useMemo(() => {
return projects
.filter((project) => {
if (viewSettings.status === "hidden" && !project.hidden_for_user) {
return false;
}
if (viewSettings.status === "open" && project.closed_at) {
return false;
}
if (viewSettings.status === "closed" && !project.closed_at) {
return false;
}
if (!matchesZammadStateFilter(project.zammad_state_id, viewSettings.zammadStatus)) {
return false;
}
if (!matchesZammadPriorityFilter(project.zammad_priority_id, viewSettings.zammadPriority)) {
return false;
}
if (viewSettings.organization !== "all" && projectOrganizationKey(project) !== viewSettings.organization) {
return false;
}
if (!projectMatchesQuery(project, viewSettings.query)) {
return false;
}
const remaining = projectRemaining(project);
if (viewSettings.budget === "with-budget" && remaining === null) {
return false;
}
if (viewSettings.budget === "without-budget" && remaining !== null) {
return false;
}
if (viewSettings.budget === "remaining" && (remaining === null || remaining <= 0)) {
return false;
}
if (viewSettings.budget === "over-budget" && (remaining === null || remaining >= 0)) {
return false;
}
return true;
return matchesProjectFilters(project, viewSettings.filterValues, viewSettings.organizationValues, viewSettings.standardTeamspaceOnly);
})
.sort((left, right) => compareProjects(left, right, viewSettings.sortBy, viewSettings.sortDirection));
.sort((left, right) => compareProjects(left, right, viewSettings.sortBy, viewSettings.sortDirection, viewSettings.standardTeamspaceOnly));
}, [projects, viewSettings]);
const projectConsumptionMinutes = useMemo(() => filteredProjects.reduce((sum, project) => sum + projectConsumption(project), 0), [filteredProjects]);
const remainingMinutes = useMemo(
() => filteredProjects.reduce((sum, project) => sum + Math.max(projectRemaining(project) ?? 0, 0), 0),
[filteredProjects]
const projectConsumptionMinutes = useMemo(
() => filteredProjects.reduce((sum, project) => sum + projectConsumption(project, viewSettings.standardTeamspaceOnly), 0),
[filteredProjects, viewSettings.standardTeamspaceOnly]
);
const remainingMinutes = useMemo(
() => filteredProjects.reduce((sum, project) => sum + Math.max(projectRemaining(project, viewSettings.standardTeamspaceOnly) ?? 0, 0), 0),
[filteredProjects, viewSettings.standardTeamspaceOnly]
);
const closedProjectsLoaded = projectShouldLoadClosed(viewSettings.filterValues);
const activeFilterCount = [
viewSettings.query.trim() !== "",
viewSettings.status !== defaultProjectViewSettings.status,
viewSettings.zammadStatus !== defaultProjectViewSettings.zammadStatus,
viewSettings.zammadPriority !== defaultProjectViewSettings.zammadPriority,
viewSettings.organization !== defaultProjectViewSettings.organization,
viewSettings.budget !== defaultProjectViewSettings.budget,
JSON.stringify(viewSettings.filterValues) !== JSON.stringify(defaultProjectViewSettings.filterValues),
viewSettings.organizationValues.length > 0,
viewSettings.standardTeamspaceOnly,
viewSettings.sortBy !== defaultProjectViewSettings.sortBy,
viewSettings.sortDirection !== defaultProjectViewSettings.sortDirection
].filter(Boolean).length;
@@ -1070,7 +1334,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div>
<div className="w-28 shrink-0 rounded-md border bg-muted/20 px-3 py-2">
<span className="block text-muted-foreground">Geschlossen</span>
<span className="font-semibold">{viewSettings.status !== "open" ? closedProjects.length : "-"}</span>
<span className="font-semibold">{closedProjectsLoaded ? closedProjects.length : "-"}</span>
</div>
<div className="w-32 shrink-0 rounded-md border bg-muted/20 px-3 py-2">
<span className="block text-muted-foreground">Verbrauch</span>
@@ -1102,7 +1366,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<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="project-search">
Suche
@@ -1119,71 +1383,34 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Status</label>
<Select value={viewSettings.status} onValueChange={(value) => setViewSettings((current) => ({ ...current, status: value as ProjectStatusFilter }))}>
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{projectStatusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
<label className="text-xs font-medium text-muted-foreground">Filter</label>
<ProjectFilterDropdown
values={viewSettings.filterValues}
options={projectStaticFilterOptions}
onChange={(filterValues) => setViewSettings((current) => ({ ...current, filterValues }))}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Zammad-Status</label>
<Select value={viewSettings.zammadStatus} onValueChange={(value) => setViewSettings((current) => ({ ...current, zammadStatus: value as ZammadStateFilter }))}>
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{zammadStateFilterOptions.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">Zammad-Priorität</label>
<Select value={viewSettings.zammadPriority} onValueChange={(value) => setViewSettings((current) => ({ ...current, zammadPriority: value as ZammadPriorityFilter }))}>
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{zammadPriorityFilterOptions.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">Organisation</label>
<Select value={viewSettings.organization} onValueChange={(value) => setViewSettings((current) => ({ ...current, organization: value }))}>
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle Organisationen</SelectItem>
{organizationOptions.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">Kontingent</label>
<Select value={viewSettings.budget} onValueChange={(value) => setViewSettings((current) => ({ ...current, budget: value as ProjectBudgetFilter }))}>
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{projectBudgetOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
<label className="text-xs font-medium text-muted-foreground">Organisationen</label>
<ProjectFilterDropdown
values={viewSettings.organizationValues}
options={organizationOptions}
onChange={(organizationValues) => setViewSettings((current) => ({ ...current, organizationValues }))}
placeholder="Alle Org."
countLabel="Org."
emptyLabel="Keine Org."
/>
</div>
<label className={cn("flex min-w-0 items-center gap-3 rounded-md border px-3 py-2", viewSettings.standardTeamspaceOnly ? "bg-muted/30" : "bg-background")}>
<Checkbox
checked={viewSettings.standardTeamspaceOnly}
onCheckedChange={(checked) => setViewSettings((current) => ({ ...current, standardTeamspaceOnly: checked === true }))}
/>
<span className="min-w-0">
<span className="block truncate text-xs font-medium text-muted-foreground">Nur Standard-TS</span>
<span className="block truncate text-xs">Hauptbalken</span>
</span>
</label>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Sortierung</label>
<Select value={viewSettings.sortBy} onValueChange={(value) => setViewSettings((current) => ({ ...current, sortBy: value as ProjectSortBy }))}>
@@ -1224,7 +1451,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div>
</CardContent>
</Card>
{hiddenProjectCount > 0 && viewSettings.status !== "hidden" ? (
{hiddenProjectCount > 0 && !projectShouldLoadHidden(viewSettings.filterValues) ? (
<div className="flex flex-col gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-800 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-2">
<EyeOff className="size-4 shrink-0" />
@@ -1237,7 +1464,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
size="sm"
variant="secondary"
className="shrink-0"
onClick={() => setViewSettings((current) => ({ ...current, status: "hidden" }))}
onClick={() => setViewSettings((current) => ({ ...current, filterValues: ["hidden"] }))}
>
Anzeigen
</Button>
@@ -1287,11 +1514,12 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div>
{filteredProjects.map((project) => {
const isEditing = editingProjectId === project.id;
const teamspaceEntries = projectTeamspaceEntries(project);
const consumptionMinutes = project.consumption_minutes ?? 0;
const remaining = projectRemaining(project);
const usedPercent = projectConsumptionPercent(project);
const hasMultipleTeamspaceEntries = teamspaceEntries.length > 1;
const budgetedTeamspaceEntries = budgetedProjectTeamspaceEntries(project);
const consumptionMinutes = projectConsumption(project, viewSettings.standardTeamspaceOnly);
const budgetMinutes = projectBudget(project, viewSettings.standardTeamspaceOnly);
const remaining = projectRemaining(project, viewSettings.standardTeamspaceOnly);
const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly);
const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1;
const canEditProject = project.can_edit !== false;
return (
@@ -1329,9 +1557,9 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<div className="min-w-0">
<p className="truncate text-sm font-medium xl:hidden">Verbrauch/Gesamt</p>
<p className={cn("text-sm font-semibold", remaining !== null && remaining < 0 ? "text-destructive" : undefined)}>
{formatDecimalHours(consumptionMinutes)} / {project.hour_budget_minutes ? formatDecimalHours(project.hour_budget_minutes) : "offen"}
{formatDecimalHours(consumptionMinutes)} / {budgetMinutes ? formatDecimalHours(budgetMinutes) : "offen"}
</p>
{hasMultipleTeamspaceEntries ? <p className="text-xs text-muted-foreground">{projectTeamspaceEntryCount(project)} Teamspace</p> : null}
{showBudgetedTeamspaceEntries ? <p className="text-xs text-muted-foreground">{budgetedTeamspaceEntries.length} Budgets</p> : null}
</div>
<div className="min-w-0 text-sm">
<p className="truncate font-medium xl:hidden">Verbrauch %</p>
@@ -1416,9 +1644,9 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
Zammad meldet das Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen.
</p>
) : null}
{hasMultipleTeamspaceEntries ? (
{showBudgetedTeamspaceEntries ? (
<div className="grid gap-1.5 md:grid-cols-2">
{teamspaceEntries.map((entry) => {
{budgetedTeamspaceEntries.map((entry) => {
const entryPercent = projectEntryPercent(entry);
const entryConsumption = projectEntryConsumption(entry);
const label = entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");