Update
This commit is contained in:
1 parent
9be6c6e36a
commit
fa6db04370
16 files changed
+1123
-118
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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