Refine project and todo workflow
This commit is contained in:
1 parent
3da436423f
commit
239be9f218
9 files changed
+487
-301
No files matched your search
+172
-124
@@ -1,10 +1,11 @@
|
||||
import { CalendarDays, FolderKanban, Play, Save, Search, Target } from "lucide-react";
|
||||
import { CalendarDays, FolderKanban, Play, Plus, Save, Search, Target } from "lucide-react";
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { HelpLink } from "@/components/HelpLink";
|
||||
@@ -22,6 +23,7 @@ type ProjectsPageProps = {
|
||||
|
||||
type ProjectForm = {
|
||||
ticketNumber: string;
|
||||
title: string;
|
||||
organizationId: string;
|
||||
organizationName: string | null;
|
||||
teamspaceProject: string;
|
||||
@@ -35,6 +37,7 @@ type ProjectForm = {
|
||||
function emptyProjectForm(): ProjectForm {
|
||||
return {
|
||||
ticketNumber: "",
|
||||
title: "",
|
||||
organizationId: "",
|
||||
organizationName: null,
|
||||
teamspaceProject: "",
|
||||
@@ -73,6 +76,7 @@ function parseHoursInput(value: string) {
|
||||
function formFromProject(project: ProjectSummary): ProjectForm {
|
||||
return {
|
||||
ticketNumber: project.ticket_number,
|
||||
title: project.title ?? "",
|
||||
organizationId: project.organization_id ?? "",
|
||||
organizationName: project.organization_name ?? project.customer_name ?? null,
|
||||
teamspaceProject: project.teamspace_project ?? "",
|
||||
@@ -84,6 +88,120 @@ function formFromProject(project: ProjectSummary): ProjectForm {
|
||||
};
|
||||
}
|
||||
|
||||
type ProjectFormFieldsProps = {
|
||||
form: ProjectForm;
|
||||
setForm: (form: ProjectForm) => void;
|
||||
organizationLocked: boolean;
|
||||
lookupStatus: "idle" | "loading" | "found" | "not-found";
|
||||
saving: boolean;
|
||||
submitLabel: string;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
};
|
||||
|
||||
function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, saving, submitLabel, onSubmit }: ProjectFormFieldsProps) {
|
||||
return (
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div className="grid gap-3 lg:grid-cols-[180px_minmax(240px,1fr)_180px]">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="project-ticket-number">
|
||||
Ticket
|
||||
</label>
|
||||
<Input
|
||||
id="project-ticket-number"
|
||||
placeholder="Ticket#123456"
|
||||
value={form.ticketNumber}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
ticketNumber: event.currentTarget.value,
|
||||
organizationId: "",
|
||||
organizationName: null
|
||||
})
|
||||
}
|
||||
required
|
||||
/>
|
||||
{form.ticketNumber && !ticketPattern.test(form.ticketNumber.trim()) ? <p className="text-xs text-destructive">Format: Ticket#XXXXXX</p> : null}
|
||||
{lookupStatus === "loading" ? <p className="text-xs text-muted-foreground">Suche Ticket...</p> : null}
|
||||
{lookupStatus === "found" ? <p className="text-xs text-muted-foreground">Ticket gefunden. Daten werden übernommen.</p> : null}
|
||||
{lookupStatus === "not-found" ? <p className="text-xs text-muted-foreground">Neues Ticket. Organisation bitte wählen.</p> : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="project-title">
|
||||
Titel
|
||||
</label>
|
||||
<Input id="project-title" value={form.title} onChange={(event) => setForm({ ...form, title: event.currentTarget.value })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Art</label>
|
||||
<div className="flex h-9 items-center rounded-lg border bg-muted/30 px-3 text-sm">Consulting</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(240px,1fr)_minmax(220px,1fr)]">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Organisation</label>
|
||||
<OrganizationSelect
|
||||
value={form.organizationId}
|
||||
selectedName={form.organizationName}
|
||||
disabled={organizationLocked}
|
||||
onChange={(organization) => setForm({ ...form, organizationId: organization.id, organizationName: organization.name })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="teamspace-project">
|
||||
Teamspace-Projekt
|
||||
</label>
|
||||
<Input id="teamspace-project" value={form.teamspaceProject} onChange={(event) => setForm({ ...form, teamspaceProject: event.currentTarget.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="project-starts-on">
|
||||
Zeitraum von
|
||||
</label>
|
||||
<Input id="project-starts-on" type="date" value={form.startsOn} onChange={(event) => setForm({ ...form, startsOn: event.currentTarget.value })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="project-ends-on">
|
||||
Zeitraum bis
|
||||
</label>
|
||||
<Input id="project-ends-on" type="date" value={form.endsOn} onChange={(event) => setForm({ ...form, endsOn: event.currentTarget.value })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="project-budget">
|
||||
Kontingent h
|
||||
</label>
|
||||
<Input id="project-budget" inputMode="decimal" value={form.hourBudget} onChange={(event) => setForm({ ...form, hourBudget: event.currentTarget.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="project-description">
|
||||
Projektbeschreibung
|
||||
</label>
|
||||
<Textarea id="project-description" className="min-h-24" value={form.description} onChange={(event) => setForm({ ...form, description: event.currentTarget.value })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="project-goals">
|
||||
Ziele
|
||||
</label>
|
||||
<Textarea id="project-goals" className="min-h-24" value={form.goals} onChange={(event) => setForm({ ...form, goals: event.currentTarget.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={saving}>
|
||||
<Save className="size-4" />
|
||||
{saving ? "Speichert..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
const [projects, setProjects] = useState<ProjectSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -91,6 +209,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
const [lookupStatus, setLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
|
||||
const [lookupResult, setLookupResult] = useState<TicketMeta | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
|
||||
const [editForm, setEditForm] = useState<ProjectForm>(() => emptyProjectForm());
|
||||
const [savingProjectId, setSavingProjectId] = useState<string | null>(null);
|
||||
@@ -141,6 +260,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
current.ticketNumber.trim() === normalizedTicketNumber
|
||||
? {
|
||||
...current,
|
||||
title: current.title || result.ticket?.title || "",
|
||||
organizationId: result.ticket?.organization_id ?? "",
|
||||
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null
|
||||
}
|
||||
@@ -197,6 +317,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
try {
|
||||
const result = await createProject({
|
||||
ticketNumber,
|
||||
title: form.title || null,
|
||||
organizationId: form.organizationId,
|
||||
teamspaceProject: form.teamspaceProject,
|
||||
description: form.description,
|
||||
@@ -209,6 +330,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
description: `${result.project.ticket_number} ist jetzt als Consulting-Projekt markiert.`
|
||||
});
|
||||
setForm(emptyProjectForm());
|
||||
setCreateOpen(false);
|
||||
setLookupResult(null);
|
||||
setLookupStatus("idle");
|
||||
await load();
|
||||
@@ -238,6 +360,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
try {
|
||||
await updateProject(project.id, {
|
||||
ticketNumber: editForm.ticketNumber,
|
||||
title: editForm.title || null,
|
||||
organizationId: editForm.organizationId,
|
||||
teamspaceProject: editForm.teamspaceProject,
|
||||
description: editForm.description,
|
||||
@@ -265,126 +388,48 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Projekte</h2>
|
||||
<p className="text-sm text-muted-foreground">Consulting-Projekte als Erweiterung bestehender oder neuer Tickets.</p>
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-3">
|
||||
<div className="rounded-md border bg-muted/20 px-3 py-2">
|
||||
<span className="block text-muted-foreground">Projekte</span>
|
||||
<span className="font-semibold">{projects.length}</span>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/20 px-3 py-2">
|
||||
<span className="block text-muted-foreground">Abgerechnet</span>
|
||||
<span className="font-semibold">{formatMinutes(billedMinutes)}</span>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/20 px-3 py-2">
|
||||
<span className="block text-muted-foreground">Offene ToDos</span>
|
||||
<span className="font-semibold">{openTodos}</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-3">
|
||||
<div className="rounded-md border bg-muted/20 px-3 py-2">
|
||||
<span className="block text-muted-foreground">Projekte</span>
|
||||
<span className="font-semibold">{projects.length}</span>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/20 px-3 py-2">
|
||||
<span className="block text-muted-foreground">Abgerechnet</span>
|
||||
<span className="font-semibold">{formatMinutes(billedMinutes)}</span>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/20 px-3 py-2">
|
||||
<span className="block text-muted-foreground">Offene ToDos</span>
|
||||
<span className="font-semibold">{openTodos}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button">
|
||||
<Plus className="size-4" />
|
||||
Projekt anlegen
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Projekt anlegen</DialogTitle>
|
||||
<DialogDescription>Ticketnummer eingeben. Vorhandene Ticketdaten werden automatisch übernommen.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ProjectFormFields
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
organizationLocked={organizationLocked}
|
||||
lookupStatus={lookupStatus}
|
||||
saving={saving}
|
||||
submitLabel="Projekt anlegen"
|
||||
onSubmit={submitProject}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<HelpLink anchor="projekte" label="Hilfe zu Projekten" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Projekt anlegen</CardTitle>
|
||||
<CardDescription>Ticketnummer eingeben. Vorhandene Ticketdaten werden automatisch übernommen.</CardDescription>
|
||||
</div>
|
||||
<HelpLink anchor="projekte" label="Hilfe zu Projekten" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<form className="space-y-4" onSubmit={submitProject}>
|
||||
<div className="grid gap-3 lg:grid-cols-[180px_minmax(240px,1fr)_220px]">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="project-ticket-number">
|
||||
Ticket
|
||||
</label>
|
||||
<Input
|
||||
id="project-ticket-number"
|
||||
placeholder="Ticket#123456"
|
||||
value={form.ticketNumber}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
ticketNumber: event.currentTarget.value,
|
||||
organizationId: "",
|
||||
organizationName: null
|
||||
})
|
||||
}
|
||||
required
|
||||
/>
|
||||
{form.ticketNumber && !ticketPattern.test(form.ticketNumber.trim()) ? <p className="text-xs text-destructive">Format: Ticket#XXXXXX</p> : null}
|
||||
{lookupStatus === "loading" ? <p className="text-xs text-muted-foreground">Suche Ticket...</p> : null}
|
||||
{lookupStatus === "found" ? <p className="text-xs text-muted-foreground">Ticket gefunden. Organisation wird übernommen.</p> : null}
|
||||
{lookupStatus === "not-found" ? <p className="text-xs text-muted-foreground">Neues Ticket. Organisation bitte wählen.</p> : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Organisation</label>
|
||||
<OrganizationSelect
|
||||
value={form.organizationId}
|
||||
selectedName={form.organizationName}
|
||||
disabled={organizationLocked}
|
||||
onChange={(organization) => setForm({ ...form, organizationId: organization.id, organizationName: organization.name })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Art</label>
|
||||
<div className="flex h-9 items-center rounded-lg border bg-muted/30 px-3 text-sm">Consulting</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_140px_140px_160px]">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="teamspace-project">
|
||||
Teamspace-Projekt
|
||||
</label>
|
||||
<Input id="teamspace-project" value={form.teamspaceProject} onChange={(event) => setForm({ ...form, teamspaceProject: event.currentTarget.value })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="project-starts-on">
|
||||
Zeitraum von
|
||||
</label>
|
||||
<Input id="project-starts-on" type="date" value={form.startsOn} onChange={(event) => setForm({ ...form, startsOn: event.currentTarget.value })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="project-ends-on">
|
||||
Zeitraum bis
|
||||
</label>
|
||||
<Input id="project-ends-on" type="date" value={form.endsOn} onChange={(event) => setForm({ ...form, endsOn: event.currentTarget.value })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="project-budget">
|
||||
Kontingent h
|
||||
</label>
|
||||
<Input id="project-budget" inputMode="decimal" value={form.hourBudget} onChange={(event) => setForm({ ...form, hourBudget: event.currentTarget.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="project-description">
|
||||
Projektbeschreibung
|
||||
</label>
|
||||
<Textarea id="project-description" className="min-h-24" value={form.description} onChange={(event) => setForm({ ...form, description: event.currentTarget.value })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="project-goals">
|
||||
Ziele
|
||||
</label>
|
||||
<Textarea id="project-goals" className="min-h-24" value={form.goals} onChange={(event) => setForm({ ...form, goals: event.currentTarget.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={saving}>
|
||||
<Save className="size-4" />
|
||||
{saving ? "Speichert..." : "Projekt anlegen"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
{projects.map((project) => {
|
||||
const isEditing = editingProjectId === project.id;
|
||||
@@ -399,10 +444,12 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CardTitle className="truncate text-base">{project.ticket_number}</CardTitle>
|
||||
<CardTitle className="truncate text-base">{project.title || project.ticket_number}</CardTitle>
|
||||
<Badge variant="outline">Projekt</Badge>
|
||||
</div>
|
||||
<CardDescription className="truncate">{project.teamspace_project || project.organization_name || "Ohne Teamspace-Projekt"}</CardDescription>
|
||||
<CardDescription className="truncate">
|
||||
{project.ticket_number} · {project.teamspace_project || project.organization_name || "Ohne Teamspace-Projekt"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void onStartTimer(project.ticket_number)}>
|
||||
@@ -418,7 +465,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
{isEditing ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-[160px_minmax(200px,1fr)_160px]">
|
||||
<Input value={editForm.ticketNumber} onChange={(event) => setEditForm({ ...editForm, ticketNumber: event.currentTarget.value })} />
|
||||
<OrganizationSelect
|
||||
@@ -429,6 +476,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
/>
|
||||
<div className="flex h-9 items-center rounded-lg border bg-muted/30 px-3 text-sm">Consulting</div>
|
||||
</div>
|
||||
<Input placeholder="Titel" value={editForm.title} onChange={(event) => setEditForm({ ...editForm, title: event.currentTarget.value })} />
|
||||
<Input
|
||||
placeholder="Teamspace-Projekt"
|
||||
value={editForm.teamspaceProject}
|
||||
@@ -454,6 +502,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-3">
|
||||
<div className="rounded-md border bg-background p-2">
|
||||
<span className="block text-xs text-muted-foreground">Ticket</span>
|
||||
<span className="line-clamp-1 font-medium">{project.ticket_number}</span>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background p-2">
|
||||
<span className="block text-xs text-muted-foreground">Organisation</span>
|
||||
<span className="line-clamp-1 font-medium">{project.organization_name ?? project.customer_name ?? "Keine Organisation"}</span>
|
||||
@@ -462,10 +514,6 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
<span className="block text-xs text-muted-foreground">Zeitraum</span>
|
||||
<span className="font-medium">{project.starts_on || "offen"} - {project.ends_on || "offen"}</span>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background p-2">
|
||||
<span className="block text-xs text-muted-foreground">Kontingent</span>
|
||||
<span className="font-medium">{project.hour_budget_minutes ? formatMinutes(project.hour_budget_minutes) : "offen"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{usedPercent !== null ? (
|
||||
|
||||
Reference in new issue
Block a user