Initial TicketTracker release

This commit is contained in:
michael committed 2026-09-09 09:42:48 +02:00
1 parent d1da86beab
commit c2063fe1ed
11 files changed
+1124 -175

No files matched your search

+112 -4
View File
@@ -54,6 +54,7 @@ import type {
DashboardWidget,
DashboardWidgetType,
PeriodOverview,
ProjectTeamspaceEntry,
ProjectSummary,
RecurringBilling,
StatisticsOverview,
@@ -571,6 +572,25 @@ function projectConsumptionPercent(project: ProjectSummary) {
return Math.round(((project.consumption_minutes ?? 0) / budgetMinutes) * 100);
}
function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry[] {
if (project.teamspace_entries?.length) {
return project.teamspace_entries;
}
return [
{
id: `${project.id}-default`,
teamspace_project: project.teamspace_project,
is_default: true,
hour_budget_minutes: project.hour_budget_minutes,
manual_consumption_minutes: project.manual_consumption_minutes ?? 0,
teamspace_consumption_minutes: project.teamspace_consumption_minutes ?? 0,
consumption_minutes: project.consumption_minutes ?? project.teamspace_consumption_minutes ?? 0,
display_order: 0
}
];
}
function openRatingFilterGroup(filter: string) {
if (filter === "fixedBilling" || filter === "manualBilling") {
return "source";
@@ -1648,18 +1668,48 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
const consumptionMinutes = project.consumption_minutes ?? 0;
const budgetMinutes = project.hour_budget_minutes ?? 0;
const percent = projectConsumptionPercent(project);
const entries = projectTeamspaceEntries(project);
const hasMultipleTeamspaceEntries = entries.length > 1;
return (
<button key={project.id} type="button" className="block w-full min-w-0 rounded-md px-2 py-1.5 text-left hover:bg-muted" onClick={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}>
<span className="block truncate text-xs font-medium">{project.title || project.ticket_number}</span>
<span className="block truncate text-[11px] text-muted-foreground">
{project.ticket_number} · {budgetMinutes > 0 ? `${decimalHours(consumptionMinutes)} / ${decimalHours(budgetMinutes)} · ${percent ?? 0}%` : "ohne Kontingent"}
{project.ticket_number} · {hasMultipleTeamspaceEntries ? `${entries.length} Teamspace · ` : ""}{budgetMinutes > 0 ? `${decimalHours(consumptionMinutes)} / ${decimalHours(budgetMinutes)} · ${percent ?? 0}%` : "ohne Kontingent"}
</span>
{percent !== null ? (
<span className="mt-1 block h-1.5 overflow-hidden rounded-full bg-muted" title={`${percent}% Verbrauch`} aria-label={`${percent}% Verbrauch`}>
<span className={cn("block h-full rounded-full", consumptionBarClass(percent))} style={{ width: `${Math.min(100, percent)}%` }} />
</span>
) : null}
{hasMultipleTeamspaceEntries ? (
<span className="mt-1 grid gap-1">
{entries.map((entry) => {
const entryBudgetMinutes = entry.hour_budget_minutes ?? 0;
const entryConsumptionMinutes = entry.consumption_minutes ?? 0;
const entryPercent = entryBudgetMinutes > 0 ? Math.round((entryConsumptionMinutes / entryBudgetMinutes) * 100) : null;
return (
<span key={entry.id} className="block">
<span className="flex min-w-0 items-center justify-between gap-2 text-[10px] text-muted-foreground">
<span className="truncate">
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
{entry.is_default ? " · Standard" : ""}
</span>
<span className="shrink-0">
{entryBudgetMinutes > 0 ? `${decimalHours(entryConsumptionMinutes)} / ${decimalHours(entryBudgetMinutes)}` : decimalHours(entryConsumptionMinutes)}
</span>
</span>
{entryPercent !== null ? (
<span className="mt-0.5 block h-1 overflow-hidden rounded-full bg-muted">
<span className={cn("block h-full rounded-full", consumptionBarClass(entryPercent))} style={{ width: `${Math.min(100, entryPercent)}%` }} />
</span>
) : null}
</span>
);
})}
</span>
) : null}
</button>
);
})}
@@ -1973,6 +2023,46 @@ function NotesWidget({ widget, editing, onUpdateConfig }: WidgetContentProps) {
const { notes, activeNoteId } = readWidgetNotes(widget.config);
const activeNote = notes.find((note) => note.id === activeNoteId) ?? notes[0];
const tabsScrollerRef = useRef<HTMLDivElement | null>(null);
const [tabScrollState, setTabScrollState] = useState({ canScrollLeft: false, canScrollRight: false });
function updateTabScrollState() {
const scroller = tabsScrollerRef.current;
if (!scroller) {
setTabScrollState({ canScrollLeft: false, canScrollRight: false });
return;
}
const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth;
setTabScrollState({
canScrollLeft: scroller.scrollLeft > 1,
canScrollRight: scroller.scrollLeft < maxScrollLeft - 1
});
}
useEffect(() => {
updateTabScrollState();
const scroller = tabsScrollerRef.current;
if (!scroller) {
return;
}
const handleScroll = () => updateTabScrollState();
const handleResize = () => updateTabScrollState();
scroller.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("resize", handleResize);
return () => {
scroller.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleResize);
};
}, [notes.length]);
useEffect(() => {
updateTabScrollState();
}, [activeNoteId, notes.length]);
function saveNotes(nextNotes: DashboardNote[], nextActiveNoteId = activeNoteId) {
const safeActiveNoteId = nextNotes.some((note) => note.id === nextActiveNoteId) ? nextActiveNoteId : nextNotes[0]?.id;
@@ -2005,10 +2095,27 @@ function NotesWidget({ widget, editing, onUpdateConfig }: WidgetContentProps) {
return (
<Tabs value={activeNoteId} onValueChange={(value) => onUpdateConfig({ activeNoteId: value })} className="flex h-full min-h-0 overflow-hidden gap-1">
<div className="flex h-8 min-w-0 shrink-0 items-center gap-1 overflow-hidden">
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" onClick={() => scrollTabs(-1)}>
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" disabled={!tabScrollState.canScrollLeft} onClick={() => scrollTabs(-1)}>
<ChevronLeft className="size-3.5" />
</Button>
<div ref={tabsScrollerRef} className="min-w-0 flex-1 overflow-x-auto overflow-y-hidden">
<div className="relative min-w-0 flex-1">
<div
className={cn(
"pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-gradient-to-r from-card to-transparent transition-opacity",
tabScrollState.canScrollLeft ? "opacity-100" : "opacity-0"
)}
/>
<div
className={cn(
"pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-gradient-to-l from-card to-transparent transition-opacity",
tabScrollState.canScrollRight ? "opacity-100" : "opacity-0"
)}
/>
<div
ref={tabsScrollerRef}
className="min-w-0 flex-1 overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
onScroll={updateTabScrollState}
>
<TabsList variant="line" className="h-8 w-max min-w-max justify-start rounded-none p-0 pb-1">
{notes.map((note) => (
<TabsTrigger key={note.id} value={note.id} className="h-7 w-32 shrink-0 flex-none px-2 text-xs">
@@ -2016,8 +2123,9 @@ function NotesWidget({ widget, editing, onUpdateConfig }: WidgetContentProps) {
</TabsTrigger>
))}
</TabsList>
</div>
</div>
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" onClick={() => scrollTabs(1)}>
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" disabled={!tabScrollState.canScrollRight} onClick={() => scrollTabs(1)}>
<ChevronRight className="size-3.5" />
</Button>
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" onClick={addNote}>
+70 -7
View File
@@ -1,5 +1,5 @@
import { FormEvent, useEffect, useState } from "react";
import { KeyRound, Save, UserCircle } from "lucide-react";
import { KeyRound, Save, Unplug, UserCircle, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -19,11 +19,15 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [zammadApiToken, setZammadApiToken] = useState("");
const [zammadTokenDirty, setZammadTokenDirty] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
setUsername(currentUser.username);
setDisplayName(currentUser.display_name);
setZammadApiToken("");
setZammadTokenDirty(false);
}, [currentUser]);
async function submit(event: FormEvent) {
@@ -45,17 +49,22 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setSaving(true);
const payload = {
username,
displayName,
currentPassword: wantsPasswordChange ? currentPassword : undefined,
newPassword: wantsPasswordChange ? newPassword : undefined,
...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {})
};
try {
const result = await updateCurrentUser({
username,
displayName,
currentPassword: wantsPasswordChange ? currentPassword : undefined,
newPassword: wantsPasswordChange ? newPassword : undefined
});
const result = await updateCurrentUser(payload);
onUserUpdated(result.user);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setZammadApiToken("");
setZammadTokenDirty(false);
toast.success("Profil gespeichert");
} catch (error) {
toast.error("Profil konnte nicht gespeichert werden", {
@@ -146,6 +155,60 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
</div>
</div>
{currentUser.role === "user" ? (
<div className="rounded-md border bg-muted/30 p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Unplug className="size-4 text-muted-foreground" />
<p className="text-sm font-medium">Zammad API</p>
</div>
<HelpLink anchor="profil" label="Hilfe zu Zammad im Profil" />
</div>
<div className="grid gap-3 md:grid-cols-[minmax(220px,1fr)_180px]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="profile-zammad-token">
Persönlicher API-Token
</label>
<Input
id="profile-zammad-token"
type="password"
value={zammadApiToken}
placeholder={currentUser.has_zammad_api_token ? "••••••••••••••••" : "Token aus Zammad einfügen"}
autoComplete="off"
onChange={(event) => {
setZammadApiToken(event.currentTarget.value);
setZammadTokenDirty(true);
}}
/>
<p className="text-xs text-muted-foreground">
Die Zammad-User-ID wird nur geprüft, wenn du hier einen neuen Token speicherst.
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="profile-zammad-user-id">
Zammad User-ID
</label>
<Input id="profile-zammad-user-id" value={currentUser.zammad_user_id ?? "nicht verknüpft"} readOnly />
{currentUser.has_zammad_api_token ? (
<Button
type="button"
size="sm"
variant="secondary"
className="w-full"
onClick={() => {
setZammadApiToken("");
setZammadTokenDirty(true);
}}
>
<X className="size-4" />
Token entfernen
</Button>
) : null}
</div>
</div>
</div>
) : null}
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
<Save className="size-4" />
+282 -79
View File
@@ -1,4 +1,4 @@
import { ArrowDown, ArrowUp, CheckCircle2, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Save, Search, SlidersHorizontal, Star } from "lucide-react";
import { ArrowDown, ArrowUp, CheckCircle2, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Save, Search, SlidersHorizontal, Star, Trash2 } from "lucide-react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -17,7 +17,7 @@ import { addFavoriteTicket, createProject, getProjects, lookupTicket, updateProj
import { currentMonth, formatMinutes } from "../format";
import { projectHasRecurringBilling } from "../recurring-billings";
import { ticketPattern } from "../timers";
import type { ProjectSummary, TicketMeta } from "../types";
import type { ProjectSummary, ProjectTeamspaceEntry, TicketMeta } from "../types";
type ProjectsPageProps = {
onNavigate: (to: string) => void;
@@ -29,12 +29,18 @@ type ProjectForm = {
title: string;
organizationId: string;
organizationName: string | null;
teamspaceProject: string;
description: string;
startsOn: string;
endsOn: string;
teamspaceEntries: ProjectTeamspaceEntryForm[];
};
type ProjectTeamspaceEntryForm = {
clientId: string;
teamspaceProject: string;
hourBudget: string;
manualConsumption: string;
isDefault: boolean;
};
type ProjectStatusFilter = "open" | "all" | "closed";
@@ -88,6 +94,24 @@ const projectTodoOptions: Array<{ value: ProjectTodoFilter; label: string }> = [
const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "consumption", "consumptionPercent", "remaining", "budget", "todos"];
function projectEntryId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `entry-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function emptyProjectTeamspaceEntry(isDefault = false): ProjectTeamspaceEntryForm {
return {
clientId: projectEntryId(),
teamspaceProject: "",
hourBudget: "",
manualConsumption: "",
isDefault
};
}
function readStoredProjectViewSettings(): ProjectViewSettings {
if (typeof window === "undefined") {
return defaultProjectViewSettings;
@@ -122,12 +146,10 @@ function emptyProjectForm(): ProjectForm {
title: "",
organizationId: "",
organizationName: null,
teamspaceProject: "",
description: "",
startsOn: "",
endsOn: "",
hourBudget: "",
manualConsumption: ""
teamspaceEntries: [emptyProjectTeamspaceEntry(true)]
};
}
@@ -147,6 +169,41 @@ function formatDecimalHours(minutes: number) {
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(minutes / 60)} h`;
}
function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry[] {
if (project.teamspace_entries?.length) {
return project.teamspace_entries;
}
return [
{
id: `${project.id}-default`,
teamspace_project: project.teamspace_project,
is_default: true,
hour_budget_minutes: project.hour_budget_minutes,
manual_consumption_minutes: project.manual_consumption_minutes ?? 0,
teamspace_consumption_minutes: project.teamspace_consumption_minutes ?? 0,
consumption_minutes: project.consumption_minutes ?? project.teamspace_consumption_minutes ?? 0,
display_order: 0
}
];
}
function projectTeamspaceEntryCount(project: ProjectSummary) {
return Number(project.teamspace_entry_count ?? projectTeamspaceEntries(project).length);
}
function projectEntryConsumption(entry: ProjectTeamspaceEntry) {
return Number(entry.consumption_minutes ?? 0);
}
function projectEntryPercent(entry: ProjectTeamspaceEntry) {
if (!entry.hour_budget_minutes || entry.hour_budget_minutes <= 0) {
return null;
}
return Math.round((projectEntryConsumption(entry) / entry.hour_budget_minutes) * 100);
}
function projectConsumptionBarClass(percent: number) {
if (percent >= 100) {
return "bg-blue-500";
@@ -224,6 +281,7 @@ function projectMatchesQuery(project: ProjectSummary, query: string) {
project.title ?? "",
projectOrganizationLabel(project),
project.teamspace_project ?? "",
...projectTeamspaceEntries(project).map((entry) => entry.teamspace_project ?? ""),
project.description,
project.goals
];
@@ -268,17 +326,24 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr
}
function formFromProject(project: ProjectSummary): ProjectForm {
const entries = projectTeamspaceEntries(project).map((entry, index) => ({
clientId: entry.id || projectEntryId(),
teamspaceProject: entry.teamspace_project ?? "",
hourBudget: formatHoursInput(entry.hour_budget_minutes),
manualConsumption: formatHoursInput(entry.manual_consumption_minutes),
isDefault: entry.is_default || index === 0
}));
const hasDefault = entries.some((entry) => entry.isDefault);
return {
ticketNumber: project.ticket_number,
title: project.title ?? "",
organizationId: project.organization_id ?? "",
organizationName: project.organization_name ?? project.customer_name ?? null,
teamspaceProject: project.teamspace_project ?? "",
description: combinedProjectDescription(project),
startsOn: project.starts_on ?? "",
endsOn: project.ends_on ?? "",
hourBudget: formatHoursInput(project.hour_budget_minutes),
manualConsumption: formatHoursInput(project.manual_consumption_minutes)
teamspaceEntries: hasDefault ? entries : entries.map((entry, index) => ({ ...entry, isDefault: index === 0 }))
};
}
@@ -292,6 +357,132 @@ type ProjectFormFieldsProps = {
onSubmit: (event: FormEvent) => void;
};
function normalizeProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
const source = entries.length ? entries : [emptyProjectTeamspaceEntry(true)];
const defaultIndex = Math.max(0, source.findIndex((entry) => entry.isDefault));
return source.map((entry, index) => ({
...entry,
isDefault: index === defaultIndex
}));
}
function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
const parsedEntries = normalizedEntries.map((entry) => {
const hourBudgetMinutes = parseHoursInput(entry.hourBudget);
const manualConsumptionMinutes = parseHoursInput(entry.manualConsumption);
if (hourBudgetMinutes === undefined || manualConsumptionMinutes === undefined) {
return undefined;
}
return {
teamspaceProject: entry.teamspaceProject.trim() || null,
isDefault: entry.isDefault,
hourBudgetMinutes,
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
};
});
if (parsedEntries.some((entry) => entry === undefined)) {
return undefined;
}
return parsedEntries as Array<{
teamspaceProject: string | null;
isDefault: boolean;
hourBudgetMinutes: number | null;
manualConsumptionMinutes: number;
}>;
}
function defaultProjectTeamspacePayload(entries: ReturnType<typeof parseProjectTeamspaceEntryForms>) {
return entries?.find((entry) => entry.isDefault) ?? entries?.[0] ?? null;
}
function ProjectTeamspaceEntriesFields({
entries,
onChange
}: {
entries: ProjectTeamspaceEntryForm[];
onChange: (entries: ProjectTeamspaceEntryForm[]) => void;
}) {
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
function updateEntry(clientId: string, changes: Partial<ProjectTeamspaceEntryForm>) {
onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.map((entry) => (entry.clientId === clientId ? { ...entry, ...changes } : entry))));
}
function setDefault(clientId: string) {
onChange(normalizedEntries.map((entry) => ({ ...entry, isDefault: entry.clientId === clientId })));
}
function removeEntry(clientId: string) {
onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.filter((entry) => entry.clientId !== clientId)));
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<label className="text-sm font-medium">Teamspace-Einträge</label>
<Button type="button" size="sm" variant="secondary" onClick={() => onChange([...normalizedEntries, emptyProjectTeamspaceEntry(false)])}>
<Plus className="size-3.5" />
Eintrag
</Button>
</div>
<div className="space-y-2">
{normalizedEntries.map((entry, index) => (
<div key={entry.clientId} className="grid gap-2 rounded-lg border bg-muted/10 p-3 lg:grid-cols-[minmax(180px,1fr)_130px_150px_120px_36px] lg:items-end">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-${entry.clientId}`}>
Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""}
</label>
<Input
id={`project-teamspace-${entry.clientId}`}
value={entry.teamspaceProject}
onChange={(event) => updateEntry(entry.clientId, { teamspaceProject: event.currentTarget.value })}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-budget-${entry.clientId}`}>
Budget h
</label>
<Input
id={`project-budget-${entry.clientId}`}
inputMode="decimal"
value={entry.hourBudget}
onChange={(event) => updateEntry(entry.clientId, { hourBudget: event.currentTarget.value })}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-manual-${entry.clientId}`}>
Manueller Verbrauch h
</label>
<Input
id={`project-manual-${entry.clientId}`}
inputMode="decimal"
value={entry.manualConsumption}
onChange={(event) => updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })}
/>
</div>
<Button type="button" size="sm" variant={entry.isDefault ? "default" : "secondary"} onClick={() => setDefault(entry.clientId)}>
{entry.isDefault ? "Standard" : "Als Standard"}
</Button>
<Button type="button" size="icon" variant="ghost" disabled={normalizedEntries.length === 1} onClick={() => removeEntry(entry.clientId)}>
<Trash2 className="size-4 text-destructive" />
<span className="sr-only">Teamspace-Eintrag {index + 1} löschen</span>
</Button>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Der Standard-Eintrag bekommt die aus Teamspace gebuchten Tageswerte. Manueller Verbrauch zählt nur im Projektverbrauch.
</p>
</div>
);
}
function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, saving, submitLabel, onSubmit }: ProjectFormFieldsProps) {
return (
<form className="space-y-4" onSubmit={onSubmit}>
@@ -331,7 +522,7 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, sa
</div>
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(240px,1fr)_minmax(220px,1fr)]">
<div className="grid gap-3 lg:grid-cols-[minmax(240px,1fr)_320px_320px]">
<div className="space-y-2">
<label className="text-sm font-medium">Organisation</label>
<OrganizationSelect
@@ -342,15 +533,6 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, sa
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-4">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-starts-on">
Zeitraum von
@@ -363,26 +545,10 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, sa
</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 className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-manual-consumption">
Manueller Verbrauch h
</label>
<Input
id="project-manual-consumption"
inputMode="decimal"
value={form.manualConsumption}
onChange={(event) => setForm({ ...form, manualConsumption: event.currentTarget.value })}
/>
<p className="text-xs text-muted-foreground">Nur für Projektverbrauch, nicht für Auswertungen.</p>
</div>
</div>
<ProjectTeamspaceEntriesFields entries={form.teamspaceEntries} onChange={(teamspaceEntries) => setForm({ ...form, teamspaceEntries })} />
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-description">
Beschreibung/Ziele
@@ -610,24 +776,16 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
return;
}
const hourBudgetMinutes = parseHoursInput(form.hourBudget);
const parsedManualConsumptionMinutes = parseHoursInput(form.manualConsumption);
const teamspaceEntries = parseProjectTeamspaceEntryForms(form.teamspaceEntries);
if (hourBudgetMinutes === undefined) {
toast.error("Stundenkontingent prüfen", {
if (!teamspaceEntries) {
toast.error("Teamspace-Einträge prüfen", {
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
});
return;
}
if (parsedManualConsumptionMinutes === undefined) {
toast.error("Manuellen Verbrauch prüfen", {
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
});
return;
}
const manualConsumptionMinutes = parsedManualConsumptionMinutes ?? 0;
const defaultTeamspaceEntry = defaultProjectTeamspacePayload(teamspaceEntries);
setSaving(true);
try {
@@ -635,13 +793,14 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
ticketNumber,
title: form.title || null,
organizationId: form.organizationId,
teamspaceProject: form.teamspaceProject,
teamspaceProject: defaultTeamspaceEntry?.teamspaceProject ?? null,
teamspaceEntries,
description: form.description,
goals: "",
startsOn: form.startsOn || null,
endsOn: form.endsOn || null,
hourBudgetMinutes,
manualConsumptionMinutes
hourBudgetMinutes: defaultTeamspaceEntry?.hourBudgetMinutes ?? null,
manualConsumptionMinutes: defaultTeamspaceEntry?.manualConsumptionMinutes ?? 0
});
toast.success("Projekt angelegt", {
description: `${result.project.ticket_number} ist jetzt als Consulting-Projekt markiert.`
@@ -661,20 +820,16 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
}
async function saveProject(project: ProjectSummary) {
const hourBudgetMinutes = parseHoursInput(editForm.hourBudget);
const parsedManualConsumptionMinutes = parseHoursInput(editForm.manualConsumption);
const teamspaceEntries = parseProjectTeamspaceEntryForms(editForm.teamspaceEntries);
if (hourBudgetMinutes === undefined) {
toast.error("Stundenkontingent prüfen");
if (!teamspaceEntries) {
toast.error("Teamspace-Einträge prüfen", {
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
});
return;
}
if (parsedManualConsumptionMinutes === undefined) {
toast.error("Manuellen Verbrauch prüfen");
return;
}
const manualConsumptionMinutes = parsedManualConsumptionMinutes ?? 0;
const defaultTeamspaceEntry = defaultProjectTeamspacePayload(teamspaceEntries);
if (!editForm.organizationId) {
toast.error("Organisation wählen");
@@ -687,13 +842,14 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
ticketNumber: editForm.ticketNumber,
title: editForm.title || null,
organizationId: editForm.organizationId,
teamspaceProject: editForm.teamspaceProject,
teamspaceProject: defaultTeamspaceEntry?.teamspaceProject ?? null,
teamspaceEntries,
description: editForm.description,
goals: "",
startsOn: editForm.startsOn || null,
endsOn: editForm.endsOn || null,
hourBudgetMinutes,
manualConsumptionMinutes
hourBudgetMinutes: defaultTeamspaceEntry?.hourBudgetMinutes ?? null,
manualConsumptionMinutes: defaultTeamspaceEntry?.manualConsumptionMinutes ?? 0
});
toast.success("Projekt gespeichert");
setEditingProjectId(null);
@@ -765,6 +921,29 @@ 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}
saving={saving}
submitLabel="Projekt anlegen"
onSubmit={submitProject}
/>
</DialogContent>
</Dialog>
<HelpLink anchor="projekte" label="Hilfe zu Projekten" className="shrink-0" />
</div>
</div>
@@ -915,9 +1094,11 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div>
{filteredProjects.map((project) => {
const isEditing = editingProjectId === project.id;
const teamspaceEntries = projectTeamspaceEntries(project);
const consumptionMinutes = project.consumption_minutes ?? 0;
const remaining = projectRemaining(project);
const usedPercent = projectConsumptionPercent(project);
const hasMultipleTeamspaceEntries = teamspaceEntries.length > 1;
return (
<div key={project.id} className="border-b last:border-b-0">
@@ -943,6 +1124,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<p className={cn("text-sm font-semibold", remaining !== null && remaining < 0 ? "text-destructive" : undefined)}>
{formatDecimalHours(consumptionMinutes)} / {project.hour_budget_minutes ? formatDecimalHours(project.hour_budget_minutes) : "offen"}
</p>
{hasMultipleTeamspaceEntries ? <p className="text-xs text-muted-foreground">{projectTeamspaceEntryCount(project)} Teamspace</p> : null}
</div>
<div className="min-w-0 text-sm">
<p className="truncate font-medium xl:hidden">Verbrauch %</p>
@@ -999,7 +1181,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</DropdownMenu>
</div>
</div>
<div className="px-4 pb-3">
<div className="space-y-2 px-4 pb-3">
{usedPercent !== null ? (
<div className="h-1.5 overflow-hidden rounded-full bg-muted" title={`${usedPercent}% Verbrauch`} aria-label={`${usedPercent}% Verbrauch`}>
<div className={cn("h-full rounded-full", projectConsumptionBarClass(usedPercent))} style={{ width: `${Math.min(100, usedPercent)}%` }} />
@@ -1009,6 +1191,35 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<div className="h-full rounded-full bg-muted-foreground/10" style={{ width: "0%" }} />
</div>
)}
{hasMultipleTeamspaceEntries ? (
<div className="grid gap-1.5 md:grid-cols-2">
{teamspaceEntries.map((entry) => {
const entryPercent = projectEntryPercent(entry);
const entryConsumption = projectEntryConsumption(entry);
const label = entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
return (
<div key={entry.id} className="min-w-0 rounded-md border bg-background/50 px-2 py-1">
<div className="mb-1 flex min-w-0 items-center justify-between gap-2 text-[11px]">
<span className="truncate text-muted-foreground">
{label}
{entry.is_default ? " · Standard" : ""}
</span>
<span className="shrink-0 font-medium">
{formatMinutes(entryConsumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
</span>
</div>
<div className="h-1 overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full", entryPercent === null ? "bg-muted-foreground/20" : projectConsumptionBarClass(entryPercent))}
style={{ width: `${entryPercent === null ? 0 : Math.min(100, entryPercent)}%` }}
/>
</div>
</div>
);
})}
</div>
) : null}
</div>
{isEditing ? (
<div className="border-t bg-muted/10 p-4">
@@ -1024,22 +1235,14 @@ 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}
onChange={(event) => setEditForm({ ...editForm, teamspaceProject: event.currentTarget.value })}
/>
<div className="grid gap-3 md:grid-cols-4">
<div className="grid gap-3 md:grid-cols-2">
<Input type="date" value={editForm.startsOn} onChange={(event) => setEditForm({ ...editForm, startsOn: event.currentTarget.value })} />
<Input type="date" value={editForm.endsOn} onChange={(event) => setEditForm({ ...editForm, endsOn: event.currentTarget.value })} />
<Input inputMode="decimal" placeholder="Kontingent h" value={editForm.hourBudget} onChange={(event) => setEditForm({ ...editForm, hourBudget: event.currentTarget.value })} />
<Input
inputMode="decimal"
placeholder="Manueller Verbrauch h"
value={editForm.manualConsumption}
onChange={(event) => setEditForm({ ...editForm, manualConsumption: event.currentTarget.value })}
/>
</div>
<ProjectTeamspaceEntriesFields
entries={editForm.teamspaceEntries}
onChange={(teamspaceEntries) => setEditForm({ ...editForm, teamspaceEntries })}
/>
<Textarea className="min-h-24" value={editForm.description} onChange={(event) => setEditForm({ ...editForm, description: event.currentTarget.value })} />
<div className="flex flex-wrap justify-end gap-2">
<Button
+259 -57
View File
@@ -29,7 +29,7 @@ import {
} from "../api";
import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format";
import { ticketHasRecurringBilling } from "../recurring-billings";
import type { BillingStatus, PeriodType, ProjectSummary, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types";
import type { BillingStatus, PeriodType, ProjectSummary, ProjectTeamspaceEntry, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types";
import { cn } from "@/lib/utils";
type TicketDetailPageProps = {
@@ -64,12 +64,18 @@ type ProjectEditFormState = {
title: string;
organizationId: string;
organizationName: string | null;
teamspaceProject: string;
description: string;
startsOn: string;
endsOn: string;
teamspaceEntries: ProjectTeamspaceEntryForm[];
};
type ProjectTeamspaceEntryForm = {
clientId: string;
teamspaceProject: string;
hourBudget: string;
manualConsumption: string;
isDefault: boolean;
};
function emptyTicketSessionForm(): TicketSessionFormState {
@@ -84,17 +90,51 @@ function emptyTicketSessionForm(): TicketSessionFormState {
};
}
function projectEntryId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `entry-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry[] {
if (project.teamspace_entries?.length) {
return project.teamspace_entries;
}
return [
{
id: `${project.id}-default`,
teamspace_project: project.teamspace_project,
is_default: true,
hour_budget_minutes: project.hour_budget_minutes,
manual_consumption_minutes: project.manual_consumption_minutes ?? 0,
teamspace_consumption_minutes: project.teamspace_consumption_minutes ?? 0,
consumption_minutes: project.consumption_minutes ?? project.teamspace_consumption_minutes ?? 0,
display_order: 0
}
];
}
function projectEditForm(project: ProjectSummary): ProjectEditFormState {
const entries = projectTeamspaceEntries(project).map((entry, index) => ({
clientId: entry.id || projectEntryId(),
teamspaceProject: entry.teamspace_project ?? "",
hourBudget: formatHoursInput(entry.hour_budget_minutes),
manualConsumption: formatHoursInput(entry.manual_consumption_minutes),
isDefault: entry.is_default || index === 0
}));
const hasDefault = entries.some((entry) => entry.isDefault);
return {
title: project.title ?? "",
organizationId: project.organization_id ?? "",
organizationName: project.organization_name ?? project.customer_name ?? null,
teamspaceProject: project.teamspace_project ?? "",
description: combinedProjectDescription(project),
startsOn: project.starts_on ?? "",
endsOn: project.ends_on ?? "",
hourBudget: formatHoursInput(project.hour_budget_minutes),
manualConsumption: formatHoursInput(project.manual_consumption_minutes)
teamspaceEntries: hasDefault ? entries : entries.map((entry, index) => ({ ...entry, isDefault: index === 0 }))
};
}
@@ -186,10 +226,145 @@ function parseHoursInput(value: string) {
return Math.round(hours * 60);
}
function projectConsumptionBarClass(percent: number) {
if (percent >= 100) {
return "bg-blue-500";
}
if (percent >= 90) {
return "bg-red-500";
}
return "bg-emerald-500";
}
function projectConsumptionPercent(consumptionMinutes: number, budgetMinutes?: number | null) {
if (!budgetMinutes || budgetMinutes <= 0) {
return null;
}
return Math.round((consumptionMinutes / budgetMinutes) * 100);
}
function isPartialHoursInput(value: string) {
return /^\d*(?:[,.]\d*)?$/.test(value.trim());
}
function normalizeProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
const source = entries.length
? entries
: [
{
clientId: projectEntryId(),
teamspaceProject: "",
hourBudget: "",
manualConsumption: "",
isDefault: true
}
];
const defaultIndex = Math.max(0, source.findIndex((entry) => entry.isDefault));
return source.map((entry, index) => ({
...entry,
isDefault: index === defaultIndex
}));
}
function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
const parsedEntries = normalizedEntries.map((entry) => {
const hourBudgetMinutes = parseHoursInput(entry.hourBudget);
const manualConsumptionMinutes = parseHoursInput(entry.manualConsumption);
if (hourBudgetMinutes === undefined || manualConsumptionMinutes === undefined) {
return undefined;
}
return {
teamspaceProject: entry.teamspaceProject.trim() || null,
isDefault: entry.isDefault,
hourBudgetMinutes,
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
};
});
if (parsedEntries.some((entry) => entry === undefined)) {
return undefined;
}
return parsedEntries as Array<{
teamspaceProject: string | null;
isDefault: boolean;
hourBudgetMinutes: number | null;
manualConsumptionMinutes: number;
}>;
}
function ProjectTeamspaceEntriesEditor({
entries,
onChange
}: {
entries: ProjectTeamspaceEntryForm[];
onChange: (entries: ProjectTeamspaceEntryForm[]) => void;
}) {
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
function updateEntry(clientId: string, changes: Partial<ProjectTeamspaceEntryForm>) {
onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.map((entry) => (entry.clientId === clientId ? { ...entry, ...changes } : entry))));
}
function removeEntry(clientId: string) {
onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.filter((entry) => entry.clientId !== clientId)));
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<label className="text-sm font-medium">Teamspace-Einträge</label>
<Button type="button" size="sm" variant="secondary" onClick={() => onChange([...normalizedEntries, { clientId: projectEntryId(), teamspaceProject: "", hourBudget: "", manualConsumption: "", isDefault: false }])}>
<Plus className="size-3.5" />
Eintrag
</Button>
</div>
{normalizedEntries.map((entry, index) => (
<div key={entry.clientId} className="grid gap-2 rounded-lg border bg-muted/10 p-3 lg:grid-cols-[minmax(180px,1fr)_120px_150px_120px_36px] lg:items-end">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-ticket-teamspace-${entry.clientId}`}>
Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""}
</label>
<Input
id={`project-ticket-teamspace-${entry.clientId}`}
value={entry.teamspaceProject}
onChange={(event) => updateEntry(entry.clientId, { teamspaceProject: event.currentTarget.value })}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-ticket-budget-${entry.clientId}`}>Budget h</label>
<Input id={`project-ticket-budget-${entry.clientId}`} inputMode="decimal" value={entry.hourBudget} onChange={(event) => updateEntry(entry.clientId, { hourBudget: event.currentTarget.value })} />
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-ticket-manual-${entry.clientId}`}>Manueller Verbrauch h</label>
<Input id={`project-ticket-manual-${entry.clientId}`} inputMode="decimal" value={entry.manualConsumption} onChange={(event) => updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })} />
</div>
<Button
type="button"
size="sm"
variant={entry.isDefault ? "default" : "secondary"}
onClick={() => onChange(normalizedEntries.map((candidate) => ({ ...candidate, isDefault: candidate.clientId === entry.clientId })))}
>
{entry.isDefault ? "Standard" : "Als Standard"}
</Button>
<Button type="button" size="icon" variant="ghost" disabled={normalizedEntries.length === 1} onClick={() => removeEntry(entry.clientId)}>
<Trash2 className="size-4 text-destructive" />
<span className="sr-only">Teamspace-Eintrag {index + 1} löschen</span>
</Button>
</div>
))}
<p className="text-xs text-muted-foreground">Der Standard-Eintrag erhält die gebuchten Teamspace-Tageswerte. Manuelle Stunden zählen nur im Projektverbrauch.</p>
</div>
);
}
export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onOpenTicketChange, returnPath }: TicketDetailPageProps) {
const initialGlobalMonthFilter = periodType === "all" && period !== "all" ? period : currentMonth();
const [data, setData] = useState<TicketPeriod | null>(null);
@@ -226,12 +401,18 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
title: "",
organizationId: "",
organizationName: null,
teamspaceProject: "",
description: "",
startsOn: "",
endsOn: "",
hourBudget: "",
manualConsumption: ""
teamspaceEntries: [
{
clientId: projectEntryId(),
teamspaceProject: "",
hourBudget: "",
manualConsumption: "",
isDefault: true
}
]
});
const [todoFilter, setTodoFilter] = useState<"open" | "all" | "done">("open");
const [expandedSessionIds, setExpandedSessionIds] = useState<string[]>([]);
@@ -298,6 +479,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const project = data?.project ?? null;
const isRecurringBillingTicket = ticketHasRecurringBilling(ticket);
const projectDescription = project ? combinedProjectDescription(project) : "";
const projectEntries = useMemo(() => (project ? projectTeamspaceEntries(project) : []), [project]);
const hasMultipleProjectTeamspaceEntries = projectEntries.length > 1;
const projectConsumptionMinutes = project?.consumption_minutes ?? 0;
const projectBudgetMinutes = project?.hour_budget_minutes ?? null;
const projectConsumptionUsagePercent = project ? projectConsumptionPercent(projectConsumptionMinutes, projectBudgetMinutes) : null;
const isProject = Boolean(project);
const isGlobalTicket = periodType === "all";
const isPeriodClosed = Boolean(data?.closed);
@@ -558,24 +744,16 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return;
}
const hourBudgetMinutes = parseHoursInput(projectForm.hourBudget);
const parsedManualConsumptionMinutes = parseHoursInput(projectForm.manualConsumption);
const teamspaceEntries = parseProjectTeamspaceEntryForms(projectForm.teamspaceEntries);
if (hourBudgetMinutes === undefined) {
toast.error("Stundenkontingent prüfen", {
if (!teamspaceEntries) {
toast.error("Teamspace-Einträge prüfen", {
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
});
return;
}
if (parsedManualConsumptionMinutes === undefined) {
toast.error("Manuellen Verbrauch prüfen", {
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
});
return;
}
const manualConsumptionMinutes = parsedManualConsumptionMinutes ?? 0;
const defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
setSavingProject(true);
try {
@@ -583,13 +761,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
ticketNumber: ticket.ticket_number,
title: projectForm.title || null,
organizationId: projectForm.organizationId,
teamspaceProject: projectForm.teamspaceProject || null,
teamspaceProject: defaultTeamspaceEntry.teamspaceProject,
teamspaceEntries,
description: projectForm.description,
goals: "",
startsOn: projectForm.startsOn || null,
endsOn: projectForm.endsOn || null,
hourBudgetMinutes,
manualConsumptionMinutes
hourBudgetMinutes: defaultTeamspaceEntry.hourBudgetMinutes,
manualConsumptionMinutes: defaultTeamspaceEntry.manualConsumptionMinutes
});
toast.success("Projekt gespeichert");
setEditingProject(false);
@@ -983,7 +1162,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
Projekt
{project.closed_at ? <Badge variant="secondary">geschlossen</Badge> : <Badge variant="success">offen</Badge>}
</CardTitle>
<CardDescription>{project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}</CardDescription>
<CardDescription>
{hasMultipleProjectTeamspaceEntries
? `${projectEntries.length} Teamspace-Einträge`
: project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}
</CardDescription>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
@@ -1007,11 +1190,53 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<CardContent className="space-y-3 px-4 pb-4">
<div className="flex flex-wrap gap-2 text-sm">
<Badge variant="secondary">Zeitraum {project.starts_on || "offen"} - {project.ends_on || "offen"}</Badge>
<Badge variant="secondary">Kontingent {project.hour_budget_minutes ? formatMinutes(project.hour_budget_minutes) : "offen"}</Badge>
<Badge variant="secondary">Verbrauch {formatMinutes(project.consumption_minutes ?? 0)}</Badge>
{project.manual_consumption_minutes > 0 ? <Badge variant="outline">Manuell {formatMinutes(project.manual_consumption_minutes)}</Badge> : null}
{project.teamspace_project ? <Badge variant="outline">{project.teamspace_project}</Badge> : null}
<Badge variant="secondary">Kontingent {projectBudgetMinutes ? formatMinutes(projectBudgetMinutes) : "offen"}</Badge>
<Badge variant="secondary">Verbrauch {formatMinutes(projectConsumptionMinutes)}</Badge>
{hasMultipleProjectTeamspaceEntries ? <Badge variant="outline">{projectEntries.length} Teamspace</Badge> : null}
</div>
{hasMultipleProjectTeamspaceEntries ? (
<div className="space-y-2">
<div>
<div className="mb-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>Gesamtverbrauch</span>
<span>{projectConsumptionUsagePercent !== null ? `${projectConsumptionUsagePercent}%` : "ohne Gesamtbudget"}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full", projectConsumptionUsagePercent === null ? "bg-muted-foreground/20" : projectConsumptionBarClass(projectConsumptionUsagePercent))}
style={{ width: `${projectConsumptionUsagePercent === null ? 0 : Math.min(100, projectConsumptionUsagePercent)}%` }}
/>
</div>
</div>
<div className="grid gap-1.5 md:grid-cols-2">
{projectEntries.map((entry) => {
const consumption = Number(entry.consumption_minutes ?? 0);
const percent = projectConsumptionPercent(consumption, entry.hour_budget_minutes);
const label = entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
return (
<div key={entry.id} className="rounded-md border bg-background/50 px-2 py-1">
<div className="mb-1 flex min-w-0 items-center justify-between gap-2 text-[11px]">
<span className="truncate text-muted-foreground">
{label}
{entry.is_default ? " · Standard" : ""}
</span>
<span className="shrink-0 font-medium">
{formatMinutes(consumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
</span>
</div>
<div className="h-1 overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full", percent === null ? "bg-muted-foreground/20" : projectConsumptionBarClass(percent))}
style={{ width: `${percent === null ? 0 : Math.min(100, percent)}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
) : null}
<div className="text-sm">
<div className="min-w-0">
<div className="mb-1 flex items-center gap-2 font-medium">
@@ -1065,16 +1290,6 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
placeholder="optional"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-teamspace">Teamspace-Projekt</label>
<Input
id="project-edit-teamspace"
value={projectForm.teamspaceProject}
onChange={(event) => setProjectForm({ ...projectForm, teamspaceProject: event.currentTarget.value })}
/>
</div>
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_140px_140px_150px_170px]">
<div className="space-y-2">
<label className="text-sm font-medium">Organisation</label>
<OrganizationSelect
@@ -1090,6 +1305,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
required
/>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-start">Von</label>
<Input id="project-edit-start" type="date" value={projectForm.startsOn} onChange={(event) => setProjectForm({ ...projectForm, startsOn: event.currentTarget.value })} />
@@ -1098,26 +1315,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<label className="text-sm font-medium" htmlFor="project-edit-end">Bis</label>
<Input id="project-edit-end" type="date" value={projectForm.endsOn} onChange={(event) => setProjectForm({ ...projectForm, endsOn: event.currentTarget.value })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-budget">Kontingent h</label>
<Input
id="project-edit-budget"
inputMode="decimal"
value={projectForm.hourBudget}
onChange={(event) => setProjectForm({ ...projectForm, hourBudget: event.currentTarget.value })}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-manual-consumption">Manueller Verbrauch h</label>
<Input
id="project-edit-manual-consumption"
inputMode="decimal"
value={projectForm.manualConsumption}
onChange={(event) => setProjectForm({ ...projectForm, manualConsumption: event.currentTarget.value })}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">Manueller Verbrauch wird nur im Projektverbrauch verrechnet, nicht in Auswertungen, Statistiken oder Monatsabschluss.</p>
<ProjectTeamspaceEntriesEditor
entries={projectForm.teamspaceEntries}
onChange={(teamspaceEntries) => setProjectForm({ ...projectForm, teamspaceEntries })}
/>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-description">Beschreibung/Ziele</label>
<Textarea