Update
This commit is contained in:
1 parent
9be6c6e36a
commit
fa6db04370
16 files changed
+1123
-118
No files matched your search
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user