This commit is contained in:
michael committed 2026-09-29 13:26:18 +02:00
1 parent c89d4825e2
commit 62794f72f1
8 files changed
+402 -78

No files matched your search

+17 -9
View File
@@ -85,7 +85,7 @@ import {
} from "./api";
import { currentDay, formatDateTime, formatMinutes, formatTimer } from "./format";
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "./recurring-billings";
import { activeElapsedMs, readStoredTimers, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
import { activeElapsedMs, mergeTimerActivityDrafts, readStoredTimers, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
import { formatTodoPeriod, isTodoActiveOnDay, isTodoFixedOnDay, isTodoOverdue } from "./todos";
import { AdminUsersPage } from "./views/AdminUsersPage";
import { AnalysisPage } from "./views/AnalysisPage";
@@ -888,11 +888,11 @@ export function App() {
localStorage.removeItem(storageKeyForUser(userId));
if (!cancelled) {
setTimers(serverTimers);
setTimers(mergeTimerActivityDrafts(userId, serverTimers));
}
} catch (error) {
if (!cancelled) {
setTimers(readStoredTimers(userId));
setTimers(mergeTimerActivityDrafts(userId, readStoredTimers(userId)));
toast.error("Timer konnten nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
@@ -949,7 +949,7 @@ export function App() {
try {
const result = await getActiveTimers();
setTimers(result.timers);
setTimers(mergeTimerActivityDrafts(currentUser.id, result.timers));
} catch {
// Keep the currently displayed timers if the refresh fails.
}
@@ -1092,9 +1092,10 @@ export function App() {
}, []);
async function toggleActiveTimer(timerId: string) {
const userId = currentUser?.id;
const timer = timers.find((entry) => entry.id === timerId);
if (!timer) {
if (!timer || !userId) {
return;
}
@@ -1102,7 +1103,7 @@ export function App() {
const result = await updateActiveTimer(timerId, {
phase: timer.phase === "running" ? "paused" : "running"
});
setTimers(result.timers);
setTimers(mergeTimerActivityDrafts(userId, result.timers));
} catch (error) {
toast.error("Timer konnte nicht aktualisiert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
@@ -1123,6 +1124,12 @@ export function App() {
}, [currentUser, route.page]);
async function startQuickTimer(rawTicketNumber: string) {
const userId = currentUser?.id;
if (!userId) {
return false;
}
if (currentUser?.role === "admin") {
toast.error("Admin erfasst keine Sessions", {
description: "Der Adminbereich ist nur für die Systemverwaltung vorgesehen."
@@ -1160,7 +1167,7 @@ export function App() {
if (existingTimer) {
try {
const result = await updateActiveTimer(existingTimer.id, { phase: "running" });
setTimers(result.timers);
setTimers(mergeTimerActivityDrafts(userId, result.timers));
setSelectedTimerId(existingTimer.id);
toast.info("Bestehender Timer aktiviert", {
description: ticketNumber
@@ -1189,8 +1196,9 @@ export function App() {
billingStatus: null,
phase: "running"
});
setTimers(result.timers);
setSelectedTimerId(result.timers.find((timer) => timer.ticketNumber === ticketNumber)?.id ?? null);
const timersWithDrafts = mergeTimerActivityDrafts(userId, result.timers);
setTimers(timersWithDrafts);
setSelectedTimerId(timersWithDrafts.find((timer) => timer.ticketNumber === ticketNumber)?.id ?? null);
toast.success("Timer gestartet", {
description: ticketResult.ticket?.is_project ? `${ticketNumber} ist ein Projekt` : ticketNumber
});
+135 -6
View File
@@ -20,6 +20,17 @@ function escapeHtml(value: string) {
.replace(/"/g, """);
}
function escapeAttribute(value: string) {
return escapeHtml(value).replace(/'/g, "'");
}
function escapeHtmlText(value: string) {
return escapeHtml(value)
.replace(/\t/g, "    ")
.replace(/ /g, " ")
.replace(/\r\n|\r|\n/g, "<br>");
}
function textToEditorHtml(value: string) {
return value
.split(/\r?\n/)
@@ -59,11 +70,111 @@ function hasInlineImage(element: HTMLElement) {
return Boolean(element.querySelector("img[src^='data:image/']"));
}
function textStyleTagsFromElement(element: HTMLElement) {
const style = element.getAttribute("style")?.toLowerCase() ?? "";
const tags: Array<"b" | "i" | "u" | "strike"> = [];
if (/font-weight\s*:\s*(bold|[7-9]00)\b/.test(style)) {
tags.push("b");
}
if (/font-style\s*:\s*italic\b/.test(style)) {
tags.push("i");
}
if (/text-decoration(?:-line)?\s*:[^;]*underline/.test(style)) {
tags.push("u");
}
if (/text-decoration(?:-line)?\s*:[^;]*(line-through|strike)/.test(style)) {
tags.push("strike");
}
return tags;
}
function wrapHtml(html: string, tags: string[]) {
return tags.reduceRight((current, tag) => `<${tag}>${current}</${tag}>`, html);
}
function serializeEditorChildren(element: Element | DocumentFragment) {
return Array.from(element.childNodes).map(serializeEditorNode).join("");
}
function serializeEditorNode(node: ChildNode): string {
if (node.nodeType === Node.TEXT_NODE) {
return escapeHtmlText(node.textContent ?? "");
}
if (!(node instanceof HTMLElement)) {
return "";
}
const tagName = node.tagName.toLowerCase();
const children = serializeEditorChildren(node);
if (tagName === "br") {
return "<br>";
}
if (tagName === "img") {
const src = node.getAttribute("src") ?? "";
return src.startsWith("data:image/") ? `<img src="${escapeAttribute(src)}" style="max-width:100%;">` : "";
}
if (tagName === "b" || tagName === "strong") {
return `<b>${children}</b>`;
}
if (tagName === "i" || tagName === "em") {
return `<i>${children}</i>`;
}
if (tagName === "u") {
return `<u>${children}</u>`;
}
if (tagName === "s" || tagName === "strike") {
return `<strike>${children}</strike>`;
}
if (tagName === "span" || tagName === "font") {
return wrapHtml(children, textStyleTagsFromElement(node));
}
if (tagName === "div" || tagName === "p" || tagName === "blockquote" || tagName === "pre" || tagName === "code") {
return `<${tagName}>${children || "<br>"}</${tagName}>`;
}
if (tagName === "h1" || tagName === "h2" || tagName === "h3" || tagName === "ul" || tagName === "ol" || tagName === "li") {
return `<${tagName}>${children}</${tagName}>`;
}
return children;
}
function editorHtml(element: HTMLElement) {
return serializeEditorChildren(element);
}
function normalizedEditorHtml(element: HTMLElement) {
const html = element.innerHTML;
const html = editorHtml(element);
return html && (editorPlainText(element).trim() || hasInlineImage(element)) ? html : "";
}
export function readRichActivityEditorValue(id: string) {
const element = typeof document === "undefined" ? null : document.getElementById(id);
if (!(element instanceof HTMLElement)) {
return null;
}
return {
activity: editorPlainText(element),
zammadArticleBody: normalizedEditorHtml(element)
};
}
function editorButtonClass(active = false) {
return cn(
"inline-flex size-8 items-center justify-center rounded-sm text-sm font-semibold text-zinc-100 outline-none transition-colors hover:bg-zinc-700 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
@@ -80,6 +191,8 @@ export function RichActivityEditor({
onChange
}: RichActivityEditorProps) {
const editorRef = useRef<HTMLDivElement | null>(null);
const lastEmittedTextRef = useRef("");
const lastEmittedHtmlRef = useRef("");
const [selectedImage, setSelectedImage] = useState<HTMLImageElement | null>(null);
useEffect(() => {
@@ -90,10 +203,22 @@ export function RichActivityEditor({
}
const nextHtml = htmlValue || (value ? textToEditorHtml(value) : "");
const currentHtml = normalizedEditorHtml(editor);
if (htmlValue === lastEmittedHtmlRef.current && value === lastEmittedTextRef.current) {
return;
}
if (!htmlValue && value === lastEmittedTextRef.current && currentHtml === lastEmittedHtmlRef.current) {
return;
}
if (editor.innerHTML !== nextHtml) {
editor.innerHTML = nextHtml;
}
lastEmittedTextRef.current = editorPlainText(editor);
lastEmittedHtmlRef.current = normalizedEditorHtml(editor);
}, [htmlValue, value]);
function clearSelectedImage() {
@@ -108,7 +233,11 @@ export function RichActivityEditor({
return;
}
onChange(editorPlainText(editor), normalizedEditorHtml(editor));
const text = editorPlainText(editor);
const html = normalizedEditorHtml(editor);
lastEmittedTextRef.current = text;
lastEmittedHtmlRef.current = html;
onChange(text, html);
}
function focusEditor() {
@@ -191,7 +320,7 @@ export function RichActivityEditor({
clearSelectedImage();
let insertionRange = currentSelectionRange();
const text = event.clipboardData.getData("text/plain").trim();
const text = event.clipboardData.getData("text/plain");
if (text) {
document.execCommand("styleWithCSS", false, "false");
document.execCommand("insertText", false, `${text}\n`);
@@ -216,8 +345,8 @@ export function RichActivityEditor({
const toolbarDisabled = disabled;
return (
<div className={cn("overflow-hidden rounded-lg border border-input bg-transparent", disabled && "opacity-60")}>
<div className="flex flex-wrap items-center gap-1 border-b border-zinc-700 bg-zinc-900 px-2 py-1">
<div className={cn("min-w-0 max-w-full overflow-hidden rounded-lg border border-input bg-transparent", disabled && "opacity-60")}>
<div className="flex min-w-0 flex-wrap items-center gap-1 border-b border-zinc-700 bg-zinc-900 px-2 py-1">
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Fett" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("bold")}>
<Bold className="size-4" />
</button>
@@ -258,7 +387,7 @@ export function RichActivityEditor({
aria-multiline="true"
contentEditable={!disabled}
suppressContentEditableWarning
className="min-h-24 w-full overflow-hidden bg-transparent px-2.5 py-2 text-base outline-none transition-colors empty:before:text-muted-foreground empty:before:content-[attr(data-placeholder)] focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm [&_h1]:text-2xl [&_h1]:font-semibold [&_h2]:text-xl [&_h2]:font-semibold [&_h3]:text-lg [&_h3]:font-semibold [&_img]:my-2 [&_img]:max-h-[28rem] [&_img]:max-w-full [&_img]:rounded-md [&_ol]:ml-5 [&_ol]:list-decimal [&_ul]:ml-5 [&_ul]:list-disc"
className="min-h-24 w-full min-w-0 overflow-hidden break-words bg-transparent px-2.5 py-2 text-base outline-none transition-colors [overflow-wrap:anywhere] empty:before:text-muted-foreground empty:before:content-[attr(data-placeholder)] focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm [&_h1]:text-2xl [&_h1]:font-semibold [&_h2]:text-xl [&_h2]:font-semibold [&_h3]:text-lg [&_h3]:font-semibold [&_img]:my-2 [&_img]:max-h-[28rem] [&_img]:max-w-full [&_img]:rounded-md [&_ol]:ml-5 [&_ol]:list-decimal [&_ul]:ml-5 [&_ul]:list-disc"
data-placeholder={placeholder}
onInput={emitChange}
onBlur={() => {
+72
View File
@@ -21,6 +21,7 @@ export type TimerEntry = {
const storageKeyPrefix = "tickettracker.timers";
const legacyStorageKey = "tickettracker.activeTimer";
const activityDraftStorageKeyPrefix = "tickettracker.timerActivityDrafts";
export const ticketPattern = /^Ticket#\d{6}$/;
@@ -95,6 +96,77 @@ export function storageKeyForUser(userId: string) {
return `${storageKeyPrefix}.${userId}`;
}
function activityDraftStorageKeyForUser(userId: string) {
return `${activityDraftStorageKeyPrefix}.${userId}`;
}
type TimerActivityDraft = {
activity: string;
zammadArticleBody: string;
updatedAt: number;
};
function readTimerActivityDraftMap(userId: string) {
if (typeof window === "undefined") {
return {} as Record<string, TimerActivityDraft>;
}
const raw = sessionStorage.getItem(activityDraftStorageKeyForUser(userId));
if (!raw) {
return {} as Record<string, TimerActivityDraft>;
}
try {
const parsed = JSON.parse(raw) as Record<string, TimerActivityDraft>;
return Object.fromEntries(
Object.entries(parsed).filter(([, draft]) =>
typeof draft.activity === "string" &&
typeof draft.zammadArticleBody === "string" &&
typeof draft.updatedAt === "number"
)
);
} catch {
return {} as Record<string, TimerActivityDraft>;
}
}
function writeTimerActivityDraftMap(userId: string, drafts: Record<string, TimerActivityDraft>) {
if (typeof window === "undefined") {
return;
}
sessionStorage.setItem(activityDraftStorageKeyForUser(userId), JSON.stringify(drafts));
}
export function writeTimerActivityDraft(userId: string, timerId: string, draft: Omit<TimerActivityDraft, "updatedAt">) {
const drafts = readTimerActivityDraftMap(userId);
drafts[timerId] = { ...draft, updatedAt: Date.now() };
writeTimerActivityDraftMap(userId, drafts);
}
export function removeTimerActivityDraft(userId: string, timerId: string) {
const drafts = readTimerActivityDraftMap(userId);
delete drafts[timerId];
writeTimerActivityDraftMap(userId, drafts);
}
export function mergeTimerActivityDrafts(userId: string, timers: TimerEntry[]) {
const drafts = readTimerActivityDraftMap(userId);
const timerIds = new Set(timers.map((timer) => timer.id));
const keptDrafts = Object.fromEntries(Object.entries(drafts).filter(([timerId]) => timerIds.has(timerId)));
if (Object.keys(keptDrafts).length !== Object.keys(drafts).length) {
writeTimerActivityDraftMap(userId, keptDrafts);
}
return timers.map((timer) => {
const draft = keptDrafts[timer.id];
return draft ? { ...timer, activity: draft.activity, zammadArticleBody: draft.zammadArticleBody } : timer;
});
}
export function readStoredTimers(userId: string): TimerEntry[] {
if (typeof window === "undefined") {
return [];
+6 -1
View File
@@ -808,6 +808,7 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
{
id: `${project.id}-default`,
teamspace_project: project.teamspace_project,
teamspace_description: "",
is_default: true,
hour_budget_minutes: project.hour_budget_minutes,
manual_consumption_minutes: project.manual_consumption_minutes ?? 0,
@@ -818,6 +819,10 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
];
}
function projectTeamspaceEntryLabel(entry: ProjectTeamspaceEntry) {
return entry.teamspace_description?.trim() || entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
}
function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0);
}
@@ -2348,7 +2353,7 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
<span key={entry.id} className="block">
<span className="flex min-w-0 items-center justify-between gap-2 text-[10px] text-muted-foreground">
<span className="truncate">
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
{projectTeamspaceEntryLabel(entry)}
{entry.is_default ? " · Standard" : ""}
</span>
<span className="shrink-0">
+28 -4
View File
@@ -54,6 +54,7 @@ type ProjectForm = {
type ProjectTeamspaceEntryForm = {
clientId: string;
teamspaceProject: string;
teamspaceDescription: string;
hourBudget: string;
budgetSyncDisabled: boolean;
manualConsumption: string;
@@ -142,6 +143,7 @@ function emptyProjectTeamspaceEntry(isDefault = false): ProjectTeamspaceEntryFor
return {
clientId: projectEntryId(),
teamspaceProject: "",
teamspaceDescription: "",
hourBudget: "",
budgetSyncDisabled: false,
manualConsumption: "",
@@ -328,6 +330,7 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
{
id: `${project.id}-default`,
teamspace_project: project.teamspace_project,
teamspace_description: "",
is_default: true,
hour_budget_minutes: project.hour_budget_minutes,
manual_consumption_minutes: project.manual_consumption_minutes ?? 0,
@@ -338,6 +341,10 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
];
}
function projectTeamspaceEntryLabel(entry: { teamspace_project: string | null; teamspace_description?: string | null; is_default: boolean }) {
return entry.teamspace_description?.trim() || entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
}
function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0);
}
@@ -457,7 +464,7 @@ function projectMatchesQuery(project: ProjectSummary, query: string) {
project.teamspace_project ?? "",
zammadStateLabel(project.zammad_state_id),
zammadPriorityLabel(project.zammad_priority_id),
...projectTeamspaceEntries(project).map((entry) => entry.teamspace_project ?? ""),
...projectTeamspaceEntries(project).flatMap((entry) => [entry.teamspace_project ?? "", entry.teamspace_description ?? ""]),
project.description,
project.goals
];
@@ -662,7 +669,8 @@ function projectGroupMeta(project: ProjectSummary, groupBy: ProjectGroupBy, stan
}
if (groupBy === "teamspace") {
const label = defaultProjectTeamspaceEntry(project)?.teamspace_project || "Ohne Teamspace-ID";
const defaultEntry = defaultProjectTeamspaceEntry(project);
const label = defaultEntry ? projectTeamspaceEntryLabel(defaultEntry) : "Ohne Teamspace-ID";
return { key: `teamspace:${label.toLocaleLowerCase("de-DE")}`, label, sortLabel: label, rank: 0 };
}
@@ -714,6 +722,7 @@ function formFromProject(project: ProjectSummary): ProjectForm {
const entries = projectTeamspaceEntries(project).map((entry, index) => ({
clientId: entry.id || projectEntryId(),
teamspaceProject: entry.teamspace_project ?? "",
teamspaceDescription: entry.teamspace_description ?? "",
hourBudget: formatHoursInput(entry.hour_budget_minutes),
budgetSyncDisabled: entry.budget_sync_disabled ?? false,
manualConsumption: formatHoursInput(entry.manual_consumption_minutes),
@@ -768,6 +777,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
return {
id: /^\d+$/.test(entry.clientId) ? entry.clientId : null,
teamspaceProject: entry.teamspaceProject.trim() || null,
teamspaceDescription: entry.teamspaceDescription.trim() || null,
isDefault: entry.isDefault,
hourBudgetMinutes,
budgetSyncDisabled: entry.budgetSyncDisabled,
@@ -782,6 +792,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
return parsedEntries as Array<{
id?: string | null;
teamspaceProject: string | null;
teamspaceDescription: string | null;
isDefault: boolean;
hourBudgetMinutes: number | null;
budgetSyncDisabled: boolean;
@@ -825,7 +836,7 @@ function ProjectTeamspaceEntriesFields({
</div>
<div className="space-y-2">
{normalizedEntries.map((entry, index) => (
<div key={entry.clientId} className="grid gap-2 rounded-lg border bg-muted/10 p-3 lg:grid-cols-[minmax(180px,1fr)_130px_150px_130px_120px_36px] lg:items-end">
<div key={entry.clientId} className="grid gap-2 rounded-lg border bg-muted/10 p-3 lg:grid-cols-[minmax(180px,1fr)_minmax(160px,0.9fr)_130px_150px_130px_120px_36px] lg:items-end">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-${entry.clientId}`}>
Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""}
@@ -836,6 +847,17 @@ function ProjectTeamspaceEntriesFields({
onChange={(event) => updateEntry(entry.clientId, { teamspaceProject: event.currentTarget.value })}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-description-${entry.clientId}`}>
Alias
</label>
<Input
id={`project-teamspace-description-${entry.clientId}`}
value={entry.teamspaceDescription}
placeholder="z.B. Cloud-Umzug"
onChange={(event) => updateEntry(entry.clientId, { teamspaceDescription: event.currentTarget.value })}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-budget-${entry.clientId}`}>
Budget h
@@ -1153,6 +1175,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
? zammadEntries.map((entry, index) => ({
clientId: entry.id || projectEntryId(),
teamspaceProject: entry.teamspace_project ?? "",
teamspaceDescription: entry.teamspace_description ?? "",
hourBudget: formatHoursInput(entry.hour_budget_minutes),
budgetSyncDisabled: entry.budget_sync_disabled ?? false,
manualConsumption: "",
@@ -1165,6 +1188,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
? {
...entry,
teamspaceProject: entry.teamspaceProject || result.ticket?.project_name || "",
teamspaceDescription: entry.teamspaceDescription,
hourBudget: entry.hourBudget || formatHoursInput(result.ticket?.project_hour_budget_minutes ?? null),
budgetSyncDisabled: entry.hourBudget ? entry.budgetSyncDisabled : false
}
@@ -1754,7 +1778,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
{budgetedTeamspaceEntries.map((entry) => {
const entryPercent = projectEntryPercent(entry);
const entryConsumption = projectEntryConsumption(entry);
const label = entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
const label = projectTeamspaceEntryLabel(entry);
return (
<div key={entry.id} className="min-w-0 rounded-md border bg-background/50 px-2 py-1">
+41 -14
View File
@@ -12,7 +12,7 @@ import { Textarea } from "@/components/ui/textarea";
import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { RichActivityEditor } from "@/components/RichActivityEditor";
import { readRichActivityEditorValue, RichActivityEditor } from "@/components/RichActivityEditor";
import { TodoPanel } from "@/components/TodoPanel";
import {
createSession,
@@ -240,14 +240,18 @@ function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0);
}
function teamspaceEntryOptionLabel(entry: { teamspace_project: string | null; is_default: boolean }) {
const label = entry.teamspace_project || "Ohne Teamspace-ID";
function teamspaceEntryDisplayLabel(entry: { teamspace_project: string | null; teamspace_description?: string | null; is_default: boolean }) {
return entry.teamspace_description?.trim() || entry.teamspace_project || "Ohne Teamspace-ID";
}
function teamspaceEntryOptionLabel(entry: { teamspace_project: string | null; teamspace_description?: string | null; is_default: boolean }) {
const label = teamspaceEntryDisplayLabel(entry);
return entry.is_default ? `${label} (Standard)` : label;
}
function teamspaceEntryDescription(entry: { teamspace_description?: string | null } | null | undefined) {
return entry?.teamspace_description?.trim() || "";
function teamspaceEntryDescription(entry: { teamspace_project?: string | null; teamspace_description?: string | null } | null | undefined) {
return entry?.teamspace_description?.trim() ? entry.teamspace_project?.trim() || "" : "";
}
function teamspaceBillingKey(day: string, projectTeamspaceEntryId: string | null | undefined) {
@@ -495,6 +499,19 @@ function teamspacePreparationText(ticket: TicketPeriod["ticket"] | null | undefi
return [`${ticketNumber} - ${customerName}`, "", activities, "", "Tickethandling und Dokumentation"].join("\n");
}
function SessionActivityBody({ session }: { session: SessionEntry }) {
if (session.zammad_article_body) {
return (
<div
className="break-words text-sm font-medium [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_br]:block [&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-1 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:text-base [&_h3]:font-semibold [&_img]:my-2 [&_img]:max-h-80 [&_img]:max-w-full [&_img]:rounded-md [&_ol]:ml-5 [&_ol]:list-decimal [&_p]:my-1 [&_pre]:whitespace-pre-wrap [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-2 [&_strike]:line-through [&_ul]:ml-5 [&_ul]:list-disc"
dangerouslySetInnerHTML={{ __html: session.zammad_article_body }}
/>
);
}
return <p className="whitespace-pre-wrap break-words text-sm font-medium">{session.activity || "Keine Tätigkeit erfasst."}</p>;
}
function toDateLocalValue(value: string) {
const date = new Date(value);
const offsetMs = date.getTimezoneOffset() * 60_000;
@@ -921,7 +938,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
return [
...projectEntries.map((entry) => ({
id: entry.id,
label: entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID"),
label: teamspaceEntryDisplayLabel(entry),
description: teamspaceEntryDescription(entry),
entry
})),
@@ -1446,18 +1463,22 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
setSavingTicketSession(true);
try {
const activityValue = readRichActivityEditorValue("ticket-session-activity") ?? {
activity: ticketSession.activity,
zammadArticleBody: ticketSession.zammadArticleBody
};
const result = await createSession({
ticketNumber: ticket.ticket_number,
organizationId: effectiveOrganizationId,
activity: ticketSession.activity,
zammadArticleBody: ticketSession.zammadArticleBody || null,
activity: activityValue.activity,
zammadArticleBody: activityValue.zammadArticleBody || null,
workType: effectiveWorkType,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
durationSeconds,
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null,
billingStatus: ticketSession.billingStatus,
createZammadArticle: ticketSession.createZammadArticle,
createZammadArticle: ticketSession.createZammadArticle && (activityValue.activity.trim().length > 0 || Boolean(activityValue.zammadArticleBody)),
zammadArticleTypeId: ticketSession.zammadArticleTypeId
});
toast.success("Session nachgetragen", {
@@ -1563,10 +1584,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
setSavingSessionDetails(true);
try {
const activityValue = readRichActivityEditorValue("session-activity") ?? {
activity: editSessionActivity,
zammadArticleBody: editSessionZammadArticleBody
};
await updateSessionDetails(editingSession.id, {
organizationId: editSessionOrganizationId,
activity: editSessionActivity,
zammadArticleBody: editSessionZammadArticleBody || null,
activity: activityValue.activity,
zammadArticleBody: activityValue.zammadArticleBody || null,
workType: editSessionWorkType,
removeProjectData,
projectTeamspaceEntryId: editSessionWorkType === "consulting" && requiresProjectTeamspaceEntry ? editSessionProjectTeamspaceEntryId : null,
@@ -1855,7 +1880,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
? `Eigentümer: ${project.owner_display_name}`
: showBudgetedProjectEntries
? `${budgetedProjectEntries.length} budgetierte Teamspace-Einträge`
: budgetedProjectEntries[0]?.teamspace_project || project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}
: budgetedProjectEntries[0] ? teamspaceEntryDisplayLabel(budgetedProjectEntries[0]) : project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}
</CardDescription>
</div>
<div className="flex shrink-0 items-center gap-1">
@@ -1927,7 +1952,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
{budgetedProjectEntries.map((entry) => {
const consumption = Number(entry.consumption_minutes ?? 0);
const percent = projectConsumptionPercent(consumption, entry.hour_budget_minutes);
const label = entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
const label = teamspaceEntryDisplayLabel(entry);
return (
<div key={entry.id} className="rounded-md border bg-background/50 px-2 py-1">
@@ -2371,7 +2396,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</div>
{expanded ? (
<div className="border-t px-3 pb-3 pt-2">
<p className="whitespace-pre-wrap break-words text-sm font-medium">{session.activity || "Keine Tätigkeit erfasst."}</p>
<SessionActivityBody session={session} />
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>{session.organization_name ?? session.customer_name}</span>
<span>{session.work_type === "support" ? "Support" : "Consulting"}</span>
@@ -2566,6 +2591,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
Tätigkeit
</label>
<RichActivityEditor
key={`ticket-session-${ticketId}`}
id="ticket-session-activity"
value={ticketSession.activity}
htmlValue={ticketSession.zammadArticleBody}
@@ -2727,6 +2753,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="session-activity">Tätigkeit</label>
<RichActivityEditor
key={editingSession?.id ?? "session-activity-empty"}
id="session-activity"
value={editSessionActivity}
htmlValue={editSessionZammadArticleBody}
+48 -32
View File
@@ -11,12 +11,12 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { RichActivityEditor } from "@/components/RichActivityEditor";
import { readRichActivityEditorValue, RichActivityEditor } from "@/components/RichActivityEditor";
import { createActiveTimer, createSession, deleteActiveTimer, getRecurringBillings, lookupTicket, updateActiveTimer } from "../api";
import type { CreateSessionResult } from "../api";
import { formatTimer } from "../format";
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
import { activeElapsedMs, ticketPattern, type TimerEntry } from "../timers";
import { activeElapsedMs, mergeTimerActivityDrafts, removeTimerActivityDraft, ticketPattern, type TimerEntry, writeTimerActivityDraft } from "../timers";
import type { AuthUser, BillingStatus, RecurringBilling, TicketMeta, WorkType } from "../types";
import { zammadArticleTypeFromValue, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
@@ -79,14 +79,14 @@ 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";
function teamspaceEntryOptionLabel(entry: { teamspace_project: string | null; teamspace_description?: string | null; is_default: boolean }) {
const label = entry.teamspace_description?.trim() || entry.teamspace_project || "Ohne Teamspace-ID";
return entry.is_default ? `${label} (Standard)` : label;
}
function teamspaceEntryDescription(entry: { teamspace_description?: string | null } | null | undefined) {
return entry?.teamspace_description?.trim() || "";
function teamspaceEntryDescription(entry: { teamspace_project?: string | null; teamspace_description?: string | null } | null | undefined) {
return entry?.teamspace_description?.trim() ? entry.teamspace_project?.trim() || "" : "";
}
function requiresTeamspaceEntry(ticket: TicketMeta | null | undefined) {
@@ -496,7 +496,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
billingStatus: null,
phase: "running"
});
setTimers(result.timers);
setTimers(mergeTimerActivityDrafts(currentUser.id, result.timers));
setSelectedTimerId(result.timers.find((timer) => timer.ticketNumber === normalizedTicketNumber)?.id ?? null);
setTicketNumber("");
} catch (error) {
@@ -583,7 +583,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
async function activateTimer(timerId: string) {
try {
const result = await updateActiveTimer(timerId, { phase: "running" });
setTimers(result.timers);
setTimers(mergeTimerActivityDrafts(currentUser.id, result.timers));
setSelectedTimerId(timerId);
} catch (error) {
toast.error("Timer konnte nicht aktiviert werden", {
@@ -595,7 +595,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
async function pauseTimer(timerId: string) {
try {
const result = await updateActiveTimer(timerId, { phase: "paused" });
setTimers(result.timers);
setTimers(mergeTimerActivityDrafts(currentUser.id, result.timers));
setSelectedTimerId(timerId);
} catch (error) {
toast.error("Timer konnte nicht pausiert werden", {
@@ -607,7 +607,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
async function resetTimer(timerId: string) {
try {
const result = await deleteActiveTimer(timerId);
setTimers(result.timers);
removeTimerActivityDraft(currentUser.id, timerId);
setTimers(mergeTimerActivityDrafts(currentUser.id, result.timers));
} catch (error) {
toast.error("Timer konnte nicht entfernt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
@@ -617,6 +618,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
function updateTimerActivity(timerId: string, nextActivity: string, zammadArticleBody: string) {
activityDrafts.current.set(timerId, { activity: nextActivity, zammadArticleBody });
writeTimerActivityDraft(currentUser.id, timerId, { activity: nextActivity, zammadArticleBody });
setTimers((current) => current.map((timer) => (timer.id === timerId ? { ...timer, activity: nextActivity, zammadArticleBody } : timer)));
const existingTimeout = activitySaveTimeouts.current.get(timerId);
@@ -636,7 +638,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
function updateTimerBillingStatus(timerId: string, billingStatus: BillingStatus) {
setTimers((current) => current.map((timer) => (timer.id === timerId ? { ...timer, billingStatus } : timer)));
void updateActiveTimer(timerId, { billingStatus }).then((result) => setTimers(result.timers)).catch((error) => {
void updateActiveTimer(timerId, { billingStatus }).then((result) => setTimers(mergeTimerActivityDrafts(currentUser.id, result.timers))).catch((error) => {
toast.error("Bewertung konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
@@ -666,7 +668,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
activity: timerActivity,
zammadArticleBody: timerZammadArticleBody || null
});
setTimers(timerResult.timers);
setTimers(mergeTimerActivityDrafts(currentUser.id, timerResult.timers));
setSelectedTimerId(timer.id);
const result = await lookupTicket(timer.ticketNumber);
@@ -674,6 +676,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
if (ticketHasRecurringBilling(ticket, recurringBillings) || isRecurringBillingTicketNumber(timer.ticketNumber, recurringBillings)) {
const timerResult = await deleteActiveTimer(timer.id).catch(() => null);
removeTimerActivityDraft(currentUser.id, timer.id);
setTimers((current) => timerResult?.timers ?? 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."
@@ -746,19 +749,23 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setSaving(true);
try {
const activityValue = readRichActivityEditorValue("finished-activity") ?? {
activity: finished.activity,
zammadArticleBody: finished.zammadArticleBody
};
const result = await createSession({
ticketNumber: finished.ticketNumber,
startedAt: finished.startedAt,
endedAt: finished.endedAt,
durationSeconds: finished.durationSeconds,
organizationId: effectiveOrganizationId,
activity: finished.activity,
zammadArticleBody: finished.zammadArticleBody || null,
activity: activityValue.activity,
zammadArticleBody: activityValue.zammadArticleBody || null,
workType: effectiveWorkType,
zammadLookup: Boolean(finished.ticket?.zammad_ticket_id),
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null,
billingStatus: finished.billingStatus,
createZammadArticle: finished.createZammadArticle && (finished.activity.trim().length > 0 || Boolean(finished.zammadArticleBody)),
createZammadArticle: finished.createZammadArticle && (activityValue.activity.trim().length > 0 || Boolean(activityValue.zammadArticleBody)),
zammadArticleTypeId: finished.zammadArticleTypeId
});
@@ -768,8 +775,10 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
notifyZammadArticleResult(result);
try {
const timerResult = await deleteActiveTimer(finished.timerId);
setTimers(timerResult.timers);
removeTimerActivityDraft(currentUser.id, finished.timerId);
setTimers(mergeTimerActivityDrafts(currentUser.id, timerResult.timers));
} catch (error) {
removeTimerActivityDraft(currentUser.id, finished.timerId);
setTimers((current) => current.filter((timer) => timer.id !== finished.timerId));
toast.warning("Session gespeichert, Timer lokal entfernt", {
description: error instanceof Error ? error.message : "Der Timer konnte serverseitig nicht entfernt werden."
@@ -836,11 +845,15 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setSavingManualSession(true);
try {
const activityValue = readRichActivityEditorValue("manual-activity") ?? {
activity: manualSession.activity,
zammadArticleBody: manualSession.zammadArticleBody
};
const result = await createSession({
ticketNumber: manualSession.ticketNumber.trim(),
organizationId: manualSession.organizationId,
activity: manualSession.activity,
zammadArticleBody: manualSession.zammadArticleBody || null,
activity: activityValue.activity,
zammadArticleBody: activityValue.zammadArticleBody || null,
workType: manualSession.workType,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
@@ -848,7 +861,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id),
projectTeamspaceEntryId: requiresTeamspaceEntry(manualLookupTicket) ? manualSession.projectTeamspaceEntryId : null,
billingStatus: manualSession.billingStatus,
createZammadArticle: manualSession.createZammadArticle,
createZammadArticle: manualSession.createZammadArticle && (activityValue.activity.trim().length > 0 || Boolean(activityValue.zammadArticleBody)),
zammadArticleTypeId: manualSession.zammadArticleTypeId
});
toast.success("Session nachgetragen", {
@@ -959,6 +972,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
Tätigkeit
</label>
<RichActivityEditor
key={selectedTimer?.id ?? "timer-activity-empty"}
id="timer-activity"
value={selectedTimer?.activity ?? ""}
htmlValue={selectedTimer?.zammadArticleBody ?? ""}
@@ -1160,6 +1174,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-activity">Tätigkeit</label>
<RichActivityEditor
key="manual-activity"
id="manual-activity"
value={manualSession.activity}
htmlValue={manualSession.zammadArticleBody}
@@ -1207,7 +1222,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</section>
<Dialog open={Boolean(finished)} onOpenChange={(open) => !open && setFinished(null)}>
<DialogContent>
<DialogContent className="w-[calc(100vw-2rem)] max-w-3xl overflow-x-hidden sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Session abschließen</DialogTitle>
<DialogDescription>
@@ -1215,7 +1230,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={submitSession}>
<form className="min-w-0 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()}>
@@ -1225,9 +1240,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</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">
<div className="min-w-0 rounded-lg border bg-muted/40 p-3 text-sm">
<p className="break-words font-medium">{finished.ticket.organization_name ?? finished.ticket.customer_name}</p>
<p className="break-words text-muted-foreground">
{finished.ticket.is_project ? "Projekt erkannt. Consulting" : finished.ticket.work_type === "support" ? "Support" : "Consulting"} wurde vom bestehenden Ticket übernommen.
</p>
</div>
@@ -1250,11 +1265,12 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
/>
</div>
)}
<div className="space-y-2">
<div className="min-w-0 space-y-2">
<label className="text-sm font-medium" htmlFor="finished-activity">
Tätigkeit
</label>
<RichActivityEditor
key={finished?.timerId ?? "finished-activity-empty"}
id="finished-activity"
value={finished?.activity ?? ""}
htmlValue={finished?.zammadArticleBody ?? ""}
@@ -1269,8 +1285,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
/>
</div>
{finished ? (
<div className="space-y-3 rounded-md border bg-muted/30 p-3">
<div className="flex items-start gap-3">
<div className="min-w-0 space-y-3 rounded-md border bg-muted/30 p-3">
<div className="flex min-w-0 items-start gap-3">
{(() => {
const hasZammadContent = finished.activity.trim().length > 0 || Boolean(finished.zammadArticleBody);
@@ -1282,11 +1298,11 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
disabled={!hasZammadContent}
onCheckedChange={(checked) => setFinished((current) => (current ? { ...current, createZammadArticle: checked === true && (current.activity.trim().length > 0 || Boolean(current.zammadArticleBody)) } : current))}
/>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="finished-zammad-article">
<div className="min-w-0 space-y-1">
<label className="break-words text-sm font-medium" htmlFor="finished-zammad-article">
Tätigkeit als interne Zammad-Notiz übertragen
</label>
<p className="text-xs text-muted-foreground">
<p className="break-words text-xs text-muted-foreground">
{hasZammadContent
? "Erstellt einen internen Zammad-Article mit Tätigkeit und getrackter Zeit."
: "Ohne Tätigkeit wird keine Zammad-Notiz erstellt."}
@@ -1300,13 +1316,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
})()}
</div>
{finished.createZammadArticle ? (
<div className="space-y-2">
<div className="min-w-0 space-y-2">
<label className="text-sm font-medium" htmlFor="finished-zammad-article-type">
Zammad-Typ
</label>
<select
id="finished-zammad-article-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"
className="h-10 w-full min-w-0 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={zammadArticleTypeValue(finished.zammadArticleTypeId)}
onChange={(event) => setFinished((current) => (current ? { ...current, zammadArticleTypeId: zammadArticleTypeFromValue(event.currentTarget.value) } : current))}
>