Update
This commit is contained in:
1 parent
2105a0c5f7
commit
c89d4825e2
30 files changed
+5435
-627
No files matched your search
@@ -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 ? (
|
||||
|
||||
Reference in new issue
Block a user