Initial TicketTracker release
This commit is contained in:
commit
125b1d22ff
128 files changed
+26208
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user