Restrict admin to system management

This commit is contained in:
michael committed 2026-08-06 08:07:26 +02:00
1 parent 0bef65ab75
commit 19e4621aeb
13 files changed
+634 -882

No files matched your search

+116 -440
View File
@@ -1,115 +1,76 @@
import { FormEvent, useEffect, useState } from "react";
import { CalendarPlus, DatabaseZap, Save, Shield, Shuffle, UserPlus } from "lucide-react";
import { DatabaseBackup, DatabaseZap, Save, Shield, UserPlus } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import {
createAdminSession,
createAdminUser,
getAdminSessions,
downloadDatabaseExport,
getAdminUsers,
getZammadSettings,
reassignAdminSession,
saveZammadSettings,
syncZammadOrganizations,
updateAdminUser
} from "../api";
import { currentDay, formatDateTime, formatMinutes } from "../format";
import type { AdminSession, AdminUser, AuthUser, UserRole, WorkType } from "../types";
import type { AdminUser, AuthUser } from "../types";
type UserFormState = {
username: string;
displayName: string;
password: string;
role: UserRole;
active: boolean;
};
type ManualSessionFormState = {
userId: string;
ticketNumber: string;
organizationId: string;
organizationName: string | null;
activity: string;
workType: WorkType;
day: string;
startTime: string;
endTime: string;
};
const emptyForm: UserFormState = {
username: "",
displayName: "",
password: "",
role: "user",
active: true
};
function emptyManualSession(): ManualSessionFormState {
return {
userId: "",
ticketNumber: "",
organizationId: "",
organizationName: null,
activity: "",
workType: "support",
day: currentDay(),
startTime: "09:00",
endTime: "09:30"
};
}
function formFromUser(user: AdminUser): UserFormState {
return {
username: user.username,
displayName: user.display_name,
password: "",
role: user.role,
active: user.active
};
}
export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
const [users, setUsers] = useState<AdminUser[]>([]);
const [sessions, setSessions] = useState<AdminSession[]>([]);
const [forms, setForms] = useState<Record<string, UserFormState>>({});
const [sessionOwners, setSessionOwners] = useState<Record<string, string>>({});
const [newUser, setNewUser] = useState<UserFormState>(emptyForm);
const [manualSession, setManualSession] = useState<ManualSessionFormState>(() => emptyManualSession());
const [zammadBaseUrl, setZammadBaseUrl] = useState("");
const [zammadApiKey, setZammadApiKey] = useState("");
const [hasZammadApiKey, setHasZammadApiKey] = useState(false);
const [syncingOrganizations, setSyncingOrganizations] = useState(false);
const [savingZammadSettings, setSavingZammadSettings] = useState(false);
const [exportingDatabase, setExportingDatabase] = useState(false);
const [loading, setLoading] = useState(false);
const [savingId, setSavingId] = useState<string | null>(null);
const [movingId, setMovingId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [creatingSession, setCreatingSession] = useState(false);
async function load(options: { preserveEdits?: boolean } = {}) {
setLoading(true);
try {
const [usersResult, sessionsResult, zammadSettingsResult] = await Promise.all([getAdminUsers(), getAdminSessions(), getZammadSettings()]);
const [usersResult, zammadSettingsResult] = await Promise.all([getAdminUsers(), getZammadSettings()]);
setUsers(usersResult.users);
setSessions(sessionsResult.sessions);
if (!options.preserveEdits) {
setZammadBaseUrl(zammadSettingsResult.settings.baseUrl);
setHasZammadApiKey(zammadSettingsResult.settings.hasApiKey);
}
setForms((current) =>
Object.fromEntries(usersResult.users.map((user) => [user.id, options.preserveEdits ? current[user.id] ?? formFromUser(user) : formFromUser(user)]))
);
setSessionOwners((current) =>
Object.fromEntries(sessionsResult.sessions.map((session) => [session.id, options.preserveEdits ? current[session.id] ?? session.user_id : session.user_id]))
);
} catch (error) {
toast.error("Adminbereich konnte nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
@@ -157,7 +118,9 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
try {
await createAdminUser(newUser);
toast.success("Benutzer angelegt");
toast.success("Benutzer angelegt", {
description: "Neue Accounts erhalten automatisch die Rolle User."
});
setNewUser(emptyForm);
await load();
} catch (error) {
@@ -181,7 +144,6 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
await updateAdminUser(user.id, {
username: form.username,
displayName: form.displayName,
role: form.role,
active: form.active,
password: form.password || undefined
});
@@ -196,91 +158,6 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
}
}
async function moveSession(session: AdminSession) {
const nextUserId = sessionOwners[session.id] ?? session.user_id;
if (nextUserId === session.user_id) {
toast.info("Keine Änderung", {
description: "Diese Session gehört bereits diesem Benutzer."
});
return;
}
setMovingId(session.id);
try {
await reassignAdminSession(session.id, nextUserId);
toast.success("Session umverteilt", {
description: "Betroffene Tages- und Monatsabschlüsse wurden wieder geöffnet."
});
await load();
} catch (error) {
toast.error("Session konnte nicht umverteilt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setMovingId(null);
}
}
const activeUsers = users.filter((user) => user.active);
const manualSessionUserId = manualSession.userId || activeUsers[0]?.id || "";
async function createManualSession(event: FormEvent) {
event.preventDefault();
if (!manualSessionUserId) {
toast.error("Kein aktiver Benutzer vorhanden");
return;
}
if (!manualSession.organizationId) {
toast.error("Organisation wählen");
return;
}
const startedAt = new Date(`${manualSession.day}T${manualSession.startTime}:00`);
const endedAt = new Date(`${manualSession.day}T${manualSession.endTime}:00`);
if (Number.isNaN(startedAt.getTime()) || Number.isNaN(endedAt.getTime())) {
toast.error("Datum oder Uhrzeit prüfen");
return;
}
if (endedAt <= startedAt) {
toast.error("Ende muss nach Beginn liegen");
return;
}
setCreatingSession(true);
try {
await createAdminSession({
userId: manualSessionUserId,
ticketNumber: manualSession.ticketNumber,
organizationId: manualSession.organizationId,
activity: manualSession.activity,
workType: manualSession.workType,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString()
});
toast.success("Session nachgetragen", {
description: "Der Eintrag ist in der Auswertung des gewählten Benutzers offen."
});
setManualSession({
...emptyManualSession(),
userId: manualSessionUserId,
day: manualSession.day
});
await load();
} catch (error) {
toast.error("Session konnte nicht nachgetragen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setCreatingSession(false);
}
}
async function syncOrganizations(event: FormEvent) {
event.preventDefault();
setSyncingOrganizations(true);
@@ -327,58 +204,109 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
}
}
async function exportDatabase() {
setExportingDatabase(true);
try {
const result = await downloadDatabaseExport();
const url = window.URL.createObjectURL(result.blob);
const link = document.createElement("a");
link.href = url;
link.download = result.filename;
document.body.append(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
toast.success("Datenbankexport erstellt", {
description: result.filename
});
} catch (error) {
toast.error("Datenbankexport fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setExportingDatabase(false);
}
}
const regularUserCount = users.filter((user) => user.role === "user").length;
return (
<div className="space-y-4">
<div>
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Benutzer</h2>
<p className="text-sm text-muted-foreground">Accounts verwalten, Besitzer von Sessions prüfen und Einträge umverteilen.</p>
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Administration</h2>
<p className="text-sm text-muted-foreground">Systemverwaltung für Benutzer, Zammad-Organisationen und restore-fähige Backups.</p>
</div>
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<DatabaseZap className="size-5 text-muted-foreground" />
<CardTitle>Data-Sync</CardTitle>
<div className="grid gap-4 xl:grid-cols-[1.35fr_0.65fr]">
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<DatabaseZap className="size-5 text-muted-foreground" />
<CardTitle>Data-Sync</CardTitle>
</div>
<HelpLink anchor="organisationen" label="Hilfe zum Zammad-Sync" />
</div>
<HelpLink anchor="organisationen" label="Hilfe zum Zammad-Sync" />
</div>
<CardDescription>Zammad-Zugang speichern und Organisationen in die lokale TicketTracker-Datenbank übernehmen.</CardDescription>
</CardHeader>
<CardContent className="px-4 pb-4">
<form className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)_auto_auto] lg:items-end" onSubmit={syncOrganizations}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="zammad-base-url">Zammad URL</label>
<Input
id="zammad-base-url"
placeholder="https://zammad.example.de"
value={zammadBaseUrl}
onChange={(event) => setZammadBaseUrl(event.currentTarget.value)}
required
/>
<CardDescription>Zammad-Zugang speichern und Organisationen in die lokale TicketTracker-Datenbank übernehmen.</CardDescription>
</CardHeader>
<CardContent className="px-4 pb-4">
<form className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)_auto_auto] lg:items-end" onSubmit={syncOrganizations}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="zammad-base-url">Zammad URL</label>
<Input
id="zammad-base-url"
placeholder="https://zammad.example.de"
value={zammadBaseUrl}
onChange={(event) => setZammadBaseUrl(event.currentTarget.value)}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="zammad-api-key">API-Key</label>
<Input
id="zammad-api-key"
type="password"
placeholder={hasZammadApiKey ? "Gespeicherter API-Key wird verwendet" : ""}
value={zammadApiKey}
onChange={(event) => setZammadApiKey(event.currentTarget.value)}
/>
{hasZammadApiKey ? <p className="text-xs text-muted-foreground">API-Key ist gespeichert. Leer lassen, um ihn weiter zu verwenden.</p> : null}
</div>
<Button type="button" variant="secondary" disabled={savingZammadSettings} onClick={() => void saveZammadAccess()}>
<Save className="size-4" />
{savingZammadSettings ? "Speichert..." : "Zugang speichern"}
</Button>
<Button type="submit" disabled={syncingOrganizations}>
<DatabaseZap className="size-4" />
{syncingOrganizations ? "Synchronisiert..." : "Organisationen synchronisieren"}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<DatabaseBackup className="size-5 text-muted-foreground" />
<CardTitle>Datenbankexport</CardTitle>
</div>
<HelpLink anchor="admin" label="Hilfe zum Datenbankexport" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="zammad-api-key">API-Key</label>
<Input
id="zammad-api-key"
type="password"
placeholder={hasZammadApiKey ? "Gespeicherter API-Key wird verwendet" : ""}
value={zammadApiKey}
onChange={(event) => setZammadApiKey(event.currentTarget.value)}
/>
{hasZammadApiKey ? <p className="text-xs text-muted-foreground">API-Key ist gespeichert. Leer lassen, um ihn weiter zu verwenden.</p> : null}
</div>
<Button type="button" variant="secondary" disabled={savingZammadSettings} onClick={() => void saveZammadAccess()}>
<Save className="size-4" />
{savingZammadSettings ? "Speichert..." : "Zugang speichern"}
<CardDescription>SQL-Backup für einen vollständigen Restore auf einer frischen TicketTracker-Installation.</CardDescription>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
<p className="text-sm text-muted-foreground">
Enthält Benutzer, Einstellungen, Organisationen, Tickets, Sessions, Teamspace-Werte, fixe Abrechnungen und Abschlüsse.
</p>
<Button className="w-full" disabled={exportingDatabase} onClick={() => void exportDatabase()}>
<DatabaseBackup className="size-4" />
{exportingDatabase ? "Exportiert..." : "SQL-Backup herunterladen"}
</Button>
<Button type="submit" disabled={syncingOrganizations}>
<DatabaseZap className="size-4" />
{syncingOrganizations ? "Synchronisiert..." : "Organisationen synchronisieren"}
</Button>
</form>
</CardContent>
</Card>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="p-4">
@@ -391,7 +319,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
<form className="grid gap-3 lg:grid-cols-[160px_minmax(180px,1fr)_160px_130px_auto_auto] lg:items-end" onSubmit={createUser}>
<form className="grid gap-3 lg:grid-cols-[160px_minmax(180px,1fr)_160px_110px_auto] lg:items-end" onSubmit={createUser}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="new-username">Benutzername</label>
<Input id="new-username" value={newUser.username} onChange={(event) => setNewUser({ ...newUser, username: event.currentTarget.value })} required />
@@ -405,16 +333,8 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<Input id="new-password" type="password" value={newUser.password} onChange={(event) => setNewUser({ ...newUser, password: event.currentTarget.value })} required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="new-role">Rolle</label>
<select
id="new-role"
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={newUser.role}
onChange={(event) => setNewUser({ ...newUser, role: event.currentTarget.value as UserRole })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<span className="text-sm font-medium">Rolle</span>
<Badge variant="secondary" className="flex h-8 w-full items-center justify-center">User</Badge>
</div>
<label className="flex h-8 items-center gap-2 text-sm">
<Switch checked={newUser.active} onCheckedChange={(active) => setNewUser({ ...newUser, active })} />
@@ -428,130 +348,11 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<CalendarPlus className="size-5 text-muted-foreground" />
<CardTitle>Session nachtragen</CardTitle>
</div>
<HelpLink anchor="session-nachtragen" label="Hilfe zum Nachtragen für Benutzer" />
</div>
<CardDescription>Vergessene Zeiten manuell erfassen und direkt einem Benutzer zuweisen.</CardDescription>
</CardHeader>
<CardContent className="px-4 pb-4">
<form className="space-y-4" onSubmit={createManualSession}>
<div className="grid gap-3 lg:grid-cols-[160px_minmax(180px,1fr)_150px_150px]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-ticket-number">Ticket</label>
<Input
id="manual-ticket-number"
placeholder="Ticket#123456"
value={manualSession.ticketNumber}
onChange={(event) => setManualSession({ ...manualSession, ticketNumber: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-organization">Organisation</label>
<OrganizationSelect
value={manualSession.organizationId}
selectedName={manualSession.organizationName}
onChange={(organization) =>
setManualSession({
...manualSession,
organizationId: organization.id,
organizationName: organization.name
})
}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-work-type">Art</label>
<select
id="manual-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={manualSession.workType}
onChange={(event) => setManualSession({ ...manualSession, workType: event.currentTarget.value as WorkType })}
>
<option value="support">Support</option>
<option value="consulting">Consulting</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-user">Benutzer</label>
<select
id="manual-user"
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={manualSessionUserId}
onChange={(event) => setManualSession({ ...manualSession, userId: event.currentTarget.value })}
required
>
{activeUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.display_name}
</option>
))}
</select>
</div>
</div>
<div className="grid gap-3 lg:grid-cols-[160px_120px_120px_minmax(220px,1fr)_auto] lg:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-day">Datum</label>
<Input
id="manual-day"
type="date"
value={manualSession.day}
onChange={(event) => setManualSession({ ...manualSession, day: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-start">Von</label>
<Input
id="manual-start"
type="time"
value={manualSession.startTime}
onChange={(event) => setManualSession({ ...manualSession, startTime: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-end">Bis</label>
<Input
id="manual-end"
type="time"
value={manualSession.endTime}
onChange={(event) => setManualSession({ ...manualSession, endTime: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-activity">Tätigkeit</label>
<Textarea
id="manual-activity"
className="min-h-20"
value={manualSession.activity}
onChange={(event) => setManualSession({ ...manualSession, activity: event.currentTarget.value })}
required
/>
</div>
<Button type="submit" disabled={creatingSession || activeUsers.length === 0}>
<CalendarPlus className="size-4" />
{creatingSession ? "Speichert..." : "Nachtragen"}
</Button>
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<CardTitle>Vorhandene Benutzer</CardTitle>
<CardDescription>{loading ? "Lädt..." : `${users.length} Account(s)`}</CardDescription>
<CardDescription>{loading ? "Lädt..." : `${regularUserCount} User, 1 fester Admin`}</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{currentUser.display_name}</Badge>
@@ -574,6 +375,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<TableBody>
{users.map((user) => {
const form = forms[user.id] ?? formFromUser(user);
const isFixedAdmin = user.role === "admin";
return (
<TableRow key={user.id}>
@@ -584,18 +386,11 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<Input className="h-8" value={form.displayName} onChange={(event) => updateForm(user.id, { displayName: event.currentTarget.value })} />
</TableCell>
<TableCell>
<select
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={form.role}
onChange={(event) => updateForm(user.id, { role: event.currentTarget.value as UserRole })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<Badge variant={isFixedAdmin ? "default" : "secondary"}>{isFixedAdmin ? "Fester Admin" : "User"}</Badge>
</TableCell>
<TableCell>
<label className="flex items-center gap-2 text-sm">
<Switch checked={form.active} onCheckedChange={(active) => updateForm(user.id, { active })} />
<Switch checked={form.active} disabled={isFixedAdmin} onCheckedChange={(active) => updateForm(user.id, { active })} />
{form.active ? "aktiv" : "inaktiv"}
</label>
</TableCell>
@@ -624,6 +419,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<div className="space-y-2 xl:hidden">
{users.map((user) => {
const form = forms[user.id] ?? formFromUser(user);
const isFixedAdmin = user.role === "admin";
return (
<div key={user.id} className="space-y-3 rounded-md border bg-background p-3">
@@ -632,18 +428,10 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<Shield className="size-4 text-muted-foreground" />
<span className="font-medium">{user.username}</span>
</div>
<Badge variant={form.role === "admin" ? "default" : "secondary"}>{form.role === "admin" ? "Admin" : "User"}</Badge>
<Badge variant={isFixedAdmin ? "default" : "secondary"}>{isFixedAdmin ? "Fester Admin" : "User"}</Badge>
</div>
<Input value={form.username} onChange={(event) => updateForm(user.id, { username: event.currentTarget.value })} />
<Input value={form.displayName} onChange={(event) => updateForm(user.id, { displayName: event.currentTarget.value })} />
<select
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={form.role}
onChange={(event) => updateForm(user.id, { role: event.currentTarget.value as UserRole })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<Input
type="password"
placeholder="Passwort unverändert lassen"
@@ -652,7 +440,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
/>
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2 text-sm">
<Switch checked={form.active} onCheckedChange={(active) => updateForm(user.id, { active })} />
<Switch checked={form.active} disabled={isFixedAdmin} onCheckedChange={(active) => updateForm(user.id, { active })} />
{form.active ? "aktiv" : "inaktiv"}
</label>
<Button size="sm" onClick={() => void saveUser(user)} disabled={savingId === user.id}>
@@ -666,118 +454,6 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<CardTitle>Session-Zuordnung</CardTitle>
<CardDescription>{loading ? "Lädt..." : `${sessions.length} letzte Session(s), inklusive Besitzer.`}</CardDescription>
</div>
<HelpLink anchor="admin" label="Hilfe zur Session-Zuordnung" />
</CardHeader>
<CardContent className="px-4 pb-4">
<div className="hidden xl:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Besitzer</TableHead>
<TableHead>Ticket</TableHead>
<TableHead>Organisation</TableHead>
<TableHead>Tätigkeit</TableHead>
<TableHead>Beginn</TableHead>
<TableHead>Zeit</TableHead>
<TableHead>Zuordnen zu</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((session) => (
<TableRow key={session.id}>
<TableCell>
<div className="font-medium">{session.owner_display_name}</div>
<div className="text-xs text-muted-foreground">{session.owner_username}</div>
</TableCell>
<TableCell className="font-semibold">{session.ticket_number}</TableCell>
<TableCell>{session.customer_name}</TableCell>
<TableCell className="max-w-xs whitespace-normal">{session.activity}</TableCell>
<TableCell className="whitespace-nowrap">{formatDateTime(session.started_at)}</TableCell>
<TableCell>{formatMinutes(session.rounded_minutes)}</TableCell>
<TableCell>
<select
className="h-8 w-full min-w-40 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={sessionOwners[session.id] ?? session.user_id}
onChange={(event) =>
setSessionOwners((current) => ({
...current,
[session.id]: event.currentTarget.value
}))
}
>
{activeUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.display_name}
</option>
))}
</select>
</TableCell>
<TableCell className="text-right">
<Button size="sm" onClick={() => void moveSession(session)} disabled={movingId === session.id}>
<Shuffle className="size-4" />
{movingId === session.id ? "Speichert..." : "Umverteilen"}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="space-y-2 xl:hidden">
{sessions.map((session) => (
<div key={session.id} className="space-y-3 rounded-md border bg-background p-3">
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-semibold">{session.ticket_number}</p>
<p className="text-sm text-muted-foreground">{session.customer_name}</p>
</div>
<Badge variant="outline">{formatMinutes(session.rounded_minutes)}</Badge>
</div>
<div className="rounded-md bg-muted/40 p-2 text-sm">
<p className="font-medium">{session.owner_display_name}</p>
<p className="text-muted-foreground">{session.owner_username} · {formatDateTime(session.started_at)}</p>
</div>
<p className="text-sm">{session.activity}</p>
<div className="grid gap-2 sm:grid-cols-[1fr_auto]">
<select
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={sessionOwners[session.id] ?? session.user_id}
onChange={(event) =>
setSessionOwners((current) => ({
...current,
[session.id]: event.currentTarget.value
}))
}
>
{activeUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.display_name}
</option>
))}
</select>
<Button size="sm" onClick={() => void moveSession(session)} disabled={movingId === session.id}>
<Shuffle className="size-4" />
Umverteilen
</Button>
</div>
</div>
))}
</div>
{sessions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Noch keine Sessions vorhanden.</p>
) : null}
</CardContent>
</Card>
</div>
);
}
+48 -9
View File
@@ -1,7 +1,8 @@
import { ChevronLeft, ChevronRight, Clock3, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react";
import { ChevronLeft, ChevronRight, Clock3, Download, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Alert } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -10,8 +11,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink";
import { getPeriodOverview } from "../api";
import { currentDay, currentMonth, formatMinutes } from "../format";
import { downloadMonthExport, getPeriodOverview } from "../api";
import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format";
import type { PeriodOverview, PeriodType, TicketSummary } from "../types";
type AnalysisPageProps = {
@@ -304,6 +305,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
const [period, setPeriod] = useState(currentMonth());
const [overview, setOverview] = useState<PeriodOverview | null>(null);
const [loading, setLoading] = useState(false);
const [exportingMonth, setExportingMonth] = useState(false);
const [ticketViewSettings, setTicketViewSettings] = useState<AnalysisTicketViewSettings>(() => readStoredTicketViewSettings());
const loadRequestId = useRef(0);
@@ -379,6 +381,35 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
}
async function exportMonth() {
if (periodType !== "month") {
return;
}
setExportingMonth(true);
try {
const { blob, filename } = await downloadMonthExport(period);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
toast.success("Monatsexport erstellt", {
description: filename
});
} catch (error) {
toast.error("Monatsexport fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setExportingMonth(false);
}
}
const totals = overview?.totals;
const periodLabel = periodType === "month" ? "Monat" : "Tag";
const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [
@@ -493,9 +524,21 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
</Button>
</div>
</div>
{periodType === "month" ? (
<Button type="button" variant="secondary" className="sm:col-span-2" disabled={exportingMonth} onClick={() => void exportMonth()}>
<Download className="size-4" />
{exportingMonth ? "Exportiert..." : "Monat exportieren"}
</Button>
) : null}
</div>
</div>
{periodType === "month" && overview?.closed ? (
<Alert variant="success" className="py-3 text-sm">
Dieser Monat wurde am {formatDateTime(overview.closedAt!)} abgeschlossen. Bewertungen und Änderungen sind geschützt, bis der Monat im Monatsabschluss wieder geöffnet wird.
</Alert>
) : null}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
{metricCards.map(({ label, value, detail, icon: Icon }) => (
<Card key={label}>
@@ -834,19 +877,15 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
</div>
<TicketStatus ticket={ticket} />
</div>
<div className="grid grid-cols-3 gap-2 text-sm">
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="rounded-md bg-muted/50 p-2">
<p className="text-muted-foreground">Abr.</p>
<p className="font-medium">{formatMinutes(ticket.total_minutes)}</p>
</div>
<div className="rounded-md bg-muted/50 p-2">
<p className="text-muted-foreground">Abr.</p>
<p className="text-muted-foreground">Sessions</p>
<p className="font-medium">{ticket.billed_count}</p>
</div>
<div className="rounded-md bg-muted/50 p-2">
<p className="text-muted-foreground">Nicht</p>
<p className="font-medium">{formatMinutes(ticket.non_billable_minutes)}</p>
</div>
</div>
<Button className="w-full" size="sm" variant="secondary" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${ticket.id}`)}>
Öffnen
+22 -6
View File
@@ -280,10 +280,26 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const canClose = Boolean(stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
const checklist = useMemo(
() => [
{ label: "Offene Bewertungen", count: openSessions.length, detail: `${openTicketGroups.length} Ticket(s)`, blocker: true },
{ label: "Tage ohne Teamspace-Wert", count: missingCrmDays.length, detail: `${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`, blocker: false }
{
label: "Offene Bewertungen",
ok: openSessions.length === 0,
value: openSessions.length,
detail: openSessions.length === 0 ? "Alle Sessions sind bewertet." : `${openTicketGroups.length} Ticket(s) bearbeiten`
},
{
label: "Teamspace geprüft",
ok: missingCrmDays.length === 0,
value: missingCrmDays.length,
detail: missingCrmDays.length === 0 ? "Keine offenen Teamspace-Prüfpunkte." : `${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`
},
{
label: "Monat abschließbar",
ok: Boolean(stats?.closed || canClose),
value: stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen",
detail: stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen."
}
],
[acknowledgedMissingCrmDays.length, openSessions.length, openTicketGroups.length, missingCrmDays.length]
[acknowledgedMissingCrmDays.length, canClose, openSessions.length, openTicketGroups.length, missingCrmDays.length, stats?.closed]
);
return (
@@ -348,14 +364,14 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
<HelpLink anchor="monatsabschluss" label="Hilfe zum Monatsabschluss" />
</div>
</CardHeader>
<CardContent className="grid gap-2 px-4 pb-4 md:grid-cols-2">
<CardContent className="grid gap-2 px-4 pb-4 md:grid-cols-3">
{checklist.map((item) => (
<div key={item.label} className="rounded-md border bg-background p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-sm font-medium">{item.label}</span>
<Badge variant={item.count > 0 ? "warning" : "success"}>{item.count}</Badge>
<Badge variant={item.ok ? "success" : "warning"}>{item.value}</Badge>
</div>
<p className="text-xs text-muted-foreground">{item.blocker ? "Muss erledigt sein." : item.detail}</p>
<p className="text-xs text-muted-foreground">{item.detail}</p>
</div>
))}
</CardContent>
+7
View File
@@ -1,6 +1,7 @@
import { ArrowLeft, Pencil, Save, Trash2 } from "lucide-react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Alert } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -502,6 +503,12 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
</Badge>
</div>
{periodType === "month" && data?.closed ? (
<Alert variant="success" className="py-3 text-sm">
Dieser Monat ist abgeschlossen. Bewertungen und Änderungen sind geschützt, bis du den Monat im Monatsabschluss wieder öffnest.
</Alert>
) : null}
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
+2 -1
View File
@@ -318,6 +318,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
id="ticket-number"
placeholder="Ticket#123456"
value={ticketNumber}
autoFocus
onChange={(event) => setTicketNumber(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
@@ -525,7 +526,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<label className="text-sm font-medium" htmlFor="activity">
Tätigkeit
</label>
<Textarea id="activity" value={activity} onChange={(event) => setActivity(event.currentTarget.value)} required />
<Textarea id="activity" value={activity} onChange={(event) => setActivity(event.currentTarget.value)} autoFocus required />
</div>
{finished?.ticket?.organization_id && finished.ticket.work_type ? null : (
<div className="space-y-2">