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