Update
This commit is contained in:
1 parent
3d9674bd5a
commit
d1da86beab
20 files changed
+6109
-923
No files matched your search
@@ -19,13 +19,6 @@ type AnalysisPageProps = {
|
||||
onNavigate: (to: string) => void;
|
||||
};
|
||||
|
||||
type TimelineBucket = {
|
||||
key: string;
|
||||
label: string;
|
||||
totalMinutes: number;
|
||||
sessionCount: number;
|
||||
};
|
||||
|
||||
type TicketGroupBy = "none" | "workType" | "organization" | "status";
|
||||
type TicketSortBy = "ticket" | "workType" | "organization" | "status";
|
||||
type TicketSortDirection = "asc" | "desc";
|
||||
@@ -224,82 +217,6 @@ function groupTicketSections(tickets: TicketSummary[], groupBy: TicketGroupBy):
|
||||
});
|
||||
}
|
||||
|
||||
function buildChartPath(points: Array<{ x: number; y: number }>) {
|
||||
if (points.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (points.length === 1) {
|
||||
return `M ${points[0].x - 18} ${points[0].y} L ${points[0].x + 18} ${points[0].y}`;
|
||||
}
|
||||
|
||||
return points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" ");
|
||||
}
|
||||
|
||||
function niceCeilMinutes(value: number) {
|
||||
if (value <= 30) {
|
||||
return Math.max(5, Math.ceil(value / 5) * 5);
|
||||
}
|
||||
|
||||
if (value <= 120) {
|
||||
return Math.ceil(value / 15) * 15;
|
||||
}
|
||||
|
||||
if (value <= 480) {
|
||||
return Math.ceil(value / 30) * 30;
|
||||
}
|
||||
|
||||
return Math.ceil(value / 60) * 60;
|
||||
}
|
||||
|
||||
function formatAxisMinutes(value: number) {
|
||||
const minutes = Math.round(value);
|
||||
|
||||
if (minutes === 0) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
if (minutes >= 60) {
|
||||
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: minutes < 600 ? 1 : 0 }).format(minutes / 60)} h`;
|
||||
}
|
||||
|
||||
return `${minutes} min`;
|
||||
}
|
||||
|
||||
function buildTimeline(periodType: PeriodType, period: string, overview: PeriodOverview | null): TimelineBucket[] {
|
||||
const series = new Map((overview?.activitySeries ?? []).map((bucket) => [bucket.bucket_key, bucket]));
|
||||
|
||||
if (periodType === "day") {
|
||||
return Array.from({ length: 24 }).map((_, hour) => {
|
||||
const key = String(hour).padStart(2, "0");
|
||||
const bucket = series.get(key);
|
||||
|
||||
return {
|
||||
key,
|
||||
label: `${key}:00`,
|
||||
totalMinutes: bucket?.total_minutes ?? 0,
|
||||
sessionCount: bucket?.session_count ?? 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const [year, month] = period.split("-").map(Number);
|
||||
const daysInMonth = Number.isFinite(year) && Number.isFinite(month) ? new Date(year, month, 0).getDate() : 31;
|
||||
|
||||
return Array.from({ length: daysInMonth }).map((_, index) => {
|
||||
const day = String(index + 1).padStart(2, "0");
|
||||
const key = `${period}-${day}`;
|
||||
const bucket = series.get(key);
|
||||
|
||||
return {
|
||||
key,
|
||||
label: day,
|
||||
totalMinutes: bucket?.total_minutes ?? 0,
|
||||
sessionCount: bucket?.session_count ?? 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
const [periodType, setPeriodType] = useState<PeriodType>("month");
|
||||
const [period, setPeriod] = useState(currentMonth());
|
||||
@@ -325,7 +242,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId === loadRequestId.current) {
|
||||
toast.error("Auswertung konnte nicht geladen werden", {
|
||||
toast.error("Tickets konnten nicht geladen werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
}
|
||||
@@ -458,37 +375,11 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
ticketViewSettings.sortBy !== "ticket",
|
||||
ticketViewSettings.sortDirection !== "asc"
|
||||
].filter(Boolean).length;
|
||||
const timeline = buildTimeline(periodType, period, overview);
|
||||
const chartWidth = 900;
|
||||
const chartHeight = 180;
|
||||
const chartPadding = { top: 18, right: 18, bottom: 34, left: 64 };
|
||||
const chartInnerWidth = chartWidth - chartPadding.left - chartPadding.right;
|
||||
const chartInnerHeight = chartHeight - chartPadding.top - chartPadding.bottom;
|
||||
const maxMinutes = Math.max(...timeline.map((bucket) => bucket.totalMinutes), 1);
|
||||
const chartMaxMinutes = niceCeilMinutes(maxMinutes);
|
||||
const yAxisTicks = Array.from({ length: 5 }).map((_, index) => chartMaxMinutes - (index * chartMaxMinutes) / 4);
|
||||
const barWidth = Math.max(4, Math.min(18, chartInnerWidth / Math.max(timeline.length, 1) / 1.8));
|
||||
const chartPoints = timeline.map((bucket, index) => {
|
||||
const x =
|
||||
timeline.length === 1
|
||||
? chartPadding.left + chartInnerWidth / 2
|
||||
: chartPadding.left + (index * chartInnerWidth) / (timeline.length - 1);
|
||||
|
||||
return {
|
||||
bucket,
|
||||
x,
|
||||
barHeight: bucket.totalMinutes > 0 ? Math.max(2, (bucket.totalMinutes / chartMaxMinutes) * chartInnerHeight) : 0,
|
||||
y: chartPadding.top + (1 - bucket.totalMinutes / chartMaxMinutes) * chartInnerHeight
|
||||
};
|
||||
});
|
||||
const effortPath = buildChartPath(chartPoints.filter((point) => point.bucket.totalMinutes > 0).map((point) => ({ x: point.x, y: point.y })));
|
||||
const labelStep = periodType === "month" ? Math.max(1, Math.ceil(timeline.length / 8)) : 3;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-normal">Auswertung</h2>
|
||||
<h2 className="text-2xl font-semibold tracking-normal">Tickets</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{periodType === "month" ? "Tickets prüfen, Sessions bewerten und Zeiten kontrollieren." : "Sessions des Tages prüfen und bewerten."}
|
||||
</p>
|
||||
@@ -557,95 +448,6 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<CardTitle>Abgerechneter Aufwand</CardTitle>
|
||||
<CardDescription>
|
||||
{periodType === "month" ? "Abrechenbare Zeit pro Tag im ausgewählten Monat." : "Abrechenbare Zeit pro Stunde am ausgewählten Tag."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{formatMinutes(totals?.minutes ?? 0)}</Badge>
|
||||
<HelpLink anchor="auswertung" label="Hilfe zur Auswertung" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<div className="h-44 w-full overflow-hidden rounded-md border bg-background">
|
||||
{(totals?.minutes ?? 0) > 0 ? (
|
||||
<svg viewBox={`0 0 ${chartWidth} ${chartHeight}`} className="h-full w-full">
|
||||
{yAxisTicks.map((tick) => {
|
||||
const y = chartPadding.top + (1 - tick / chartMaxMinutes) * chartInnerHeight;
|
||||
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line x1={chartPadding.left} x2={chartWidth - chartPadding.right} y1={y} y2={y} stroke="currentColor" className="text-muted/70" />
|
||||
<line x1={chartPadding.left - 5} x2={chartPadding.left} y1={y} y2={y} stroke="currentColor" className="text-muted-foreground/70" />
|
||||
<text x={chartPadding.left - 9} y={y + 4} textAnchor="end" className="fill-muted-foreground text-[11px]">
|
||||
{formatAxisMinutes(tick)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<line
|
||||
x1={chartPadding.left}
|
||||
x2={chartPadding.left}
|
||||
y1={chartPadding.top}
|
||||
y2={chartPadding.top + chartInnerHeight}
|
||||
stroke="currentColor"
|
||||
className="text-muted-foreground/70"
|
||||
/>
|
||||
|
||||
{chartPoints.map((point) => {
|
||||
const barY = chartPadding.top + chartInnerHeight - point.barHeight;
|
||||
|
||||
return (
|
||||
<g key={point.bucket.key}>
|
||||
{point.bucket.totalMinutes > 0 ? (
|
||||
<rect
|
||||
x={point.x - barWidth / 2}
|
||||
y={barY}
|
||||
width={barWidth}
|
||||
height={point.barHeight}
|
||||
rx="4"
|
||||
className="fill-neutral-300 dark:fill-neutral-700"
|
||||
/>
|
||||
) : null}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{effortPath ? <path d={effortPath} fill="none" stroke="currentColor" strokeWidth="2.5" className="text-foreground" /> : null}
|
||||
|
||||
{chartPoints.filter((point) => point.bucket.totalMinutes > 0).map((point) => (
|
||||
<circle key={`${point.bucket.key}-effort`} cx={point.x} cy={point.y} r="3" className="fill-background stroke-foreground" strokeWidth="2" />
|
||||
))}
|
||||
|
||||
{chartPoints.map((point, index) =>
|
||||
index % labelStep === 0 || index === chartPoints.length - 1 ? (
|
||||
<text key={`${point.bucket.key}-label`} x={point.x} y={chartHeight - 10} textAnchor="middle" className="fill-muted-foreground text-[11px]">
|
||||
{point.bucket.label}
|
||||
</text>
|
||||
) : null
|
||||
)}
|
||||
</svg>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center text-sm text-muted-foreground">Keine Daten für den gewählten Zeitraum.</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-sm bg-neutral-300 dark:bg-neutral-700" />
|
||||
Abgerechnet
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="h-0.5 w-4 bg-foreground" />
|
||||
Verlauf
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
@@ -690,7 +492,10 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
className="h-8"
|
||||
value={ticketViewSettings.query}
|
||||
placeholder="Ticket oder Organisation"
|
||||
onChange={(event) => setTicketViewSettings((current) => ({ ...current, query: event.currentTarget.value }))}
|
||||
onChange={(event) => {
|
||||
const query = event.currentTarget.value;
|
||||
setTicketViewSettings((current) => ({ ...current, query }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -823,11 +628,14 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
{section.tickets.map((ticket) => (
|
||||
<TableRow key={ticket.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-semibold">{ticket.title || ticket.ticket_number}</span>
|
||||
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="truncate font-semibold">{ticket.ticket_number}</span>
|
||||
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
||||
</div>
|
||||
{ticket.project_id ? <Badge variant="outline" className="shrink-0">Projekt</Badge> : null}
|
||||
</div>
|
||||
{ticket.title ? <p className="text-xs text-muted-foreground">{ticket.ticket_number}</p> : null}
|
||||
{ticket.title ? <p className="max-w-[220px] truncate text-xs text-muted-foreground">{ticket.title}</p> : null}
|
||||
</TableCell>
|
||||
<TableCell>{ticket.customer_name ?? "-"}</TableCell>
|
||||
<TableCell>{workTypeLabel(ticket.work_type)}</TableCell>
|
||||
@@ -875,11 +683,14 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
<div key={ticket.id} className="space-y-2 rounded-md border bg-background p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="truncate font-semibold">{ticket.title || ticket.ticket_number}</p>
|
||||
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<p className="truncate font-semibold">{ticket.ticket_number}</p>
|
||||
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
||||
</div>
|
||||
{ticket.project_id ? <Badge variant="outline" className="shrink-0">Projekt</Badge> : null}
|
||||
</div>
|
||||
{ticket.title ? <p className="text-xs text-muted-foreground">{ticket.ticket_number}</p> : null}
|
||||
{ticket.title ? <p className="truncate text-xs text-muted-foreground">{ticket.title}</p> : null}
|
||||
<p className="text-sm text-muted-foreground">{ticket.customer_name ?? "Keine Organisation"} · {workTypeLabel(ticket.work_type)} · {ticket.session_count} Session(s)</p>
|
||||
</div>
|
||||
<TicketStatus ticket={ticket} />
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -46,7 +46,7 @@ export function FaqPage() {
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-normal">FAQ und Anleitung</h2>
|
||||
<p className="text-sm text-muted-foreground">Ausführliche Anleitung für Timer, Auswertung, Teamspace, fixe Abrechnungen, Monatsabschluss und Adminfunktionen.</p>
|
||||
<p className="text-sm text-muted-foreground">Ausführliche Anleitung für Timer, Tickets, Teamspace, fixe Abrechnungen, Monatsabschluss und Adminfunktionen.</p>
|
||||
</div>
|
||||
<Badge variant="outline">{helpSections.length} Abschnitt(e)</Badge>
|
||||
</div>
|
||||
|
||||
+676
-179
File diff suppressed because it is too large.
Load diff
@@ -204,7 +204,7 @@ export function RecurringBillingsPage() {
|
||||
setRecurringForm(emptyRecurringForm());
|
||||
setEditingBillingId(null);
|
||||
toast.success(editingBillingId ? "Fixe Abrechnung gespeichert" : "Fixe Abrechnung angelegt", {
|
||||
description: "Die Sessions erscheinen automatisch in der Auswertung, sobald du den Zeitraum öffnest. Nicht mehr gültige erzeugte Sessions werden entfernt."
|
||||
description: "Die Sessions erscheinen automatisch in Tickets, sobald du den Zeitraum öffnest. Nicht mehr gültige erzeugte Sessions werden entfernt."
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Fixe Abrechnung konnte nicht angelegt werden", {
|
||||
@@ -454,7 +454,7 @@ export function RecurringBillingsPage() {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Regeln</CardTitle>
|
||||
<CardDescription>Erzeugte Einträge können in der Auswertung verschoben oder gelöscht werden.</CardDescription>
|
||||
<CardDescription>Erzeugte Einträge können in Tickets verschoben oder gelöscht werden.</CardDescription>
|
||||
</div>
|
||||
<HelpLink anchor="fixe-abrechnungen" label="Hilfe zu erzeugten fixen Sessions" />
|
||||
</div>
|
||||
|
||||
@@ -712,7 +712,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => onNavigate("/analysis")}>
|
||||
Auswertung öffnen
|
||||
Tickets öffnen
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
<HelpLink anchor="statistik-hotspots" label="Hilfe zu Ticket-Hotspots" />
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -10,10 +10,11 @@ import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { HelpLink } from "@/components/HelpLink";
|
||||
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
||||
import { createSession, lookupTicket } from "../api";
|
||||
import { createSession, getRecurringBillings, lookupTicket } from "../api";
|
||||
import { formatTimer } from "../format";
|
||||
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
|
||||
import { activeElapsedMs, pauseEntry, resumeEntry, ticketPattern, type TimerEntry } from "../timers";
|
||||
import type { TicketMeta, WorkType } from "../types";
|
||||
import type { RecurringBilling, TicketMeta, WorkType } from "../types";
|
||||
|
||||
type ManualSessionFormState = {
|
||||
ticketNumber: string;
|
||||
@@ -75,14 +76,41 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
const [savingManualSession, setSavingManualSession] = useState(false);
|
||||
const [manualLookupStatus, setManualLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
|
||||
const [manualLookupTicket, setManualLookupTicket] = useState<TicketMeta | 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);
|
||||
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();
|
||||
|
||||
@@ -104,7 +132,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
|
||||
const timeout = window.setTimeout(async () => {
|
||||
try {
|
||||
const result = await lookupTicket(normalizedTicketNumber);
|
||||
const result = await lookupTicket(normalizedTicketNumber);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -149,6 +177,16 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
return;
|
||||
}
|
||||
|
||||
const ticketResult = await lookupTicket(ticketNumber).catch(() => ({ ticket: null }));
|
||||
|
||||
if (ticketHasRecurringBilling(ticketResult.ticket, recurringBillings) || isRecurringBillingTicketNumber(ticketNumber, recurringBillings)) {
|
||||
setTimers((current) => current.filter((timer) => timer.ticketNumber !== ticketNumber));
|
||||
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 === ticketNumber)) {
|
||||
toast.error("Timer existiert bereits", {
|
||||
description: "Für dieses Ticket läuft oder pausiert schon ein Timer."
|
||||
@@ -157,7 +195,6 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const ticketResult = await lookupTicket(ticketNumber).catch(() => ({ ticket: null }));
|
||||
const newTimer: TimerEntry = {
|
||||
id: crypto.randomUUID(),
|
||||
ticketNumber,
|
||||
@@ -221,6 +258,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
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);
|
||||
setWorkType(ticket?.work_type ?? "support");
|
||||
@@ -250,6 +295,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
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;
|
||||
@@ -307,6 +360,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
return;
|
||||
}
|
||||
|
||||
if (manualTicketIsRecurringBilling) {
|
||||
toast.error("Fixe Abrechnung", {
|
||||
description: "Für fixe Abrechnungen dürfen keine Sessions nachgetragen werden. Bitte die Definition bearbeiten."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = new Date(`${manualSession.day}T${manualSession.startTime}:00`);
|
||||
const endedAt = new Date(`${manualSession.day}T${manualSession.endTime}:00`);
|
||||
|
||||
@@ -335,7 +395,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
durationSeconds
|
||||
});
|
||||
toast.success("Session nachgetragen", {
|
||||
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Auswertung offen."
|
||||
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen."
|
||||
});
|
||||
setManualSession({
|
||||
...emptyManualSession(),
|
||||
@@ -488,6 +548,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
{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" ? <p className="text-xs text-muted-foreground">Neues Ticket. Organisation und Art bitte wählen.</p> : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -562,7 +625,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={savingManualSession}>
|
||||
<Button type="submit" disabled={savingManualSession || manualTicketIsRecurringBilling}>
|
||||
<Upload className="size-4" />
|
||||
{savingManualSession ? "Speichert..." : "Nachtragen"}
|
||||
</Button>
|
||||
|
||||
+1074
-22
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user