This commit is contained in:
michael committed 2026-09-29 07:46:21 +02:00
1 parent 2105a0c5f7
commit c89d4825e2
30 files changed
+5435 -627

No files matched your search

+202 -15
View File
@@ -27,6 +27,8 @@ import {
zammadPriorityFilterOptions,
zammadPriorityLabel,
zammadPrioritySortRank,
zammadId,
zammadStateId,
ZammadStateBadge,
zammadStateFilterOptions,
zammadStateLabel,
@@ -60,6 +62,7 @@ type ProjectTeamspaceEntryForm = {
type ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "consumption" | "consumptionPercent" | "remaining" | "budget";
type ProjectSortDirection = "asc" | "desc";
type ProjectGroupBy = "none" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "budgetState" | "teamspace";
type ProjectFilterOption = MultiFilterOption;
type ProjectViewSettings = {
@@ -67,6 +70,8 @@ type ProjectViewSettings = {
filterValues: string[];
organizationValues: string[];
standardTeamspaceOnly: boolean;
groupBy: ProjectGroupBy;
groupDirection: ProjectSortDirection;
sortBy: ProjectSortBy;
sortDirection: ProjectSortDirection;
};
@@ -78,6 +83,8 @@ const defaultProjectViewSettings: ProjectViewSettings = {
filterValues: ["open"],
organizationValues: [],
standardTeamspaceOnly: false,
groupBy: "none",
groupDirection: "asc",
sortBy: "updated",
sortDirection: "desc"
};
@@ -97,6 +104,7 @@ const projectStaticFilterOptions: ProjectFilterOption[] = [
const projectStaticFilterValues = new Set(projectStaticFilterOptions.map((option) => option.value));
const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "zammadStatus", "zammadPriority", "consumption", "consumptionPercent", "remaining", "budget"];
const projectGroupValues: ProjectGroupBy[] = ["none", "organization", "status", "zammadStatus", "zammadPriority", "budgetState", "teamspace"];
const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [
{ value: "updated", label: "Aktualisierung" },
@@ -112,6 +120,16 @@ const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [
{ value: "budget", label: "Kontingent" }
];
const projectGroupOptions: Array<{ value: ProjectGroupBy; label: string }> = [
{ value: "none", label: "Keine Gruppierung" },
{ value: "organization", label: "Organisation" },
{ value: "status", label: "TT-Status" },
{ value: "zammadStatus", label: "Zammad-Status" },
{ value: "zammadPriority", label: "Zammad-Prio" },
{ value: "budgetState", label: "Kontingentstatus" },
{ value: "teamspace", label: "Standard-Teamspace" }
];
function projectEntryId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
@@ -262,6 +280,8 @@ function readStoredProjectViewSettings(): ProjectViewSettings {
filterValues,
organizationValues: legacyProjectOrganizationValues(parsed as Partial<ProjectViewSettings> & Record<string, unknown>),
standardTeamspaceOnly: legacyStandardTeamspaceOnly(parsed as Partial<ProjectViewSettings> & Record<string, unknown>),
groupBy: projectGroupValues.find((value) => value === parsed.groupBy) ?? defaultProjectViewSettings.groupBy,
groupDirection: parsed.groupDirection === "desc" ? "desc" : defaultProjectViewSettings.groupDirection,
sortBy: projectSortValues.find((value) => value === parsed.sortBy) ?? defaultProjectViewSettings.sortBy,
sortDirection: parsed.sortDirection === "asc" ? "asc" : defaultProjectViewSettings.sortDirection
};
@@ -579,6 +599,117 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr
return comparison * factor || compareText(left.ticket_number, right.ticket_number);
}
type ProjectGroupMeta = {
key: string;
label: string;
sortLabel: string;
rank: number;
};
type ProjectGroup = ProjectGroupMeta & {
projects: ProjectSummary[];
consumptionMinutes: number;
remainingMinutes: number;
};
function projectBudgetStateGroup(project: ProjectSummary, standardTeamspaceOnly: boolean): ProjectGroupMeta {
const budgetMinutes = projectBudget(project, standardTeamspaceOnly) ?? 0;
const remaining = projectRemaining(project, standardTeamspaceOnly);
if (budgetMinutes <= 0) {
return { key: "budgetState:none", label: "Ohne Kontingent", sortLabel: "Ohne Kontingent", rank: 40 };
}
if (remaining !== null && remaining < 0) {
return { key: "budgetState:over", label: "Überzogen", sortLabel: "Überzogen", rank: 10 };
}
if (remaining === 0) {
return { key: "budgetState:used", label: "Aufgebraucht", sortLabel: "Aufgebraucht", rank: 30 };
}
return { key: "budgetState:remaining", label: "Mit Rest", sortLabel: "Mit Rest", rank: 20 };
}
function projectGroupMeta(project: ProjectSummary, groupBy: ProjectGroupBy, standardTeamspaceOnly: boolean): ProjectGroupMeta {
if (groupBy === "organization") {
const label = projectOrganizationLabel(project);
return { key: `organization:${projectOrganizationKey(project)}`, label, sortLabel: label, rank: 0 };
}
if (groupBy === "status") {
return project.closed_at
? { key: "status:closed", label: "Geschlossen", sortLabel: "Geschlossen", rank: 20 }
: { key: "status:open", label: "Offen", sortLabel: "Offen", rank: 10 };
}
if (groupBy === "zammadStatus") {
const id = zammadStateId(project.zammad_state_id);
const label = id ? zammadStateLabel(id) : "Ohne Zammad-Status";
return { key: `zammadStatus:${id ?? "none"}`, label, sortLabel: label, rank: zammadStateSortRank(id) };
}
if (groupBy === "zammadPriority") {
const id = zammadId(project.zammad_priority_id);
const label = id ? zammadPriorityLabel(id) : "Ohne Zammad-Prio";
return { key: `zammadPriority:${id ?? "none"}`, label, sortLabel: label, rank: zammadPrioritySortRank(id) };
}
if (groupBy === "budgetState") {
return projectBudgetStateGroup(project, standardTeamspaceOnly);
}
if (groupBy === "teamspace") {
const label = defaultProjectTeamspaceEntry(project)?.teamspace_project || "Ohne Teamspace-ID";
return { key: `teamspace:${label.toLocaleLowerCase("de-DE")}`, label, sortLabel: label, rank: 0 };
}
return { key: "all", label: "Alle Projekte", sortLabel: "Alle Projekte", rank: 0 };
}
function groupProjects(projects: ProjectSummary[], groupBy: ProjectGroupBy, groupDirection: ProjectSortDirection, standardTeamspaceOnly: boolean): ProjectGroup[] {
if (groupBy === "none") {
return [
{
key: "all",
label: "Alle Projekte",
sortLabel: "Alle Projekte",
rank: 0,
projects,
consumptionMinutes: projects.reduce((sum, project) => sum + projectConsumption(project, standardTeamspaceOnly), 0),
remainingMinutes: projects.reduce((sum, project) => sum + Math.max(projectRemaining(project, standardTeamspaceOnly) ?? 0, 0), 0)
}
];
}
const factor = groupDirection === "asc" ? 1 : -1;
const groups = new Map<string, ProjectGroup>();
for (const project of projects) {
const meta = projectGroupMeta(project, groupBy, standardTeamspaceOnly);
const group = groups.get(meta.key);
if (group) {
group.projects.push(project);
group.consumptionMinutes += projectConsumption(project, standardTeamspaceOnly);
group.remainingMinutes += Math.max(projectRemaining(project, standardTeamspaceOnly) ?? 0, 0);
} else {
groups.set(meta.key, {
...meta,
projects: [project],
consumptionMinutes: projectConsumption(project, standardTeamspaceOnly),
remainingMinutes: Math.max(projectRemaining(project, standardTeamspaceOnly) ?? 0, 0)
});
}
}
return Array.from(groups.values()).sort((left, right) => ((left.rank - right.rank) || compareText(left.sortLabel, right.sortLabel)) * factor);
}
function formFromProject(project: ProjectSummary): ProjectForm {
const entries = projectTeamspaceEntries(project).map((entry, index) => ({
clientId: entry.id || projectEntryId(),
@@ -967,12 +1098,18 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
() => filteredProjects.reduce((sum, project) => sum + Math.max(projectRemaining(project, viewSettings.standardTeamspaceOnly) ?? 0, 0), 0),
[filteredProjects, viewSettings.standardTeamspaceOnly]
);
const projectGroups = useMemo(
() => groupProjects(filteredProjects, viewSettings.groupBy, viewSettings.groupDirection, viewSettings.standardTeamspaceOnly),
[filteredProjects, viewSettings.groupBy, viewSettings.groupDirection, viewSettings.standardTeamspaceOnly]
);
const closedProjectsLoaded = projectShouldLoadClosed(viewSettings.filterValues);
const activeFilterCount = [
viewSettings.query.trim() !== "",
JSON.stringify(viewSettings.filterValues) !== JSON.stringify(defaultProjectViewSettings.filterValues),
viewSettings.organizationValues.length > 0,
viewSettings.standardTeamspaceOnly,
viewSettings.groupBy !== defaultProjectViewSettings.groupBy,
viewSettings.groupDirection !== defaultProjectViewSettings.groupDirection,
viewSettings.sortBy !== defaultProjectViewSettings.sortBy,
viewSettings.sortDirection !== defaultProjectViewSettings.sortDirection
].filter(Boolean).length;
@@ -1286,7 +1423,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-5">
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-8">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor="project-search">
Suche
@@ -1331,6 +1468,44 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<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">Gruppierung</label>
<Select value={viewSettings.groupBy} onValueChange={(value) => setViewSettings((current) => ({ ...current, groupBy: value as ProjectGroupBy }))}>
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{projectGroupOptions.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">Gruppenrichtung</label>
<div className="grid grid-cols-2 rounded-md border bg-background p-0.5">
<Button
type="button"
size="sm"
variant={viewSettings.groupDirection === "asc" ? "secondary" : "ghost"}
className="h-7 px-2"
onClick={() => setViewSettings((current) => ({ ...current, groupDirection: "asc" }))}
>
<ArrowUp className="size-3.5" />
Auf
</Button>
<Button
type="button"
size="sm"
variant={viewSettings.groupDirection === "desc" ? "secondary" : "ghost"}
className="h-7 px-2"
onClick={() => setViewSettings((current) => ({ ...current, groupDirection: "desc" }))}
>
<ArrowDown className="size-3.5" />
Ab
</Button>
</div>
</div>
<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 }))}>
@@ -1345,7 +1520,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</Select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Richtung</label>
<label className="text-xs font-medium text-muted-foreground">Sortierrichtung</label>
<div className="grid grid-cols-2 rounded-md border bg-background p-0.5">
<Button
type="button"
@@ -1432,18 +1607,28 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
{sortHeader("Verbrauch %", "consumptionPercent")}
<span className="text-right">Aktion</span>
</div>
{filteredProjects.map((project) => {
const isEditing = editingProjectId === project.id;
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;
{projectGroups.map((group) => (
<div key={group.key} className={cn(viewSettings.groupBy !== "none" ? "border-b last:border-b-0" : undefined)}>
{viewSettings.groupBy !== "none" ? (
<div className="flex flex-col gap-1 border-b bg-muted/15 px-4 py-2 sm:flex-row sm:items-center sm:justify-between">
<h4 className="min-w-0 truncate text-sm font-medium">{group.label}</h4>
<p className="text-xs text-muted-foreground">
{group.projects.length} Projekt(e) · {formatMinutes(group.consumptionMinutes)} Verbrauch · {formatMinutes(group.remainingMinutes)} Rest offen
</p>
</div>
) : null}
{group.projects.map((project) => {
const isEditing = editingProjectId === project.id;
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 (
<div key={project.id} className="border-b last:border-b-0">
return (
<div key={project.id} className="border-b last:border-b-0">
<div className="grid gap-3 px-4 py-3 xl:grid-cols-[minmax(220px,1.35fr)_minmax(170px,1fr)_105px_115px_105px_165px_90px_44px] xl:items-center">
<div className="min-w-0">
<button
@@ -1640,8 +1825,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div>
) : null}
</div>
);
})}
);
})}
</div>
))}
</Card>
{!loading && filteredProjects.length === 0 ? (