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

+13
View File
@@ -94,6 +94,7 @@ export function updateCurrentUser(payload: {
displayName: string;
currentPassword?: string;
newPassword?: string;
zammadApiToken?: string;
}) {
return request<{ user: AuthUser }>("/api/auth/me", {
method: "PATCH",
@@ -338,6 +339,12 @@ export function createProject(payload: {
title?: string | null;
organizationId?: string | null;
teamspaceProject?: string | null;
teamspaceEntries?: Array<{
teamspaceProject?: string | null;
isDefault: boolean;
hourBudgetMinutes?: number | null;
manualConsumptionMinutes?: number | null;
}>;
description: string;
goals: string;
startsOn?: string | null;
@@ -358,6 +365,12 @@ export function updateProject(
title?: string | null;
organizationId: string;
teamspaceProject?: string | null;
teamspaceEntries?: Array<{
teamspaceProject?: string | null;
isDefault: boolean;
hourBudgetMinutes?: number | null;
manualConsumptionMinutes?: number | null;
}>;
description: string;
goals: string;
startsOn?: string | null;
+7
View File
@@ -500,6 +500,7 @@ export const helpSections: HelpSection[] = [
quickItems: [
"Benutzername und voller Name sind änderbar.",
"Passwortänderung benötigt das aktuelle Passwort.",
"Normale Benutzer können ihren persönlichen Zammad API-Token hinterlegen.",
"Der volle Name wird in der Oberfläche angezeigt."
],
details: [
@@ -507,6 +508,12 @@ export const helpSections: HelpSection[] = [
"Der volle Name erscheint in der App, zum Beispiel im Kopfbereich oder im Adminbereich.",
"Wenn du dein Passwort ändern willst, musst du das aktuelle Passwort sowie das neue Passwort zweimal eingeben.",
"Bleiben die Passwortfelder leer, werden nur Benutzername und voller Name gespeichert.",
"Normale Benutzer können im Profil einen persönlichen Zammad API-Token speichern.",
"Beim Speichern eines neuen Tokens fragt TicketTracker Zammad über /api/v1/users/me ab und speichert die zurückgelieferte Zammad User-ID.",
"Die gespeicherte Zammad User-ID wird danach read-only im Profil angezeigt.",
"Die Token-Prüfung läuft nur, wenn der Token im Profil geändert oder entfernt wird. Normale Profiländerungen prüfen Zammad nicht erneut.",
"Der persönliche Zammad Token ist vom Admin-Token für den Organisations-Sync getrennt. Es kann zufällig derselbe Token sein, wird aber separat gespeichert und verwendet.",
"Im Admin-Profil wird kein persönlicher Zammad-Token angezeigt.",
"Admins können Benutzer ebenfalls verwalten, aber jeder Benutzer kann die eigenen Daten jederzeit selbst pflegen."
]
},
+16
View File
@@ -10,6 +10,8 @@ export type AuthUser = {
display_name: string;
role: UserRole;
active: boolean;
zammad_user_id: string | null;
has_zammad_api_token: boolean;
};
export type DashboardWidgetType = "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes";
@@ -139,6 +141,20 @@ export type ProjectSummary = {
open_todos?: number;
overdue_todos?: number;
done_todos?: number;
teamspace_entry_count?: number;
teamspace_entries?: ProjectTeamspaceEntry[];
};
export type ProjectTeamspaceEntry = {
id: string;
project_id?: string;
teamspace_project: string | null;
is_default: boolean;
hour_budget_minutes: number | null;
manual_consumption_minutes: number;
teamspace_consumption_minutes?: number;
consumption_minutes?: number;
display_order?: number;
};
export type TodoPriority = "low" | "medium" | "high" | "urgent";
+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