Compare commits

..
2 Commits
Author SHA1 Message Date
michael 07ef8ef025 Weiter 2026-09-09 12:55:56 +02:00
michael c2063fe1ed Initial TicketTracker release 2026-09-09 09:42:48 +02:00
15 changed files with 2732 additions and 252 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
DATABASE_URL=postgresql://ticket:ticket@10.10.10.21:5433/tickettracker_dev
APP_PORT=3910
TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
COOKIE_SECURE=false
DB_POOL_SIZE=10
+13 -2
View File
@@ -15,6 +15,8 @@ export type AuthUser = {
display_name: string;
role: UserRole;
active: boolean;
zammad_user_id: string | null;
has_zammad_api_token: boolean;
};
declare global {
@@ -73,7 +75,9 @@ function publicUser(user: AuthUser) {
username: user.username,
display_name: user.display_name,
role: user.role,
active: user.active
active: user.active,
zammad_user_id: user.zammad_user_id ?? null,
has_zammad_api_token: Boolean(user.has_zammad_api_token)
};
}
@@ -123,7 +127,14 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
const tokenHash = hashToken(token);
const result = await query<AuthUser>(
`
SELECT u.id, u.username, u.display_name, u.role, u.active
SELECT
u.id,
u.username,
u.display_name,
u.role,
u.active,
u.zammad_user_id::text AS zammad_user_id,
(u.zammad_api_token IS NOT NULL) AS has_zammad_api_token
FROM auth_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1
+1246 -76
View File
File diff suppressed because it is too large. Load diff
+91
View File
@@ -60,6 +60,8 @@ export async function migrate() {
DROP CONSTRAINT IF EXISTS users_username_format,
ADD CONSTRAINT users_username_format CHECK (char_length(username) BETWEEN 3 AND 40 AND username !~ '\\s');
`);
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_api_token TEXT;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_user_id BIGINT;");
await query(`
CREATE TABLE IF NOT EXISTS auth_sessions (
@@ -137,6 +139,12 @@ export async function migrate() {
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS title TEXT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(zammad_id);");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS work_type TEXT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_ticket_id BIGINT UNIQUE;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_group_id BIGINT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_priority_id BIGINT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_state_id BIGINT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_owner_id BIGINT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_preferences JSONB NOT NULL DEFAULT '{}'::jsonb;");
await query(`
ALTER TABLE tickets
DROP CONSTRAINT IF EXISTS ticket_number_format,
@@ -184,6 +192,7 @@ export async function migrate() {
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_billing_slot_id BIGINT;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_date DATE;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_key TEXT;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS project_teamspace_entry_id BIGINT;");
if (adminId) {
await query("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]);
}
@@ -485,6 +494,37 @@ export async function migrate() {
END $$;
`);
await query(`
CREATE TABLE IF NOT EXISTS project_teamspace_entries (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
teamspace_project TEXT,
is_default BOOLEAN NOT NULL DEFAULT false,
hour_budget_minutes INTEGER,
manual_consumption_minutes INTEGER NOT NULL DEFAULT 0,
display_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT project_teamspace_entries_budget_non_negative CHECK (hour_budget_minutes IS NULL OR hour_budget_minutes >= 0),
CONSTRAINT project_teamspace_entries_manual_non_negative CHECK (manual_consumption_minutes >= 0)
);
`);
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_teamspace_entries_default ON project_teamspace_entries(project_id) WHERE is_default;");
await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_entries_project_order ON project_teamspace_entries(project_id, display_order, id);");
await query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'sessions_project_teamspace_entry_fkey'
) THEN
ALTER TABLE sessions
ADD CONSTRAINT sessions_project_teamspace_entry_fkey
FOREIGN KEY (project_teamspace_entry_id) REFERENCES project_teamspace_entries(id) ON DELETE SET NULL;
END IF;
END $$;
`);
await query(`
UPDATE tickets t
SET work_type = 'consulting'
@@ -506,6 +546,56 @@ export async function migrate() {
ON CONFLICT (user_id, ticket_id) DO NOTHING;
`);
await query(`
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order)
SELECT p.id, p.teamspace_project, true, p.hour_budget_minutes, p.manual_consumption_minutes, 0
FROM projects p
WHERE NOT EXISTS (
SELECT 1
FROM project_teamspace_entries pte
WHERE pte.project_id = p.id
);
`);
await query(`
WITH single_entry_projects AS (
SELECT
p.id AS project_id,
p.user_id,
p.ticket_id,
MIN(pte.id) AS project_teamspace_entry_id
FROM projects p
JOIN project_teamspace_entries pte ON pte.project_id = p.id
GROUP BY p.id, p.user_id, p.ticket_id
HAVING COUNT(pte.id) = 1
)
UPDATE sessions s
SET project_teamspace_entry_id = sep.project_teamspace_entry_id
FROM single_entry_projects sep
WHERE s.ticket_id = sep.ticket_id
AND s.user_id = sep.user_id
AND s.work_type = 'consulting'
AND s.project_teamspace_entry_id IS NULL;
`);
await query(`
WITH single_entry_tickets AS (
SELECT
p.ticket_id,
MIN(pte.id) AS project_teamspace_entry_id
FROM projects p
JOIN project_teamspace_entries pte ON pte.project_id = p.id
GROUP BY p.ticket_id
HAVING COUNT(pte.id) = 1
)
UPDATE sessions s
SET project_teamspace_entry_id = single_ticket.project_teamspace_entry_id
FROM single_entry_tickets single_ticket
WHERE s.ticket_id = single_ticket.ticket_id
AND s.work_type = 'consulting'
AND s.project_teamspace_entry_id IS NULL;
`);
await query(`
CREATE TABLE IF NOT EXISTS ticket_todos (
id BIGSERIAL PRIMARY KEY,
@@ -569,6 +659,7 @@ export async function migrate() {
await query("CREATE INDEX IF NOT EXISTS idx_sessions_user_started ON sessions(user_id, started_at);");
await query("CREATE INDEX IF NOT EXISTS idx_sessions_user_ticket_started ON sessions(user_id, ticket_id, started_at);");
await query("CREATE INDEX IF NOT EXISTS idx_sessions_user_organization_started ON sessions(user_id, organization_id, started_at);");
await query("CREATE INDEX IF NOT EXISTS idx_sessions_project_teamspace_entry ON sessions(project_teamspace_entry_id);");
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_recurring_occurrence_key ON sessions(recurring_occurrence_key) WHERE recurring_occurrence_key IS NOT NULL;");
await query("CREATE INDEX IF NOT EXISTS idx_sessions_billing_status ON sessions(billing_status);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_closures_day ON ticket_day_closures(day);");
+7
View File
@@ -510,9 +510,16 @@ Unter `Profil` kann jeder Benutzer seine eigenen Daten pflegen:
- Benutzername
- Voller Name
- Passwort
- persoenlicher Zammad API-Token
Passwortaenderungen erfordern das aktuelle Passwort. Bleiben die Passwortfelder leer, werden nur Benutzername und voller Name gespeichert.
Normale Benutzer koennen im Profil einen persoenlichen Zammad API-Token hinterlegen. Beim Speichern eines neuen Tokens ruft TicketTracker Zammad ueber `/api/v1/users/me` auf und speichert die User-ID, die Zammad fuer diesen Token zurueckliefert. Die User-ID wird danach read-only im Profil angezeigt.
Die Pruefung findet nur statt, wenn der Token im Profil neu eingetragen oder entfernt wird. Wenn nur Benutzername, voller Name oder Passwort gespeichert werden, wird Zammad nicht erneut abgefragt.
Der persoenliche Zammad Token ist unabhaengig vom Admin-Token fuer den Organisations-Sync. Der gleiche Token kann technisch in beiden Bereichen eingetragen sein, wird aber getrennt gespeichert und getrennt verwendet. Im Admin-Profil gibt es keinen persoenlichen Zammad-Token.
## Adminbereich
Der feste Admin ist nur fuer die Systemverwaltung vorgesehen. Er kann:
+29 -2
View File
@@ -26,6 +26,8 @@ type CreateSessionPayload = {
startedAt: string;
endedAt: string;
durationSeconds: number;
zammadLookup?: boolean;
projectTeamspaceEntryId?: string | null;
};
type DeleteSessionResult = {
@@ -35,6 +37,8 @@ type DeleteSessionResult = {
started_at: string;
userTicketEmpty: boolean;
ticketDeleted: boolean;
projectDeleteAvailable: boolean;
project_id: string | null;
};
};
@@ -94,6 +98,7 @@ export function updateCurrentUser(payload: {
displayName: string;
currentPassword?: string;
newPassword?: string;
zammadApiToken?: string;
}) {
return request<{ user: AuthUser }>("/api/auth/me", {
method: "PATCH",
@@ -337,7 +342,15 @@ export function createProject(payload: {
ticketNumber: string;
title?: string | null;
organizationId?: string | null;
zammadLookup?: boolean;
teamspaceProject?: string | null;
teamspaceEntries?: Array<{
id?: string | null;
teamspaceProject?: string | null;
isDefault: boolean;
hourBudgetMinutes?: number | null;
manualConsumptionMinutes?: number | null;
}>;
description: string;
goals: string;
startsOn?: string | null;
@@ -358,6 +371,13 @@ export function updateProject(
title?: string | null;
organizationId: string;
teamspaceProject?: string | null;
teamspaceEntries?: Array<{
id?: string | null;
teamspaceProject?: string | null;
isDefault: boolean;
hourBudgetMinutes?: number | null;
manualConsumptionMinutes?: number | null;
}>;
description: string;
goals: string;
startsOn?: string | null;
@@ -448,8 +468,14 @@ export function createSession(payload: CreateSessionPayload) {
});
}
export function lookupTicket(ticketNumber: string) {
return request<{ ticket: TicketMeta | null }>(`/api/tickets/lookup?ticketNumber=${encodeURIComponent(ticketNumber)}`);
export function lookupTicket(ticketNumber: string, source: "local" | "zammad" = "local") {
const params = new URLSearchParams({ ticketNumber });
if (source === "zammad") {
params.set("source", source);
}
return request<{ ticket: TicketMeta | null }>(`/api/tickets/lookup?${params}`);
}
export function searchTickets(query: string) {
@@ -479,6 +505,7 @@ export function updateSessionDetails(
activity: string;
workType: WorkType;
removeProjectData?: boolean;
projectTeamspaceEntryId?: string | null;
startedAt: string;
endedAt: string;
}
+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."
]
},
+36
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";
@@ -87,9 +89,20 @@ export type TicketMeta = {
organization_name: string | null;
customer_name: string | null;
work_type: WorkType | null;
zammad_ticket_id?: string | null;
zammad_group_id?: string | null;
zammad_priority_id?: string | null;
zammad_state_id?: string | null;
zammad_owner_id?: string | null;
project_id?: string | null;
is_project?: boolean;
project_name?: string | null;
project_hour_budget_minutes?: number | null;
project_teamspace_entry_count?: number;
project_teamspace_entries?: ProjectTeamspaceEntry[];
project_owner_user_id?: string | null;
project_owner_display_name?: string | null;
can_edit_project?: boolean;
has_recurring_billing?: boolean;
};
@@ -112,6 +125,10 @@ export type TicketSearchResult = {
export type ProjectSummary = {
id: string;
user_id?: string;
owner_username?: string | null;
owner_display_name?: string | null;
can_edit?: boolean;
ticket_id: string;
ticket_number: string;
title: string | null;
@@ -139,6 +156,21 @@ export type ProjectSummary = {
open_todos?: number;
overdue_todos?: number;
done_todos?: number;
teamspace_entry_count?: number;
teamspace_entries?: ProjectTeamspaceEntry[];
unassigned_teamspace_session_count?: number;
};
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";
@@ -196,6 +228,8 @@ export type OpenSession = {
customer_name: string;
activity: string;
work_type: WorkType;
project_teamspace_entry_id?: string | null;
project_teamspace_project?: string | null;
started_at: string;
rounded_minutes: number;
recurring_billing_id?: string | null;
@@ -244,6 +278,8 @@ export type SessionEntry = {
customer_name: string;
activity: string;
work_type: WorkType;
project_teamspace_entry_id?: string | null;
project_teamspace_project?: string | null;
started_at: string;
ended_at: string;
duration_seconds: number;
+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" />
+380 -82
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 }))
};
}
@@ -287,12 +352,142 @@ type ProjectFormFieldsProps = {
setForm: (form: ProjectForm) => void;
organizationLocked: boolean;
lookupStatus: "idle" | "loading" | "found" | "not-found";
zammadLookupBusy?: boolean;
saving: boolean;
submitLabel: string;
onZammadLookup?: () => void;
onSubmit: (event: FormEvent) => void;
};
function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, saving, submitLabel, onSubmit }: ProjectFormFieldsProps) {
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 {
id: /^\d+$/.test(entry.clientId) ? entry.clientId : null,
teamspaceProject: entry.teamspaceProject.trim() || null,
isDefault: entry.isDefault,
hourBudgetMinutes,
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
};
});
if (parsedEntries.some((entry) => entry === undefined)) {
return undefined;
}
return parsedEntries as Array<{
id?: string | null;
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, zammadLookupBusy = false, saving, submitLabel, onZammadLookup, onSubmit }: ProjectFormFieldsProps) {
return (
<form className="space-y-4" onSubmit={onSubmit}>
<div className="grid gap-3 lg:grid-cols-[180px_minmax(240px,1fr)_180px]">
@@ -317,7 +512,17 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, sa
{form.ticketNumber && !ticketPattern.test(form.ticketNumber.trim()) ? <p className="text-xs text-destructive">Format: Ticket#XXXXXX</p> : null}
{lookupStatus === "loading" ? <p className="text-xs text-muted-foreground">Suche Ticket...</p> : null}
{lookupStatus === "found" ? <p className="text-xs text-muted-foreground">Ticket gefunden. Daten werden übernommen.</p> : null}
{lookupStatus === "not-found" ? <p className="text-xs text-muted-foreground">Neues Ticket. Organisation bitte wählen.</p> : null}
{lookupStatus === "not-found" ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Neues Ticket. Organisation bitte wählen oder aus Zammad übernehmen.</p>
{onZammadLookup ? (
<Button type="button" size="sm" variant="secondary" disabled={zammadLookupBusy} onClick={onZammadLookup}>
<Search className="size-3.5" />
{zammadLookupBusy ? "Sucht..." : "In Zammad suchen"}
</Button>
) : null}
</div>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-title">
@@ -331,7 +536,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 +547,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 +559,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
@@ -406,6 +586,8 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
const [form, setForm] = useState<ProjectForm>(() => emptyProjectForm());
const [lookupStatus, setLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
const [lookupResult, setLookupResult] = useState<TicketMeta | null>(null);
const [zammadLookupBusy, setZammadLookupBusy] = useState(false);
const [useZammadForCreate, setUseZammadForCreate] = useState(false);
const [saving, setSaving] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
@@ -442,6 +624,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
if (!ticketPattern.test(normalizedTicketNumber)) {
setLookupStatus("idle");
setLookupResult(null);
setUseZammadForCreate(false);
return;
}
@@ -458,6 +641,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
setLookupResult(result.ticket);
setLookupStatus(result.ticket ? "found" : "not-found");
setUseZammadForCreate(false);
if (result.ticket?.organization_id) {
setForm((current) =>
@@ -568,6 +752,70 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
viewSettings.sortDirection !== defaultProjectViewSettings.sortDirection
].filter(Boolean).length;
async function lookupProjectTicketInZammad() {
const ticketNumber = form.ticketNumber.trim();
if (!ticketPattern.test(ticketNumber)) {
toast.error("Ticketnummer prüfen", {
description: "Das Format muss Ticket#XXXXXX sein."
});
return;
}
setZammadLookupBusy(true);
try {
const result = await lookupTicket(ticketNumber, "zammad");
if (!result.ticket) {
setLookupStatus("not-found");
toast.error("Ticket nicht gefunden", {
description: "Zammad hat zu dieser Ticketnummer keinen Treffer geliefert."
});
return;
}
if (result.ticket.work_type && result.ticket.work_type !== "consulting") {
toast.error("Kein Consulting-Ticket", {
description: "Für Support-Tickets wird kein Projekt angelegt."
});
return;
}
setLookupResult(result.ticket);
setLookupStatus("found");
setUseZammadForCreate(true);
setForm((current) => {
const entries = normalizeProjectTeamspaceEntryForms(current.teamspaceEntries);
const defaultIndex = Math.max(0, entries.findIndex((entry) => entry.isDefault));
const nextEntries = entries.map((entry, index) =>
index === defaultIndex
? {
...entry,
teamspaceProject: entry.teamspaceProject || result.ticket?.project_name || "",
hourBudget: entry.hourBudget || formatHoursInput(result.ticket?.project_hour_budget_minutes ?? null)
}
: entry
);
return {
...current,
title: current.title || result.ticket?.title || "",
organizationId: result.ticket?.organization_id ?? "",
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null,
teamspaceEntries: nextEntries
};
});
toast.success("Zammad-Daten übernommen");
} catch (error) {
toast.error("Zammad-Suche fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setZammadLookupBusy(false);
}
}
function setProjectSort(sortBy: ProjectSortBy) {
setViewSettings((current) => ({
...current,
@@ -610,24 +858,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 +875,15 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
ticketNumber,
title: form.title || null,
organizationId: form.organizationId,
teamspaceProject: form.teamspaceProject,
zammadLookup: useZammadForCreate,
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.`
@@ -650,6 +892,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
setCreateOpen(false);
setLookupResult(null);
setLookupStatus("idle");
setUseZammadForCreate(false);
await load();
} catch (error) {
toast.error("Projekt konnte nicht angelegt werden", {
@@ -661,20 +904,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 +926,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 +1005,31 @@ 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}
zammadLookupBusy={zammadLookupBusy}
saving={saving}
submitLabel="Projekt anlegen"
onZammadLookup={() => void lookupProjectTicketInZammad()}
onSubmit={submitProject}
/>
</DialogContent>
</Dialog>
<HelpLink anchor="projekte" label="Hilfe zu Projekten" className="shrink-0" />
</div>
</div>
@@ -915,9 +1180,12 @@ 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;
const canEditProject = project.can_edit !== false;
return (
<div key={project.id} className="border-b last:border-b-0">
@@ -938,11 +1206,14 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div>
<div className="flex flex-wrap gap-1.5">
{project.closed_at ? <Badge variant="secondary">geschlossen</Badge> : <Badge variant="success">offen</Badge>}
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? <Badge variant="warning">{project.unassigned_teamspace_session_count} ohne TS</Badge> : null}
</div>
<div className="min-w-0">
<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>
@@ -978,7 +1249,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
Projekt öffnen
</DropdownMenuItem>
<DropdownMenuItem
disabled={savingProjectStatusId === project.id}
disabled={!canEditProject || savingProjectStatusId === project.id}
className="hover:cursor-pointer"
onSelect={() => void setProjectClosed(project, !project.closed_at)}
>
@@ -986,6 +1257,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
{project.closed_at ? "Projekt öffnen" : "Projekt schließen"}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!canEditProject}
className="hover:cursor-pointer"
onSelect={() => {
setEditingProjectId(project.id);
@@ -999,7 +1271,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 +1281,40 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<div className="h-full rounded-full bg-muted-foreground/10" style={{ width: "0%" }} />
</div>
)}
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700 dark:text-amber-300">
{project.unassigned_teamspace_session_count} Session(s) haben keinen Teamspace-Eintrag.
</p>
) : null}
{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 +1330,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
+442 -65
View File
@@ -14,6 +14,7 @@ import { OrganizationSelect } from "@/components/OrganizationSelect";
import { TodoPanel } from "@/components/TodoPanel";
import {
createSession,
deleteProject,
deleteSession,
addFavoriteTicket,
getDashboardPreference,
@@ -29,7 +30,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 = {
@@ -53,6 +54,7 @@ type TicketDetailPageProps = {
type TicketSessionFormState = {
organizationId: string;
organizationName: string | null;
projectTeamspaceEntryId: string;
activity: string;
workType: WorkType;
day: string;
@@ -64,18 +66,25 @@ 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 {
return {
organizationId: "",
organizationName: null,
projectTeamspaceEntryId: "",
activity: "",
workType: "support",
day: currentDay(),
@@ -84,17 +93,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 +229,147 @@ 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 {
id: /^\d+$/.test(entry.clientId) ? entry.clientId : null,
teamspaceProject: entry.teamspaceProject.trim() || null,
isDefault: entry.isDefault,
hourBudgetMinutes,
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
};
});
if (parsedEntries.some((entry) => entry === undefined)) {
return undefined;
}
return parsedEntries as Array<{
id?: string | null;
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);
@@ -204,10 +384,12 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const [editOrganizationName, setEditOrganizationName] = useState<string | null>(null);
const [editWorkType, setEditWorkType] = useState<WorkType>("support");
const [editingSession, setEditingSession] = useState<SessionEntry | null>(null);
const [pendingProjectDelete, setPendingProjectDelete] = useState<{ projectId: string } | null>(null);
const [editSessionOrganizationId, setEditSessionOrganizationId] = useState("");
const [editSessionOrganizationName, setEditSessionOrganizationName] = useState<string | null>(null);
const [editSessionActivity, setEditSessionActivity] = useState("");
const [editSessionWorkType, setEditSessionWorkType] = useState<WorkType>("support");
const [editSessionProjectTeamspaceEntryId, setEditSessionProjectTeamspaceEntryId] = useState("");
const [editSessionDay, setEditSessionDay] = useState("");
const [editSessionStartTime, setEditSessionStartTime] = useState("");
const [editSessionEndTime, setEditSessionEndTime] = useState("");
@@ -221,17 +403,24 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all");
const [editingProject, setEditingProject] = useState(false);
const [savingProject, setSavingProject] = useState(false);
const [deletingEmptyProject, setDeletingEmptyProject] = useState(false);
const [savingProjectStatus, setSavingProjectStatus] = useState(false);
const [projectForm, setProjectForm] = useState<ProjectEditFormState>({
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[]>([]);
@@ -282,13 +471,15 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
setEditOrganizationId(data.ticket.organization_id ?? "");
setEditOrganizationName(data.ticket.organization_name ?? null);
setEditWorkType(data.ticket.work_type ?? "support");
const loadedProjectEntries = data.project ? projectTeamspaceEntries(data.project) : [];
setTicketSession((current) => ({
...current,
organizationId: data.ticket.organization_id ?? current.organizationId,
organizationName: data.ticket.organization_name ?? current.organizationName,
projectTeamspaceEntryId: loadedProjectEntries.length === 1 ? loadedProjectEntries[0].id : current.projectTeamspaceEntryId,
workType: data.ticket.work_type ?? current.workType
}));
}, [data?.ticket]);
}, [data?.ticket, data?.project]);
useEffect(() => {
setProjectDescriptionExpanded(false);
@@ -298,6 +489,14 @@ 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 requiresProjectTeamspaceEntry = Boolean(project && hasMultipleProjectTeamspaceEntries);
const projectConsumptionMinutes = project?.consumption_minutes ?? 0;
const projectBudgetMinutes = project?.hour_budget_minutes ?? null;
const projectConsumptionUsagePercent = project ? projectConsumptionPercent(projectConsumptionMinutes, projectBudgetMinutes) : null;
const canEditProject = project?.can_edit !== false;
const canEditTicketData = !project || canEditProject;
const isProject = Boolean(project);
const isGlobalTicket = periodType === "all";
const isPeriodClosed = Boolean(data?.closed);
@@ -498,6 +697,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return;
}
if (!canEditTicketData) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann Ticket- und Projektdaten ändern."
});
return;
}
if (!editOrganizationId) {
toast.error("Organisation wählen");
return;
@@ -542,6 +748,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return;
}
if (project.can_edit === false) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann die Projektdaten bearbeiten."
});
return;
}
setProjectForm(projectEditForm(project));
setEditingProject(true);
}
@@ -553,29 +766,28 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return;
}
if (project.can_edit === false) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann die Projektdaten bearbeiten."
});
return;
}
if (!projectForm.organizationId) {
toast.error("Organisation wählen");
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 +795,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);
@@ -608,6 +821,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return;
}
if (project.can_edit === false) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann den Projektstatus ändern."
});
return;
}
setSavingProjectStatus(true);
try {
await updateProjectStatus(project.id, closed);
@@ -644,6 +864,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return;
}
if (effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry && !ticketSession.projectTeamspaceEntryId) {
toast.error("Teamspace-Eintrag wählen", {
description: "Dieses Projekt hat mehrere Teamspace-Einträge."
});
return;
}
const startedAt = new Date(`${ticketSession.day}T${ticketSession.startTime}:00`);
const endedAt = new Date(`${ticketSession.day}T${ticketSession.endTime}:00`);
@@ -657,6 +884,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return;
}
if (editSessionWorkType === "consulting" && requiresProjectTeamspaceEntry && !editSessionProjectTeamspaceEntryId) {
toast.error("Teamspace-Eintrag wählen", {
description: "Dieses Projekt hat mehrere Teamspace-Einträge."
});
return;
}
const durationSeconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
setSavingTicketSession(true);
@@ -669,7 +903,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
workType: effectiveWorkType,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
durationSeconds
durationSeconds,
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null
});
toast.success("Session nachgetragen", {
description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet."
@@ -678,6 +913,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
...emptyTicketSessionForm(),
organizationId: ticket.organization_id ?? "",
organizationName: ticket.organization_name ?? null,
projectTeamspaceEntryId: "",
workType: ticket.work_type ?? "support",
day: ticketSession.day
});
@@ -697,6 +933,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
setEditSessionOrganizationName(session.organization_name ?? null);
setEditSessionActivity(session.activity);
setEditSessionWorkType(session.work_type);
setEditSessionProjectTeamspaceEntryId(session.project_teamspace_entry_id ?? "");
setEditSessionDay(toDateLocalValue(session.started_at));
setEditSessionStartTime(toTimeLocalValue(session.started_at));
setEditSessionEndTime(toTimeLocalValue(session.ended_at));
@@ -746,6 +983,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
activity: editSessionActivity,
workType: editSessionWorkType,
removeProjectData,
projectTeamspaceEntryId: editSessionWorkType === "consulting" && requiresProjectTeamspaceEntry ? editSessionProjectTeamspaceEntryId : null,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString()
});
@@ -772,6 +1010,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
try {
const result = await deleteSession(session.id);
if (result.deleted.projectDeleteAvailable && result.deleted.project_id) {
setPendingProjectDelete({ projectId: result.deleted.project_id });
toast.success("Session gelöscht", {
description: "Für dieses Projekt sind keine Sessions mehr vorhanden."
});
return;
}
if (result.deleted.ticketDeleted || result.deleted.userTicketEmpty) {
toast.success("Letzte Session gelöscht", {
description: result.deleted.ticketDeleted ? "Das leere Ticket wurde entfernt." : "Für dich gibt es zu diesem Ticket keine Einträge mehr."
@@ -793,6 +1039,32 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
}
}
async function keepEmptyProject() {
setPendingProjectDelete(null);
toast.success("Projekt behalten");
await load();
}
async function deleteEmptyProject() {
if (!pendingProjectDelete) {
return;
}
setDeletingEmptyProject(true);
try {
await deleteProject(pendingProjectDelete.projectId);
toast.success("Projekt gelöscht");
setPendingProjectDelete(null);
onNavigate(returnPath);
} catch (error) {
toast.error("Projekt konnte nicht gelöscht werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setDeletingEmptyProject(false);
}
}
async function toggleTicketFavorite() {
if (!ticket?.ticket_number) {
return;
@@ -922,7 +1194,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Ticketdaten</CardTitle>
<CardDescription>Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen.</CardDescription>
<CardDescription>
{canEditTicketData
? "Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen."
: "Dieses Projekt ist geteilt. Nur der Zammad-Owner kann Ticket- und Projektdaten ändern."}
</CardDescription>
</div>
<HelpLink anchor="ticketdaten" label="Hilfe zu Ticketdaten" />
</div>
@@ -931,11 +1207,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<form className="grid gap-3 lg:grid-cols-[180px_minmax(220px,1fr)_minmax(220px,1fr)_160px_auto] lg:items-end" onSubmit={saveTicketData}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="edit-ticket-number">Ticketnummer</label>
<Input id="edit-ticket-number" value={editTicketNumber} onChange={(event) => setEditTicketNumber(event.currentTarget.value)} required />
<Input id="edit-ticket-number" value={editTicketNumber} disabled={!canEditTicketData} onChange={(event) => setEditTicketNumber(event.currentTarget.value)} required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="edit-ticket-title">Titel</label>
<Input id="edit-ticket-title" value={editTicketTitle} onChange={(event) => setEditTicketTitle(event.currentTarget.value)} placeholder="optional" />
<Input id="edit-ticket-title" value={editTicketTitle} disabled={!canEditTicketData} onChange={(event) => setEditTicketTitle(event.currentTarget.value)} placeholder="optional" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="edit-ticket-organization">Organisation</label>
@@ -945,6 +1221,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<OrganizationSelect
value={editOrganizationId}
selectedName={editOrganizationName}
disabled={!canEditTicketData}
onChange={(organization) => {
setEditOrganizationId(organization.id);
setEditOrganizationName(organization.name);
@@ -958,13 +1235,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
id="edit-ticket-work-type"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={editWorkType}
disabled={!canEditTicketData}
onChange={(event) => setEditWorkType(event.currentTarget.value as WorkType)}
>
<option value="support">Support</option>
<option value="consulting">Consulting</option>
</select>
</div>
<Button type="submit" disabled={savingTicket}>
<Button type="submit" disabled={!canEditTicketData || savingTicket}>
<Save className="size-4" />
{savingTicket ? "Speichert..." : "Speichern"}
</Button>
@@ -982,21 +1260,29 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<FolderKanban className="size-5 text-muted-foreground" />
Projekt
{project.closed_at ? <Badge variant="secondary">geschlossen</Badge> : <Badge variant="success">offen</Badge>}
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? <Badge variant="warning">{project.unassigned_teamspace_session_count} ohne TS</Badge> : null}
</CardTitle>
<CardDescription>{project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}</CardDescription>
<CardDescription>
{!canEditProject && project.owner_display_name
? `Eigentümer: ${project.owner_display_name}`
: 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
type="button"
size="sm"
variant="secondary"
disabled={savingProjectStatus}
disabled={!canEditProject || savingProjectStatus}
onClick={() => void setProjectClosed(!project.closed_at)}
>
{project.closed_at ? <RotateCcw className="size-4" /> : <CheckCircle2 className="size-4" />}
{project.closed_at ? "Öffnen" : "Schließen"}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={openProjectEditor}>
<Button type="button" size="sm" variant="secondary" disabled={!canEditProject} onClick={openProjectEditor}>
<Pencil className="size-4" />
Bearbeiten
</Button>
@@ -1005,13 +1291,63 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? (
<Alert variant="warning">
<div className="font-medium">Nicht zugeordnete Sessions</div>
<div className="text-sm text-muted-foreground">
{project.unassigned_teamspace_session_count} Session(s) haben keinen Teamspace-Eintrag. Bitte über die Session-Bearbeitung neu zuordnen.
</div>
</Alert>
) : null}
<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 +1401,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 +1416,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 +1426,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
@@ -1295,6 +1608,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="w-fit">{formatMinutes(session.rounded_minutes)}</Badge>
{session.recurring_billing_id ? <Badge variant="secondary">fix</Badge> : null}
{requiresProjectTeamspaceEntry ? (
session.project_teamspace_entry_id ? (
<Badge variant="info">{session.project_teamspace_project || "Teamspace"}</Badge>
) : (
<Badge variant="warning">ohne Teamspace</Badge>
)
) : null}
</div>
<BillingButtons
session={session}
@@ -1402,6 +1722,26 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</div>
)}
{requiresProjectTeamspaceEntry ? (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-teamspace-entry">Teamspace-Eintrag</label>
<select
id="ticket-session-teamspace-entry"
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={ticketSession.projectTeamspaceEntryId}
onChange={(event) => setTicketSession({ ...ticketSession, projectTeamspaceEntryId: event.currentTarget.value })}
required
>
<option value="">Bitte wählen</option>
{projectEntries.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
</option>
))}
</select>
</div>
) : null}
<div className="grid gap-3 lg:grid-cols-[150px_120px_120px_minmax(220px,1fr)_auto] lg:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-day">
@@ -1468,6 +1808,24 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</p>
) : null}
<Dialog open={Boolean(pendingProjectDelete)} onOpenChange={(open) => !open && void keepEmptyProject()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Keine Sessions mehr vorhanden</DialogTitle>
<DialogDescription>Projekt löschen?</DialogDescription>
</DialogHeader>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="secondary" disabled={deletingEmptyProject} onClick={() => void keepEmptyProject()}>
Projekt behalten
</Button>
<Button type="button" variant="destructive" disabled={deletingEmptyProject} onClick={() => void deleteEmptyProject()}>
<Trash2 className="size-4" />
{deletingEmptyProject ? "Löscht..." : "Projekt löschen"}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={Boolean(editingSession)} onOpenChange={(open) => !open && setEditingSession(null)}>
<DialogContent className="max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] overflow-y-auto p-5 sm:max-w-2xl">
<DialogHeader className="pr-8">
@@ -1545,6 +1903,25 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<option value="consulting">Consulting</option>
</select>
</div>
{editSessionWorkType === "consulting" && requiresProjectTeamspaceEntry ? (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="session-teamspace-entry">Teamspace-Eintrag</label>
<select
id="session-teamspace-entry"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={editSessionProjectTeamspaceEntryId}
onChange={(event) => setEditSessionProjectTeamspaceEntryId(event.currentTarget.value)}
required
>
<option value="">Bitte wählen</option>
{projectEntries.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
</option>
))}
</select>
</div>
) : null}
<div className="flex justify-end">
<Button type="submit" disabled={savingSessionDetails}>
<Save className="size-4" />
+204 -12
View File
@@ -1,6 +1,6 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import { Pause, Play, Plus, RotateCcw, Square, Upload } from "lucide-react";
import { Pause, Play, Plus, RotateCcw, Search, Square, Upload } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -20,6 +20,7 @@ type ManualSessionFormState = {
ticketNumber: string;
organizationId: string;
organizationName: string | null;
projectTeamspaceEntryId: string;
activity: string;
workType: WorkType;
day: string;
@@ -37,6 +38,7 @@ function emptyManualSession(): ManualSessionFormState {
ticketNumber: "",
organizationId: "",
organizationName: null,
projectTeamspaceEntryId: "",
activity: "",
workType: "support",
day: currentDay(),
@@ -55,6 +57,14 @@ type FinishedSession = {
roundedMinutes: number;
};
function ticketTeamspaceEntries(ticket: TicketMeta | null | undefined) {
return ticket?.project_teamspace_entries ?? [];
}
function requiresTeamspaceEntry(ticket: TicketMeta | null | undefined) {
return Boolean(ticket?.is_project && ticketTeamspaceEntries(ticket).length > 1);
}
type TimerPageProps = {
timers: TimerEntry[];
setTimers: Dispatch<SetStateAction<TimerEntry[]>>;
@@ -69,6 +79,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const [finishingId, setFinishingId] = useState<string | null>(null);
const [organizationId, setOrganizationId] = useState("");
const [organizationName, setOrganizationName] = useState<string | null>(null);
const [projectTeamspaceEntryId, setProjectTeamspaceEntryId] = useState("");
const [activity, setActivity] = useState("");
const [workType, setWorkType] = useState<WorkType>("support");
const [saving, setSaving] = useState(false);
@@ -76,11 +87,12 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const [savingManualSession, setSavingManualSession] = useState(false);
const [manualLookupStatus, setManualLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
const [manualLookupTicket, setManualLookupTicket] = useState<TicketMeta | null>(null);
const [zammadLookupBusy, setZammadLookupBusy] = useState<string | null>(null);
const [recurringBillings, setRecurringBillings] = useState<RecurringBilling[]>([]);
const selectedTimer = timers.find((timer) => timer.id === selectedTimerId) ?? timers[0] ?? null;
const elapsedSeconds = useMemo(() => Math.floor(activeElapsedMs(selectedTimer, tick) / 1000), [selectedTimer, tick]);
const isValidTicket = ticketPattern.test(ticketNumber);
const isValidTicket = ticketPattern.test(ticketNumber.trim());
const isManualTicketValid = ticketPattern.test(manualSession.ticketNumber.trim());
const manualTicketDefaultsLocked = Boolean(manualLookupTicket?.organization_id && manualLookupTicket.work_type);
const manualTicketIsRecurringBilling =
@@ -151,6 +163,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
...current,
organizationId: result.ticket?.organization_id ?? "",
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null,
projectTeamspaceEntryId: "",
workType: result.ticket?.work_type ?? current.workType
};
});
@@ -169,7 +182,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
};
}, [manualSession.ticketNumber]);
async function addTimer() {
async function addTimer(source: "local" | "zammad" = "local") {
const normalizedTicketNumber = ticketNumber.trim();
if (!isValidTicket) {
toast.error("Ticketnummer prüfen", {
description: "Das Format muss Ticket#XXXXXX sein."
@@ -177,17 +192,40 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return;
}
const ticketResult = await lookupTicket(ticketNumber).catch(() => ({ ticket: null }));
if (source === "zammad") {
setZammadLookupBusy("timer");
}
if (ticketHasRecurringBilling(ticketResult.ticket, recurringBillings) || isRecurringBillingTicketNumber(ticketNumber, recurringBillings)) {
setTimers((current) => current.filter((timer) => timer.ticketNumber !== ticketNumber));
const ticketResult = await lookupTicket(normalizedTicketNumber, source).catch((error) => {
if (source === "zammad") {
toast.error("Zammad-Suche fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
return { ticket: null };
});
if (source === "zammad") {
setZammadLookupBusy(null);
if (!ticketResult.ticket) {
toast.error("Ticket nicht gefunden", {
description: "Zammad hat zu dieser Ticketnummer keinen Treffer geliefert."
});
return;
}
}
if (ticketHasRecurringBilling(ticketResult.ticket, recurringBillings) || isRecurringBillingTicketNumber(normalizedTicketNumber, recurringBillings)) {
setTimers((current) => current.filter((timer) => timer.ticketNumber !== normalizedTicketNumber));
toast.error("Fixe Abrechnung", {
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gestartet werden. Bitte die Definition bearbeiten."
});
return;
}
if (timers.some((timer) => timer.ticketNumber === ticketNumber)) {
if (timers.some((timer) => timer.ticketNumber === normalizedTicketNumber)) {
toast.error("Timer existiert bereits", {
description: "Für dieses Ticket läuft oder pausiert schon ein Timer."
});
@@ -197,7 +235,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const now = Date.now();
const newTimer: TimerEntry = {
id: crypto.randomUUID(),
ticketNumber,
ticketNumber: normalizedTicketNumber,
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
workType: ticketResult.ticket?.work_type ?? null,
isProject: Boolean(ticketResult.ticket?.is_project),
@@ -214,6 +252,80 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setTicketNumber("");
}
async function lookupManualTicketInZammad() {
const normalizedTicketNumber = manualSession.ticketNumber.trim();
if (!ticketPattern.test(normalizedTicketNumber)) {
toast.error("Ticketnummer prüfen", {
description: "Das Format muss Ticket#XXXXXX sein."
});
return;
}
setZammadLookupBusy("manual");
try {
const result = await lookupTicket(normalizedTicketNumber, "zammad");
if (!result.ticket) {
setManualLookupStatus("not-found");
toast.error("Ticket nicht gefunden", {
description: "Zammad hat zu dieser Ticketnummer keinen Treffer geliefert."
});
return;
}
setManualLookupTicket(result.ticket);
setManualLookupStatus("found");
setManualSession((current) => ({
...current,
organizationId: result.ticket?.organization_id ?? "",
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null,
projectTeamspaceEntryId: "",
workType: result.ticket?.work_type ?? current.workType
}));
toast.success("Zammad-Daten übernommen");
} catch (error) {
toast.error("Zammad-Suche fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setZammadLookupBusy(null);
}
}
async function lookupFinishedTicketInZammad() {
if (!finished) {
return;
}
setZammadLookupBusy("finished");
try {
const result = await lookupTicket(finished.ticketNumber, "zammad");
if (!result.ticket) {
toast.error("Ticket nicht gefunden", {
description: "Zammad hat zu dieser Ticketnummer keinen Treffer geliefert."
});
return;
}
setFinished({ ...finished, ticket: result.ticket });
setOrganizationId(result.ticket.organization_id ?? "");
setOrganizationName(result.ticket.organization_name ?? result.ticket.customer_name ?? null);
setProjectTeamspaceEntryId("");
setWorkType(result.ticket.work_type ?? workType);
toast.success("Zammad-Daten übernommen");
} catch (error) {
toast.error("Zammad-Suche fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setZammadLookupBusy(null);
}
}
function activateTimer(timerId: string) {
const now = Date.now();
setTimers((current) =>
@@ -268,6 +380,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setOrganizationId(ticket?.organization_id ?? "");
setOrganizationName(ticket?.organization_name ?? null);
setProjectTeamspaceEntryId("");
setWorkType(ticket?.work_type ?? "support");
setActivity("");
setFinished({
@@ -306,6 +419,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const existingTicketHasDefaults = Boolean(finished.ticket?.organization_id && finished.ticket?.work_type);
const effectiveOrganizationId = existingTicketHasDefaults ? finished.ticket!.organization_id! : organizationId;
const effectiveWorkType = existingTicketHasDefaults ? finished.ticket!.work_type! : workType;
const finishedRequiresTeamspaceEntry = requiresTeamspaceEntry(finished.ticket);
if (!effectiveOrganizationId) {
toast.error("Organisation wählen", {
@@ -314,6 +428,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return;
}
if (finishedRequiresTeamspaceEntry && !projectTeamspaceEntryId) {
toast.error("Teamspace-Eintrag wählen", {
description: "Dieses Projekt hat mehrere Teamspace-Einträge."
});
return;
}
setSaving(true);
try {
@@ -324,7 +445,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
durationSeconds: finished.durationSeconds,
organizationId: effectiveOrganizationId,
activity,
workType: effectiveWorkType
workType: effectiveWorkType,
zammadLookup: Boolean(finished.ticket?.zammad_ticket_id),
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null
});
toast.success("Session gespeichert", {
@@ -334,6 +457,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setFinished(null);
setOrganizationId("");
setOrganizationName(null);
setProjectTeamspaceEntryId("");
setActivity("");
setWorkType("support");
} catch (error) {
@@ -367,6 +491,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return;
}
if (requiresTeamspaceEntry(manualLookupTicket) && !manualSession.projectTeamspaceEntryId) {
toast.error("Teamspace-Eintrag wählen", {
description: "Dieses Projekt hat mehrere Teamspace-Einträge."
});
return;
}
const startedAt = new Date(`${manualSession.day}T${manualSession.startTime}:00`);
const endedAt = new Date(`${manualSession.day}T${manualSession.endTime}:00`);
@@ -392,7 +523,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
workType: manualSession.workType,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
durationSeconds
durationSeconds,
zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id),
projectTeamspaceEntryId: requiresTeamspaceEntry(manualLookupTicket) ? manualSession.projectTeamspaceEntryId : null
});
toast.success("Session nachgetragen", {
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen."
@@ -433,7 +566,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<HelpLink anchor="timer" label="Hilfe zu Timern" />
</div>
</CardHeader>
<CardContent className="grid gap-3 p-4 sm:grid-cols-[minmax(220px,320px)_auto] sm:items-end">
<CardContent className="grid gap-3 p-4 sm:grid-cols-[minmax(220px,320px)_auto_auto] sm:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-number">
Ticketnummer
@@ -456,6 +589,10 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<Plus className="size-4" />
Timer starten
</Button>
<Button type="button" variant="secondary" disabled={!isValidTicket || zammadLookupBusy === "timer"} onClick={() => void addTimer("zammad")}>
<Search className="size-4" />
{zammadLookupBusy === "timer" ? "Sucht..." : "In Zammad suchen"}
</Button>
</CardContent>
</Card>
@@ -533,6 +670,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
ticketNumber: event.currentTarget.value,
organizationId: "",
organizationName: null,
projectTeamspaceEntryId: "",
workType: "support"
})
}
@@ -551,7 +689,15 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
{manualTicketIsRecurringBilling ? (
<p className="text-xs text-destructive">Fixe Abrechnung: Sessions kommen ausschließlich aus der Definition.</p>
) : null}
{manualLookupStatus === "not-found" ? <p className="text-xs text-muted-foreground">Neues Ticket. Organisation und Art bitte wählen.</p> : null}
{manualLookupStatus === "not-found" ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Neues Ticket. Organisation und Art bitte wählen oder aus Zammad übernehmen.</p>
<Button type="button" size="sm" variant="secondary" disabled={zammadLookupBusy === "manual"} onClick={() => void lookupManualTicketInZammad()}>
<Search className="size-3.5" />
{zammadLookupBusy === "manual" ? "Sucht..." : "In Zammad suchen"}
</Button>
</div>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Organisation</label>
@@ -582,6 +728,25 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<option value="consulting">Consulting</option>
</select>
</div>
{requiresTeamspaceEntry(manualLookupTicket) ? (
<div className="space-y-2 lg:col-span-3">
<label className="text-sm font-medium" htmlFor="manual-teamspace-entry">Teamspace-Eintrag</label>
<select
id="manual-teamspace-entry"
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={manualSession.projectTeamspaceEntryId}
onChange={(event) => setManualSession({ ...manualSession, projectTeamspaceEntryId: event.currentTarget.value })}
required
>
<option value="">Bitte wählen</option>
{ticketTeamspaceEntries(manualLookupTicket).map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
</option>
))}
</select>
</div>
) : null}
</div>
<div className="grid gap-3 lg:grid-cols-[160px_120px_120px_minmax(220px,1fr)_auto] lg:items-end">
@@ -646,6 +811,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</DialogHeader>
<form className="space-y-4" onSubmit={submitSession}>
{finished && !finished.ticket?.organization_id ? (
<div className="flex justify-end">
<Button type="button" size="sm" variant="secondary" disabled={zammadLookupBusy === "finished"} onClick={() => void lookupFinishedTicketInZammad()}>
<Search className="size-3.5" />
{zammadLookupBusy === "finished" ? "Sucht..." : "In Zammad suchen"}
</Button>
</div>
) : null}
{finished?.ticket?.organization_id && finished.ticket.work_type ? (
<div className="rounded-lg border bg-muted/40 p-3 text-sm">
<p className="font-medium">{finished.ticket.organization_name ?? finished.ticket.customer_name}</p>
@@ -694,6 +867,25 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</select>
</div>
)}
{requiresTeamspaceEntry(finished?.ticket) ? (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="finished-teamspace-entry">Teamspace-Eintrag</label>
<select
id="finished-teamspace-entry"
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={projectTeamspaceEntryId}
onChange={(event) => setProjectTeamspaceEntryId(event.currentTarget.value)}
required
>
<option value="">Bitte wählen</option>
{ticketTeamspaceEntries(finished?.ticket).map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
</option>
))}
</select>
</div>
) : null}
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
<Upload className="size-4" />
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
set -euo pipefail
NODE_MAJOR_REQUIRED=22
NODESOURCE_SETUP_URL="https://deb.nodesource.com/setup_${NODE_MAJOR_REQUIRED}.x"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
if [[ ! -f "${REPO_ROOT}/package.json" ]]; then
echo "Repo-Root nicht gefunden. Erwartet: ${REPO_ROOT}/package.json"
exit 1
fi
if ! command -v apt-get >/dev/null 2>&1; then
echo "Dieses Setup-Script unterstuetzt aktuell Debian/Ubuntu mit apt-get."
exit 1
fi
SUDO=()
if [[ "${EUID}" -ne 0 ]]; then
if ! command -v sudo >/dev/null 2>&1; then
echo "Bitte als root ausfuehren oder sudo installieren."
exit 1
fi
SUDO=(sudo)
fi
run_apt() {
if [[ "${#SUDO[@]}" -gt 0 ]]; then
"${SUDO[@]}" env DEBIAN_FRONTEND=noninteractive apt-get "$@"
else
DEBIAN_FRONTEND=noninteractive apt-get "$@"
fi
}
run_as_root_preserve_env() {
if [[ "${#SUDO[@]}" -gt 0 ]]; then
"${SUDO[@]}" -E "$@"
else
"$@"
fi
}
node_major() {
node -p 'Number(process.versions.node.split(".")[0])' 2>/dev/null || echo 0
}
echo "Installiere Basiswerkzeuge fuer die lokale Webapp..."
run_apt update
run_apt install -y ca-certificates curl gnupg git bash build-essential
CURRENT_NODE_MAJOR="$(node_major)"
if [[ "${CURRENT_NODE_MAJOR}" -lt "${NODE_MAJOR_REQUIRED}" ]]; then
echo "Installiere Node.js ${NODE_MAJOR_REQUIRED}.x ueber NodeSource..."
TMP_SETUP="$(mktemp)"
curl -fsSL "${NODESOURCE_SETUP_URL}" -o "${TMP_SETUP}"
run_as_root_preserve_env bash "${TMP_SETUP}"
rm -f "${TMP_SETUP}"
run_apt install -y nodejs
else
echo "Node.js $(node --version) ist bereits kompatibel."
fi
CURRENT_NODE_MAJOR="$(node_major)"
if [[ "${CURRENT_NODE_MAJOR}" -lt "${NODE_MAJOR_REQUIRED}" ]]; then
echo "Node.js >= ${NODE_MAJOR_REQUIRED} wird benoetigt, aktiv ist aber: $(node --version 2>/dev/null || echo nicht installiert)"
exit 1
fi
if ! command -v npm >/dev/null 2>&1; then
echo "npm wurde nicht gefunden. Bitte Node.js/npm Installation pruefen."
exit 1
fi
echo "Installiere npm-Abhaengigkeiten im Repo..."
cd "${REPO_ROOT}"
if [[ -f package-lock.json ]]; then
npm ci --include=dev
else
npm install --include=dev
fi
chmod +x "${REPO_ROOT}/scripts/start-local.sh"
echo
echo "Fertig. Zum Starten noch DATABASE_URL setzen und dann ausfuehren:"
echo " npm run serve:local"
+2 -2
View File
@@ -6,9 +6,9 @@ BACKEND_PORT="${BACKEND_PORT:-3001}"
export API_PROXY_TARGET="${API_PROXY_TARGET:-http://127.0.0.1:${BACKEND_PORT}}"
export NEXT_TELEMETRY_DISABLED=1
if [[ -f ".env" ]]; then
if [[ -f ".env_dev" ]]; then
set -a
source .env
source .env_dev
set +a
fi