This commit is contained in:
michael committed 2026-09-23 11:09:10 +00:00
1 parent 9be6c6e36a
commit fa6db04370
16 files changed
+1123 -118

No files matched your search

+3 -2
View File
@@ -443,6 +443,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
{ label: "Abgerechnet", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 },
{ label: "Abrechenbar", value: formatMinutes(totals?.billableMinutes ?? 0), icon: UserCheck },
{ label: "Nicht abrechenbar", value: formatMinutes(totals?.nonBillableMinutes ?? 0), icon: UserCheck },
{ label: "Offen", value: totals?.openSessions ?? 0, detail: formatMinutes(totals?.openMinutes ?? 0), icon: UserCheck }
];
@@ -786,8 +787,8 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<p className="font-medium">{formatMinutes(ticket.total_minutes)}</p>
</div>
<div className="rounded-md bg-muted/50 p-2">
<p className="text-muted-foreground">Sessions</p>
<p className="font-medium">{ticket.billed_count}</p>
<p className="text-muted-foreground">Abrechenbar</p>
<p className="font-medium">{ticket.billable_count}</p>
</div>
</div>
<Button
+14 -9
View File
@@ -1536,7 +1536,7 @@ function WidgetContent(props: WidgetContentProps) {
return (
<MetricGrid
items={[
{ label: "Bewertet heute", value: formatMinutes(stats.dayTotals?.minutes ?? 0), icon: Timer },
{ label: "Abgerechnet heute", value: formatMinutes(stats.dayTotals?.minutes ?? 0), icon: Timer },
{ label: "Offene Bewertungen", value: stats.dayTotals?.openSessions ?? 0, icon: ClipboardCheck }
]}
/>
@@ -1547,7 +1547,7 @@ function WidgetContent(props: WidgetContentProps) {
return (
<MetricGrid
items={[
{ label: "Bewertet Monat", value: formatMinutes(stats.monthTotals?.minutes ?? 0), icon: CheckCircle2 },
{ label: "Abgerechnet Monat", value: formatMinutes(stats.monthTotals?.minutes ?? 0), icon: CheckCircle2 },
{ label: "Offene Bewertungen", value: stats.monthTotals?.openSessions ?? 0, icon: ClipboardCheck }
]}
/>
@@ -1560,7 +1560,7 @@ function WidgetContent(props: WidgetContentProps) {
<MetricGrid
items={[
{ label: "TS-Zeit Monat", value: formatMinutes(stats.monthTotals?.crmBilledMinutes ?? 0), icon: LayoutDashboard },
{ label: "Bewertet - TS", value: `${delta >= 0 ? "+" : "-"}${formatMinutes(Math.abs(delta))}`, icon: ArrowUp, tone: delta === 0 ? "normal" : delta > 0 ? "warning" : "good" }
{ label: "Abgerechnet - TS", value: `${delta >= 0 ? "+" : "-"}${formatMinutes(Math.abs(delta))}`, icon: ArrowUp, tone: delta === 0 ? "normal" : delta > 0 ? "warning" : "good" }
]}
/>
);
@@ -1697,7 +1697,7 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
const todayTickets = stableSort(stats.todayTickets, (left, right) => compareText(left.ticket_number, right.ticket_number));
const todayOpenRatings = stableSort(stats.todayOpenRatings, (left, right) => compareOptionalDate(left.started_at, right.started_at, "asc") || compareText(left.ticket_number, right.ticket_number));
const teamspaceReviewTickets = todayTickets.filter((ticket) => ticket.open_count === 0 && teamspaceDeltaMinutes(ticket) > 0);
const afterworkTickets = todayTickets.filter((ticket) => ticket.open_count > 0 || teamspaceDeltaMinutes(ticket) > 0 || (ticket.total_minutes === 0 && ticket.non_billable_count > 0));
const afterworkTickets = todayTickets.filter((ticket) => ticket.open_count > 0 || teamspaceDeltaMinutes(ticket) > 0 || (ticket.total_minutes === 0 && (ticket.billable_count > 0 || ticket.non_billable_count > 0)));
const budgetAttention = stableSort(
projects.filter((project) => {
const percent = projectConsumptionPercent(project);
@@ -1779,7 +1779,7 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
tone: "warning" as const,
title: session.ticket_number,
detail: `${formatMinutes(session.rounded_minutes)} offen · ${session.organization_name ?? "Keine Organisation"}`,
note: "Bewertung setzen: Abgerechnet oder Nicht abrechenbar.",
note: "Bewertung setzen: Abrechenbar oder Nicht abrechenbar.",
onClick: () => onNavigate(`/tickets/${session.ticket_id}?month=${currentMonth()}&status=open`)
})),
...teamspaceReviewTickets.slice(0, 4).map((ticket) => ({
@@ -1831,7 +1831,7 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
items={afterworkTickets.slice(0, 4).map((ticket) => ({
key: `today-ticket-${ticket.id}`,
title: ticket.title || ticket.ticket_number,
detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`,
detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.billable_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`,
note: dayControlTodaySessionReason(ticket),
onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`)
}))}
@@ -1896,6 +1896,10 @@ function dayControlTodaySessionReason(ticket: PeriodOverview["tickets"][number])
return `Teamspace ok: ${formatMinutes(crmMinutes)} erfasst.`;
}
if (ticket.billable_count > 0) {
return `Abrechenbar gesetzt: später abrechnen oder Status finalisieren.`;
}
if (ticket.non_billable_count > 0) {
return `Nicht abrechenbar gesetzt: Zammad-Dokumentation kontrollieren.`;
}
@@ -2504,7 +2508,7 @@ function StatisticWidget({ widget, editing, stats, onNavigate, onUpdateConfig }:
{settings}
<MetricGrid
items={[
{ label: "Bewertet", value: formatMinutes(statistics.totals.minutes), icon: CheckCircle2 },
{ label: "Abgerechnet", value: formatMinutes(statistics.totals.minutes), icon: CheckCircle2 },
{ label: "Offen", value: statistics.totals.openSessions, icon: ClipboardCheck },
{ label: "Ø Session", value: formatMinutes(statistics.totals.averageSessionMinutes), icon: Timer },
{ label: "Aktive Tage", value: statistics.totals.activeDays, icon: LayoutDashboard }
@@ -2520,7 +2524,8 @@ function StatisticWidget({ widget, editing, stats, onNavigate, onUpdateConfig }:
{settings}
<BarList
items={[
{ key: "billed", title: "Bewertet", value: statistics.totals.billedMinutes, detail: `${statistics.totals.billedSessions} Session(s)` },
{ key: "billed", title: "Abgerechnet", value: statistics.totals.billedMinutes, detail: `${statistics.totals.billedSessions} Session(s)` },
{ key: "billable", title: "Abrechenbar", value: statistics.totals.billableMinutes, detail: `${statistics.totals.billableSessions} Session(s)` },
{ key: "non", title: "Nicht abrechenbar", value: statistics.totals.nonBillableMinutes, detail: `${statistics.totals.nonBillableSessions} Session(s)` },
{ key: "open", title: "Offen", value: statistics.totals.openMinutes, detail: `${statistics.totals.openSessions} Session(s)` }
]}
@@ -2616,7 +2621,7 @@ function StatisticWidget({ widget, editing, stats, onNavigate, onUpdateConfig }:
key: day.day,
title: formatDate(`${day.day}T00:00:00`),
value: view === "teamspaceDaily" ? day.crm_billed_minutes : day.total_minutes,
detail: view === "teamspaceDaily" ? `Bewertet ${formatMinutes(day.total_minutes)}` : `${day.sessions} Session(s)`
detail: view === "teamspaceDaily" ? `Abgerechnet ${formatMinutes(day.total_minutes)}` : `${day.sessions} Session(s)`
}));
return (
+186 -4
View File
@@ -1,13 +1,14 @@
import { FormEvent, useEffect, useState } from "react";
import { KeyRound, MailPlus, Save, Unplug, UserCircle, X } from "lucide-react";
import { Clock3, ClipboardCheck, FileText, KeyRound, MailPlus, RefreshCw, Save, Send, Unplug, UserCircle, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { HelpLink } from "@/components/HelpLink";
import { updateCurrentUser } from "../api";
import type { AuthUser } from "../types";
import { getZammadSessionArticleQueue, retryZammadSessionArticleQueue, updateCurrentUser } from "../api";
import { formatDateTime, formatMinutes } from "../format";
import type { AuthUser, ZammadSessionArticleQueueItem } from "../types";
type ProfilePageProps = {
currentUser: AuthUser;
@@ -23,6 +24,13 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
const [zammadApiToken, setZammadApiToken] = useState("");
const [zammadTokenDirty, setZammadTokenDirty] = useState(false);
const [zammadEmailSessionWebhookEnabled, setZammadEmailSessionWebhookEnabled] = useState(currentUser.zammad_email_session_webhook_enabled);
const [sessionBillingPromptEnabled, setSessionBillingPromptEnabled] = useState(currentUser.session_billing_prompt_enabled);
const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled);
const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled);
const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes);
const [zammadQueue, setZammadQueue] = useState<ZammadSessionArticleQueueItem[]>([]);
const [loadingZammadQueue, setLoadingZammadQueue] = useState(false);
const [retryingZammadQueue, setRetryingZammadQueue] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
@@ -31,8 +39,61 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setZammadApiToken("");
setZammadTokenDirty(false);
setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled);
setSessionBillingPromptEnabled(currentUser.session_billing_prompt_enabled);
setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled);
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes);
}, [currentUser]);
async function loadZammadQueue() {
if (currentUser.role !== "user") {
setZammadQueue([]);
return;
}
setLoadingZammadQueue(true);
try {
const result = await getZammadSessionArticleQueue();
setZammadQueue(result.queue);
} catch (error) {
toast.error("Zammad-Parktabelle konnte nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setLoadingZammadQueue(false);
}
}
useEffect(() => {
void loadZammadQueue();
}, [currentUser.role]);
async function retryZammadQueue() {
setRetryingZammadQueue(true);
try {
const result = await retryZammadSessionArticleQueue();
setZammadQueue(result.queue);
if (result.failed > 0) {
toast.warning("Zammad-Notizen teilweise gesendet", {
description: `${result.sent} gesendet, ${result.failed} weiter geparkt.`
});
} else {
toast.success("Zammad-Notizen gesendet", {
description: `${result.sent} Eintrag/Einträge erfolgreich übertragen.`
});
}
} catch (error) {
toast.error("Zammad-Notizen konnten nicht gesendet werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setRetryingZammadQueue(false);
}
}
async function submit(event: FormEvent) {
event.preventDefault();
@@ -58,7 +119,15 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
currentPassword: wantsPasswordChange ? currentPassword : undefined,
newPassword: wantsPasswordChange ? newPassword : undefined,
...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {}),
...(currentUser.role === "user" ? { zammadEmailSessionWebhookEnabled } : {})
...(currentUser.role === "user"
? {
zammadEmailSessionWebhookEnabled,
sessionBillingPromptEnabled,
zammadSessionNoteDefaultEnabled,
zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes
}
: {})
};
try {
@@ -226,6 +295,119 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
onCheckedChange={setZammadEmailSessionWebhookEnabled}
/>
</div>
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
<div className="flex items-center gap-2">
<ClipboardCheck className="size-4 text-muted-foreground" />
<div>
<label className="text-sm font-medium" htmlFor="profile-session-billing-prompt">
Bewertungsabfrage beim Timerabschluss
</label>
<p className="text-xs text-muted-foreground">Bei Bewertung --- vor dem Speichern noch einmal nachfragen.</p>
</div>
</div>
<Switch
id="profile-session-billing-prompt"
checked={sessionBillingPromptEnabled}
onCheckedChange={setSessionBillingPromptEnabled}
/>
</div>
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
<div className="flex items-center gap-2">
<FileText className="size-4 text-muted-foreground" />
<div>
<label className="text-sm font-medium" htmlFor="profile-zammad-session-note">
Interne Zammad-Notiz beim Timerabschluss
</label>
<p className="text-xs text-muted-foreground">Checkbox im Speichern-Dialog standardmäßig aktivieren.</p>
</div>
</div>
<Switch
id="profile-zammad-session-note"
checked={zammadSessionNoteDefaultEnabled}
onCheckedChange={(checked) => {
setZammadSessionNoteDefaultEnabled(checked);
if (!checked) {
setZammadTimeUnitRoundingEnabled(false);
}
}}
/>
</div>
{zammadSessionNoteDefaultEnabled ? (
<div className="mt-3 rounded-md border bg-background p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Clock3 className="size-4 text-muted-foreground" />
<div>
<label className="text-sm font-medium" htmlFor="profile-zammad-time-rounding">
Zammad-Zeit aufrunden
</label>
<p className="text-xs text-muted-foreground">Ohne Rundung wird die konkrete Zeit als Stundenwert mit 2 Nachkommastellen gesendet.</p>
</div>
</div>
<Switch
id="profile-zammad-time-rounding"
checked={zammadTimeUnitRoundingEnabled}
onCheckedChange={setZammadTimeUnitRoundingEnabled}
/>
</div>
{zammadTimeUnitRoundingEnabled ? (
<div className="mt-3 grid gap-2 sm:w-56">
<label className="text-xs font-medium text-muted-foreground" htmlFor="profile-zammad-rounding-minutes">
Rundungsintervall
</label>
<select
id="profile-zammad-rounding-minutes"
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={zammadTimeUnitRoundingMinutes}
onChange={(event) => setZammadTimeUnitRoundingMinutes(Number(event.currentTarget.value))}
>
<option value={5}>5 Minuten</option>
<option value={10}>10 Minuten</option>
<option value={15}>15 Minuten</option>
<option value={30}>30 Minuten</option>
</select>
</div>
) : null}
</div>
) : null}
<div className="mt-3 rounded-md border bg-background p-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-2">
<Send className="mt-0.5 size-4 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Geparkte Zammad-Notizen</p>
<p className="text-xs text-muted-foreground">Nicht übertragene interne Notizen nachträglich senden.</p>
</div>
</div>
<div className="flex gap-2">
<Button type="button" size="sm" variant="secondary" disabled={loadingZammadQueue} onClick={() => void loadZammadQueue()}>
<RefreshCw className="size-3.5" />
Prüfen
</Button>
<Button type="button" size="sm" disabled={zammadQueue.length === 0 || retryingZammadQueue} onClick={() => void retryZammadQueue()}>
<Send className="size-3.5" />
{retryingZammadQueue ? "Sendet..." : "Senden"}
</Button>
</div>
</div>
<div className="mt-3 space-y-2">
{loadingZammadQueue ? <p className="text-xs text-muted-foreground">Lädt...</p> : null}
{!loadingZammadQueue && zammadQueue.length === 0 ? <p className="text-xs text-muted-foreground">Keine geparkten Zammad-Notizen.</p> : null}
{zammadQueue.slice(0, 5).map((item) => (
<div key={item.id} className="rounded-md border bg-muted/30 p-2 text-xs">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-medium">{item.ticket_number}</span>
<span className="text-muted-foreground">{formatDateTime(item.started_at)}</span>
<span className="text-muted-foreground">{formatMinutes(item.rounded_minutes)}</span>
<span className="text-muted-foreground">time_unit {item.time_unit}</span>
</div>
{item.last_error ? <p className="mt-1 line-clamp-2 text-destructive">{item.last_error}</p> : null}
</div>
))}
{zammadQueue.length > 5 ? <p className="text-xs text-muted-foreground">Weitere {zammadQueue.length - 5} Einträge werden beim Senden ebenfalls berücksichtigt.</p> : null}
</div>
</div>
</div>
) : null}
+34 -7
View File
@@ -7,6 +7,7 @@ import {
RotateCcw,
Ticket,
TrendingUp,
UserCheck,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -36,6 +37,7 @@ const teamspaceStrokeClass = "text-fuchsia-600 dark:text-fuchsia-300";
const trackedDotClass = "bg-sky-500 dark:bg-sky-400";
const teamspaceDotClass = "bg-fuchsia-500 dark:bg-fuchsia-400";
const openTextClass = "text-amber-700 dark:text-amber-300";
const nonBillableTextClass = "text-indigo-700 dark:text-indigo-300";
const organizationPageSize = 8;
function workTypeLabel(workType: WorkType | undefined) {
@@ -115,6 +117,14 @@ function ColorLegend() {
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
<span className={teamspaceTextClass}>Teamspace</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-sm bg-slate-500 dark:bg-slate-400" />
<span>Abrechenbar</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-sm bg-indigo-500 dark:bg-indigo-400" />
<span className={nonBillableTextClass}>Nicht abrechenbar</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-emerald-500 dark:bg-emerald-400" />
<span className="text-emerald-700 dark:text-emerald-300">Differenz plus oder ausgeglichen</span>
@@ -307,7 +317,7 @@ function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organizat
<TableCell className="max-w-[320px] whitespace-normal">
<div className="font-medium">{organization.organization_name ?? "Keine Organisation"}</div>
<div className="text-xs text-muted-foreground">
{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet
{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet · {organization.billable_sessions} abrechenbar
</div>
</TableCell>
<TableCell>{organization.billed_sessions}</TableCell>
@@ -338,6 +348,7 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
<span className={trackedTextClass}>Abgerechnet {formatMinutes(ticket.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
<span className="text-muted-foreground">{ticket.billed_sessions} abgerechnet</span>
<span className="text-muted-foreground">{ticket.billable_sessions} abrechenbar</span>
<span>{ticket.active_days} Tag(e)</span>
<span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span>
</div>
@@ -413,8 +424,13 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
const totals = stats?.totals;
const billedSessions = totals?.billedSessions ?? 0;
const billedMinutes = totals?.billedMinutes ?? 0;
const billableSessions = totals?.billableSessions ?? 0;
const billableMinutes = totals?.billableMinutes ?? 0;
const nonBillableSessions = totals?.nonBillableSessions ?? 0;
const nonBillableMinutes = totals?.nonBillableMinutes ?? 0;
const openSessions = totals?.openSessions ?? 0;
const openMinutes = totals?.openMinutes ?? 0;
const evaluatedSessions = billedSessions + billableSessions + nonBillableSessions;
const crmCoverage = percentage(totals?.crmBilledMinutes ?? 0, totals?.minutes ?? 0);
const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1);
const topOrganization = stats?.organizations[0] ?? null;
@@ -460,7 +476,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard
label="Abgerechnet"
value={formatMinutes(totals?.minutes ?? 0)}
@@ -470,6 +486,14 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
helpAnchor="statistik-getrackt"
helpLabel="Hilfe zur Kennzahl Abgerechnet"
/>
<MetricCard
label="Abrechenbar"
value={formatMinutes(billableMinutes)}
detail={`${billableSessions} Session(s)`}
icon={UserCheck}
helpAnchor="bewertungen"
helpLabel="Hilfe zu Bewertungen"
/>
<MetricCard
label="Teamspace"
value={formatMinutes(totals?.crmBilledMinutes ?? 0)}
@@ -510,17 +534,19 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="space-y-3">
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} />
<ColorLegend />
{strongestDay ? <p className="text-xs text-muted-foreground">Stärkster abrechenbarer Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</p> : null}
{strongestDay ? <p className="text-xs text-muted-foreground">Stärkster abgerechneter Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</p> : null}
</div>
<div className="space-y-4">
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Bewertungsstand</span>
<span className="font-medium">{billedSessions} abrechenbar</span>
<span className="font-medium">{evaluatedSessions} bewertet</span>
</div>
<div className="grid gap-2 text-xs sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<div className={trackedTextClass}>Abgerechnet: {billedSessions} Session(s), {formatMinutes(billedMinutes)}</div>
<div className="text-muted-foreground">Abrechenbar: {billableSessions} Session(s), {formatMinutes(billableMinutes)}</div>
<div className={nonBillableTextClass}>Nicht abrechenbar: {nonBillableSessions} Session(s), {formatMinutes(nonBillableMinutes)}</div>
<div className={openTextClass}>Offen: {openSessions} Session(s), {formatMinutes(openMinutes)}</div>
</div>
</div>
@@ -615,7 +641,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-medium">{organization.organization_name ?? "Keine Organisation"}</p>
<p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet</p>
<p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet · {organization.billable_sessions} abrechenbar</p>
</div>
<DeltaBadge minutes={organization.crm_delta_minutes} />
</div>
@@ -684,7 +710,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-semibold">{workTypeLabel(group.work_type)}</p>
<p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.billed_sessions} abgerechnet</p>
<p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.billed_sessions} abgerechnet, {group.billable_sessions} abrechenbar</p>
</div>
<DeltaBadge minutes={group.crm_delta_minutes} />
</div>
@@ -693,8 +719,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<span className={trackedTextClass}>Abgerechnet {formatMinutes(group.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(group.crm_billed_minutes)}</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
<span>Abr. {group.billed_sessions} / {formatMinutes(group.billed_minutes)}</span>
<span>Abrechb. {group.billable_sessions} / {formatMinutes(group.billable_minutes)}</span>
<span>Offen {group.open_sessions} / {formatMinutes(group.open_minutes)}</span>
</div>
</div>
+62 -9
View File
@@ -59,6 +59,7 @@ type TicketSessionFormState = {
organizationName: string | null;
projectTeamspaceEntryId: string;
activity: string;
billingStatus: BillingStatus;
workType: WorkType;
day: string;
startTime: string;
@@ -90,6 +91,7 @@ function emptyTicketSessionForm(): TicketSessionFormState {
organizationName: null,
projectTeamspaceEntryId: "",
activity: "",
billingStatus: null,
workType: "support",
day: currentDay(),
startTime: "09:00",
@@ -174,7 +176,17 @@ function BillingButtons({
const status = session.billing_status;
return (
<div className="grid grid-cols-2 gap-1.5 sm:inline-grid">
<div className="grid grid-cols-3 gap-1.5 sm:inline-grid">
<Button
type="button"
size="sm"
variant={status === "billable" ? "default" : "secondary"}
disabled={loading || (disabled && status !== "billable")}
onClick={() => onChange(status === "billable" ? null : "billable")}
className={cn("h-8", status === "billable" ? "" : "bg-secondary/70")}
>
Abrechenbar
</Button>
<Button
type="button"
size="sm"
@@ -199,6 +211,29 @@ function BillingButtons({
);
}
function BillingCaptureSelect({
id,
value,
onChange
}: {
id: string;
value: BillingStatus;
onChange: (status: BillingStatus) => void;
}) {
return (
<select
id={id}
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={value ?? ""}
onChange={(event) => onChange(event.currentTarget.value === "" ? null : event.currentTarget.value as Exclude<BillingStatus, null>)}
>
<option value="">---</option>
<option value="billable">Abrechenbar</option>
<option value="non_billable">Nicht abrechenbar</option>
</select>
);
}
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")}`;
@@ -474,7 +509,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const [ticketSession, setTicketSession] = useState<TicketSessionFormState>(() => emptyTicketSessionForm());
const [savingTicketSession, setSavingTicketSession] = useState(false);
const [globalMonthFilter, setGlobalMonthFilter] = useState(initialGlobalMonthFilter);
const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billed" | "non_billable">("all");
const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billable" | "billed" | "non_billable">("all");
const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all");
const [editingProject, setEditingProject] = useState(false);
const [savingProject, setSavingProject] = useState(false);
@@ -595,19 +630,21 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const sessionGroups = useMemo(() => {
const groups = new Map<
string,
{ key: string; label: string; totalMinutes: number; nonBillableMinutes: number; openMinutes: number; sessions: SessionEntry[] }
{ key: string; label: string; totalMinutes: number; billableMinutes: number; nonBillableMinutes: number; openMinutes: number; sessions: SessionEntry[] }
>();
for (const session of data?.sessions ?? []) {
const key = sessionDayKey(session.started_at);
const group = groups.get(key);
const billedMinutes = session.billing_status === "billed" ? session.rounded_minutes : 0;
const billableMinutes = session.billing_status === "billable" ? session.rounded_minutes : 0;
const nonBillableMinutes = session.billing_status === "non_billable" ? session.rounded_minutes : 0;
const openMinutes = session.billing_status === null ? session.rounded_minutes : 0;
if (group) {
group.sessions.push(session);
group.totalMinutes += billedMinutes;
group.billableMinutes += billableMinutes;
group.nonBillableMinutes += nonBillableMinutes;
group.openMinutes += openMinutes;
continue;
@@ -617,6 +654,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
key,
label: formatDate(session.started_at),
totalMinutes: billedMinutes,
billableMinutes,
nonBillableMinutes,
openMinutes,
sessions: [session]
@@ -984,7 +1022,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
durationSeconds,
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null,
billingStatus: ticketSession.billingStatus
});
toast.success("Session nachgetragen", {
description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet."
@@ -995,7 +1034,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
organizationName: ticket.organization_name ?? null,
projectTeamspaceEntryId: "",
workType: ticket.work_type ?? "support",
day: ticketSession.day
day: ticketSession.day,
billingStatus: null
});
await load();
} catch (error) {
@@ -1277,7 +1317,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
>
<option value="all">Alle Bewertungen</option>
<option value="open">Offen</option>
<option value="billed">Abgerechnet</option>
<option value="billable">Abrechenbar</option>
<option value="non_billable">Nicht abrechenbar</option>
</select>
</div>
@@ -1657,7 +1697,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<p className="truncate font-medium">{group.label}</p>
<p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
</div>
<div className="grid w-full gap-2 sm:grid-cols-[13rem_10rem_8rem] xl:w-auto xl:grid-cols-[13rem_10rem_8rem_auto] xl:items-center xl:justify-end">
<div className="grid w-full gap-2 sm:grid-cols-[13rem_10rem_10rem_8rem] xl:w-auto xl:grid-cols-[13rem_10rem_10rem_8rem_auto] xl:items-center xl:justify-end">
<Button type="button" size="sm" variant="secondary" className="h-7 w-full justify-center" onClick={() => openTeamspacePreparation(group)}>
<ClipboardList className="size-3.5" />
TS Aufbereitung
@@ -1665,10 +1705,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<Badge variant="outline" className="flex h-7 w-full justify-center whitespace-nowrap">
Abgerechnet {formatMinutes(group.totalMinutes)}
</Badge>
<Badge variant="outline" className="flex h-7 w-full justify-center whitespace-nowrap">
Abrechenbar {formatMinutes(group.billableMinutes)}
</Badge>
<Badge variant={group.openMinutes > 0 ? "warning" : "success"} className="flex h-7 w-full justify-center whitespace-nowrap">
Offen {formatMinutes(group.openMinutes)}
</Badge>
<div className="flex items-center gap-2 text-xs text-muted-foreground sm:col-span-3 xl:col-span-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground sm:col-span-4 xl:col-span-1">
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
<Input
id={`crm-billing-${group.key}`}
@@ -1872,7 +1915,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</div>
) : null}
<div className="grid gap-3 lg:grid-cols-[150px_120px_120px_minmax(220px,1fr)_auto] lg:items-end">
<div className="grid gap-3 lg:grid-cols-[150px_120px_120px_160px_minmax(220px,1fr)_auto] lg:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-day">
Datum
@@ -1909,6 +1952,16 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-billing-status">
Bewertung
</label>
<BillingCaptureSelect
id="ticket-session-billing-status"
value={ticketSession.billingStatus}
onChange={(billingStatus) => setTicketSession({ ...ticketSession, billingStatus })}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-activity">
Tätigkeit
+164 -16
View File
@@ -5,6 +5,7 @@ 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 { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@@ -14,7 +15,7 @@ import { createSession, getRecurringBillings, lookupTicket } from "../api";
import { formatTimer } from "../format";
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
import { activeElapsedMs, createClientId, pauseEntry, resumeEntry, ticketPattern, type TimerEntry } from "../timers";
import type { RecurringBilling, TicketMeta, WorkType } from "../types";
import type { AuthUser, BillingStatus, RecurringBilling, TicketMeta, WorkType } from "../types";
type ManualSessionFormState = {
ticketNumber: string;
@@ -22,6 +23,7 @@ type ManualSessionFormState = {
organizationName: string | null;
projectTeamspaceEntryId: string;
activity: string;
billingStatus: BillingStatus;
workType: WorkType;
day: string;
startTime: string;
@@ -40,6 +42,7 @@ function emptyManualSession(): ManualSessionFormState {
organizationName: null,
projectTeamspaceEntryId: "",
activity: "",
billingStatus: null,
workType: "support",
day: currentDay(),
startTime: "09:00",
@@ -51,6 +54,10 @@ type FinishedSession = {
timerId: string;
ticketNumber: string;
ticket: TicketMeta | null;
activity: string;
billingStatus: BillingStatus;
promptBillingStatus: boolean;
createZammadArticle: boolean;
startedAt: string;
endedAt: string;
durationSeconds: number;
@@ -77,16 +84,42 @@ type TimerPageProps = {
selectedTimerId: string | null;
setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
tick: number;
currentUser: AuthUser;
};
export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimerId, tick }: TimerPageProps) {
function BillingStatusSelect({
id,
value,
onChange,
disabled = false
}: {
id: string;
value: BillingStatus;
onChange: (status: BillingStatus) => void;
disabled?: boolean;
}) {
return (
<select
id={id}
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={value ?? ""}
disabled={disabled}
onChange={(event) => onChange(event.currentTarget.value === "" ? null : event.currentTarget.value as Exclude<BillingStatus, null>)}
>
<option value="">---</option>
<option value="billable">Abrechenbar</option>
<option value="non_billable">Nicht abrechenbar</option>
</select>
);
}
export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimerId, tick, currentUser }: 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 [projectTeamspaceEntryId, setProjectTeamspaceEntryId] = useState("");
const [activity, setActivity] = useState("");
const [workType, setWorkType] = useState<WorkType>("support");
const [saving, setSaving] = useState(false);
const [manualSession, setManualSession] = useState<ManualSessionFormState>(() => emptyManualSession());
@@ -250,6 +283,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
startedAt: now,
pausedTotalMs: 0,
pausedAt: null,
activity: "",
billingStatus: null,
phase: "running"
};
@@ -356,6 +391,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setTimers((current) => current.filter((timer) => timer.id !== timerId));
}
function updateTimerActivity(timerId: string, nextActivity: string) {
setTimers((current) => current.map((timer) => (timer.id === timerId ? { ...timer, activity: nextActivity } : timer)));
}
function updateTimerBillingStatus(timerId: string, billingStatus: BillingStatus) {
setTimers((current) => current.map((timer) => (timer.id === timerId ? { ...timer, billingStatus } : timer)));
}
async function finishTimer(timerId: string) {
const timer = timers.find((entry) => entry.id === timerId);
@@ -363,6 +406,16 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return;
}
const timerActivity = timer.activity;
if (!timerActivity.trim()) {
setSelectedTimerId(timer.id);
toast.error("Tätigkeit erfassen", {
description: "Bitte dokumentiere die Tätigkeit vor dem Beenden der Session."
});
return;
}
const now = Date.now();
const stoppedTimer = pauseEntry(timer, now);
const durationSeconds = Math.floor(activeElapsedMs(stoppedTimer, now) / 1000);
@@ -388,11 +441,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setOrganizationName(ticket?.organization_name ?? null);
setProjectTeamspaceEntryId("");
setWorkType(ticket?.work_type ?? "support");
setActivity("");
setFinished({
timerId: timer.id,
ticketNumber: timer.ticketNumber,
ticket,
activity: timerActivity,
billingStatus: timer.billingStatus,
promptBillingStatus: currentUser.session_billing_prompt_enabled && timer.billingStatus === null,
createZammadArticle: currentUser.zammad_session_note_default_enabled,
startedAt: new Date(stoppedTimer.startedAt).toISOString(),
endedAt: new Date(now).toISOString(),
durationSeconds,
@@ -414,6 +470,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return;
}
if (!finished.activity.trim()) {
toast.error("Tätigkeit erfassen", {
description: "Bitte dokumentiere die Tätigkeit vor dem Speichern der Session."
});
return;
}
if (ticketHasRecurringBilling(finished.ticket, recurringBillings) || isRecurringBillingTicketNumber(finished.ticketNumber, recurringBillings)) {
toast.error("Fixe Abrechnung", {
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gespeichert werden. Bitte die Definition bearbeiten."
@@ -450,10 +513,12 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
endedAt: finished.endedAt,
durationSeconds: finished.durationSeconds,
organizationId: effectiveOrganizationId,
activity,
activity: finished.activity,
workType: effectiveWorkType,
zammadLookup: Boolean(finished.ticket?.zammad_ticket_id),
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null,
billingStatus: finished.billingStatus,
createZammadArticle: finished.createZammadArticle
});
toast.success("Session gespeichert", {
@@ -464,7 +529,6 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setOrganizationId("");
setOrganizationName(null);
setProjectTeamspaceEntryId("");
setActivity("");
setWorkType("support");
} catch (error) {
toast.error("Speichern fehlgeschlagen", {
@@ -531,7 +595,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
endedAt: endedAt.toISOString(),
durationSeconds,
zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id),
projectTeamspaceEntryId: requiresTeamspaceEntry(manualLookupTicket) ? manualSession.projectTeamspaceEntryId : null
projectTeamspaceEntryId: requiresTeamspaceEntry(manualLookupTicket) ? manualSession.projectTeamspaceEntryId : null,
billingStatus: manualSession.billingStatus
});
toast.success("Session nachgetragen", {
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen."
@@ -625,11 +690,44 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</div>
</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 className="grid gap-4 lg:grid-cols-[minmax(220px,320px)_1fr]">
<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 gap-3 md:grid-cols-[1fr_180px]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="timer-activity">
Tätigkeit
</label>
<Textarea
id="timer-activity"
className="min-h-32"
value={selectedTimer?.activity ?? ""}
disabled={!selectedTimer}
placeholder={selectedTimer ? "Beschreibe, woran du in dieser Session arbeitest." : "Timer auswählen oder starten."}
onChange={(event) => selectedTimer && updateTimerActivity(selectedTimer.id, event.currentTarget.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="timer-billing-status">
Bewertung
</label>
<BillingStatusSelect
id="timer-billing-status"
value={selectedTimer?.billingStatus ?? null}
disabled={!selectedTimer}
onChange={(billingStatus) => selectedTimer && updateTimerBillingStatus(selectedTimer.id, billingStatus)}
/>
</div>
{selectedTimer ? (
<p className="text-xs text-muted-foreground md:col-span-2">
Tätigkeit und Bewertung bleiben bei pausierten Timern erhalten und werden beim Beenden mit der Session gespeichert.
</p>
) : null}
</div>
</div>
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
@@ -761,7 +859,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
) : null}
</div>
<div className="grid gap-3 lg:grid-cols-[160px_120px_120px_minmax(220px,1fr)_auto] lg:items-end">
<div className="grid gap-3 lg:grid-cols-[160px_120px_120px_160px_minmax(220px,1fr)_auto] lg:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-day">Datum</label>
<Input
@@ -792,6 +890,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-billing-status">Bewertung</label>
<BillingStatusSelect
id="manual-billing-status"
value={manualSession.billingStatus}
onChange={(billingStatus) => setManualSession({ ...manualSession, billingStatus })}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-activity">Tätigkeit</label>
<Textarea
@@ -858,11 +964,53 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="activity">
<label className="text-sm font-medium" htmlFor="finished-activity">
Tätigkeit
</label>
<Textarea id="activity" value={activity} onChange={(event) => setActivity(event.currentTarget.value)} autoFocus required />
<Textarea
id="finished-activity"
className="min-h-24"
value={finished?.activity ?? ""}
onChange={(event) => {
const nextActivity = event.currentTarget.value;
setFinished((current) => (current ? { ...current, activity: nextActivity } : current));
}}
required
/>
</div>
{finished ? (
<div className="flex items-start gap-3 rounded-md border bg-muted/30 p-3">
<Checkbox
id="finished-zammad-article"
checked={finished.createZammadArticle}
onCheckedChange={(checked) => setFinished((current) => (current ? { ...current, createZammadArticle: checked === true } : current))}
/>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="finished-zammad-article">
Tätigkeit als interne Zammad-Notiz übertragen
</label>
<p className="text-xs text-muted-foreground">
Erstellt einen internen Zammad-Article mit Tätigkeit und getrackter Zeit.
</p>
{!finished.ticket?.zammad_ticket_id ? (
<p className="text-xs text-muted-foreground">Falls die Zammad-ID noch fehlt, wird sie beim Speichern oder späteren Nachsenden erneut gesucht.</p>
) : null}
</div>
</div>
) : null}
{finished?.promptBillingStatus ? (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="finished-billing-status">Bewertung</label>
<BillingStatusSelect
id="finished-billing-status"
value={finished.billingStatus}
onChange={(billingStatus) => setFinished((current) => (current ? { ...current, billingStatus } : current))}
/>
<p className="text-xs text-muted-foreground">
<span className="font-mono">---</span> lässt die Session offen und kann später in der Ticketdetailansicht bewertet werden.
</p>
</div>
) : null}
{finished?.ticket?.organization_id && finished.ticket.work_type ? null : (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="work-type">