This commit is contained in:
michael committed 2026-09-15 10:49:33 +00:00
1 parent c01d747352
commit 235c19e51b
10 files changed
+175 -31

No files matched your search

+3 -3
View File
@@ -69,7 +69,7 @@ import { cn } from "./lib/utils";
import { ApiError, deleteTodo, getCurrentUser, getRecurringBillings, getSetupStatus, getTodos, logout, lookupTicket, searchTickets, updateTodo } from "./api";
import { currentDay, formatDateTime, formatMinutes, formatTimer } from "./format";
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "./recurring-billings";
import { activeElapsedMs, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
import { activeElapsedMs, createClientId, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
import { formatTodoPeriod, isTodoActiveOnDay, isTodoFixedOnDay, isTodoOverdue } from "./todos";
import { AdminUsersPage } from "./views/AdminUsersPage";
import { AnalysisPage } from "./views/AnalysisPage";
@@ -679,7 +679,7 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
<button
key={`fixed-${todo.id}`}
type="button"
className="block w-full rounded-md border bg-background/70 px-2 py-1.5 text-left hover:bg-muted"
className={`block w-full rounded-md border px-2 py-1.5 text-left hover:brightness-110 ${sidebarTodoCalendarClasses[todo.priority]}`}
onClick={() => onNavigate(`/tickets/${todo.ticket_id}`)}
>
<span className="block truncate text-[11px] font-semibold">{todo.title}</span>
@@ -1056,7 +1056,7 @@ export function App() {
}
const newTimer: TimerEntry = {
id: crypto.randomUUID(),
id: createClientId("timer"),
ticketNumber,
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
workType: ticketResult.ticket?.work_type ?? null,
+1
View File
@@ -49,6 +49,7 @@ export type ZammadSyncExistingFields = {
projectName: boolean;
budget: boolean;
overrideBudget: boolean;
pendingTodo: boolean;
};
export type ZammadSyncChange = {
+24 -1
View File
@@ -8,9 +8,32 @@ type CopyTicketButtonProps = {
};
export function CopyTicketButton({ ticketNumber }: CopyTicketButtonProps) {
async function writeToClipboard(value: string) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return;
}
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.setAttribute("readonly", "");
document.body.appendChild(textarea);
textarea.select();
try {
if (!document.execCommand("copy")) {
throw new Error("Zwischenablage nicht verfügbar");
}
} finally {
document.body.removeChild(textarea);
}
}
async function copyTicketNumber() {
try {
await navigator.clipboard.writeText(ticketNumber);
await writeToClipboard(ticketNumber);
toast.success("Ticketnummer kopiert", {
description: ticketNumber
});
+9 -1
View File
@@ -19,6 +19,14 @@ const legacyStorageKey = "tickettracker.activeTimer";
export const ticketPattern = /^Ticket#\d{6}$/;
export function createClientId(prefix = "client") {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export function pauseEntry(timer: TimerEntry, now = Date.now()): TimerEntry {
if (timer.phase === "paused") {
return timer;
@@ -104,7 +112,7 @@ export function readStoredTimers(userId: string): TimerEntry[] {
return normalizeTimers([
{
...parsed,
id: crypto.randomUUID(),
id: createClientId("timer"),
},
]);
} catch {
+11 -11
View File
@@ -394,7 +394,7 @@ function stableSort<T>(items: T[], compare: (left: T, right: T) => number) {
function createNote(title: string): DashboardNote {
return {
id: typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
id: typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
title,
content: ""
};
@@ -1746,10 +1746,10 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
{ value: "critical90", label: "Ab 90%" },
{ value: "overBudget", label: "Ab 100%" },
{ value: "withoutBudget", label: "Ohne Kontingent" },
{ value: "open", label: "Offene Projekte" },
{ value: "closed", label: "Geschlossene Projekte" },
...zammadStateFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadState:${option.value}`, label: `Status: ${option.label}` })),
...zammadPriorityFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadPriority:${option.value}`, label: `Prio: ${option.label}` })),
{ value: "open", label: "TT Offen" },
{ value: "closed", label: "TT Geschl." },
...zammadStateFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadState:${option.value}`, label: `ZA ${option.label}` })),
...zammadPriorityFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadPriority:${option.value}`, label: `ZA Prio ${option.label}` })),
{ value: "openTodos", label: "Mit offenen ToDos" },
{ value: "overdueTodos", label: "Mit überfälligen ToDos" },
{ value: "fixedBilling", label: "Fixe Abrechnungen" },
@@ -1766,8 +1766,8 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
{ value: "title", label: "Titel" },
{ value: "ticket", label: "Ticket" },
{ value: "organization", label: "Organisation" },
{ value: "zammadStatus", label: "Zammad-Status" },
{ value: "zammadPriority", label: "Zammad-Prio" }
{ value: "zammadStatus", label: "ZA Status" },
{ value: "zammadPriority", label: "ZA Prio" }
]}
onChange={onUpdateConfig}
/>
@@ -2357,8 +2357,8 @@ function FavoritesWidget({
{ value: "projects", label: "Nur Projekte" },
{ value: "openTodos", label: "Mit offenen ToDos" },
{ value: "overdueTodos", label: "Mit überfälligen ToDos" },
...zammadStateFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadState:${option.value}`, label: `Status: ${option.label}` })),
...zammadPriorityFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadPriority:${option.value}`, label: `Prio: ${option.label}` })),
...zammadStateFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadState:${option.value}`, label: `ZA ${option.label}` })),
...zammadPriorityFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadPriority:${option.value}`, label: `ZA Prio ${option.label}` })),
{ value: "missingMeta", label: "Nicht gefunden" },
{ value: "fixedBilling", label: "Fixe Abrechnungen" },
{ value: "manualBilling", label: "Ohne fixe Abrechnung" }
@@ -2370,8 +2370,8 @@ function FavoritesWidget({
{ value: "ticket", label: "Ticket" },
{ value: "title", label: "Titel" },
{ value: "recent", label: "Neu" },
{ value: "zammadStatus", label: "Zammad-Status" },
{ value: "zammadPriority", label: "Zammad-Prio" }
{ value: "zammadStatus", label: "ZA Status" },
{ value: "zammadPriority", label: "ZA Prio" }
]}
onChange={onUpdateConfig}
/>
+1 -1
View File
@@ -119,7 +119,7 @@ const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [
];
function projectEntryId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
+1 -1
View File
@@ -98,7 +98,7 @@ function emptyTicketSessionForm(): TicketSessionFormState {
}
function projectEntryId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
+2 -2
View File
@@ -13,7 +13,7 @@ import { OrganizationSelect } from "@/components/OrganizationSelect";
import { createSession, getRecurringBillings, lookupTicket } from "../api";
import { formatTimer } from "../format";
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
import { activeElapsedMs, pauseEntry, resumeEntry, ticketPattern, type TimerEntry } from "../timers";
import { activeElapsedMs, createClientId, pauseEntry, resumeEntry, ticketPattern, type TimerEntry } from "../timers";
import type { RecurringBilling, TicketMeta, WorkType } from "../types";
type ManualSessionFormState = {
@@ -234,7 +234,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const now = Date.now();
const newTimer: TimerEntry = {
id: crypto.randomUUID(),
id: createClientId("timer"),
ticketNumber: normalizedTicketNumber,
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
workType: ticketResult.ticket?.work_type ?? null,
+36 -3
View File
@@ -34,7 +34,8 @@ const defaultFields: ZammadSyncExistingFields = {
type: true,
projectName: true,
budget: true,
overrideBudget: false
overrideBudget: false,
pendingTodo: false
};
const fieldOptions: Array<{ key: Exclude<keyof ZammadSyncExistingFields, "overrideBudget">; label: string; description: string }> = [
@@ -42,7 +43,8 @@ const fieldOptions: Array<{ key: Exclude<keyof ZammadSyncExistingFields, "overri
{ key: "organization", label: "Organisation", description: "Lokale Organisation anhand der Zammad-ID setzen." },
{ key: "type", label: "Typ", description: "Support/Consulting übernehmen, Projekte anlegen oder entfernen." },
{ key: "projectName", label: "Projektbezeichnung", description: "Teamspace-Projekt bei Consulting überschreiben." },
{ key: "budget", label: "Budget", description: "Teamspace-Stundenkontingent überschreiben." }
{ key: "budget", label: "Budget", description: "Teamspace-Stundenkontingent überschreiben." },
{ key: "pendingTodo", label: "Termin-ToDo", description: "Aus Wartend/Abschluss mit Datum ein ToDo erzeugen." }
];
type ZammadSyncPageState = {
@@ -264,6 +266,10 @@ function changeAllowedByFields(item: ZammadSyncItem, change: ZammadSyncChange, f
return fields.budget && consultingProjectCanBeTouched;
}
if (change.field === "ToDo") {
return fields.pendingTodo;
}
if (change.field === "TS/TT-Prüfung") {
return consultingProjectCanBeTouched;
}
@@ -294,7 +300,28 @@ function formatTeamspaceProjectList(value: unknown) {
}
function splitTeamspaceProjects(value: unknown) {
return typeof value === "string" ? value.split("+").map((entry) => entry.trim()).filter(Boolean) : [];
return typeof value === "string" ? value.match(/[^\s+]+/g) ?? [] : [];
}
function zammadPendingTodoPreview(zammad: Record<string, unknown>) {
const stateId = numericValue(cachedValue(zammad, "stateId"));
const pendingTime = cachedValue(zammad, "pendingTime");
if ((stateId !== 3 && stateId !== 7) || typeof pendingTime !== "string") {
return null;
}
const date = new Date(pendingTime);
if (Number.isNaN(date.getTime())) {
return null;
}
const kind = stateId === 7 ? "Abschluss" : "Erinnerung";
const ticketNumber = cachedValue(zammad, "ticketNumber");
const ticketLabel = typeof ticketNumber === "string" ? ticketNumber.replace(/^Ticket#?/i, "").replace(/^#/, "") : "";
return `${kind} ${ticketLabel ? `Ticket#${ticketLabel}` : "Ticket"} (${date.toISOString().slice(0, 10)})`;
}
function comparablePreviewValue(value: unknown) {
@@ -370,6 +397,11 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
addCachedChange(changes, "Zammad-Priorität", cachedValue(local, "zammadPriorityId"), cachedValue(zammad, "priorityId"));
addCachedChange(changes, "Zammad-Status", cachedValue(local, "zammadStateId"), cachedValue(zammad, "stateId"));
const pendingTodo = zammadPendingTodoPreview(zammad);
if (fields.pendingTodo && pendingTodo) {
changes.push({ field: "ToDo", from: null, to: pendingTodo });
}
if (zammadWorkType === "support" && zammadStateId === 4) {
return changes;
}
@@ -532,6 +564,7 @@ function zammadDetails(item: ZammadSyncItem) {
`Typ ${formatWorkType(zammad.workType)}`,
`Status ${formatZammadState(zammad.stateId)}`,
`Priorität ${formatZammadPriority(zammad.priorityId)}`,
`Warten ${formatValue(zammad.pendingTime)}`,
`Owner ${formatValue(zammad.ownerId)}`,
`Org ${formatValue(zammad.organizationId)}`,
`Titel ${formatValue(zammad.title)}`,