1088 lines
45 KiB
TypeScript
1088 lines
45 KiB
TypeScript
import { FormEvent, useEffect, useMemo, useState } from "react";
|
|
import type { Dispatch, SetStateAction } from "react";
|
|
import { FolderGit, Pause, Play, Plus, RotateCcw, Search, Square, Upload } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { 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";
|
|
import { HelpLink } from "@/components/HelpLink";
|
|
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
|
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 { AuthUser, BillingStatus, RecurringBilling, TicketMeta, WorkType } from "../types";
|
|
|
|
type ManualSessionFormState = {
|
|
ticketNumber: string;
|
|
organizationId: string;
|
|
organizationName: string | null;
|
|
projectTeamspaceEntryId: string;
|
|
activity: string;
|
|
billingStatus: BillingStatus;
|
|
createZammadArticle: boolean;
|
|
workType: WorkType;
|
|
day: string;
|
|
startTime: string;
|
|
endTime: string;
|
|
};
|
|
|
|
function currentDay() {
|
|
const now = new Date();
|
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
}
|
|
|
|
function emptyManualSession(): ManualSessionFormState {
|
|
return {
|
|
ticketNumber: "",
|
|
organizationId: "",
|
|
organizationName: null,
|
|
projectTeamspaceEntryId: "",
|
|
activity: "",
|
|
billingStatus: null,
|
|
createZammadArticle: false,
|
|
workType: "support",
|
|
day: currentDay(),
|
|
startTime: "09:00",
|
|
endTime: "09:30"
|
|
};
|
|
}
|
|
|
|
type FinishedSession = {
|
|
timerId: string;
|
|
ticketNumber: string;
|
|
ticket: TicketMeta | null;
|
|
activity: string;
|
|
billingStatus: BillingStatus;
|
|
promptBillingStatus: boolean;
|
|
createZammadArticle: boolean;
|
|
startedAt: string;
|
|
endedAt: string;
|
|
durationSeconds: number;
|
|
roundedMinutes: number;
|
|
};
|
|
|
|
function ticketTeamspaceEntries(ticket: TicketMeta | null | undefined) {
|
|
return ticket?.project_teamspace_entries ?? [];
|
|
}
|
|
|
|
function teamspaceEntryOptionLabel(entry: { teamspace_project: string | null; is_default: boolean }) {
|
|
const label = entry.teamspace_project || "Ohne Teamspace-ID";
|
|
|
|
return entry.is_default ? `${label} (Standard)` : label;
|
|
}
|
|
|
|
function requiresTeamspaceEntry(ticket: TicketMeta | null | undefined) {
|
|
return Boolean(ticket?.is_project && ticketTeamspaceEntries(ticket).length > 1);
|
|
}
|
|
|
|
type TimerPageProps = {
|
|
timers: TimerEntry[];
|
|
setTimers: Dispatch<SetStateAction<TimerEntry[]>>;
|
|
selectedTimerId: string | null;
|
|
setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
|
|
tick: number;
|
|
currentUser: AuthUser;
|
|
};
|
|
|
|
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 [workType, setWorkType] = useState<WorkType>("support");
|
|
const [saving, setSaving] = useState(false);
|
|
const [manualSession, setManualSession] = useState<ManualSessionFormState>(() => emptyManualSession());
|
|
const [savingManualSession, setSavingManualSession] = useState(false);
|
|
const [manualLookupStatus, setManualLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
|
|
const [manualLookupTicket, setManualLookupTicket] = useState<TicketMeta | null>(null);
|
|
const [zammadLookupBusy, setZammadLookupBusy] = useState<string | null>(null);
|
|
const [recurringBillings, setRecurringBillings] = useState<RecurringBilling[]>([]);
|
|
|
|
const selectedTimer = timers.find((timer) => timer.id === selectedTimerId) ?? timers[0] ?? null;
|
|
const elapsedSeconds = useMemo(() => Math.floor(activeElapsedMs(selectedTimer, tick) / 1000), [selectedTimer, tick]);
|
|
const isValidTicket = ticketPattern.test(ticketNumber.trim());
|
|
const isManualTicketValid = ticketPattern.test(manualSession.ticketNumber.trim());
|
|
const manualTicketDefaultsLocked = Boolean(manualLookupTicket?.organization_id && manualLookupTicket.work_type);
|
|
const manualTicketIsRecurringBilling =
|
|
ticketHasRecurringBilling(manualLookupTicket, recurringBillings) || isRecurringBillingTicketNumber(manualSession.ticketNumber.trim(), recurringBillings);
|
|
const runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
async function loadRecurringBillings() {
|
|
try {
|
|
const result = await getRecurringBillings();
|
|
|
|
if (!cancelled) {
|
|
setRecurringBillings(result.recurringBillings);
|
|
}
|
|
} catch {
|
|
if (!cancelled) {
|
|
setRecurringBillings([]);
|
|
}
|
|
}
|
|
}
|
|
|
|
void loadRecurringBillings();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const normalizedTicketNumber = manualSession.ticketNumber.trim();
|
|
|
|
if (!normalizedTicketNumber) {
|
|
setManualLookupStatus("idle");
|
|
setManualLookupTicket(null);
|
|
return;
|
|
}
|
|
|
|
if (!ticketPattern.test(normalizedTicketNumber)) {
|
|
setManualLookupStatus("idle");
|
|
setManualLookupTicket(null);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
setManualLookupStatus("loading");
|
|
setManualLookupTicket(null);
|
|
|
|
const timeout = window.setTimeout(async () => {
|
|
try {
|
|
const result = await lookupTicket(normalizedTicketNumber);
|
|
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
setManualLookupTicket(result.ticket);
|
|
setManualLookupStatus(result.ticket ? "found" : "not-found");
|
|
|
|
if (result.ticket) {
|
|
setManualSession((current) => {
|
|
if (current.ticketNumber.trim() !== normalizedTicketNumber) {
|
|
return current;
|
|
}
|
|
|
|
return {
|
|
...current,
|
|
organizationId: result.ticket?.organization_id ?? "",
|
|
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null,
|
|
projectTeamspaceEntryId: "",
|
|
workType: result.ticket?.work_type ?? current.workType
|
|
};
|
|
});
|
|
}
|
|
} catch {
|
|
if (!cancelled) {
|
|
setManualLookupStatus("idle");
|
|
setManualLookupTicket(null);
|
|
}
|
|
}
|
|
}, 250);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
window.clearTimeout(timeout);
|
|
};
|
|
}, [manualSession.ticketNumber]);
|
|
|
|
useEffect(() => {
|
|
setManualSession((current) => {
|
|
if (current.ticketNumber || current.activity || current.organizationId || current.billingStatus || current.createZammadArticle === currentUser.zammad_session_note_default_enabled) {
|
|
return current;
|
|
}
|
|
|
|
return {
|
|
...current,
|
|
createZammadArticle: currentUser.zammad_session_note_default_enabled
|
|
};
|
|
});
|
|
}, [currentUser.zammad_session_note_default_enabled]);
|
|
|
|
async function addTimer(source: "local" | "zammad" = "local") {
|
|
const normalizedTicketNumber = ticketNumber.trim();
|
|
|
|
if (!isValidTicket) {
|
|
toast.error("Ticketnummer prüfen", {
|
|
description: "Das Format muss Ticket#XXXXXX sein."
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (source === "zammad") {
|
|
setZammadLookupBusy("timer");
|
|
}
|
|
|
|
const ticketResult = await lookupTicket(normalizedTicketNumber, source).catch((error) => {
|
|
if (source === "zammad") {
|
|
toast.error("Zammad-Suche fehlgeschlagen", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
}
|
|
|
|
return { ticket: null };
|
|
});
|
|
|
|
if (source === "zammad") {
|
|
setZammadLookupBusy(null);
|
|
|
|
if (!ticketResult.ticket) {
|
|
toast.error("Ticket nicht gefunden", {
|
|
description: "Zammad hat zu dieser Ticketnummer keinen Treffer geliefert."
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (ticketHasRecurringBilling(ticketResult.ticket, recurringBillings) || isRecurringBillingTicketNumber(normalizedTicketNumber, recurringBillings)) {
|
|
setTimers((current) => current.filter((timer) => timer.ticketNumber !== normalizedTicketNumber));
|
|
toast.error("Fixe Abrechnung", {
|
|
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gestartet werden. Bitte die Definition bearbeiten."
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (timers.some((timer) => timer.ticketNumber === normalizedTicketNumber)) {
|
|
toast.error("Timer existiert bereits", {
|
|
description: "Für dieses Ticket läuft oder pausiert schon ein Timer."
|
|
});
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const newTimer: TimerEntry = {
|
|
id: createClientId("timer"),
|
|
ticketNumber: normalizedTicketNumber,
|
|
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
|
|
workType: ticketResult.ticket?.work_type ?? null,
|
|
isProject: Boolean(ticketResult.ticket?.is_project),
|
|
projectName: ticketResult.ticket?.project_name ?? null,
|
|
ticketLookupDone: true,
|
|
startedAt: now,
|
|
pausedTotalMs: 0,
|
|
pausedAt: null,
|
|
activity: "",
|
|
billingStatus: null,
|
|
phase: "running"
|
|
};
|
|
|
|
setTimers((current) => [...current.map((timer) => pauseEntry(timer, now)), newTimer]);
|
|
setSelectedTimerId(newTimer.id);
|
|
setTicketNumber("");
|
|
}
|
|
|
|
async function lookupManualTicketInZammad() {
|
|
const normalizedTicketNumber = manualSession.ticketNumber.trim();
|
|
|
|
if (!ticketPattern.test(normalizedTicketNumber)) {
|
|
toast.error("Ticketnummer prüfen", {
|
|
description: "Das Format muss Ticket#XXXXXX sein."
|
|
});
|
|
return;
|
|
}
|
|
|
|
setZammadLookupBusy("manual");
|
|
|
|
try {
|
|
const result = await lookupTicket(normalizedTicketNumber, "zammad");
|
|
|
|
if (!result.ticket) {
|
|
setManualLookupStatus("not-found");
|
|
toast.error("Ticket nicht gefunden", {
|
|
description: "Zammad hat zu dieser Ticketnummer keinen Treffer geliefert."
|
|
});
|
|
return;
|
|
}
|
|
|
|
setManualLookupTicket(result.ticket);
|
|
setManualLookupStatus("found");
|
|
setManualSession((current) => ({
|
|
...current,
|
|
organizationId: result.ticket?.organization_id ?? "",
|
|
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null,
|
|
projectTeamspaceEntryId: "",
|
|
workType: result.ticket?.work_type ?? current.workType
|
|
}));
|
|
toast.success("Zammad-Daten übernommen");
|
|
} catch (error) {
|
|
toast.error("Zammad-Suche fehlgeschlagen", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setZammadLookupBusy(null);
|
|
}
|
|
}
|
|
|
|
async function lookupFinishedTicketInZammad() {
|
|
if (!finished) {
|
|
return;
|
|
}
|
|
|
|
setZammadLookupBusy("finished");
|
|
|
|
try {
|
|
const result = await lookupTicket(finished.ticketNumber, "zammad");
|
|
|
|
if (!result.ticket) {
|
|
toast.error("Ticket nicht gefunden", {
|
|
description: "Zammad hat zu dieser Ticketnummer keinen Treffer geliefert."
|
|
});
|
|
return;
|
|
}
|
|
|
|
setFinished({ ...finished, ticket: result.ticket });
|
|
setOrganizationId(result.ticket.organization_id ?? "");
|
|
setOrganizationName(result.ticket.organization_name ?? result.ticket.customer_name ?? null);
|
|
setProjectTeamspaceEntryId("");
|
|
setWorkType(result.ticket.work_type ?? workType);
|
|
toast.success("Zammad-Daten übernommen");
|
|
} catch (error) {
|
|
toast.error("Zammad-Suche fehlgeschlagen", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setZammadLookupBusy(null);
|
|
}
|
|
}
|
|
|
|
function activateTimer(timerId: string) {
|
|
const now = Date.now();
|
|
setTimers((current) =>
|
|
current.map((timer) => {
|
|
if (timer.id === timerId) {
|
|
return resumeEntry(timer, now);
|
|
}
|
|
|
|
return pauseEntry(timer, now);
|
|
})
|
|
);
|
|
setSelectedTimerId(timerId);
|
|
}
|
|
|
|
function pauseTimer(timerId: string) {
|
|
const now = Date.now();
|
|
setTimers((current) => current.map((timer) => (timer.id === timerId ? pauseEntry(timer, now) : timer)));
|
|
setSelectedTimerId(timerId);
|
|
}
|
|
|
|
function resetTimer(timerId: string) {
|
|
setTimers((current) => current.filter((timer) => timer.id !== timerId));
|
|
}
|
|
|
|
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);
|
|
|
|
if (!timer) {
|
|
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);
|
|
const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60));
|
|
|
|
setTimers((current) => current.map((entry) => (entry.id === timerId ? pauseEntry(entry, now) : entry)));
|
|
setSelectedTimerId(timer.id);
|
|
setFinishingId(timer.id);
|
|
|
|
try {
|
|
const result = await lookupTicket(timer.ticketNumber);
|
|
const ticket = result.ticket;
|
|
|
|
if (ticketHasRecurringBilling(ticket, recurringBillings) || isRecurringBillingTicketNumber(timer.ticketNumber, recurringBillings)) {
|
|
setTimers((current) => current.filter((entry) => entry.id !== timer.id));
|
|
toast.error("Fixe Abrechnung", {
|
|
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gespeichert werden. Bitte die Definition bearbeiten."
|
|
});
|
|
return;
|
|
}
|
|
|
|
setOrganizationId(ticket?.organization_id ?? "");
|
|
setOrganizationName(ticket?.organization_name ?? null);
|
|
setProjectTeamspaceEntryId("");
|
|
setWorkType(ticket?.work_type ?? "support");
|
|
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,
|
|
roundedMinutes
|
|
});
|
|
} catch (error) {
|
|
toast.error("Ticket konnte nicht geprüft werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setFinishingId(null);
|
|
}
|
|
}
|
|
|
|
async function submitSession(event: FormEvent) {
|
|
event.preventDefault();
|
|
|
|
if (!finished) {
|
|
return;
|
|
}
|
|
|
|
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."
|
|
});
|
|
setFinished(null);
|
|
return;
|
|
}
|
|
|
|
const existingTicketHasDefaults = Boolean(finished.ticket?.organization_id && finished.ticket?.work_type);
|
|
const effectiveOrganizationId = existingTicketHasDefaults ? finished.ticket!.organization_id! : organizationId;
|
|
const effectiveWorkType = existingTicketHasDefaults ? finished.ticket!.work_type! : workType;
|
|
const finishedRequiresTeamspaceEntry = requiresTeamspaceEntry(finished.ticket);
|
|
|
|
if (!effectiveOrganizationId) {
|
|
toast.error("Organisation wählen", {
|
|
description: "Neue Sessions können nur mit einer synchronisierten Organisation gespeichert werden."
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (finishedRequiresTeamspaceEntry && !projectTeamspaceEntryId) {
|
|
toast.error("Teamspace-Eintrag wählen", {
|
|
description: "Dieses Projekt hat mehrere Teamspace-Einträge."
|
|
});
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
|
|
try {
|
|
await createSession({
|
|
ticketNumber: finished.ticketNumber,
|
|
startedAt: finished.startedAt,
|
|
endedAt: finished.endedAt,
|
|
durationSeconds: finished.durationSeconds,
|
|
organizationId: effectiveOrganizationId,
|
|
activity: finished.activity,
|
|
workType: effectiveWorkType,
|
|
zammadLookup: Boolean(finished.ticket?.zammad_ticket_id),
|
|
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null,
|
|
billingStatus: finished.billingStatus,
|
|
createZammadArticle: finished.createZammadArticle
|
|
});
|
|
|
|
toast.success("Session gespeichert", {
|
|
description: `${finished.ticketNumber} wurde mit ${finished.roundedMinutes} Minute(n) erfasst.`
|
|
});
|
|
setTimers((current) => current.filter((timer) => timer.id !== finished.timerId));
|
|
setFinished(null);
|
|
setOrganizationId("");
|
|
setOrganizationName(null);
|
|
setProjectTeamspaceEntryId("");
|
|
setWorkType("support");
|
|
} catch (error) {
|
|
toast.error("Speichern fehlgeschlagen", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function submitManualSession(event: FormEvent) {
|
|
event.preventDefault();
|
|
|
|
if (!isManualTicketValid) {
|
|
toast.error("Ticketnummer prüfen", {
|
|
description: "Das Format muss Ticket#XXXXXX sein."
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!manualSession.organizationId) {
|
|
toast.error("Organisation wählen");
|
|
return;
|
|
}
|
|
|
|
if (manualTicketIsRecurringBilling) {
|
|
toast.error("Fixe Abrechnung", {
|
|
description: "Für fixe Abrechnungen dürfen keine Sessions nachgetragen werden. Bitte die Definition bearbeiten."
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (requiresTeamspaceEntry(manualLookupTicket) && !manualSession.projectTeamspaceEntryId) {
|
|
toast.error("Teamspace-Eintrag wählen", {
|
|
description: "Dieses Projekt hat mehrere Teamspace-Einträge."
|
|
});
|
|
return;
|
|
}
|
|
|
|
const startedAt = new Date(`${manualSession.day}T${manualSession.startTime}:00`);
|
|
const endedAt = new Date(`${manualSession.day}T${manualSession.endTime}:00`);
|
|
|
|
if (Number.isNaN(startedAt.getTime()) || Number.isNaN(endedAt.getTime())) {
|
|
toast.error("Datum oder Uhrzeit prüfen");
|
|
return;
|
|
}
|
|
|
|
if (endedAt <= startedAt) {
|
|
toast.error("Ende muss nach Beginn liegen");
|
|
return;
|
|
}
|
|
|
|
const durationSeconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
|
|
|
|
setSavingManualSession(true);
|
|
|
|
try {
|
|
await createSession({
|
|
ticketNumber: manualSession.ticketNumber.trim(),
|
|
organizationId: manualSession.organizationId,
|
|
activity: manualSession.activity,
|
|
workType: manualSession.workType,
|
|
startedAt: startedAt.toISOString(),
|
|
endedAt: endedAt.toISOString(),
|
|
durationSeconds,
|
|
zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id),
|
|
projectTeamspaceEntryId: requiresTeamspaceEntry(manualLookupTicket) ? manualSession.projectTeamspaceEntryId : null,
|
|
billingStatus: manualSession.billingStatus,
|
|
createZammadArticle: manualSession.createZammadArticle
|
|
});
|
|
toast.success("Session nachgetragen", {
|
|
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen."
|
|
});
|
|
setManualSession({
|
|
...emptyManualSession(),
|
|
day: manualSession.day,
|
|
createZammadArticle: currentUser.zammad_session_note_default_enabled
|
|
});
|
|
} catch (error) {
|
|
toast.error("Session konnte nicht nachgetragen werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setSavingManualSession(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<section className="space-y-4">
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
|
<div>
|
|
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Timer</h2>
|
|
<p className="text-sm text-muted-foreground">Mehrere Tickets vorbereiten, aber immer nur eine Session aktiv messen.</p>
|
|
</div>
|
|
{runningTimer ? <Badge variant="success">Aktiv: {runningTimer.ticketNumber}</Badge> : null}
|
|
{!runningTimer && timers.length > 0 ? <Badge variant="warning">Alle pausiert</Badge> : null}
|
|
{timers.length === 0 ? <Badge variant="info">Kein Timer aktiv</Badge> : null}
|
|
</div>
|
|
|
|
<Card className="overflow-hidden">
|
|
<CardHeader className="border-b bg-muted/30 p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<CardTitle>Neuen Timer starten</CardTitle>
|
|
<CardDescription>Ein neuer Timer startet sofort und pausiert alle anderen Timer automatisch.</CardDescription>
|
|
</div>
|
|
<HelpLink anchor="timer" label="Hilfe zu Timern" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-3 p-4 sm:grid-cols-[minmax(220px,320px)_auto_auto] sm:items-end">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="ticket-number">
|
|
Ticketnummer
|
|
</label>
|
|
<Input
|
|
id="ticket-number"
|
|
placeholder="Ticket#123456"
|
|
value={ticketNumber}
|
|
autoFocus
|
|
onChange={(event) => setTicketNumber(event.currentTarget.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
void addTimer();
|
|
}
|
|
}}
|
|
/>
|
|
{ticketNumber.length > 0 && !isValidTicket ? <p className="text-xs text-destructive">Format: Ticket#XXXXXX</p> : null}
|
|
</div>
|
|
<Button onClick={() => void addTimer()}>
|
|
<Plus className="size-4" />
|
|
Timer starten
|
|
</Button>
|
|
<Button type="button" variant="secondary" disabled={!isValidTicket || zammadLookupBusy === "timer"} onClick={() => void addTimer("zammad")}>
|
|
<Search className="size-4" />
|
|
{zammadLookupBusy === "timer" ? "Sucht..." : "In Zammad suchen"}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="overflow-hidden">
|
|
<CardHeader className="border-b bg-muted/30 p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<CardTitle>{selectedTimer ? selectedTimer.ticketNumber : "Keine Session ausgewählt"}</CardTitle>
|
|
<CardDescription>
|
|
{selectedTimer
|
|
? selectedTimer.organizationName
|
|
? (
|
|
<span className="inline-flex min-w-0 items-center gap-1.5">
|
|
{selectedTimer.isProject ? <FolderGit className="size-3.5 shrink-0" aria-label="Projekt" /> : null}
|
|
<span>{selectedTimer.organizationName}</span>
|
|
{selectedTimer.workType ? <span>· {selectedTimer.workType === "support" ? "Support" : "Consulting"}</span> : null}
|
|
</span>
|
|
)
|
|
: "Beim Umschalten wird dieser Timer aktiviert und alle anderen werden pausiert."
|
|
: "Starte einen Timer, um eine Session zu erfassen."}
|
|
</CardDescription>
|
|
</div>
|
|
<HelpLink anchor="timer" label="Hilfe zum Timerstatus" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4 p-4">
|
|
<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">
|
|
{selectedTimer?.phase === "running" ? (
|
|
<Button variant="secondary" disabled={!selectedTimer} onClick={() => selectedTimer && pauseTimer(selectedTimer.id)}>
|
|
<Pause className="size-4" />
|
|
Pausieren
|
|
</Button>
|
|
) : (
|
|
<Button disabled={!selectedTimer} onClick={() => selectedTimer && activateTimer(selectedTimer.id)}>
|
|
<Play className="size-4" />
|
|
Aktivieren
|
|
</Button>
|
|
)}
|
|
|
|
<Button variant="destructive" disabled={!selectedTimer || finishingId === selectedTimer?.id} onClick={() => selectedTimer && void finishTimer(selectedTimer.id)}>
|
|
<Square className="size-4" />
|
|
{finishingId === selectedTimer?.id ? "Prüft..." : "Beenden"}
|
|
</Button>
|
|
<Button variant="ghost" disabled={!selectedTimer} onClick={() => selectedTimer && resetTimer(selectedTimer.id)}>
|
|
<RotateCcw className="size-4" />
|
|
Entfernen
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="overflow-hidden">
|
|
<CardHeader className="border-b bg-muted/30 p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<CardTitle>Session nachtragen</CardTitle>
|
|
<CardDescription>Vergessene Zeiten für deinen eigenen Account manuell erfassen.</CardDescription>
|
|
</div>
|
|
<HelpLink anchor="session-nachtragen" label="Hilfe zum Nachtragen von Sessions" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-4">
|
|
<form className="space-y-4" onSubmit={submitManualSession}>
|
|
<div className="grid gap-3 lg:grid-cols-[160px_minmax(180px,1fr)_150px]">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="manual-ticket-number">Ticket</label>
|
|
<Input
|
|
id="manual-ticket-number"
|
|
placeholder="Ticket#123456"
|
|
value={manualSession.ticketNumber}
|
|
onChange={(event) =>
|
|
setManualSession({
|
|
...manualSession,
|
|
ticketNumber: event.currentTarget.value,
|
|
organizationId: "",
|
|
organizationName: null,
|
|
projectTeamspaceEntryId: "",
|
|
workType: "support"
|
|
})
|
|
}
|
|
required
|
|
/>
|
|
{manualSession.ticketNumber.length > 0 && !isManualTicketValid ? <p className="text-xs text-destructive">Format: Ticket#XXXXXX</p> : null}
|
|
{manualLookupStatus === "loading" ? <p className="text-xs text-muted-foreground">Prüfe vorhandenes Ticket...</p> : null}
|
|
{manualLookupStatus === "found" && manualTicketDefaultsLocked ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
{manualLookupTicket?.is_project ? "Projekt gefunden. Organisation und Consulting wurden übernommen." : "Ticket gefunden. Organisation und Art wurden übernommen."}
|
|
</p>
|
|
) : null}
|
|
{manualLookupStatus === "found" && !manualTicketDefaultsLocked ? (
|
|
<p className="text-xs text-muted-foreground">Ticket gefunden, aber Stammdaten sind unvollständig. Bitte ergänzen.</p>
|
|
) : null}
|
|
{manualTicketIsRecurringBilling ? (
|
|
<p className="text-xs text-destructive">Fixe Abrechnung: Sessions kommen ausschließlich aus der Definition.</p>
|
|
) : null}
|
|
{manualLookupStatus === "not-found" ? (
|
|
<div className="space-y-2">
|
|
<p className="text-xs text-muted-foreground">Neues Ticket. Organisation und Art bitte wählen oder aus Zammad übernehmen.</p>
|
|
<Button type="button" size="sm" variant="secondary" disabled={zammadLookupBusy === "manual"} onClick={() => void lookupManualTicketInZammad()}>
|
|
<Search className="size-3.5" />
|
|
{zammadLookupBusy === "manual" ? "Sucht..." : "In Zammad suchen"}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Organisation</label>
|
|
<OrganizationSelect
|
|
value={manualSession.organizationId}
|
|
selectedName={manualSession.organizationName}
|
|
disabled={manualTicketDefaultsLocked}
|
|
onChange={(organization) =>
|
|
setManualSession({
|
|
...manualSession,
|
|
organizationId: organization.id,
|
|
organizationName: organization.name
|
|
})
|
|
}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="manual-work-type">Art</label>
|
|
<select
|
|
id="manual-work-type"
|
|
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
value={manualSession.workType}
|
|
disabled={manualTicketDefaultsLocked}
|
|
onChange={(event) => setManualSession({ ...manualSession, workType: event.currentTarget.value as WorkType })}
|
|
>
|
|
<option value="support">Support</option>
|
|
<option value="consulting">Consulting</option>
|
|
</select>
|
|
</div>
|
|
{requiresTeamspaceEntry(manualLookupTicket) ? (
|
|
<div className="space-y-2 lg:col-span-3">
|
|
<label className="text-sm font-medium" htmlFor="manual-teamspace-entry">Teamspace-Eintrag</label>
|
|
<select
|
|
id="manual-teamspace-entry"
|
|
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
value={manualSession.projectTeamspaceEntryId}
|
|
onChange={(event) => setManualSession({ ...manualSession, projectTeamspaceEntryId: event.currentTarget.value })}
|
|
required
|
|
>
|
|
<option value="">Bitte wählen</option>
|
|
{ticketTeamspaceEntries(manualLookupTicket).map((entry) => (
|
|
<option key={entry.id} value={entry.id}>
|
|
{teamspaceEntryOptionLabel(entry)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<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
|
|
id="manual-day"
|
|
type="date"
|
|
value={manualSession.day}
|
|
onChange={(event) => setManualSession({ ...manualSession, day: event.currentTarget.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="manual-start">Von</label>
|
|
<Input
|
|
id="manual-start"
|
|
type="time"
|
|
value={manualSession.startTime}
|
|
onChange={(event) => setManualSession({ ...manualSession, startTime: event.currentTarget.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="manual-end">Bis</label>
|
|
<Input
|
|
id="manual-end"
|
|
type="time"
|
|
value={manualSession.endTime}
|
|
onChange={(event) => setManualSession({ ...manualSession, endTime: event.currentTarget.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="manual-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
|
|
id="manual-activity"
|
|
className="min-h-20"
|
|
value={manualSession.activity}
|
|
onChange={(event) => setManualSession({ ...manualSession, activity: event.currentTarget.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
<label className="flex min-h-10 items-start gap-2 rounded-md border bg-background p-3 text-sm lg:col-span-5">
|
|
<Checkbox
|
|
checked={manualSession.createZammadArticle}
|
|
onCheckedChange={(checked) => setManualSession({ ...manualSession, createZammadArticle: checked === true })}
|
|
/>
|
|
<span>
|
|
<span className="block font-medium">Interne Zammad-Notiz erstellen</span>
|
|
<span className="block text-xs text-muted-foreground">Überträgt die Tätigkeit inklusive time_unit nach Zammad oder parkt sie bei Fehlern.</span>
|
|
</span>
|
|
</label>
|
|
<Button type="submit" disabled={savingManualSession || manualTicketIsRecurringBilling}>
|
|
<Upload className="size-4" />
|
|
{savingManualSession ? "Speichert..." : "Nachtragen"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
</section>
|
|
|
|
<Dialog open={Boolean(finished)} onOpenChange={(open) => !open && setFinished(null)}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Session abschließen</DialogTitle>
|
|
<DialogDescription>
|
|
{finished?.ticketNumber}: {finished?.roundedMinutes} Minute(n), Pausen bereits abgezogen.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<form className="space-y-4" onSubmit={submitSession}>
|
|
{finished && !finished.ticket?.organization_id ? (
|
|
<div className="flex justify-end">
|
|
<Button type="button" size="sm" variant="secondary" disabled={zammadLookupBusy === "finished"} onClick={() => void lookupFinishedTicketInZammad()}>
|
|
<Search className="size-3.5" />
|
|
{zammadLookupBusy === "finished" ? "Sucht..." : "In Zammad suchen"}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
{finished?.ticket?.organization_id && finished.ticket.work_type ? (
|
|
<div className="rounded-lg border bg-muted/40 p-3 text-sm">
|
|
<p className="font-medium">{finished.ticket.organization_name ?? finished.ticket.customer_name}</p>
|
|
<p className="text-muted-foreground">
|
|
{finished.ticket.is_project ? "Projekt erkannt. Consulting" : finished.ticket.work_type === "support" ? "Support" : "Consulting"} wurde vom bestehenden Ticket übernommen.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="organization">
|
|
Organisation
|
|
</label>
|
|
{finished?.ticket?.customer_name && !finished.ticket.organization_id ? (
|
|
<p className="text-xs text-muted-foreground">Bisheriger Freitext: {finished.ticket.customer_name}</p>
|
|
) : null}
|
|
<OrganizationSelect
|
|
value={organizationId}
|
|
selectedName={organizationName}
|
|
onChange={(organization) => {
|
|
setOrganizationId(organization.id);
|
|
setOrganizationName(organization.name);
|
|
}}
|
|
required
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="finished-activity">
|
|
Tätigkeit
|
|
</label>
|
|
<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">
|
|
Art
|
|
</label>
|
|
<select
|
|
id="work-type"
|
|
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
value={workType}
|
|
onChange={(event) => setWorkType(event.currentTarget.value as WorkType)}
|
|
>
|
|
<option value="support">Support</option>
|
|
<option value="consulting">Consulting</option>
|
|
</select>
|
|
</div>
|
|
)}
|
|
{requiresTeamspaceEntry(finished?.ticket) ? (
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="finished-teamspace-entry">Teamspace-Eintrag</label>
|
|
<select
|
|
id="finished-teamspace-entry"
|
|
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={projectTeamspaceEntryId}
|
|
onChange={(event) => setProjectTeamspaceEntryId(event.currentTarget.value)}
|
|
required
|
|
>
|
|
<option value="">Bitte wählen</option>
|
|
{ticketTeamspaceEntries(finished?.ticket).map((entry) => (
|
|
<option key={entry.id} value={entry.id}>
|
|
{teamspaceEntryOptionLabel(entry)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
) : null}
|
|
<div className="flex justify-end">
|
|
<Button type="submit" disabled={saving}>
|
|
<Upload className="size-4" />
|
|
{saving ? "Speichern..." : "Eintrag speichern"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|