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

+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">