Initial TicketTracker release
This commit is contained in:
1 parent
d1da86beab
commit
c2063fe1ed
11 files changed
+1124
-175
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user