Update
This commit is contained in:
1 parent
c89d4825e2
commit
62794f72f1
8 files changed
+402
-78
No files matched your search
+17
-9
@@ -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
|
||||
});
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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))}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user