Initial TicketTracker release
This commit is contained in:
@@ -0,0 +1,767 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { CalendarPlus, DatabaseZap, Save, Shield, Shuffle, 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 { OrganizationSelect } from "@/components/OrganizationSelect";
|
||||
import {
|
||||
createAdminSession,
|
||||
createAdminUser,
|
||||
getAdminSessions,
|
||||
getAdminUsers,
|
||||
getZammadSettings,
|
||||
reassignAdminSession,
|
||||
saveZammadSettings,
|
||||
syncZammadOrganizations,
|
||||
updateAdminUser
|
||||
} from "../api";
|
||||
import { currentDay, formatDateTime, formatMinutes } from "../format";
|
||||
import type { AdminSession, AdminUser, AuthUser, UserRole, WorkType } 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 [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()]);
|
||||
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"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function refreshVisible() {
|
||||
if (document.visibilityState === "visible") {
|
||||
void load({ preserveEdits: true });
|
||||
}
|
||||
}
|
||||
|
||||
const interval = window.setInterval(refreshVisible, 60_000);
|
||||
window.addEventListener("focus", refreshVisible);
|
||||
document.addEventListener("visibilitychange", refreshVisible);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener("focus", refreshVisible);
|
||||
document.removeEventListener("visibilitychange", refreshVisible);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function updateForm(userId: string, patch: Partial<UserFormState>) {
|
||||
setForms((current) => ({
|
||||
...current,
|
||||
[userId]: {
|
||||
...current[userId],
|
||||
...patch
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async function createUser(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setCreating(true);
|
||||
|
||||
try {
|
||||
await createAdminUser(newUser);
|
||||
toast.success("Benutzer angelegt");
|
||||
setNewUser(emptyForm);
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error("Benutzer konnte nicht angelegt werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUser(user: AdminUser) {
|
||||
const form = forms[user.id];
|
||||
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingId(user.id);
|
||||
try {
|
||||
await updateAdminUser(user.id, {
|
||||
username: form.username,
|
||||
displayName: form.displayName,
|
||||
role: form.role,
|
||||
active: form.active,
|
||||
password: form.password || undefined
|
||||
});
|
||||
toast.success("Benutzer gespeichert");
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error("Benutzer konnte nicht gespeichert werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
try {
|
||||
const result = await syncZammadOrganizations({
|
||||
baseUrl: zammadBaseUrl,
|
||||
apiKey: zammadApiKey.trim() || undefined
|
||||
});
|
||||
toast.success("Organisationen synchronisiert", {
|
||||
description: `${result.synced} gespeichert, ${result.removed} entfernt, ${result.unlinkedTickets + result.unlinkedSessions} Verknüpfung(en) gelöst.`
|
||||
});
|
||||
setHasZammadApiKey(true);
|
||||
setZammadApiKey("");
|
||||
} catch (error) {
|
||||
toast.error("Zammad-Sync fehlgeschlagen", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setSyncingOrganizations(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveZammadAccess() {
|
||||
setSavingZammadSettings(true);
|
||||
|
||||
try {
|
||||
const result = await saveZammadSettings({
|
||||
baseUrl: zammadBaseUrl,
|
||||
apiKey: zammadApiKey.trim() || undefined
|
||||
});
|
||||
setZammadBaseUrl(result.settings.baseUrl);
|
||||
setHasZammadApiKey(result.settings.hasApiKey);
|
||||
setZammadApiKey("");
|
||||
toast.success("Zammad-Zugang gespeichert", {
|
||||
description: result.settings.hasApiKey ? "URL und API-Key sind hinterlegt." : "URL wurde gespeichert."
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Zammad-Zugang konnte nicht gespeichert werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setSavingZammadSettings(false);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<DatabaseZap className="size-5 text-muted-foreground" />
|
||||
<CardTitle>Data-Sync</CardTitle>
|
||||
</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
|
||||
/>
|
||||
</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">
|
||||
<CardTitle>Neuer Benutzer</CardTitle>
|
||||
<CardDescription>Neue Benutzer sehen später nur ihre eigenen Sessions und Abschlüsse.</CardDescription>
|
||||
</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}>
|
||||
<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 />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="new-display-name">Name</label>
|
||||
<Input id="new-display-name" value={newUser.displayName} onChange={(event) => setNewUser({ ...newUser, displayName: event.currentTarget.value })} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="new-password">Passwort</label>
|
||||
<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>
|
||||
</div>
|
||||
<label className="flex h-8 items-center gap-2 text-sm">
|
||||
<Switch checked={newUser.active} onCheckedChange={(active) => setNewUser({ ...newUser, active })} />
|
||||
Aktiv
|
||||
</label>
|
||||
<Button type="submit" disabled={creating}>
|
||||
<UserPlus className="size-4" />
|
||||
{creating ? "Legt an..." : "Anlegen"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarPlus className="size-5 text-muted-foreground" />
|
||||
<CardTitle>Session nachtragen</CardTitle>
|
||||
</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>
|
||||
</div>
|
||||
<Badge variant="outline">{currentUser.display_name}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<div className="hidden xl:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Benutzername</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Rolle</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Neues Passwort</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => {
|
||||
const form = forms[user.id] ?? formFromUser(user);
|
||||
|
||||
return (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<Input className="h-8" value={form.username} onChange={(event) => updateForm(user.id, { username: event.currentTarget.value })} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<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>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch checked={form.active} onCheckedChange={(active) => updateForm(user.id, { active })} />
|
||||
{form.active ? "aktiv" : "inaktiv"}
|
||||
</label>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
className="h-8"
|
||||
type="password"
|
||||
placeholder="unverändert"
|
||||
value={form.password}
|
||||
onChange={(event) => updateForm(user.id, { password: event.currentTarget.value })}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" onClick={() => void saveUser(user)} disabled={savingId === user.id}>
|
||||
<Save className="size-4" />
|
||||
{savingId === user.id ? "Speichert..." : "Speichern"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 xl:hidden">
|
||||
{users.map((user) => {
|
||||
const form = forms[user.id] ?? formFromUser(user);
|
||||
|
||||
return (
|
||||
<div key={user.id} className="space-y-3 rounded-md border bg-background p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
</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"
|
||||
value={form.password}
|
||||
onChange={(event) => updateForm(user.id, { password: event.currentTarget.value })}
|
||||
/>
|
||||
<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 })} />
|
||||
{form.active ? "aktiv" : "inaktiv"}
|
||||
</label>
|
||||
<Button size="sm" onClick={() => void saveUser(user)} disabled={savingId === user.id}>
|
||||
<Save className="size-4" />
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,960 @@
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, CircleAlert, Clock3, ExternalLink, LockOpen, 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";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
||||
import { closePeriod, getPeriodOverview, reopenPeriod } from "../api";
|
||||
import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format";
|
||||
import type { PeriodOverview, PeriodType, TicketSummary } from "../types";
|
||||
|
||||
type AnalysisPageProps = {
|
||||
onNavigate: (to: string) => void;
|
||||
};
|
||||
|
||||
type TimelineBucket = {
|
||||
key: string;
|
||||
label: string;
|
||||
totalMinutes: number;
|
||||
sessionCount: number;
|
||||
};
|
||||
|
||||
type TicketGroupBy = "none" | "workType" | "organization" | "status";
|
||||
type TicketSortBy = "ticket" | "workType" | "organization" | "status";
|
||||
type TicketSortDirection = "asc" | "desc";
|
||||
type TicketStatusFilter = "all" | "open" | "evaluated";
|
||||
type TicketWorkTypeFilter = "all" | "support" | "consulting";
|
||||
|
||||
type AnalysisTicketViewSettings = {
|
||||
groupBy: TicketGroupBy;
|
||||
sortBy: TicketSortBy;
|
||||
sortDirection: TicketSortDirection;
|
||||
query: string;
|
||||
workType: TicketWorkTypeFilter;
|
||||
organization: string;
|
||||
status: TicketStatusFilter;
|
||||
};
|
||||
|
||||
type TicketSection = {
|
||||
key: string;
|
||||
label: string;
|
||||
tickets: TicketSummary[];
|
||||
ticketCount: number;
|
||||
totalMinutes: number;
|
||||
openCount: number;
|
||||
};
|
||||
|
||||
const analysisTicketViewSettingsKey = "tickettracker.analysis.ticketViewSettings";
|
||||
|
||||
const defaultTicketViewSettings: AnalysisTicketViewSettings = {
|
||||
groupBy: "none",
|
||||
sortBy: "ticket",
|
||||
sortDirection: "asc",
|
||||
query: "",
|
||||
workType: "all",
|
||||
organization: "all",
|
||||
status: "all"
|
||||
};
|
||||
|
||||
const groupByOptions: Array<{ value: TicketGroupBy; label: string }> = [
|
||||
{ value: "none", label: "Keine" },
|
||||
{ value: "workType", label: "Typ" },
|
||||
{ value: "organization", label: "Organisation" },
|
||||
{ value: "status", label: "Status" }
|
||||
];
|
||||
|
||||
const sortByOptions: Array<{ value: TicketSortBy; label: string }> = [
|
||||
{ value: "ticket", label: "Ticket" },
|
||||
{ value: "workType", label: "Typ" },
|
||||
{ value: "organization", label: "Organisation" },
|
||||
{ value: "status", label: "Status" }
|
||||
];
|
||||
|
||||
function readStoredTicketViewSettings(): AnalysisTicketViewSettings {
|
||||
if (typeof window === "undefined") {
|
||||
return defaultTicketViewSettings;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(analysisTicketViewSettingsKey);
|
||||
|
||||
if (!raw) {
|
||||
return defaultTicketViewSettings;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<AnalysisTicketViewSettings>;
|
||||
|
||||
return {
|
||||
groupBy: groupByOptions.find((option) => option.value === parsed.groupBy)?.value ?? defaultTicketViewSettings.groupBy,
|
||||
sortBy: sortByOptions.find((option) => option.value === parsed.sortBy)?.value ?? defaultTicketViewSettings.sortBy,
|
||||
sortDirection: parsed.sortDirection === "desc" ? "desc" : defaultTicketViewSettings.sortDirection,
|
||||
query: typeof parsed.query === "string" ? parsed.query : defaultTicketViewSettings.query,
|
||||
workType: parsed.workType === "support" || parsed.workType === "consulting" ? parsed.workType : defaultTicketViewSettings.workType,
|
||||
organization: typeof parsed.organization === "string" ? parsed.organization : defaultTicketViewSettings.organization,
|
||||
status: parsed.status === "open" || parsed.status === "evaluated" ? parsed.status : defaultTicketViewSettings.status
|
||||
};
|
||||
} catch {
|
||||
return defaultTicketViewSettings;
|
||||
}
|
||||
}
|
||||
|
||||
function TicketStatus({ ticket }: { ticket: TicketSummary }) {
|
||||
const ticketOpenCount = ticket.ticket_open_count ?? ticket.open_count;
|
||||
|
||||
if (ticketOpenCount > 0) {
|
||||
return <Badge variant="warning">{ticketOpenCount} offen</Badge>;
|
||||
}
|
||||
|
||||
return <Badge variant="success">bewertet</Badge>;
|
||||
}
|
||||
|
||||
function ticketStatusKey(ticket: TicketSummary): "open" | "evaluated" {
|
||||
return (ticket.ticket_open_count ?? ticket.open_count) > 0 ? "open" : "evaluated";
|
||||
}
|
||||
|
||||
function ticketStatusLabel(ticket: TicketSummary) {
|
||||
return ticketStatusKey(ticket) === "open" ? "Offen" : "Bewertet";
|
||||
}
|
||||
|
||||
function workTypeLabel(workType: TicketSummary["work_type"]) {
|
||||
if (workType === "support") {
|
||||
return "Support";
|
||||
}
|
||||
|
||||
if (workType === "consulting") {
|
||||
return "Consulting";
|
||||
}
|
||||
|
||||
return "Ohne Typ";
|
||||
}
|
||||
|
||||
function organizationLabel(ticket: TicketSummary) {
|
||||
return ticket.customer_name ?? ticket.organization_name ?? "Keine Organisation";
|
||||
}
|
||||
|
||||
function organizationKey(ticket: TicketSummary) {
|
||||
return ticket.organization_id ? `id:${ticket.organization_id}` : `name:${organizationLabel(ticket)}`;
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string) {
|
||||
return left.localeCompare(right, "de", { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
|
||||
function sortTickets(tickets: TicketSummary[], sortBy: TicketSortBy, direction: TicketSortDirection) {
|
||||
const sorted = [...tickets].sort((left, right) => {
|
||||
let result = 0;
|
||||
|
||||
if (sortBy === "ticket") {
|
||||
result = compareText(left.ticket_number, right.ticket_number);
|
||||
} else if (sortBy === "workType") {
|
||||
result = compareText(workTypeLabel(left.work_type), workTypeLabel(right.work_type));
|
||||
} else if (sortBy === "organization") {
|
||||
result = compareText(organizationLabel(left), organizationLabel(right));
|
||||
} else {
|
||||
result = compareText(ticketStatusLabel(left), ticketStatusLabel(right));
|
||||
}
|
||||
|
||||
if (result === 0) {
|
||||
result = compareText(left.ticket_number, right.ticket_number);
|
||||
}
|
||||
|
||||
return direction === "asc" ? result : -result;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function groupTicketSections(tickets: TicketSummary[], groupBy: TicketGroupBy): TicketSection[] {
|
||||
if (groupBy === "none") {
|
||||
return [
|
||||
{
|
||||
key: "all",
|
||||
label: "Alle Tickets",
|
||||
tickets,
|
||||
ticketCount: tickets.length,
|
||||
totalMinutes: tickets.reduce((sum, ticket) => sum + ticket.total_minutes, 0),
|
||||
openCount: tickets.reduce((sum, ticket) => sum + (ticket.ticket_open_count ?? ticket.open_count), 0)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
const groups = new Map<string, TicketSection>();
|
||||
|
||||
for (const ticket of tickets) {
|
||||
const key =
|
||||
groupBy === "workType"
|
||||
? `workType:${ticket.work_type ?? "none"}`
|
||||
: groupBy === "organization"
|
||||
? organizationKey(ticket)
|
||||
: `status:${ticketStatusKey(ticket)}`;
|
||||
const label = groupBy === "workType" ? workTypeLabel(ticket.work_type) : groupBy === "organization" ? organizationLabel(ticket) : ticketStatusLabel(ticket);
|
||||
const existing = groups.get(key);
|
||||
|
||||
if (existing) {
|
||||
existing.tickets.push(ticket);
|
||||
existing.ticketCount += 1;
|
||||
existing.totalMinutes += ticket.total_minutes;
|
||||
existing.openCount += ticket.ticket_open_count ?? ticket.open_count;
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.set(key, {
|
||||
key,
|
||||
label,
|
||||
tickets: [ticket],
|
||||
ticketCount: 1,
|
||||
totalMinutes: ticket.total_minutes,
|
||||
openCount: ticket.ticket_open_count ?? ticket.open_count
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(groups.values()).sort((left, right) => {
|
||||
if (groupBy === "status") {
|
||||
return left.key === "status:open" ? -1 : right.key === "status:open" ? 1 : compareText(left.label, right.label);
|
||||
}
|
||||
|
||||
return compareText(left.label, right.label);
|
||||
});
|
||||
}
|
||||
|
||||
function buildChartPath(points: Array<{ x: number; y: number }>) {
|
||||
if (points.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (points.length === 1) {
|
||||
return `M ${points[0].x - 18} ${points[0].y} L ${points[0].x + 18} ${points[0].y}`;
|
||||
}
|
||||
|
||||
return points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" ");
|
||||
}
|
||||
|
||||
function niceCeilMinutes(value: number) {
|
||||
if (value <= 30) {
|
||||
return Math.max(5, Math.ceil(value / 5) * 5);
|
||||
}
|
||||
|
||||
if (value <= 120) {
|
||||
return Math.ceil(value / 15) * 15;
|
||||
}
|
||||
|
||||
if (value <= 480) {
|
||||
return Math.ceil(value / 30) * 30;
|
||||
}
|
||||
|
||||
return Math.ceil(value / 60) * 60;
|
||||
}
|
||||
|
||||
function formatAxisMinutes(value: number) {
|
||||
const minutes = Math.round(value);
|
||||
|
||||
if (minutes === 0) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
if (minutes >= 60) {
|
||||
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: minutes < 600 ? 1 : 0 }).format(minutes / 60)} h`;
|
||||
}
|
||||
|
||||
return `${minutes} min`;
|
||||
}
|
||||
|
||||
function buildTimeline(periodType: PeriodType, period: string, overview: PeriodOverview | null): TimelineBucket[] {
|
||||
const series = new Map((overview?.activitySeries ?? []).map((bucket) => [bucket.bucket_key, bucket]));
|
||||
|
||||
if (periodType === "day") {
|
||||
return Array.from({ length: 24 }).map((_, hour) => {
|
||||
const key = String(hour).padStart(2, "0");
|
||||
const bucket = series.get(key);
|
||||
|
||||
return {
|
||||
key,
|
||||
label: `${key}:00`,
|
||||
totalMinutes: bucket?.total_minutes ?? 0,
|
||||
sessionCount: bucket?.session_count ?? 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const [year, month] = period.split("-").map(Number);
|
||||
const daysInMonth = Number.isFinite(year) && Number.isFinite(month) ? new Date(year, month, 0).getDate() : 31;
|
||||
|
||||
return Array.from({ length: daysInMonth }).map((_, index) => {
|
||||
const day = String(index + 1).padStart(2, "0");
|
||||
const key = `${period}-${day}`;
|
||||
const bucket = series.get(key);
|
||||
|
||||
return {
|
||||
key,
|
||||
label: day,
|
||||
totalMinutes: bucket?.total_minutes ?? 0,
|
||||
sessionCount: bucket?.session_count ?? 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
const [periodType, setPeriodType] = useState<PeriodType>("month");
|
||||
const [period, setPeriod] = useState(currentMonth());
|
||||
const [overview, setOverview] = useState<PeriodOverview | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [reopening, setReopening] = useState(false);
|
||||
const [showOpen, setShowOpen] = useState(false);
|
||||
const [ticketViewSettings, setTicketViewSettings] = useState<AnalysisTicketViewSettings>(() => readStoredTicketViewSettings());
|
||||
const loadRequestId = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(analysisTicketViewSettingsKey, JSON.stringify(ticketViewSettings));
|
||||
}, [ticketViewSettings]);
|
||||
|
||||
async function load() {
|
||||
const requestId = loadRequestId.current + 1;
|
||||
loadRequestId.current = requestId;
|
||||
setLoading(true);
|
||||
try {
|
||||
const nextOverview = await getPeriodOverview(periodType, period);
|
||||
|
||||
if (requestId === loadRequestId.current) {
|
||||
setOverview(nextOverview);
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId === loadRequestId.current) {
|
||||
toast.error("Auswertung konnte nicht geladen werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (requestId === loadRequestId.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [periodType, period]);
|
||||
|
||||
useEffect(() => {
|
||||
function refreshVisible() {
|
||||
if (document.visibilityState === "visible") {
|
||||
void load();
|
||||
}
|
||||
}
|
||||
|
||||
const interval = window.setInterval(refreshVisible, 60_000);
|
||||
window.addEventListener("focus", refreshVisible);
|
||||
document.addEventListener("visibilitychange", refreshVisible);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener("focus", refreshVisible);
|
||||
document.removeEventListener("visibilitychange", refreshVisible);
|
||||
};
|
||||
}, [periodType, period]);
|
||||
|
||||
function switchPeriodType(nextType: PeriodType) {
|
||||
setPeriodType(nextType);
|
||||
setPeriod(nextType === "month" ? currentMonth() : currentDay());
|
||||
setShowOpen(false);
|
||||
}
|
||||
|
||||
function changePeriodBy(delta: number) {
|
||||
if (periodType === "month") {
|
||||
const [year, month] = period.split("-").map(Number);
|
||||
const date = Number.isFinite(year) && Number.isFinite(month) ? new Date(year, month - 1, 1) : new Date();
|
||||
date.setMonth(date.getMonth() + delta);
|
||||
setPeriod(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`);
|
||||
} else {
|
||||
const [year, month, day] = period.split("-").map(Number);
|
||||
const date =
|
||||
Number.isFinite(year) && Number.isFinite(month) && Number.isFinite(day)
|
||||
? new Date(year, month - 1, day)
|
||||
: new Date();
|
||||
date.setDate(date.getDate() + delta);
|
||||
setPeriod(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`);
|
||||
}
|
||||
|
||||
setShowOpen(false);
|
||||
}
|
||||
|
||||
async function finishPeriod() {
|
||||
setClosing(true);
|
||||
try {
|
||||
await closePeriod(periodType, period);
|
||||
toast.success(`${periodType === "month" ? "Monat" : "Tag"} abgeschlossen`, {
|
||||
description: `${period} ist abgeschlossen.`
|
||||
});
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error(`${periodType === "month" ? "Monat" : "Tag"} noch nicht abschließbar`, {
|
||||
description: error instanceof Error ? error.message : "Bitte offene Sessions prüfen."
|
||||
});
|
||||
setShowOpen(true);
|
||||
await load();
|
||||
} finally {
|
||||
setClosing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openPeriodAgain() {
|
||||
setReopening(true);
|
||||
try {
|
||||
await reopenPeriod(periodType, period);
|
||||
toast.success(`${periodType === "month" ? "Monat" : "Tag"} wieder geöffnet`, {
|
||||
description: `${period} kann wieder bearbeitet werden.`
|
||||
});
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error(`${periodType === "month" ? "Monat" : "Tag"} konnte nicht geöffnet werden`, {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setReopening(false);
|
||||
}
|
||||
}
|
||||
|
||||
const totals = overview?.totals;
|
||||
const periodLabel = periodType === "month" ? "Monat" : "Tag";
|
||||
const supportsPeriodClosure = periodType === "month";
|
||||
const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [
|
||||
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
|
||||
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
|
||||
{ label: "Zeit", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 },
|
||||
{ label: "Offen", value: totals?.openSessions ?? 0, icon: UserCheck }
|
||||
];
|
||||
const tickets = overview?.tickets ?? [];
|
||||
const organizationOptions = useMemo(() => {
|
||||
const options = new Map<string, string>();
|
||||
|
||||
for (const ticket of tickets) {
|
||||
options.set(organizationKey(ticket), organizationLabel(ticket));
|
||||
}
|
||||
|
||||
return Array.from(options.entries())
|
||||
.map(([value, label]) => ({ value, label }))
|
||||
.sort((left, right) => compareText(left.label, right.label));
|
||||
}, [tickets]);
|
||||
const visibleTickets = useMemo(() => {
|
||||
const normalizedQuery = ticketViewSettings.query.trim().toLocaleLowerCase("de");
|
||||
const filtered = tickets.filter((ticket) => {
|
||||
const matchesQuery =
|
||||
!normalizedQuery ||
|
||||
ticket.ticket_number.toLocaleLowerCase("de").includes(normalizedQuery) ||
|
||||
organizationLabel(ticket).toLocaleLowerCase("de").includes(normalizedQuery);
|
||||
const matchesWorkType = ticketViewSettings.workType === "all" || ticket.work_type === ticketViewSettings.workType;
|
||||
const matchesOrganization = ticketViewSettings.organization === "all" || organizationKey(ticket) === ticketViewSettings.organization;
|
||||
const matchesStatus = ticketViewSettings.status === "all" || ticketStatusKey(ticket) === ticketViewSettings.status;
|
||||
|
||||
return matchesQuery && matchesWorkType && matchesOrganization && matchesStatus;
|
||||
});
|
||||
|
||||
return sortTickets(filtered, ticketViewSettings.sortBy, ticketViewSettings.sortDirection);
|
||||
}, [tickets, ticketViewSettings]);
|
||||
const ticketSections = useMemo(() => groupTicketSections(visibleTickets, ticketViewSettings.groupBy), [visibleTickets, ticketViewSettings.groupBy]);
|
||||
const activeFilterCount = [
|
||||
ticketViewSettings.query.trim(),
|
||||
ticketViewSettings.workType !== "all",
|
||||
ticketViewSettings.organization !== "all",
|
||||
ticketViewSettings.status !== "all",
|
||||
ticketViewSettings.groupBy !== "none",
|
||||
ticketViewSettings.sortBy !== "ticket",
|
||||
ticketViewSettings.sortDirection !== "asc"
|
||||
].filter(Boolean).length;
|
||||
const timeline = buildTimeline(periodType, period, overview);
|
||||
const chartWidth = 900;
|
||||
const chartHeight = 180;
|
||||
const chartPadding = { top: 18, right: 18, bottom: 34, left: 64 };
|
||||
const chartInnerWidth = chartWidth - chartPadding.left - chartPadding.right;
|
||||
const chartInnerHeight = chartHeight - chartPadding.top - chartPadding.bottom;
|
||||
const maxMinutes = Math.max(...timeline.map((bucket) => bucket.totalMinutes), 1);
|
||||
const chartMaxMinutes = niceCeilMinutes(maxMinutes);
|
||||
const yAxisTicks = Array.from({ length: 5 }).map((_, index) => chartMaxMinutes - (index * chartMaxMinutes) / 4);
|
||||
const barWidth = Math.max(4, Math.min(18, chartInnerWidth / Math.max(timeline.length, 1) / 1.8));
|
||||
const chartPoints = timeline.map((bucket, index) => {
|
||||
const x =
|
||||
timeline.length === 1
|
||||
? chartPadding.left + chartInnerWidth / 2
|
||||
: chartPadding.left + (index * chartInnerWidth) / (timeline.length - 1);
|
||||
|
||||
return {
|
||||
bucket,
|
||||
x,
|
||||
barHeight: bucket.totalMinutes > 0 ? Math.max(2, (bucket.totalMinutes / chartMaxMinutes) * chartInnerHeight) : 0,
|
||||
y: chartPadding.top + (1 - bucket.totalMinutes / chartMaxMinutes) * chartInnerHeight
|
||||
};
|
||||
});
|
||||
const effortPath = buildChartPath(chartPoints.filter((point) => point.bucket.totalMinutes > 0).map((point) => ({ x: point.x, y: point.y })));
|
||||
const labelStep = periodType === "month" ? Math.max(1, Math.ceil(timeline.length / 8)) : 3;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-normal">Auswertung</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{supportsPeriodClosure ? "Tickets prüfen, Sessions markieren und Monat abschließen." : "Sessions des Tages prüfen und bewerten."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-[auto_minmax(220px,300px)] sm:items-end">
|
||||
<div className="grid grid-cols-2 rounded-md border bg-background p-1">
|
||||
<Button size="sm" variant={periodType === "month" ? "default" : "ghost"} className="h-8" onClick={() => switchPeriodType("month")}>
|
||||
Monat
|
||||
</Button>
|
||||
<Button size="sm" variant={periodType === "day" ? "default" : "ghost"} className="h-8" onClick={() => switchPeriodType("day")}>
|
||||
Tag
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="period">
|
||||
{periodLabel}
|
||||
</label>
|
||||
{loading ? <span className="text-xs text-muted-foreground">lädt...</span> : null}
|
||||
</div>
|
||||
<div className="grid grid-cols-[2.25rem_minmax(0,1fr)_2.25rem] gap-1">
|
||||
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changePeriodBy(-1)} aria-label={`${periodLabel} zurück`}>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Input
|
||||
id="period"
|
||||
className="h-9"
|
||||
type={periodType === "month" ? "month" : "date"}
|
||||
value={period}
|
||||
onChange={(event) => {
|
||||
setPeriod(event.currentTarget.value);
|
||||
setShowOpen(false);
|
||||
}}
|
||||
/>
|
||||
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changePeriodBy(1)} aria-label={`${periodLabel} vor`}>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{metricCards.map(({ label, value, detail, icon: Icon }) => (
|
||||
<Card key={label}>
|
||||
<CardContent className="flex items-center gap-3 p-3">
|
||||
<div className="grid size-8 shrink-0 place-items-center rounded-lg border bg-muted/40 text-muted-foreground">
|
||||
<Icon className="size-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<p className="truncate text-xl font-semibold tracking-normal">{value}</p>
|
||||
{detail ? <p className="truncate text-xs text-muted-foreground">{detail}</p> : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<CardTitle>Gesamtaufwand</CardTitle>
|
||||
<CardDescription>
|
||||
{periodType === "month" ? "Aufwand pro Tag im ausgewählten Monat." : "Aufwand pro Stunde am ausgewählten Tag."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{formatMinutes(totals?.minutes ?? 0)}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<div className="h-44 w-full overflow-hidden rounded-md border bg-background">
|
||||
{(totals?.minutes ?? 0) > 0 ? (
|
||||
<svg viewBox={`0 0 ${chartWidth} ${chartHeight}`} className="h-full w-full">
|
||||
{yAxisTicks.map((tick) => {
|
||||
const y = chartPadding.top + (1 - tick / chartMaxMinutes) * chartInnerHeight;
|
||||
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line x1={chartPadding.left} x2={chartWidth - chartPadding.right} y1={y} y2={y} stroke="currentColor" className="text-muted/70" />
|
||||
<line x1={chartPadding.left - 5} x2={chartPadding.left} y1={y} y2={y} stroke="currentColor" className="text-muted-foreground/70" />
|
||||
<text x={chartPadding.left - 9} y={y + 4} textAnchor="end" className="fill-muted-foreground text-[11px]">
|
||||
{formatAxisMinutes(tick)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<line
|
||||
x1={chartPadding.left}
|
||||
x2={chartPadding.left}
|
||||
y1={chartPadding.top}
|
||||
y2={chartPadding.top + chartInnerHeight}
|
||||
stroke="currentColor"
|
||||
className="text-muted-foreground/70"
|
||||
/>
|
||||
|
||||
{chartPoints.map((point) => {
|
||||
const barY = chartPadding.top + chartInnerHeight - point.barHeight;
|
||||
|
||||
return (
|
||||
<g key={point.bucket.key}>
|
||||
{point.bucket.totalMinutes > 0 ? (
|
||||
<rect
|
||||
x={point.x - barWidth / 2}
|
||||
y={barY}
|
||||
width={barWidth}
|
||||
height={point.barHeight}
|
||||
rx="4"
|
||||
className="fill-neutral-300 dark:fill-neutral-700"
|
||||
/>
|
||||
) : null}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{effortPath ? <path d={effortPath} fill="none" stroke="currentColor" strokeWidth="2.5" className="text-foreground" /> : null}
|
||||
|
||||
{chartPoints.filter((point) => point.bucket.totalMinutes > 0).map((point) => (
|
||||
<circle key={`${point.bucket.key}-effort`} cx={point.x} cy={point.y} r="3" className="fill-background stroke-foreground" strokeWidth="2" />
|
||||
))}
|
||||
|
||||
{chartPoints.map((point, index) =>
|
||||
index % labelStep === 0 || index === chartPoints.length - 1 ? (
|
||||
<text key={`${point.bucket.key}-label`} x={point.x} y={chartHeight - 10} textAnchor="middle" className="fill-muted-foreground text-[11px]">
|
||||
{point.bucket.label}
|
||||
</text>
|
||||
) : null
|
||||
)}
|
||||
</svg>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center text-sm text-muted-foreground">Keine Daten für den gewählten Zeitraum.</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-sm bg-neutral-300 dark:bg-neutral-700" />
|
||||
Minuten
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="h-0.5 w-4 bg-foreground" />
|
||||
Verlauf
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{supportsPeriodClosure && overview?.closed ? (
|
||||
<Alert variant="success" className="flex items-center gap-2 py-3">
|
||||
<CheckCircle2 className="size-4 shrink-0" />
|
||||
<span>Dieser Monat wurde am {formatDateTime(overview.closedAt!)} abgeschlossen.</span>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{overview && overview.openSessions.length > 0 ? (
|
||||
<Alert variant="warning" className="flex flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleAlert className="size-4 shrink-0" />
|
||||
<span>Es gibt noch {overview.openSessions.length} Session(s) ohne Auswahl.</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowOpen((value) => !value)}>
|
||||
Offene Sessions prüfen
|
||||
</Button>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{showOpen && overview?.openSessions.length ? (
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle>Nicht bearbeitete Sessions</CardTitle>
|
||||
<CardDescription>Diese Einträge blockieren den Monatsabschluss.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 px-4 pb-4">
|
||||
{overview.openSessions.map((session) => (
|
||||
<div key={session.id} className="grid gap-2 rounded-md border p-3 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{session.ticket_number}</span>
|
||||
<Badge variant="outline">{formatMinutes(session.rounded_minutes)}</Badge>
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">{session.customer_name}</p>
|
||||
<p className="text-sm">{session.activity}</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${session.ticket_id}`)}>
|
||||
Öffnen
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<CardTitle>Tickets im Zeitraum</CardTitle>
|
||||
<CardDescription>
|
||||
{supportsPeriodClosure ? "Sessions prüfen und danach den Monat abschließen." : "Tagesansicht ohne eigenen Abschluss."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{supportsPeriodClosure ? (
|
||||
overview?.closed ? (
|
||||
<Button size="sm" variant="secondary" disabled={reopening} onClick={openPeriodAgain}>
|
||||
<LockOpen className="size-4" />
|
||||
{reopening ? "Öffnet..." : "Monat wieder öffnen"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant={overview?.canClose ? "default" : "secondary"} disabled={!overview?.canClose || closing} onClick={finishPeriod}>
|
||||
<CheckCircle2 className="size-4" />
|
||||
{closing ? "Schließt..." : overview?.canClose ? "Monat abschließen" : "Noch nicht abschließbar"}
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<div className="mb-4 rounded-md border bg-muted/20 p-3">
|
||||
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="size-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Ansicht</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{visibleTickets.length} von {tickets.length} Ticket(s)
|
||||
</p>
|
||||
</div>
|
||||
{activeFilterCount > 0 ? <Badge variant="outline">{activeFilterCount} aktiv</Badge> : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={activeFilterCount === 0}
|
||||
onClick={() => setTicketViewSettings(defaultTicketViewSettings)}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-[minmax(180px,1fr)_150px_190px_140px_140px_140px_120px]">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="ticket-filter-query">Suche</label>
|
||||
<Input
|
||||
id="ticket-filter-query"
|
||||
className="h-8"
|
||||
value={ticketViewSettings.query}
|
||||
placeholder="Ticket oder Organisation"
|
||||
onChange={(event) => setTicketViewSettings((current) => ({ ...current, query: event.currentTarget.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Typ</label>
|
||||
<Select value={ticketViewSettings.workType} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, workType: value as TicketWorkTypeFilter }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Alle Typen</SelectItem>
|
||||
<SelectItem value="support">Support</SelectItem>
|
||||
<SelectItem value="consulting">Consulting</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Organisation</label>
|
||||
<Select value={ticketViewSettings.organization} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, organization: value }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Alle Organisationen</SelectItem>
|
||||
{organizationOptions.map((organization) => (
|
||||
<SelectItem key={organization.value} value={organization.value}>
|
||||
{organization.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Status</label>
|
||||
<Select value={ticketViewSettings.status} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, status: value as TicketStatusFilter }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Alle Status</SelectItem>
|
||||
<SelectItem value="open">Offen</SelectItem>
|
||||
<SelectItem value="evaluated">Bewertet</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Gruppierung</label>
|
||||
<Select value={ticketViewSettings.groupBy} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, groupBy: value as TicketGroupBy }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupByOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Sortierung</label>
|
||||
<Select value={ticketViewSettings.sortBy} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, sortBy: value as TicketSortBy }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortByOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Richtung</label>
|
||||
<Select value={ticketViewSettings.sortDirection} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, sortDirection: value as TicketSortDirection }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="asc">Aufsteigend</SelectItem>
|
||||
<SelectItem value="desc">Absteigend</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Ticket</TableHead>
|
||||
<TableHead>Organisation</TableHead>
|
||||
<TableHead>Art</TableHead>
|
||||
<TableHead>Sessions</TableHead>
|
||||
<TableHead>Zeit</TableHead>
|
||||
<TableHead>Abgerechnet</TableHead>
|
||||
<TableHead>Nicht abrechenbar</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ticketSections.map((section) => (
|
||||
<Fragment key={section.key}>
|
||||
{ticketViewSettings.groupBy !== "none" ? (
|
||||
<TableRow className="bg-muted/40 hover:bg-muted/40">
|
||||
<TableCell colSpan={9}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold">{section.label}</span>
|
||||
<Badge variant="outline">{section.ticketCount} Ticket(s)</Badge>
|
||||
<Badge variant="outline">{formatMinutes(section.totalMinutes)}</Badge>
|
||||
{section.openCount > 0 ? <Badge variant="warning">{section.openCount} offen</Badge> : <Badge variant="success">bewertet</Badge>}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{section.tickets.map((ticket) => (
|
||||
<TableRow key={ticket.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-semibold">{ticket.ticket_number}</span>
|
||||
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{ticket.customer_name ?? "-"}</TableCell>
|
||||
<TableCell>{workTypeLabel(ticket.work_type)}</TableCell>
|
||||
<TableCell>{ticket.session_count}</TableCell>
|
||||
<TableCell>{formatMinutes(ticket.total_minutes)}</TableCell>
|
||||
<TableCell>{ticket.billed_count}</TableCell>
|
||||
<TableCell>{ticket.non_billable_count}</TableCell>
|
||||
<TableCell>
|
||||
<TicketStatus ticket={ticket} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${ticket.id}`)}>
|
||||
Öffnen
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:hidden">
|
||||
{ticketSections.map((section) => (
|
||||
<div key={section.key} className="space-y-2">
|
||||
{ticketViewSettings.groupBy !== "none" ? (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/40 px-3 py-2">
|
||||
<span className="font-semibold">{section.label}</span>
|
||||
<Badge variant="outline">{section.ticketCount} Ticket(s)</Badge>
|
||||
<Badge variant="outline">{formatMinutes(section.totalMinutes)}</Badge>
|
||||
{section.openCount > 0 ? <Badge variant="warning">{section.openCount} offen</Badge> : <Badge variant="success">bewertet</Badge>}
|
||||
</div>
|
||||
) : null}
|
||||
{section.tickets.map((ticket) => (
|
||||
<div key={ticket.id} className="space-y-2 rounded-md border bg-background p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="truncate font-semibold">{ticket.ticket_number}</p>
|
||||
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{ticket.customer_name ?? "Keine Organisation"} · {workTypeLabel(ticket.work_type)} · {ticket.session_count} Session(s)</p>
|
||||
</div>
|
||||
<TicketStatus ticket={ticket} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<p className="text-muted-foreground">Zeit</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="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">{ticket.non_billable_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full" size="sm" variant="secondary" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${ticket.id}`)}>
|
||||
Öffnen
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tickets.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Für diesen Zeitraum sind noch keine Sessions vorhanden.</p>
|
||||
) : visibleTickets.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Keine Tickets passend zu den aktuellen Filtern.</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { LogIn } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { login } from "../api";
|
||||
import type { AuthUser } from "../types";
|
||||
|
||||
type LoginPageProps = {
|
||||
onLogin: (user: AuthUser) => void;
|
||||
};
|
||||
|
||||
export function LoginPage({ onLogin }: LoginPageProps) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await login({ username, password });
|
||||
onLogin(result.user);
|
||||
} catch (error) {
|
||||
toast.error("Login fehlgeschlagen", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-background p-4">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>TicketTracker</CardTitle>
|
||||
<CardDescription>Melde dich an, um deine Sessions und Auswertungen zu sehen.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4" onSubmit={submit}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="login-username">
|
||||
Benutzername
|
||||
</label>
|
||||
<Input id="login-username" value={username} onChange={(event) => setUsername(event.currentTarget.value)} autoComplete="username" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="login-password">
|
||||
Passwort
|
||||
</label>
|
||||
<Input
|
||||
id="login-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.currentTarget.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button className="w-full" type="submit" disabled={loading}>
|
||||
<LogIn className="size-4" />
|
||||
{loading ? "Meldet an..." : "Anmelden"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { KeyRound, Save, UserCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { updateCurrentUser } from "../api";
|
||||
import type { AuthUser } from "../types";
|
||||
|
||||
type ProfilePageProps = {
|
||||
currentUser: AuthUser;
|
||||
onUserUpdated: (user: AuthUser) => void;
|
||||
};
|
||||
|
||||
export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
||||
const [username, setUsername] = useState(currentUser.username);
|
||||
const [displayName, setDisplayName] = useState(currentUser.display_name);
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setUsername(currentUser.username);
|
||||
setDisplayName(currentUser.display_name);
|
||||
}, [currentUser]);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
const wantsPasswordChange = newPassword.length > 0 || confirmPassword.length > 0 || currentPassword.length > 0;
|
||||
|
||||
if (wantsPasswordChange && newPassword !== confirmPassword) {
|
||||
toast.error("Passwörter stimmen nicht überein");
|
||||
return;
|
||||
}
|
||||
|
||||
if (wantsPasswordChange && newPassword.length < 6) {
|
||||
toast.error("Passwort ist zu kurz", {
|
||||
description: "Das neue Passwort muss mindestens 6 Zeichen lang sein."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const result = await updateCurrentUser({
|
||||
username,
|
||||
displayName,
|
||||
currentPassword: wantsPasswordChange ? currentPassword : undefined,
|
||||
newPassword: wantsPasswordChange ? newPassword : undefined
|
||||
});
|
||||
onUserUpdated(result.user);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
toast.success("Profil gespeichert");
|
||||
} catch (error) {
|
||||
toast.error("Profil konnte nicht gespeichert werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Profil</h2>
|
||||
<p className="text-sm text-muted-foreground">Benutzername, voller Name und Passwort deines Accounts.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCircle className="size-5 text-muted-foreground" />
|
||||
<CardTitle>Accountdaten</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Der Benutzername wird beim Login verwendet. Dein voller Name wird in der App angezeigt.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<form className="space-y-5" onSubmit={submit}>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="profile-username">
|
||||
Benutzername
|
||||
</label>
|
||||
<Input id="profile-username" value={username} onChange={(event) => setUsername(event.currentTarget.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="profile-display-name">
|
||||
Voller Name
|
||||
</label>
|
||||
<Input id="profile-display-name" value={displayName} onChange={(event) => setDisplayName(event.currentTarget.value)} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-muted/30 p-3">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<KeyRound className="size-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">Passwort ändern</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="profile-current-password">
|
||||
Aktuelles Passwort
|
||||
</label>
|
||||
<Input
|
||||
id="profile-current-password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.currentTarget.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="profile-new-password">
|
||||
Neues Passwort
|
||||
</label>
|
||||
<Input
|
||||
id="profile-new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.currentTarget.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="profile-confirm-password">
|
||||
Wiederholen
|
||||
</label>
|
||||
<Input
|
||||
id="profile-confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.currentTarget.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={saving}>
|
||||
<Save className="size-4" />
|
||||
{saving ? "Speichert..." : "Profil speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Pencil, Plus, Repeat, Trash2, X } 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 { Textarea } from "@/components/ui/textarea";
|
||||
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
||||
import { createRecurringBilling, deleteRecurringBilling, getRecurringBillings, updateRecurringBilling } from "../api";
|
||||
import { currentDay, formatMinutes } from "../format";
|
||||
import { ticketPattern } from "../timers";
|
||||
import type { RecurringBilling, WorkType } from "../types";
|
||||
|
||||
type RecurringSlotFormState = {
|
||||
id?: string;
|
||||
weekday: number | null;
|
||||
startTime: string;
|
||||
durationHours: string;
|
||||
};
|
||||
|
||||
type RecurringFormState = {
|
||||
ticketNumber: string;
|
||||
organizationId: string;
|
||||
organizationName: string | null;
|
||||
activity: string;
|
||||
workType: WorkType;
|
||||
recurrenceType: "weekly" | "every_n_weeks";
|
||||
intervalValue: number;
|
||||
validFrom: string;
|
||||
validUntil: string;
|
||||
slots: RecurringSlotFormState[];
|
||||
};
|
||||
|
||||
const weekdays = [
|
||||
{ value: 1, label: "Mo" },
|
||||
{ value: 2, label: "Di" },
|
||||
{ value: 3, label: "Mi" },
|
||||
{ value: 4, label: "Do" },
|
||||
{ value: 5, label: "Fr" },
|
||||
{ value: 6, label: "Sa" },
|
||||
{ value: 0, label: "So" }
|
||||
];
|
||||
|
||||
function emptyRecurringForm(): RecurringFormState {
|
||||
return {
|
||||
ticketNumber: "",
|
||||
organizationId: "",
|
||||
organizationName: null,
|
||||
activity: "",
|
||||
workType: "support",
|
||||
recurrenceType: "weekly",
|
||||
intervalValue: 1,
|
||||
validFrom: currentDay(),
|
||||
validUntil: "",
|
||||
slots: [{ weekday: 4, startTime: "09:00", durationHours: "4" }]
|
||||
};
|
||||
}
|
||||
|
||||
function formatDateLabel(value: string) {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric"
|
||||
}).format(new Date(`${value}T00:00:00`));
|
||||
}
|
||||
|
||||
export function RecurringBillingsPage() {
|
||||
const [recurringBillings, setRecurringBillings] = useState<RecurringBilling[]>([]);
|
||||
const [recurringForm, setRecurringForm] = useState<RecurringFormState>(() => emptyRecurringForm());
|
||||
const [loadingRecurring, setLoadingRecurring] = useState(false);
|
||||
const [creatingRecurring, setCreatingRecurring] = useState(false);
|
||||
const [updatingRecurringId, setUpdatingRecurringId] = useState<string | null>(null);
|
||||
const [editingBillingId, setEditingBillingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRecurringBillings();
|
||||
}, []);
|
||||
|
||||
async function loadRecurringBillings() {
|
||||
setLoadingRecurring(true);
|
||||
|
||||
try {
|
||||
const result = await getRecurringBillings();
|
||||
setRecurringBillings(result.recurringBillings);
|
||||
} catch (error) {
|
||||
toast.error("Fixe Abrechnungen konnten nicht geladen werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setLoadingRecurring(false);
|
||||
}
|
||||
}
|
||||
|
||||
function changeRecurringType(nextType: "weekly" | "every_n_weeks") {
|
||||
setRecurringForm((current) => ({
|
||||
...current,
|
||||
recurrenceType: nextType,
|
||||
intervalValue: nextType === "weekly" ? 1 : current.intervalValue || 1,
|
||||
slots:
|
||||
nextType === "weekly"
|
||||
? [{ weekday: 4, startTime: current.slots[0]?.startTime ?? "09:00", durationHours: current.slots[0]?.durationHours ?? "4" }]
|
||||
: [{ weekday: null, startTime: current.slots[0]?.startTime ?? "09:00", durationHours: current.slots[0]?.durationHours ?? "4" }]
|
||||
}));
|
||||
}
|
||||
|
||||
function durationHoursLabel(minutes: number) {
|
||||
const hours = minutes / 60;
|
||||
return Number.isInteger(hours) ? String(hours) : String(hours).replace(".", ",");
|
||||
}
|
||||
|
||||
function startEditing(billing: RecurringBilling) {
|
||||
setEditingBillingId(billing.id);
|
||||
setRecurringForm({
|
||||
ticketNumber: billing.configured_ticket_number ?? "",
|
||||
organizationId: billing.organization_id,
|
||||
organizationName: billing.organization_name,
|
||||
activity: billing.activity,
|
||||
workType: billing.work_type,
|
||||
recurrenceType: billing.recurrence_type,
|
||||
intervalValue: billing.recurrence_type === "weekly" ? 1 : billing.interval_value,
|
||||
validFrom: billing.valid_from,
|
||||
validUntil: billing.valid_until ?? "",
|
||||
slots: billing.slots.map((slot) => ({
|
||||
id: slot.id,
|
||||
weekday: billing.recurrence_type === "weekly" ? slot.weekday : null,
|
||||
startTime: slot.start_time?.slice(0, 5) ?? "09:00",
|
||||
durationHours: durationHoursLabel(slot.duration_minutes)
|
||||
}))
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
setEditingBillingId(null);
|
||||
setRecurringForm(emptyRecurringForm());
|
||||
}
|
||||
|
||||
function updateRecurringSlot(index: number, patch: Partial<RecurringSlotFormState>) {
|
||||
setRecurringForm((current) => ({
|
||||
...current,
|
||||
slots: current.slots.map((slot, slotIndex) => (slotIndex === index ? { ...slot, ...patch } : slot))
|
||||
}));
|
||||
}
|
||||
|
||||
async function submitRecurringBilling(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!recurringForm.organizationId) {
|
||||
toast.error("Organisation wählen");
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedTicketNumber = recurringForm.ticketNumber.trim();
|
||||
|
||||
if (trimmedTicketNumber && !ticketPattern.test(trimmedTicketNumber)) {
|
||||
toast.error("Ticketnummer prüfen", {
|
||||
description: "Leer lassen für Fix#ID oder Ticket#XXXXXX eintragen."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let slots: Array<{ id?: string; weekday: number | null; startTime: string; durationMinutes: number }>;
|
||||
|
||||
try {
|
||||
slots = recurringForm.slots.map((slot) => {
|
||||
const hours = Number(slot.durationHours.replace(",", "."));
|
||||
|
||||
if (!Number.isFinite(hours) || hours <= 0) {
|
||||
throw new Error("Dauer prüfen");
|
||||
}
|
||||
|
||||
return {
|
||||
id: slot.id,
|
||||
weekday: recurringForm.recurrenceType === "weekly" ? slot.weekday : null,
|
||||
startTime: slot.startTime,
|
||||
durationMinutes: Math.round(hours * 60)
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Dauer prüfen");
|
||||
return;
|
||||
}
|
||||
|
||||
setCreatingRecurring(true);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
ticketNumber: trimmedTicketNumber || null,
|
||||
organizationId: recurringForm.organizationId,
|
||||
activity: recurringForm.activity,
|
||||
workType: recurringForm.workType,
|
||||
recurrenceType: recurringForm.recurrenceType,
|
||||
intervalValue: recurringForm.recurrenceType === "weekly" ? 1 : recurringForm.intervalValue,
|
||||
validFrom: recurringForm.validFrom,
|
||||
validUntil: recurringForm.validUntil || null,
|
||||
slots
|
||||
};
|
||||
const result = editingBillingId
|
||||
? await updateRecurringBilling(editingBillingId, payload)
|
||||
: await createRecurringBilling(payload);
|
||||
setRecurringBillings(result.recurringBillings);
|
||||
setRecurringForm(emptyRecurringForm());
|
||||
setEditingBillingId(null);
|
||||
toast.success(editingBillingId ? "Fixe Abrechnung gespeichert" : "Fixe Abrechnung angelegt", {
|
||||
description: "Die Sessions erscheinen automatisch in der Auswertung, sobald du den Zeitraum öffnest. Nicht mehr gültige erzeugte Sessions werden entfernt."
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Fixe Abrechnung konnte nicht angelegt werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setCreatingRecurring(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRecurring(billing: RecurringBilling) {
|
||||
if (!window.confirm(`Fixe Abrechnung ${billing.ticket_number} wirklich löschen? Dadurch werden auch alle daraus erzeugten Sessions entfernt.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdatingRecurringId(billing.id);
|
||||
|
||||
try {
|
||||
const result = await deleteRecurringBilling(billing.id);
|
||||
setRecurringBillings(result.recurringBillings);
|
||||
toast.success("Fixe Abrechnung gelöscht", {
|
||||
description: "Die daraus erzeugten Sessions wurden entfernt."
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Fixe Abrechnung konnte nicht gelöscht werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setUpdatingRecurringId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Fixe Abrechnungen</h2>
|
||||
<p className="text-sm text-muted-foreground">Wiederkehrende Zeiten für deinen Account verwalten.</p>
|
||||
</div>
|
||||
<Badge variant="outline">{recurringBillings.length} Regel(n)</Badge>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="border-b bg-muted/30 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Repeat className="size-5 text-muted-foreground" />
|
||||
<CardTitle>{editingBillingId ? "Regel bearbeiten" : "Neue Regel"}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Ticket ist optional. Leer bedeutet automatische Gruppierung als Fix#ID.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<form className="space-y-4" onSubmit={submitRecurringBilling}>
|
||||
<div className="grid gap-3 lg:grid-cols-[170px_minmax(220px,1fr)_150px_180px]">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="recurring-ticket-number">
|
||||
Ticket
|
||||
</label>
|
||||
<Input
|
||||
id="recurring-ticket-number"
|
||||
placeholder="leer = Fix#ID"
|
||||
value={recurringForm.ticketNumber}
|
||||
onChange={(event) => setRecurringForm({ ...recurringForm, ticketNumber: event.currentTarget.value })}
|
||||
/>
|
||||
{recurringForm.ticketNumber.trim() && !ticketPattern.test(recurringForm.ticketNumber.trim()) ? (
|
||||
<p className="text-xs text-destructive">Leer lassen oder Ticket#XXXXXX</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Organisation</label>
|
||||
<OrganizationSelect
|
||||
value={recurringForm.organizationId}
|
||||
selectedName={recurringForm.organizationName}
|
||||
onChange={(organization) => setRecurringForm({ ...recurringForm, organizationId: organization.id, organizationName: organization.name })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="recurring-work-type">
|
||||
Art
|
||||
</label>
|
||||
<select
|
||||
id="recurring-work-type"
|
||||
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={recurringForm.workType}
|
||||
onChange={(event) => setRecurringForm({ ...recurringForm, 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="recurring-type">
|
||||
Muster
|
||||
</label>
|
||||
<select
|
||||
id="recurring-type"
|
||||
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={recurringForm.recurrenceType}
|
||||
onChange={(event) => changeRecurringType(event.currentTarget.value as "weekly" | "every_n_weeks")}
|
||||
>
|
||||
<option value="weekly">Wochentage</option>
|
||||
<option value="every_n_weeks">Alle X Wochen</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={recurringForm.recurrenceType === "every_n_weeks" ? "grid gap-3 sm:grid-cols-[180px_180px_180px]" : "grid gap-3 sm:grid-cols-[180px_180px]"}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="recurring-valid-from">
|
||||
Gültig von
|
||||
</label>
|
||||
<Input
|
||||
id="recurring-valid-from"
|
||||
type="date"
|
||||
value={recurringForm.validFrom}
|
||||
onChange={(event) => setRecurringForm({ ...recurringForm, validFrom: event.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="recurring-valid-until">
|
||||
Gültig bis
|
||||
</label>
|
||||
<Input
|
||||
id="recurring-valid-until"
|
||||
type="date"
|
||||
value={recurringForm.validUntil}
|
||||
onChange={(event) => setRecurringForm({ ...recurringForm, validUntil: event.currentTarget.value })}
|
||||
/>
|
||||
</div>
|
||||
{recurringForm.recurrenceType === "every_n_weeks" ? (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="recurring-interval">
|
||||
Alle X Wochen
|
||||
</label>
|
||||
<Input
|
||||
id="recurring-interval"
|
||||
type="number"
|
||||
min="1"
|
||||
value={recurringForm.intervalValue}
|
||||
onChange={(event) => setRecurringForm({ ...recurringForm, intervalValue: Number(event.currentTarget.value) || 1 })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="recurring-activity">
|
||||
Tätigkeit
|
||||
</label>
|
||||
<Textarea
|
||||
id="recurring-activity"
|
||||
value={recurringForm.activity}
|
||||
onChange={(event) => setRecurringForm({ ...recurringForm, activity: event.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{recurringForm.recurrenceType === "weekly" ? "Wochentage und Dauer" : "Startzeit und Dauer"}</label>
|
||||
<div className="space-y-2">
|
||||
{recurringForm.slots.map((slot, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={
|
||||
recurringForm.recurrenceType === "weekly"
|
||||
? "grid gap-2 rounded-md border p-2 sm:grid-cols-[90px_120px_120px_auto] sm:items-center"
|
||||
: "grid gap-2 rounded-md border p-2 sm:grid-cols-[120px_120px_auto] sm:items-center"
|
||||
}
|
||||
>
|
||||
{recurringForm.recurrenceType === "weekly" ? (
|
||||
<select
|
||||
className="h-9 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={slot.weekday ?? 1}
|
||||
onChange={(event) => updateRecurringSlot(index, { weekday: Number(event.currentTarget.value) })}
|
||||
>
|
||||
{weekdays.map((weekday) => (
|
||||
<option key={weekday.value} value={weekday.value}>
|
||||
{weekday.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<Input type="time" value={slot.startTime} onChange={(event) => updateRecurringSlot(index, { startTime: event.currentTarget.value })} required />
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={slot.durationHours}
|
||||
onChange={(event) => updateRecurringSlot(index, { durationHours: event.currentTarget.value })}
|
||||
placeholder="2,5"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-8 text-destructive hover:text-destructive"
|
||||
disabled={recurringForm.slots.length === 1}
|
||||
onClick={() => setRecurringForm({ ...recurringForm, slots: recurringForm.slots.filter((_, slotIndex) => slotIndex !== index) })}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{recurringForm.recurrenceType === "weekly" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setRecurringForm({
|
||||
...recurringForm,
|
||||
slots: [...recurringForm.slots, { weekday: 1, startTime: "09:00", durationHours: "2,5" }]
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Slot hinzufügen
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="flex gap-2">
|
||||
{editingBillingId ? (
|
||||
<Button type="button" variant="secondary" disabled={creatingRecurring} onClick={cancelEditing}>
|
||||
<X className="size-4" />
|
||||
Abbrechen
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="submit" disabled={creatingRecurring}>
|
||||
<Repeat className="size-4" />
|
||||
{creatingRecurring ? "Speichert..." : editingBillingId ? "Regel speichern" : "Fixe Abrechnung anlegen"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="border-b bg-muted/30 p-4">
|
||||
<CardTitle>Regeln</CardTitle>
|
||||
<CardDescription>Erzeugte Einträge können in der Auswertung verschoben oder gelöscht werden.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 p-4">
|
||||
{recurringBillings.map((billing) => (
|
||||
<div key={billing.id} className="grid gap-2 rounded-md border bg-background p-3 lg:grid-cols-[1fr_auto] lg:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold">{billing.ticket_number}</span>
|
||||
{billing.configured_ticket_number ? <Badge variant="outline">Ticket</Badge> : <Badge variant="outline">Fix</Badge>}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap break-words text-sm text-muted-foreground">{billing.organization_name} · {billing.activity}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{billing.recurrence_type === "weekly" ? "Wochentage" : `Alle ${billing.interval_value} Woche(n)`} · {formatDateLabel(billing.valid_from)}
|
||||
{billing.valid_until ? ` bis ${formatDateLabel(billing.valid_until)}` : ""}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{billing.slots.map((slot) => (
|
||||
<Badge key={slot.id} variant="outline">
|
||||
{slot.weekday === null ? "Starttag" : weekdays.find((weekday) => weekday.value === slot.weekday)?.label} · {slot.start_time?.slice(0, 5)} · {formatMinutes(slot.duration_minutes)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 lg:justify-end">
|
||||
<Button size="sm" variant="secondary" disabled={updatingRecurringId === billing.id} onClick={() => startEditing(billing)}>
|
||||
<Pencil className="size-4" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-8 text-destructive hover:text-destructive" disabled={updatingRecurringId === billing.id} onClick={() => void removeRecurring(billing)}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loadingRecurring ? <p className="py-2 text-center text-sm text-muted-foreground">Fixe Abrechnungen werden geladen...</p> : null}
|
||||
{!loadingRecurring && recurringBillings.length === 0 ? <p className="py-2 text-center text-sm text-muted-foreground">Noch keine fixen Abrechnungen vorhanden.</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,853 @@
|
||||
import { ArrowLeft, Pencil, Save, Trash2 } from "lucide-react";
|
||||
import { FormEvent, useEffect, useMemo, useState } from "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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
||||
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
||||
import {
|
||||
createSession,
|
||||
deleteSession,
|
||||
getTicketPeriod,
|
||||
updateSessionBilling,
|
||||
updateSessionDetails,
|
||||
updateTicket,
|
||||
updateTicketDayBilling
|
||||
} from "../api";
|
||||
import { currentDay, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format";
|
||||
import type { BillingStatus, PeriodType, SessionEntry, TicketPeriod, WorkType } from "../types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TicketDetailPageProps = {
|
||||
periodType: PeriodType;
|
||||
period: string;
|
||||
ticketId: string;
|
||||
onNavigate: (to: string) => void;
|
||||
};
|
||||
|
||||
type TicketSessionFormState = {
|
||||
organizationId: string;
|
||||
organizationName: string | null;
|
||||
activity: string;
|
||||
workType: WorkType;
|
||||
day: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
};
|
||||
|
||||
function emptyTicketSessionForm(): TicketSessionFormState {
|
||||
return {
|
||||
organizationId: "",
|
||||
organizationName: null,
|
||||
activity: "",
|
||||
workType: "support",
|
||||
day: currentDay(),
|
||||
startTime: "09:00",
|
||||
endTime: "09:30"
|
||||
};
|
||||
}
|
||||
|
||||
function BillingButtons({
|
||||
session,
|
||||
disabled,
|
||||
loading,
|
||||
onChange
|
||||
}: {
|
||||
session: SessionEntry;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
onChange: (status: BillingStatus) => void;
|
||||
}) {
|
||||
const status = session.billing_status;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1.5 sm:inline-grid">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={status === "billed" ? "default" : "secondary"}
|
||||
disabled={loading || (disabled && status !== "billed")}
|
||||
onClick={() => onChange(status === "billed" ? null : "billed")}
|
||||
className={cn("h-8", status === "billed" ? "" : "bg-secondary/70")}
|
||||
>
|
||||
Abgerechnet
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={status === "non_billable" ? "default" : "secondary"}
|
||||
disabled={loading || (disabled && status !== "non_billable")}
|
||||
onClick={() => onChange(status === "non_billable" ? null : "non_billable")}
|
||||
className={cn("h-8", status === "non_billable" ? "" : "bg-secondary/70")}
|
||||
>
|
||||
Nicht abrechenbar
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sessionDayKey(value: string) {
|
||||
const date = new Date(value);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function toDateLocalValue(value: string) {
|
||||
const date = new Date(value);
|
||||
const offsetMs = date.getTimezoneOffset() * 60_000;
|
||||
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function toTimeLocalValue(value: string) {
|
||||
const date = new Date(value);
|
||||
const offsetMs = date.getTimezoneOffset() * 60_000;
|
||||
return new Date(date.getTime() - offsetMs).toISOString().slice(11, 16);
|
||||
}
|
||||
|
||||
function formatHoursInput(minutes?: number | null) {
|
||||
if (minutes === null || minutes === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(minutes / 60);
|
||||
}
|
||||
|
||||
function parseHoursInput(value: string) {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/[,.]$/.test(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hours = Number(trimmed.replace(",", "."));
|
||||
|
||||
if (!Number.isFinite(hours) || hours < 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Math.round(hours * 60);
|
||||
}
|
||||
|
||||
function isPartialHoursInput(value: string) {
|
||||
return /^\d*(?:[,.]\d*)?$/.test(value.trim());
|
||||
}
|
||||
|
||||
export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: TicketDetailPageProps) {
|
||||
const [data, setData] = useState<TicketPeriod | null>(null);
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [savingTicket, setSavingTicket] = useState(false);
|
||||
const [editTicketNumber, setEditTicketNumber] = useState("");
|
||||
const [editOrganizationId, setEditOrganizationId] = useState("");
|
||||
const [editOrganizationName, setEditOrganizationName] = useState<string | null>(null);
|
||||
const [editWorkType, setEditWorkType] = useState<WorkType>("support");
|
||||
const [editingSession, setEditingSession] = useState<SessionEntry | null>(null);
|
||||
const [editSessionOrganizationId, setEditSessionOrganizationId] = useState("");
|
||||
const [editSessionOrganizationName, setEditSessionOrganizationName] = useState<string | null>(null);
|
||||
const [editSessionActivity, setEditSessionActivity] = useState("");
|
||||
const [editSessionWorkType, setEditSessionWorkType] = useState<WorkType>("support");
|
||||
const [editSessionDay, setEditSessionDay] = useState("");
|
||||
const [editSessionStartTime, setEditSessionStartTime] = useState("");
|
||||
const [editSessionEndTime, setEditSessionEndTime] = useState("");
|
||||
const [savingSessionDetails, setSavingSessionDetails] = useState(false);
|
||||
const [dayBillingInputs, setDayBillingInputs] = useState<Record<string, string>>({});
|
||||
const [savingDayBillingKey, setSavingDayBillingKey] = useState<string | null>(null);
|
||||
const [ticketSession, setTicketSession] = useState<TicketSessionFormState>(() => emptyTicketSessionForm());
|
||||
const [savingTicketSession, setSavingTicketSession] = useState(false);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setData(await getTicketPeriod(periodType, period, ticketId));
|
||||
} catch (error) {
|
||||
toast.error("Ticket konnte nicht geladen werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [periodType, period, ticketId]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextInputs = Object.fromEntries((data?.dayBillings ?? []).map((billing) => [billing.day, formatHoursInput(billing.billed_minutes)]));
|
||||
setDayBillingInputs(nextInputs);
|
||||
}, [data?.dayBillings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.ticket) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditTicketNumber(data.ticket.ticket_number);
|
||||
setEditOrganizationId(data.ticket.organization_id ?? "");
|
||||
setEditOrganizationName(data.ticket.organization_name ?? null);
|
||||
setEditWorkType(data.ticket.work_type ?? "support");
|
||||
setTicketSession((current) => ({
|
||||
...current,
|
||||
organizationId: data.ticket.organization_id ?? current.organizationId,
|
||||
organizationName: data.ticket.organization_name ?? current.organizationName,
|
||||
workType: data.ticket.work_type ?? current.workType
|
||||
}));
|
||||
}, [data?.ticket]);
|
||||
|
||||
const ticket = data?.ticket;
|
||||
const isPeriodClosed = Boolean(data?.closed);
|
||||
const periodLabel = periodType === "month" ? "Monat" : "Tag";
|
||||
const dayBillingMap = useMemo(() => new Map((data?.dayBillings ?? []).map((billing) => [billing.day, billing.billed_minutes])), [data?.dayBillings]);
|
||||
const savedDayBillingInput = (day: string) => formatHoursInput(dayBillingMap.get(day));
|
||||
const isDayBillingDirty = (day: string) => (dayBillingInputs[day] ?? "") !== savedDayBillingInput(day);
|
||||
const sessionGroups = useMemo(() => {
|
||||
const groups = new Map<string, { key: string; label: string; totalMinutes: number; sessions: SessionEntry[] }>();
|
||||
|
||||
for (const session of data?.sessions ?? []) {
|
||||
const key = sessionDayKey(session.started_at);
|
||||
const group = groups.get(key);
|
||||
|
||||
if (group) {
|
||||
group.sessions.push(session);
|
||||
group.totalMinutes += session.rounded_minutes;
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.set(key, {
|
||||
key,
|
||||
label: formatDate(session.started_at),
|
||||
totalMinutes: session.rounded_minutes,
|
||||
sessions: [session]
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(groups.values());
|
||||
}, [data?.sessions]);
|
||||
const periodTotalMinutes = sessionGroups.reduce((sum, group) => sum + group.totalMinutes, 0);
|
||||
|
||||
async function setBilling(sessionId: string, billingStatus: BillingStatus) {
|
||||
if (isPeriodClosed && billingStatus !== null) {
|
||||
toast.warning("Monat ist abgeschlossen", {
|
||||
description: "Öffne den Monat zuerst wieder, um Bewertungen zu ändern."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingId(sessionId);
|
||||
try {
|
||||
await updateSessionBilling(sessionId, billingStatus);
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error("Status konnte nicht gespeichert werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDayBilling(day: string) {
|
||||
if (!ticket) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedMinutes = parseHoursInput(dayBillingInputs[day] ?? "");
|
||||
|
||||
if (parsedMinutes === undefined) {
|
||||
toast.error("Teamspace-Zeit prüfen", {
|
||||
description: "Bitte Stunden als positive Zahl eintragen, z.B. 1,5."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMinutes = dayBillingMap.get(day) ?? null;
|
||||
|
||||
if (parsedMinutes === currentMinutes) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingDayBillingKey(day);
|
||||
try {
|
||||
await updateTicketDayBilling(ticket.id, day, parsedMinutes);
|
||||
toast.success("Zeit gespeichert");
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error("Zeit konnte nicht gespeichert werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setSavingDayBillingKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTicketData(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!ticket) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editOrganizationId) {
|
||||
toast.error("Organisation wählen");
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingTicket(true);
|
||||
try {
|
||||
await updateTicket(ticket.id, {
|
||||
ticketNumber: editTicketNumber,
|
||||
organizationId: editOrganizationId,
|
||||
workType: editWorkType
|
||||
});
|
||||
toast.success("Ticketdaten gespeichert", {
|
||||
description: "Organisation und Art wurden auf die vorhandenen Sessions übernommen."
|
||||
});
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error("Ticketdaten konnten nicht gespeichert werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setSavingTicket(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTicketSession(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!ticket) {
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveOrganizationId = ticket.organization_id ?? ticketSession.organizationId;
|
||||
const effectiveWorkType = ticket.work_type ?? ticketSession.workType;
|
||||
|
||||
if (!effectiveOrganizationId) {
|
||||
toast.error("Organisation wählen");
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = new Date(`${ticketSession.day}T${ticketSession.startTime}:00`);
|
||||
const endedAt = new Date(`${ticketSession.day}T${ticketSession.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;
|
||||
}
|
||||
|
||||
const durationSeconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
|
||||
|
||||
setSavingTicketSession(true);
|
||||
|
||||
try {
|
||||
await createSession({
|
||||
ticketNumber: ticket.ticket_number,
|
||||
organizationId: effectiveOrganizationId,
|
||||
activity: ticketSession.activity,
|
||||
workType: effectiveWorkType,
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: endedAt.toISOString(),
|
||||
durationSeconds
|
||||
});
|
||||
toast.success("Session nachgetragen", {
|
||||
description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet."
|
||||
});
|
||||
setTicketSession({
|
||||
...emptyTicketSessionForm(),
|
||||
organizationId: ticket.organization_id ?? "",
|
||||
organizationName: ticket.organization_name ?? null,
|
||||
workType: ticket.work_type ?? "support",
|
||||
day: ticketSession.day
|
||||
});
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error("Session konnte nicht nachgetragen werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setSavingTicketSession(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openSessionEditor(session: SessionEntry) {
|
||||
setEditingSession(session);
|
||||
setEditSessionOrganizationId(session.organization_id ?? "");
|
||||
setEditSessionOrganizationName(session.organization_name ?? null);
|
||||
setEditSessionActivity(session.activity);
|
||||
setEditSessionWorkType(session.work_type);
|
||||
setEditSessionDay(toDateLocalValue(session.started_at));
|
||||
setEditSessionStartTime(toTimeLocalValue(session.started_at));
|
||||
setEditSessionEndTime(toTimeLocalValue(session.ended_at));
|
||||
}
|
||||
|
||||
async function saveSessionData(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!editingSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editSessionOrganizationId) {
|
||||
toast.error("Organisation wählen");
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = new Date(`${editSessionDay}T${editSessionStartTime}:00`);
|
||||
const endedAt = new Date(`${editSessionDay}T${editSessionEndTime}:00`);
|
||||
|
||||
if (Number.isNaN(startedAt.getTime()) || Number.isNaN(endedAt.getTime())) {
|
||||
toast.error("Beginn und Ende prüfen");
|
||||
return;
|
||||
}
|
||||
|
||||
if (endedAt <= startedAt) {
|
||||
toast.error("Ende muss nach Beginn liegen");
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingSessionDetails(true);
|
||||
try {
|
||||
await updateSessionDetails(editingSession.id, {
|
||||
organizationId: editSessionOrganizationId,
|
||||
activity: editSessionActivity,
|
||||
workType: editSessionWorkType,
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: endedAt.toISOString()
|
||||
});
|
||||
toast.success("Session aktualisiert");
|
||||
setEditingSession(null);
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error("Session konnte nicht gespeichert werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setSavingSessionDetails(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSession(session: SessionEntry) {
|
||||
const confirmed = window.confirm(`Session vom ${formatDateTime(session.started_at)} wirklich löschen?`);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingId(session.id);
|
||||
try {
|
||||
const result = await deleteSession(session.id);
|
||||
|
||||
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."
|
||||
});
|
||||
onNavigate("/analysis");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Session gelöscht", {
|
||||
description: "Betroffene Abschlüsse wurden wieder geöffnet."
|
||||
});
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error("Session konnte nicht gelöscht werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" className="-ml-2 mb-1 h-8" onClick={() => onNavigate("/analysis")}>
|
||||
<ArrowLeft className="size-4" />
|
||||
Zur Auswertung
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">{ticket?.ticket_number ?? "Ticket"}</h2>
|
||||
{ticket?.ticket_number ? <CopyTicketButton ticketNumber={ticket.ticket_number} /> : null}
|
||||
{periodType === "month" && data?.closed ? <Badge variant="outline">Monat geschlossen</Badge> : null}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sessions im {periodLabel.toLowerCase()} {period}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Badge variant={data?.openCount === 0 ? "success" : "warning"}>
|
||||
{data?.openCount === 0 ? "Bewertet" : `${data?.openCount ?? 0} offen`}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle>Ticketdaten</CardTitle>
|
||||
<CardDescription>Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<form className="grid gap-3 lg:grid-cols-[180px_minmax(220px,1fr)_180px_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 />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="edit-ticket-organization">Organisation</label>
|
||||
{ticket?.customer_name && !ticket.organization_id ? (
|
||||
<p className="text-xs text-muted-foreground">Bisheriger Freitext: {ticket.customer_name}</p>
|
||||
) : null}
|
||||
<OrganizationSelect
|
||||
value={editOrganizationId}
|
||||
selectedName={editOrganizationName}
|
||||
onChange={(organization) => {
|
||||
setEditOrganizationId(organization.id);
|
||||
setEditOrganizationName(organization.name);
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="edit-ticket-work-type">Art</label>
|
||||
<select
|
||||
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}
|
||||
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}>
|
||||
<Save className="size-4" />
|
||||
{savingTicket ? "Speichert..." : "Speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<CardTitle>Session-Einträge</CardTitle>
|
||||
<CardDescription>Wähle für jede Session genau eine Bewertung aus oder lösche falsche Einträge.</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm">
|
||||
<span className="text-muted-foreground">{periodLabel} gesamt</span>
|
||||
<span className="font-semibold">{formatMinutes(periodTotalMinutes)}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<div className="space-y-3">
|
||||
{sessionGroups.map((group) => (
|
||||
<div key={group.key} className="rounded-md border bg-background">
|
||||
<div className="flex flex-col gap-2 border-b bg-muted/40 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="font-medium">{group.label}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">Tag gesamt {formatMinutes(group.totalMinutes)}</Badge>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
|
||||
<Input
|
||||
id={`crm-billing-${group.key}`}
|
||||
type="text"
|
||||
className="h-7 w-24 bg-background text-right"
|
||||
autoComplete="off"
|
||||
inputMode="decimal"
|
||||
value={dayBillingInputs[group.key] ?? ""}
|
||||
disabled={savingDayBillingKey === group.key}
|
||||
placeholder="0"
|
||||
onChange={(event) => {
|
||||
const nextValue = event.currentTarget.value;
|
||||
|
||||
if (!isPartialHoursInput(nextValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDayBillingInputs((current) => ({
|
||||
...current,
|
||||
[group.key]: nextValue
|
||||
}));
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>h</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className={cn("h-7 w-[104px]", !isDayBillingDirty(group.key) && "invisible")}
|
||||
disabled={!isDayBillingDirty(group.key) || savingDayBillingKey === group.key}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => void saveDayBilling(group.key)}
|
||||
>
|
||||
<Save className="size-3.5" />
|
||||
{savingDayBillingKey === group.key ? "Speichert..." : "Speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5 p-2 sm:p-3">
|
||||
{group.sessions.map((session) => (
|
||||
<div key={session.id} className="ml-3 rounded-md border bg-card/40 p-2 sm:ml-6">
|
||||
<div className="grid gap-2 lg:grid-cols-[140px_minmax(0,1fr)_90px_auto_auto] lg:items-center">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatTimeRange(session.started_at, session.ended_at)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="whitespace-pre-wrap break-words text-sm font-medium">{session.activity}</p>
|
||||
</div>
|
||||
<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}
|
||||
</div>
|
||||
<BillingButtons
|
||||
session={session}
|
||||
disabled={isPeriodClosed}
|
||||
loading={loadingId === session.id}
|
||||
onChange={(value) => setBilling(session.id, value)}
|
||||
/>
|
||||
<div className="flex gap-1 lg:justify-end">
|
||||
<Button size="icon" variant="ghost" className="size-8" onClick={() => openSessionEditor(session)}>
|
||||
<Pencil className="size-4" />
|
||||
<span className="sr-only">Session bearbeiten</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-8 text-destructive hover:text-destructive"
|
||||
disabled={deletingId === session.id}
|
||||
onClick={() => removeSession(session)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
<span className="sr-only">Session löschen</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data?.sessions.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Keine Sessions für dieses Ticket im gewählten Zeitraum.</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle>Session nachtragen</CardTitle>
|
||||
<CardDescription>Neue Zeit direkt diesem Ticket zuordnen.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<form className="space-y-4" onSubmit={submitTicketSession}>
|
||||
{ticket?.organization_id && ticket.work_type ? (
|
||||
<div className="grid gap-2 rounded-md border bg-muted/30 p-3 text-sm sm:grid-cols-[1fr_auto] sm:items-center">
|
||||
<span className="font-medium">{ticket.organization_name ?? ticket.customer_name}</span>
|
||||
<Badge variant="outline">{ticket.work_type === "support" ? "Support" : "Consulting"}</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_180px]">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Organisation</label>
|
||||
{ticket?.customer_name && !ticket.organization_id ? (
|
||||
<p className="text-xs text-muted-foreground">Bisheriger Freitext: {ticket.customer_name}</p>
|
||||
) : null}
|
||||
<OrganizationSelect
|
||||
value={ticketSession.organizationId}
|
||||
selectedName={ticketSession.organizationName}
|
||||
onChange={(organization) =>
|
||||
setTicketSession({
|
||||
...ticketSession,
|
||||
organizationId: organization.id,
|
||||
organizationName: organization.name
|
||||
})
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="ticket-session-work-type">
|
||||
Art
|
||||
</label>
|
||||
<select
|
||||
id="ticket-session-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={ticketSession.workType}
|
||||
onChange={(event) => setTicketSession({ ...ticketSession, workType: event.currentTarget.value as WorkType })}
|
||||
>
|
||||
<option value="support">Support</option>
|
||||
<option value="consulting">Consulting</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">
|
||||
Datum
|
||||
</label>
|
||||
<Input
|
||||
id="ticket-session-day"
|
||||
type="date"
|
||||
value={ticketSession.day}
|
||||
onChange={(event) => setTicketSession({ ...ticketSession, day: event.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="ticket-session-start">
|
||||
Von
|
||||
</label>
|
||||
<Input
|
||||
id="ticket-session-start"
|
||||
type="time"
|
||||
value={ticketSession.startTime}
|
||||
onChange={(event) => setTicketSession({ ...ticketSession, startTime: event.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="ticket-session-end">
|
||||
Bis
|
||||
</label>
|
||||
<Input
|
||||
id="ticket-session-end"
|
||||
type="time"
|
||||
value={ticketSession.endTime}
|
||||
onChange={(event) => setTicketSession({ ...ticketSession, endTime: event.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="ticket-session-activity">
|
||||
Tätigkeit
|
||||
</label>
|
||||
<Textarea
|
||||
id="ticket-session-activity"
|
||||
className="min-h-20"
|
||||
value={ticketSession.activity}
|
||||
onChange={(event) => setTicketSession({ ...ticketSession, activity: event.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={savingTicketSession}>
|
||||
<Save className="size-4" />
|
||||
{savingTicketSession ? "Speichert..." : "Nachtragen"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
{data && data.openCount > 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Noch {data.openCount} Session(s) ohne Auswahl. Erst wenn im Monat keine Sessions mehr offen sind, kann der Monat abgeschlossen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<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">
|
||||
<DialogTitle>Session bearbeiten</DialogTitle>
|
||||
<DialogDescription>Beginn, Ende, Tätigkeit und Stammdaten dieses Eintrags nachträglich korrigieren.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4 overflow-x-hidden" onSubmit={saveSessionData}>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="session-day">Datum</label>
|
||||
<Input
|
||||
id="session-day"
|
||||
type="date"
|
||||
value={editSessionDay}
|
||||
onChange={(event) => setEditSessionDay(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="session-start-time">Von</label>
|
||||
<Input
|
||||
id="session-start-time"
|
||||
type="time"
|
||||
value={editSessionStartTime}
|
||||
onChange={(event) => setEditSessionStartTime(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="session-end-time">Bis</label>
|
||||
<Input
|
||||
id="session-end-time"
|
||||
type="time"
|
||||
value={editSessionEndTime}
|
||||
onChange={(event) => setEditSessionEndTime(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="session-organization">Organisation</label>
|
||||
{editingSession?.customer_name && !editingSession.organization_id ? (
|
||||
<p className="text-xs text-muted-foreground">Bisheriger Freitext: {editingSession.customer_name}</p>
|
||||
) : null}
|
||||
<OrganizationSelect
|
||||
value={editSessionOrganizationId}
|
||||
selectedName={editSessionOrganizationName}
|
||||
onChange={(organization) => {
|
||||
setEditSessionOrganizationId(organization.id);
|
||||
setEditSessionOrganizationName(organization.name);
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="session-activity">Tätigkeit</label>
|
||||
<Textarea
|
||||
id="session-activity"
|
||||
className="min-h-24 resize-y"
|
||||
value={editSessionActivity}
|
||||
onChange={(event) => setEditSessionActivity(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="session-work-type">Art</label>
|
||||
<select
|
||||
id="session-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={editSessionWorkType}
|
||||
disabled={Boolean(editingSession?.recurring_billing_id)}
|
||||
onChange={(event) => setEditSessionWorkType(event.currentTarget.value as WorkType)}
|
||||
>
|
||||
<option value="support">Support</option>
|
||||
<option value="consulting">Consulting</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={savingSessionDetails}>
|
||||
<Save className="size-4" />
|
||||
{savingSessionDetails ? "Speichert..." : "Session speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Pause, Play, Plus, RotateCcw, Square, Upload } 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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
||||
import { createSession, lookupTicket } from "../api";
|
||||
import { formatTimer } from "../format";
|
||||
import { activeElapsedMs, pauseEntry, resumeEntry, ticketPattern, type TimerEntry } from "../timers";
|
||||
import type { TicketMeta, WorkType } from "../types";
|
||||
|
||||
type ManualSessionFormState = {
|
||||
ticketNumber: string;
|
||||
organizationId: string;
|
||||
organizationName: string | null;
|
||||
activity: string;
|
||||
workType: WorkType;
|
||||
day: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
};
|
||||
|
||||
function currentDay() {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function emptyManualSession(): ManualSessionFormState {
|
||||
return {
|
||||
ticketNumber: "",
|
||||
organizationId: "",
|
||||
organizationName: null,
|
||||
activity: "",
|
||||
workType: "support",
|
||||
day: currentDay(),
|
||||
startTime: "09:00",
|
||||
endTime: "09:30"
|
||||
};
|
||||
}
|
||||
|
||||
type FinishedSession = {
|
||||
timerId: string;
|
||||
ticketNumber: string;
|
||||
ticket: TicketMeta | null;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
durationSeconds: number;
|
||||
roundedMinutes: number;
|
||||
};
|
||||
|
||||
type TimerPageProps = {
|
||||
timers: TimerEntry[];
|
||||
setTimers: Dispatch<SetStateAction<TimerEntry[]>>;
|
||||
selectedTimerId: string | null;
|
||||
setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
|
||||
tick: number;
|
||||
};
|
||||
|
||||
export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimerId, tick }: TimerPageProps) {
|
||||
const [ticketNumber, setTicketNumber] = useState("");
|
||||
const [finished, setFinished] = useState<FinishedSession | null>(null);
|
||||
const [finishingId, setFinishingId] = useState<string | null>(null);
|
||||
const [organizationId, setOrganizationId] = useState("");
|
||||
const [organizationName, setOrganizationName] = useState<string | null>(null);
|
||||
const [activity, setActivity] = useState("");
|
||||
const [workType, setWorkType] = useState<WorkType>("support");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [manualSession, setManualSession] = useState<ManualSessionFormState>(() => emptyManualSession());
|
||||
const [savingManualSession, setSavingManualSession] = useState(false);
|
||||
|
||||
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 runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
|
||||
|
||||
async function addTimer() {
|
||||
if (!isValidTicket) {
|
||||
toast.error("Ticketnummer prüfen", {
|
||||
description: "Das Format muss Ticket#XXXXXX sein."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (timers.some((timer) => timer.ticketNumber === ticketNumber)) {
|
||||
toast.error("Timer existiert bereits", {
|
||||
description: "Für dieses Ticket läuft oder pausiert schon ein Timer."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const ticketResult = await lookupTicket(ticketNumber).catch(() => ({ ticket: null }));
|
||||
const newTimer: TimerEntry = {
|
||||
id: crypto.randomUUID(),
|
||||
ticketNumber,
|
||||
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
|
||||
workType: ticketResult.ticket?.work_type ?? null,
|
||||
ticketLookupDone: true,
|
||||
startedAt: now,
|
||||
pausedTotalMs: 0,
|
||||
pausedAt: null,
|
||||
phase: "running"
|
||||
};
|
||||
|
||||
setTimers((current) => [...current.map((timer) => pauseEntry(timer, now)), newTimer]);
|
||||
setSelectedTimerId(newTimer.id);
|
||||
setTicketNumber("");
|
||||
}
|
||||
|
||||
function activateTimer(timerId: string) {
|
||||
const now = Date.now();
|
||||
setTimers((current) =>
|
||||
current.map((timer) => {
|
||||
if (timer.id === timerId) {
|
||||
return resumeEntry(timer, now);
|
||||
}
|
||||
|
||||
return pauseEntry(timer, now);
|
||||
})
|
||||
);
|
||||
setSelectedTimerId(timerId);
|
||||
}
|
||||
|
||||
function pauseTimer(timerId: string) {
|
||||
const now = Date.now();
|
||||
setTimers((current) => current.map((timer) => (timer.id === timerId ? pauseEntry(timer, now) : timer)));
|
||||
setSelectedTimerId(timerId);
|
||||
}
|
||||
|
||||
function resetTimer(timerId: string) {
|
||||
setTimers((current) => current.filter((timer) => timer.id !== timerId));
|
||||
}
|
||||
|
||||
async function finishTimer(timerId: string) {
|
||||
const timer = timers.find((entry) => entry.id === timerId);
|
||||
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const stoppedTimer = pauseEntry(timer, now);
|
||||
const durationSeconds = Math.floor(activeElapsedMs(stoppedTimer, now) / 1000);
|
||||
const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60));
|
||||
|
||||
setTimers((current) => current.map((entry) => (entry.id === timerId ? pauseEntry(entry, now) : entry)));
|
||||
setSelectedTimerId(timer.id);
|
||||
setFinishingId(timer.id);
|
||||
|
||||
try {
|
||||
const result = await lookupTicket(timer.ticketNumber);
|
||||
const ticket = result.ticket;
|
||||
|
||||
setOrganizationId(ticket?.organization_id ?? "");
|
||||
setOrganizationName(ticket?.organization_name ?? null);
|
||||
setWorkType(ticket?.work_type ?? "support");
|
||||
setActivity("");
|
||||
setFinished({
|
||||
timerId: timer.id,
|
||||
ticketNumber: timer.ticketNumber,
|
||||
ticket,
|
||||
startedAt: new Date(stoppedTimer.startedAt).toISOString(),
|
||||
endedAt: new Date(now).toISOString(),
|
||||
durationSeconds,
|
||||
roundedMinutes
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Ticket konnte nicht geprüft werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setFinishingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSession(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (!effectiveOrganizationId) {
|
||||
toast.error("Organisation wählen", {
|
||||
description: "Neue Sessions können nur mit einer synchronisierten Organisation gespeichert werden."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
await createSession({
|
||||
ticketNumber: finished.ticketNumber,
|
||||
startedAt: finished.startedAt,
|
||||
endedAt: finished.endedAt,
|
||||
durationSeconds: finished.durationSeconds,
|
||||
organizationId: effectiveOrganizationId,
|
||||
activity,
|
||||
workType: effectiveWorkType
|
||||
});
|
||||
|
||||
toast.success("Session gespeichert", {
|
||||
description: `${finished.ticketNumber} wurde mit ${finished.roundedMinutes} Minute(n) erfasst.`
|
||||
});
|
||||
setTimers((current) => current.filter((timer) => timer.id !== finished.timerId));
|
||||
setFinished(null);
|
||||
setOrganizationId("");
|
||||
setOrganizationName(null);
|
||||
setActivity("");
|
||||
setWorkType("support");
|
||||
} catch (error) {
|
||||
toast.error("Speichern fehlgeschlagen", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitManualSession(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!ticketPattern.test(manualSession.ticketNumber)) {
|
||||
toast.error("Ticketnummer prüfen", {
|
||||
description: "Das Format muss Ticket#XXXXXX sein."
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
const durationSeconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
|
||||
|
||||
setSavingManualSession(true);
|
||||
|
||||
try {
|
||||
await createSession({
|
||||
ticketNumber: manualSession.ticketNumber,
|
||||
organizationId: manualSession.organizationId,
|
||||
activity: manualSession.activity,
|
||||
workType: manualSession.workType,
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: endedAt.toISOString(),
|
||||
durationSeconds
|
||||
});
|
||||
toast.success("Session nachgetragen", {
|
||||
description: "Der Eintrag ist in deiner Auswertung offen."
|
||||
});
|
||||
setManualSession({
|
||||
...emptyManualSession(),
|
||||
day: manualSession.day
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Session konnte nicht nachgetragen werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setSavingManualSession(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Timer</h2>
|
||||
<p className="text-sm text-muted-foreground">Mehrere Tickets vorbereiten, aber immer nur eine Session aktiv messen.</p>
|
||||
</div>
|
||||
{runningTimer ? <Badge variant="success">Aktiv: {runningTimer.ticketNumber}</Badge> : null}
|
||||
{!runningTimer && timers.length > 0 ? <Badge variant="warning">Alle pausiert</Badge> : null}
|
||||
{timers.length === 0 ? <Badge variant="info">Kein Timer aktiv</Badge> : null}
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="border-b bg-muted/30 p-4">
|
||||
<CardTitle>Neuen Timer starten</CardTitle>
|
||||
<CardDescription>Ein neuer Timer startet sofort und pausiert alle anderen Timer automatisch.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 p-4 sm:grid-cols-[minmax(220px,320px)_auto] sm:items-end">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="ticket-number">
|
||||
Ticketnummer
|
||||
</label>
|
||||
<Input
|
||||
id="ticket-number"
|
||||
placeholder="Ticket#123456"
|
||||
value={ticketNumber}
|
||||
onChange={(event) => setTicketNumber(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
void addTimer();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{ticketNumber.length > 0 && !isValidTicket ? <p className="text-xs text-destructive">Format: Ticket#XXXXXX</p> : null}
|
||||
</div>
|
||||
<Button onClick={() => void addTimer()}>
|
||||
<Plus className="size-4" />
|
||||
Timer starten
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="border-b bg-muted/30 p-4">
|
||||
<CardTitle>{selectedTimer ? selectedTimer.ticketNumber : "Keine Session ausgewählt"}</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedTimer
|
||||
? selectedTimer.organizationName
|
||||
? `${selectedTimer.organizationName}${selectedTimer.workType ? ` · ${selectedTimer.workType === "support" ? "Support" : "Consulting"}` : ""}`
|
||||
: "Beim Umschalten wird dieser Timer aktiviert und alle anderen werden pausiert."
|
||||
: "Starte einen Timer, um eine Session zu erfassen."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="rounded-lg border bg-background p-4 sm:p-5">
|
||||
<p className="mb-2 text-sm font-medium text-muted-foreground">Gemessene Arbeitszeit</p>
|
||||
<p className="font-mono text-4xl font-bold leading-none tracking-normal sm:text-6xl">
|
||||
{formatTimer(elapsedSeconds)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
||||
{selectedTimer?.phase === "running" ? (
|
||||
<Button variant="secondary" disabled={!selectedTimer} onClick={() => selectedTimer && pauseTimer(selectedTimer.id)}>
|
||||
<Pause className="size-4" />
|
||||
Pausieren
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled={!selectedTimer} onClick={() => selectedTimer && activateTimer(selectedTimer.id)}>
|
||||
<Play className="size-4" />
|
||||
Aktivieren
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="destructive" disabled={!selectedTimer || finishingId === selectedTimer?.id} onClick={() => selectedTimer && void finishTimer(selectedTimer.id)}>
|
||||
<Square className="size-4" />
|
||||
{finishingId === selectedTimer?.id ? "Prüft..." : "Beenden"}
|
||||
</Button>
|
||||
<Button variant="ghost" disabled={!selectedTimer} onClick={() => selectedTimer && resetTimer(selectedTimer.id)}>
|
||||
<RotateCcw className="size-4" />
|
||||
Entfernen
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="border-b bg-muted/30 p-4">
|
||||
<CardTitle>Session nachtragen</CardTitle>
|
||||
<CardDescription>Vergessene Zeiten für deinen eigenen Account manuell erfassen.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<form className="space-y-4" onSubmit={submitManualSession}>
|
||||
<div className="grid gap-3 lg:grid-cols-[160px_minmax(180px,1fr)_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">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-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.workType}
|
||||
onChange={(event) => setManualSession({ ...manualSession, workType: event.currentTarget.value as WorkType })}
|
||||
>
|
||||
<option value="support">Support</option>
|
||||
<option value="consulting">Consulting</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={savingManualSession}>
|
||||
<Upload className="size-4" />
|
||||
{savingManualSession ? "Speichert..." : "Nachtragen"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</section>
|
||||
|
||||
<Dialog open={Boolean(finished)} onOpenChange={(open) => !open && setFinished(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Session abschließen</DialogTitle>
|
||||
<DialogDescription>
|
||||
{finished?.ticketNumber}: {finished?.roundedMinutes} Minute(n), Pausen bereits abgezogen.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={submitSession}>
|
||||
{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>
|
||||
<p className="text-muted-foreground">{finished.ticket.work_type === "support" ? "Support" : "Consulting"} wurde vom bestehenden Ticket übernommen.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="organization">
|
||||
Organisation
|
||||
</label>
|
||||
{finished?.ticket?.customer_name && !finished.ticket.organization_id ? (
|
||||
<p className="text-xs text-muted-foreground">Bisheriger Freitext: {finished.ticket.customer_name}</p>
|
||||
) : null}
|
||||
<OrganizationSelect
|
||||
value={organizationId}
|
||||
selectedName={organizationName}
|
||||
onChange={(organization) => {
|
||||
setOrganizationId(organization.id);
|
||||
setOrganizationName(organization.name);
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="activity">
|
||||
Tätigkeit
|
||||
</label>
|
||||
<Textarea id="activity" value={activity} onChange={(event) => setActivity(event.currentTarget.value)} required />
|
||||
</div>
|
||||
{finished?.ticket?.organization_id && finished.ticket.work_type ? null : (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="work-type">
|
||||
Art
|
||||
</label>
|
||||
<select
|
||||
id="work-type"
|
||||
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={workType}
|
||||
onChange={(event) => setWorkType(event.currentTarget.value as WorkType)}
|
||||
>
|
||||
<option value="support">Support</option>
|
||||
<option value="consulting">Consulting</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={saving}>
|
||||
<Upload className="size-4" />
|
||||
{saving ? "Speichern..." : "Eintrag speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user