This commit is contained in:
michael committed 2026-09-11 10:41:07 +02:00
1 parent 07ef8ef025
commit 87d6c9b3ce
10 files changed
+2925 -112

No files matched your search

+134 -50
View File
@@ -1,4 +1,4 @@
import { ArrowDown, ArrowUp, CheckCircle2, 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";
@@ -13,7 +13,7 @@ import { Textarea } from "@/components/ui/textarea";
import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { cn } from "@/lib/utils";
import { addFavoriteTicket, createProject, getProjects, lookupTicket, updateProject, updateProjectStatus } from "../api";
import { addFavoriteTicket, createProject, getProjects, hideProject, lookupTicket, unhideProject, updateProject, updateProjectStatus } from "../api";
import { currentMonth, formatMinutes } from "../format";
import { projectHasRecurringBilling } from "../recurring-billings";
import { ticketPattern } from "../timers";
@@ -43,7 +43,7 @@ type ProjectTeamspaceEntryForm = {
isDefault: boolean;
};
type ProjectStatusFilter = "open" | "all" | "closed";
type ProjectStatusFilter = "open" | "all" | "closed" | "hidden";
type ProjectBudgetFilter = "all" | "with-budget" | "without-budget" | "remaining" | "over-budget";
type ProjectTodoFilter = "all" | "open" | "overdue" | "none-open";
type ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "consumption" | "consumptionPercent" | "remaining" | "budget" | "todos";
@@ -74,7 +74,8 @@ const defaultProjectViewSettings: ProjectViewSettings = {
const projectStatusOptions: Array<{ value: ProjectStatusFilter; label: string }> = [
{ value: "open", label: "Offen" },
{ value: "all", label: "Alle" },
{ value: "closed", label: "Geschlossen" }
{ value: "closed", label: "Geschlossen" },
{ value: "hidden", label: "Ausgeblendet" }
];
const projectBudgetOptions: Array<{ value: ProjectBudgetFilter; label: string }> = [
@@ -94,6 +95,19 @@ const projectTodoOptions: Array<{ value: ProjectTodoFilter; label: string }> = [
const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "consumption", "consumptionPercent", "remaining", "budget", "todos"];
const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [
{ value: "updated", label: "Aktualisierung" },
{ value: "ticket", label: "Ticket" },
{ value: "title", label: "Titel" },
{ value: "organization", label: "Organisation" },
{ value: "status", label: "Status" },
{ value: "consumption", label: "Verbrauch" },
{ value: "consumptionPercent", label: "Verbrauch %" },
{ value: "remaining", label: "Reststunden" },
{ value: "budget", label: "Kontingent" },
{ value: "todos", label: "ToDos" }
];
function projectEntryId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
@@ -582,6 +596,8 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, za
export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [hiddenProjectCount, setHiddenProjectCount] = useState(0);
const [totalProjectCount, setTotalProjectCount] = useState(0);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState<ProjectForm>(() => emptyProjectForm());
const [lookupStatus, setLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
@@ -599,8 +615,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
async function load() {
setLoading(true);
try {
const result = await getProjects(viewSettings.status !== "open");
const result = await getProjects(viewSettings.status !== "open", viewSettings.status === "hidden");
setProjects(result.projects);
setHiddenProjectCount(result.hiddenCount ?? 0);
setTotalProjectCount(result.totalCount ?? result.projects.length);
} catch (error) {
toast.error("Projekte konnten nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
@@ -686,6 +704,10 @@ 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;
}
@@ -975,6 +997,34 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
}
}
async function hideProjectForUser(project: ProjectSummary) {
try {
await hideProject(project.id);
toast.success("Projekt ausgeblendet", {
description: project.ticket_number
});
await load();
} catch (error) {
toast.error("Projekt konnte nicht ausgeblendet werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
}
async function unhideProjectForUser(project: ProjectSummary) {
try {
await unhideProject(project.id);
toast.success("Projekt eingeblendet", {
description: project.ticket_number
});
await load();
} catch (error) {
toast.error("Projekt konnte nicht eingeblendet werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
}
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
@@ -1005,31 +1055,6 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<span className="font-semibold">{openTodos}</span>
</div>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button type="button" className="shrink-0">
<Plus className="size-4" />
Projekt
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Projekt anlegen</DialogTitle>
<DialogDescription>Consulting-Projekt mit einem oder mehreren Teamspace-Einträgen anlegen.</DialogDescription>
</DialogHeader>
<ProjectFormFields
form={form}
setForm={setForm}
organizationLocked={organizationLocked}
lookupStatus={lookupStatus}
zammadLookupBusy={zammadLookupBusy}
saving={saving}
submitLabel="Projekt anlegen"
onZammadLookup={() => void lookupProjectTicketInZammad()}
onSubmit={submitProject}
/>
</DialogContent>
</Dialog>
<HelpLink anchor="projekte" label="Hilfe zu Projekten" className="shrink-0" />
</div>
</div>
@@ -1043,7 +1068,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
Filter und Sortierung
{activeFilterCount > 0 ? <Badge variant="secondary">{activeFilterCount}</Badge> : null}
</CardTitle>
<CardDescription>{filteredProjects.length} von {projects.length} Projekt(en) sichtbar.</CardDescription>
<CardDescription>{filteredProjects.length} von {totalProjectCount} Projekt(en) sichtbar.</CardDescription>
</div>
<Button type="button" size="sm" variant="ghost" onClick={() => setViewSettings(defaultProjectViewSettings)}>
<RotateCcw className="size-4" />
@@ -1051,7 +1076,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-[minmax(220px,1.4fr)_150px_minmax(180px,1fr)_170px_170px_130px_150px]">
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-[minmax(220px,1.4fr)_150px_minmax(180px,1fr)_170px_170px_170px_150px]">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor="project-search">
Suche
@@ -1122,23 +1147,16 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Sortierung</label>
<Button
type="button"
size="sm"
variant="secondary"
className="h-8 w-full justify-start"
disabled={viewSettings.sortBy === defaultProjectViewSettings.sortBy && viewSettings.sortDirection === defaultProjectViewSettings.sortDirection}
onClick={() =>
setViewSettings((current) => ({
...current,
sortBy: defaultProjectViewSettings.sortBy,
sortDirection: defaultProjectViewSettings.sortDirection
}))
}
>
<RotateCcw className="size-3.5" />
Zurücksetzen
</Button>
<Select value={viewSettings.sortBy} onValueChange={(value) => setViewSettings((current) => ({ ...current, sortBy: value as ProjectSortBy }))}>
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{projectSortOptions.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">Richtung</label>
@@ -1167,8 +1185,57 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div>
</CardContent>
</Card>
{hiddenProjectCount > 0 && viewSettings.status !== "hidden" ? (
<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" />
<span className="min-w-0">
{hiddenProjectCount} Projekt(e) sind ausgeblendet.
</span>
</div>
<Button
type="button"
size="sm"
variant="secondary"
className="shrink-0"
onClick={() => setViewSettings((current) => ({ ...current, status: "hidden" }))}
>
Anzeigen
</Button>
</div>
) : null}
<Card className="overflow-hidden">
<div className="flex items-center justify-between gap-3 border-b px-4 py-3">
<div className="min-w-0">
<h3 className="text-sm font-medium">Projektliste</h3>
<p className="text-xs text-muted-foreground">{filteredProjects.length} Projekt(e)</p>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button type="button" className="shrink-0">
<Plus className="size-4" />
Projekt
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Projekt anlegen</DialogTitle>
<DialogDescription>Consulting-Projekt mit einem oder mehreren Teamspace-Einträgen anlegen.</DialogDescription>
</DialogHeader>
<ProjectFormFields
form={form}
setForm={setForm}
organizationLocked={organizationLocked}
lookupStatus={lookupStatus}
zammadLookupBusy={zammadLookupBusy}
saving={saving}
submitLabel="Projekt anlegen"
onZammadLookup={() => void lookupProjectTicketInZammad()}
onSubmit={submitProject}
/>
</DialogContent>
</Dialog>
</div>
<div className="hidden grid-cols-[minmax(220px,1.35fr)_minmax(180px,1fr)_100px_170px_95px_110px_44px] gap-3 border-b bg-muted/25 px-4 py-2 text-xs font-medium text-muted-foreground xl:grid">
{sortHeader("Nummer/Titel", "title")}
{sortHeader("Organisation", "organization")}
@@ -1207,6 +1274,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<div className="flex flex-wrap gap-1.5">
{project.closed_at ? <Badge variant="secondary">geschlossen</Badge> : <Badge variant="success">offen</Badge>}
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
{project.close_review_required ? <Badge variant="warning">TS/TT prüfen</Badge> : null}
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? <Badge variant="warning">{project.unassigned_teamspace_session_count} ohne TS</Badge> : null}
</div>
<div className="min-w-0">
@@ -1244,6 +1312,17 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<Star className="size-4" />
Als Favorit speichern
</DropdownMenuItem>
{project.hidden_for_user ? (
<DropdownMenuItem className="hover:cursor-pointer" onSelect={() => void unhideProjectForUser(project)}>
<Eye className="size-4" />
Einblenden
</DropdownMenuItem>
) : (
<DropdownMenuItem className="hover:cursor-pointer" onSelect={() => void hideProjectForUser(project)}>
<EyeOff className="size-4" />
Ausblenden
</DropdownMenuItem>
)}
<DropdownMenuItem className="hover:cursor-pointer" onSelect={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}>
<Search className="size-4" />
Projekt öffnen
@@ -1286,6 +1365,11 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
{project.unassigned_teamspace_session_count} Session(s) haben keinen Teamspace-Eintrag.
</p>
) : null}
{project.close_review_required ? (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700 dark:text-amber-300">
Zammad meldet das Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen.
</p>
) : null}
{hasMultipleTeamspaceEntries ? (
<div className="grid gap-1.5 md:grid-cols-2">
{teamspaceEntries.map((entry) => {