From 62794f72f173134e41cd07bde4838b0a3bd67d72 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 29 Sep 2026 13:26:18 +0200 Subject: [PATCH] Update --- backend/src/index.ts | 67 +++++++-- frontend/src/App.tsx | 26 ++-- .../src/components/RichActivityEditor.tsx | 141 +++++++++++++++++- frontend/src/timers.ts | 72 +++++++++ frontend/src/views/DashboardPage.tsx | 7 +- frontend/src/views/ProjectsPage.tsx | 32 +++- frontend/src/views/TicketDetailPage.tsx | 55 +++++-- frontend/src/views/TimerPage.tsx | 80 ++++++---- 8 files changed, 402 insertions(+), 78 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 1f568fb..ac7dec2 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -343,12 +343,24 @@ function sanitizeZammadArticleHtml(value: string) { return `${tags.open}${tag.replace(/^]*>/i, "").replace(/<\/span>$/i, "")}${tags.close}`; }); + const allowedArticleTags = new Set(["br", "div", "p", "span", "b", "strong", "i", "em", "u", "s", "strike", "ul", "ol", "li", "blockquote", "pre", "code", "h1", "h2", "h3"]); const sanitized = normalizedFormatting - .replace(/<\s*(\/?)\s*(br|div|p|span|b|strong|i|em|u|s|strike|ul|ol|li|blockquote|pre|code|h1|h2|h3)\b[^>]*>/gi, (_tag, closing: string, rawName: string) => { + .replace(/<[^>]*>/g, (tag) => { + const match = /^<\s*(\/?)\s*([a-z][a-z0-9]*)\b[^>]*\/?\s*>$/i.exec(tag); + + if (!match) { + return ""; + } + + const [, closing, rawName] = match; const name = rawName.toLowerCase(); + + if (!allowedArticleTags.has(name)) { + return ""; + } + return name === "br" ? "
" : `<${closing ? "/" : ""}${name}>`; }) - .replace(/<[^>]*>/g, "") .replace(new RegExp(`${imagePlaceholderPrefix}(\\d+)_`, "g"), (_placeholder, index: string) => images[Number(index)] ?? ""); if (!htmlToPlainText(sanitized) && images.length === 0) { @@ -790,7 +802,13 @@ async function getZammadCredentialsForUser(userId: string) { throw badRequest("Zammad URL muss zuerst im Adminbereich gespeichert werden"); } - const apiToken = decryptSecret(userResult.rows[0]?.zammad_api_token); + let apiToken: string | null = null; + + try { + apiToken = decryptSecret(userResult.rows[0]?.zammad_api_token); + } catch { + throw badRequest("Der gespeicherte Zammad-Token kann nicht entschlüsselt werden. Bitte den persönlichen Zammad-Token im Profil neu speichern."); + } if (!apiToken) { throw badRequest("Zammad-Token muss zuerst im Profil hinterlegt werden"); @@ -874,6 +892,7 @@ async function zammadTimeUnitForUser(userId: string, durationSeconds: number) { type ZammadSessionArticlePayload = { ticket_id: number; type: "note"; + sender: "Agent"; internal: true; subject: string; body: string; @@ -898,6 +917,7 @@ function zammadSessionArticlePayload(input: { return { ticket_id: ticketId, type: "note", + sender: "Agent", internal: true, subject: "TicketTracker Session", body: input.body, @@ -912,7 +932,7 @@ function zammadSessionArticleBodyForDelivery(body: string, contentType: "text/pl return body; } - return `
${body}
`; + return /<\s*[a-z][\s/>]/i.test(body) ? body : `
${body}
`; } async function postZammadSessionArticleForUser(userId: string, payload: ZammadSessionArticlePayload) { @@ -1077,6 +1097,13 @@ async function postImmediateZammadSessionArticle(input: { timeUnit: input.timeUnit, articleTypeId: input.articleTypeId }); + logDebug("zammad-session-article-post", { + sessionId: input.sessionId, + contentType: input.articleContentType, + sourceBodyLength: input.articleBody.length, + deliveryBodyLength: payload.body.length, + deliveryBodyHasHtml: payload.body.includes("<") + }); const zammadResponse = await postZammadSessionArticleForUser(input.userId, payload); const articleId = zammadArticleId(zammadResponse); @@ -1278,6 +1305,10 @@ async function sendZammadSessionArticleForSession(userId: string, sessionId: num throw badRequest("Ohne Tätigkeit kann keine Zammad-Notiz gesendet werden"); } + if (!session.activity_html) { + throw badRequest("Diese Session hat keinen HTML-Notizinhalt. Bitte Session bearbeiten und speichern, dann erneut an Zammad übertragen."); + } + if (session.source_article_id !== null) { throw badRequest("Importierte Zammad-Mails können nicht erneut als Zammad-Notiz gesendet werden"); } @@ -1292,8 +1323,8 @@ async function sendZammadSessionArticleForSession(userId: string, sessionId: num } const timeUnit = await zammadTimeUnitForUser(userId, Number(session.duration_seconds)); - const articleBody = session.activity_html ?? session.activity; - const articleContentType = session.activity_html ? "text/html" as const : "text/plain" as const; + const articleBody = session.activity_html; + const articleContentType = "text/html" as const; const articleId = await postImmediateZammadSessionArticle({ userId, sessionId: session.session_id, @@ -1610,6 +1641,15 @@ async function createSessionForUser(input: SessionCreationInput) { throw Object.assign(new Error("Monat ist abgeschlossen"), { status: 409 }); } + if (createZammadArticle && !zammadArticleHtml) { + logDebug("zammad-session-article-missing-html", { + userId, + ticketNumber, + activityLength: activity.length + }); + throw badRequest("HTML-Inhalt der Zammad-Notiz fehlt. Bitte Seite neu laden und erneut speichern."); + } + const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60)); const existingLocalTicket = useZammadLookup ? await ticketExists(ticketNumber) : false; const zammadTicket = providedZammadTicket !== undefined @@ -1800,9 +1840,9 @@ async function createSessionForUser(input: SessionCreationInput) { ); const session = sessionResult.rows[0]; - const zammadArticleBody = zammadArticleHtml ?? activity; - const zammadArticleContentType = zammadArticleHtml ? "text/html" as const : "text/plain" as const; - const shouldCreateZammadArticle = createZammadArticle && (activity.trim().length > 0 || Boolean(zammadArticleHtml)); + const zammadArticleBody = zammadArticleHtml ?? ""; + const zammadArticleContentType = "text/html" as const; + const shouldCreateZammadArticle = createZammadArticle && Boolean(zammadArticleHtml); const zammadArticle: { requested: boolean; status: "not_requested" | "sent" | "failed"; @@ -5411,12 +5451,15 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use s.activity, s.activity_html AS zammad_article_body, s.work_type, + s.project_teamspace_entry_id::text AS project_teamspace_entry_id, + COALESCE(NULLIF(pte.teamspace_description, ''), pte.teamspace_project) AS project_teamspace_project, s.started_at, s.rounded_minutes, s.recurring_billing_id::text AS recurring_billing_id FROM sessions s JOIN tickets t ON t.id = s.ticket_id LEFT JOIN organizations so ON so.zammad_id = s.organization_id + LEFT JOIN project_teamspace_entries pte ON pte.id = s.project_teamspace_entry_id WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz AND s.user_id = $3 @@ -5504,7 +5547,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio COALESCE(so.name, ot.name, s.customer_name, t.customer_name, 'Keine Organisation') AS organization_name, s.activity, s.work_type, - COALESCE(pte.teamspace_project, p.teamspace_project) AS project_teamspace_project, + COALESCE(NULLIF(pte.teamspace_description, ''), pte.teamspace_project, p.teamspace_project) AS project_teamspace_project, s.started_at, (s.started_at AT TIME ZONE 'Europe/Berlin')::date AS day, s.rounded_minutes, @@ -6046,7 +6089,7 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke ) AS zammad_article_type_id, project_teamspace_entry_id::text AS project_teamspace_entry_id, ( - SELECT teamspace_project + SELECT COALESCE(NULLIF(teamspace_description, ''), teamspace_project) FROM project_teamspace_entries WHERE id = sessions.project_teamspace_entry_id ) AS project_teamspace_project @@ -6208,7 +6251,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon za.zammad_article_id::text AS zammad_article_id, za.zammad_article_type_id, s.project_teamspace_entry_id::text AS project_teamspace_entry_id, - pte.teamspace_project AS project_teamspace_project + COALESCE(NULLIF(pte.teamspace_description, ''), pte.teamspace_project) AS project_teamspace_project FROM sessions s LEFT JOIN project_teamspace_entries pte ON pte.id = s.project_teamspace_entry_id LEFT JOIN LATERAL ( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3f0d661..7097583 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 }); diff --git a/frontend/src/components/RichActivityEditor.tsx b/frontend/src/components/RichActivityEditor.tsx index ae807e1..f23c010 100644 --- a/frontend/src/components/RichActivityEditor.tsx +++ b/frontend/src/components/RichActivityEditor.tsx @@ -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, "
"); +} + 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}`, 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 "
"; + } + + if (tagName === "img") { + const src = node.getAttribute("src") ?? ""; + return src.startsWith("data:image/") ? `` : ""; + } + + if (tagName === "b" || tagName === "strong") { + return `${children}`; + } + + if (tagName === "i" || tagName === "em") { + return `${children}`; + } + + if (tagName === "u") { + return `${children}`; + } + + if (tagName === "s" || tagName === "strike") { + return `${children}`; + } + + if (tagName === "span" || tagName === "font") { + return wrapHtml(children, textStyleTagsFromElement(node)); + } + + if (tagName === "div" || tagName === "p" || tagName === "blockquote" || tagName === "pre" || tagName === "code") { + return `<${tagName}>${children || "
"}`; + } + + if (tagName === "h1" || tagName === "h2" || tagName === "h3" || tagName === "ul" || tagName === "ol" || tagName === "li") { + return `<${tagName}>${children}`; + } + + 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(null); + const lastEmittedTextRef = useRef(""); + const lastEmittedHtmlRef = useRef(""); const [selectedImage, setSelectedImage] = useState(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 ( -
-
+
+
@@ -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={() => { diff --git a/frontend/src/timers.ts b/frontend/src/timers.ts index 159eab1..c115a83 100644 --- a/frontend/src/timers.ts +++ b/frontend/src/timers.ts @@ -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; + } + + const raw = sessionStorage.getItem(activityDraftStorageKeyForUser(userId)); + + if (!raw) { + return {} as Record; + } + + try { + const parsed = JSON.parse(raw) as Record; + return Object.fromEntries( + Object.entries(parsed).filter(([, draft]) => + typeof draft.activity === "string" && + typeof draft.zammadArticleBody === "string" && + typeof draft.updatedAt === "number" + ) + ); + } catch { + return {} as Record; + } +} + +function writeTimerActivityDraftMap(userId: string, drafts: Record) { + if (typeof window === "undefined") { + return; + } + + sessionStorage.setItem(activityDraftStorageKeyForUser(userId), JSON.stringify(drafts)); +} + +export function writeTimerActivityDraft(userId: string, timerId: string, draft: Omit) { + 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 []; diff --git a/frontend/src/views/DashboardPage.tsx b/frontend/src/views/DashboardPage.tsx index 764a988..46265fa 100644 --- a/frontend/src/views/DashboardPage.tsx +++ b/frontend/src/views/DashboardPage.tsx @@ -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 - {entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")} + {projectTeamspaceEntryLabel(entry)} {entry.is_default ? " · Standard" : ""} diff --git a/frontend/src/views/ProjectsPage.tsx b/frontend/src/views/ProjectsPage.tsx index 7db8a90..eb325c2 100644 --- a/frontend/src/views/ProjectsPage.tsx +++ b/frontend/src/views/ProjectsPage.tsx @@ -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({
{normalizedEntries.map((entry, index) => ( -
+
+
+ + updateEntry(entry.clientId, { teamspaceDescription: event.currentTarget.value })} + /> +