Update
This commit is contained in:
1 parent
07ef8ef025
commit
87d6c9b3ce
10 files changed
+2925
-112
No files matched your search
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user