Update
This commit is contained in:
1 parent
2105a0c5f7
commit
c89d4825e2
30 files changed
+5435
-627
No files matched your search
+216
-99
@@ -67,10 +67,25 @@ import {
|
||||
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
|
||||
|
||||
import { cn } from "./lib/utils";
|
||||
import { ApiError, deleteTodo, getCurrentUser, getRecurringBillings, getSetupStatus, getTodos, logout, lookupTicket, searchTickets, updateTodo } from "./api";
|
||||
import {
|
||||
ApiError,
|
||||
createActiveTimer,
|
||||
deleteActiveTimer,
|
||||
deleteTodo,
|
||||
getActiveTimers,
|
||||
getCurrentUser,
|
||||
getRecurringBillings,
|
||||
getSetupStatus,
|
||||
getTodos,
|
||||
logout,
|
||||
lookupTicket,
|
||||
searchTickets,
|
||||
updateActiveTimer,
|
||||
updateTodo
|
||||
} from "./api";
|
||||
import { currentDay, formatDateTime, formatMinutes, formatTimer } from "./format";
|
||||
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "./recurring-billings";
|
||||
import { activeElapsedMs, createClientId, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
|
||||
import { activeElapsedMs, readStoredTimers, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
|
||||
import { formatTodoPeriod, isTodoActiveOnDay, isTodoFixedOnDay, isTodoOverdue } from "./todos";
|
||||
import { AdminUsersPage } from "./views/AdminUsersPage";
|
||||
import { AnalysisPage } from "./views/AnalysisPage";
|
||||
@@ -447,12 +462,12 @@ type SidebarTimersProps = {
|
||||
timers: TimerEntry[];
|
||||
selectedTimerId: string | null;
|
||||
setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
|
||||
setTimers: Dispatch<SetStateAction<TimerEntry[]>>;
|
||||
tick: number;
|
||||
onToggleTimer: (timerId: string) => void;
|
||||
onOpenTimer: () => void;
|
||||
};
|
||||
|
||||
function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers, tick, onOpenTimer }: SidebarTimersProps) {
|
||||
function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, tick, onToggleTimer, onOpenTimer }: SidebarTimersProps) {
|
||||
const sortedTimers = useMemo(
|
||||
() =>
|
||||
[...timers].sort((first, second) => {
|
||||
@@ -473,17 +488,8 @@ function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers,
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
setSelectedTimerId(timerId);
|
||||
setTimers((current) =>
|
||||
current.map((entry) => {
|
||||
if (entry.id !== timerId) {
|
||||
return pauseEntry(entry, now);
|
||||
}
|
||||
|
||||
return timer.phase === "running" ? pauseEntry(entry, now) : resumeEntry(entry, now);
|
||||
}),
|
||||
);
|
||||
onToggleTimer(timerId);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -783,6 +789,7 @@ export function App() {
|
||||
const [draggedOpenTicketKey, setDraggedOpenTicketKey] = useState<string | null>(null);
|
||||
const [sidebarTodos, setSidebarTodos] = useState<TicketTodo[]>([]);
|
||||
const openTicketsListRef = useRef<HTMLDivElement | null>(null);
|
||||
const hydratingTimerIds = useRef(new Set<string>());
|
||||
|
||||
const navigate = useCallback(
|
||||
(to: string) => {
|
||||
@@ -860,10 +867,48 @@ export function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimers(readStoredTimers(currentUser.id));
|
||||
let cancelled = false;
|
||||
const userId = currentUser.id;
|
||||
setTimersLoaded(false);
|
||||
setTimers([]);
|
||||
setOpenTickets(readOpenTickets(currentUser.id));
|
||||
setTimerOwnerId(currentUser.id);
|
||||
setTimersLoaded(true);
|
||||
|
||||
async function loadTimers() {
|
||||
try {
|
||||
let serverTimers = (await getActiveTimers()).timers;
|
||||
const storedTimers = readStoredTimers(userId);
|
||||
|
||||
if (serverTimers.length === 0 && storedTimers.length > 0) {
|
||||
for (const timer of storedTimers) {
|
||||
serverTimers = (await createActiveTimer(timer)).timers;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.removeItem(storageKeyForUser(userId));
|
||||
|
||||
if (!cancelled) {
|
||||
setTimers(serverTimers);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setTimers(readStoredTimers(userId));
|
||||
toast.error("Timer konnten nicht geladen werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setTimersLoaded(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadTimers();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -887,8 +932,6 @@ export function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(storageKeyForUser(currentUser.id), JSON.stringify(timers));
|
||||
|
||||
if (timers.length === 0) {
|
||||
setSelectedTimerId(null);
|
||||
return;
|
||||
@@ -899,6 +942,31 @@ export function App() {
|
||||
}
|
||||
}, [currentUser, timers, selectedTimerId, timersLoaded, timerOwnerId]);
|
||||
|
||||
const refreshActiveTimers = useCallback(async () => {
|
||||
if (!currentUser || currentUser.role === "admin") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getActiveTimers();
|
||||
setTimers(result.timers);
|
||||
} catch {
|
||||
// Keep the currently displayed timers if the refresh fails.
|
||||
}
|
||||
}, [currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser || currentUser.role === "admin" || !timersLoaded || timerOwnerId !== currentUser.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener("focus", refreshActiveTimers);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("focus", refreshActiveTimers);
|
||||
};
|
||||
}, [currentUser, refreshActiveTimers, timersLoaded, timerOwnerId]);
|
||||
|
||||
const loadSidebarTodos = useCallback(async () => {
|
||||
if (!currentUser || currentUser.role === "admin") {
|
||||
return;
|
||||
@@ -938,60 +1006,77 @@ export function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
const timersToHydrate = timers.filter((timer) => !timer.ticketLookupDone);
|
||||
const timersToHydrate = timers.filter((timer) => !timer.ticketLookupDone && !hydratingTimerIds.current.has(timer.id));
|
||||
|
||||
if (timersToHydrate.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
timersToHydrate.forEach((timer) => hydratingTimerIds.current.add(timer.id));
|
||||
|
||||
async function hydrateTimers() {
|
||||
const results = await Promise.all(
|
||||
timersToHydrate.map(async (timer) => {
|
||||
try {
|
||||
const result = await lookupTicket(timer.ticketNumber);
|
||||
return {
|
||||
id: timer.id,
|
||||
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null,
|
||||
workType: result.ticket?.work_type ?? null,
|
||||
isProject: Boolean(result.ticket?.is_project),
|
||||
projectName: result.ticket?.project_name ?? null
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: timer.id,
|
||||
organizationName: null,
|
||||
workType: null,
|
||||
isProject: false,
|
||||
projectName: null
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
timersToHydrate.map(async (timer) => {
|
||||
try {
|
||||
const result = await lookupTicket(timer.ticketNumber);
|
||||
return {
|
||||
id: timer.id,
|
||||
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null,
|
||||
workType: result.ticket?.work_type ?? null,
|
||||
isProject: Boolean(result.ticket?.is_project),
|
||||
projectName: result.ticket?.project_name ?? null
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: timer.id,
|
||||
organizationName: null,
|
||||
workType: null,
|
||||
isProject: false,
|
||||
projectName: null
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimers((current) =>
|
||||
current.map((timer) => {
|
||||
const result = results.find((entry) => entry.id === timer.id);
|
||||
|
||||
if (!result) {
|
||||
return timer;
|
||||
}
|
||||
|
||||
return {
|
||||
...timer,
|
||||
organizationName: result.organizationName,
|
||||
workType: result.workType,
|
||||
isProject: result.isProject,
|
||||
projectName: result.projectName,
|
||||
ticketLookupDone: true
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
results.map((result) =>
|
||||
updateActiveTimer(result.id, {
|
||||
organizationName: result.organizationName,
|
||||
workType: result.workType,
|
||||
isProject: result.isProject,
|
||||
projectName: result.projectName,
|
||||
ticketLookupDone: true
|
||||
}).catch(() => undefined)
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
timersToHydrate.forEach((timer) => hydratingTimerIds.current.delete(timer.id));
|
||||
}
|
||||
|
||||
setTimers((current) =>
|
||||
current.map((timer) => {
|
||||
const result = results.find((entry) => entry.id === timer.id);
|
||||
|
||||
if (!result) {
|
||||
return timer;
|
||||
}
|
||||
|
||||
return {
|
||||
...timer,
|
||||
organizationName: result.organizationName,
|
||||
workType: result.workType,
|
||||
isProject: result.isProject,
|
||||
projectName: result.projectName,
|
||||
ticketLookupDone: true
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
void hydrateTimers();
|
||||
@@ -1006,6 +1091,25 @@ export function App() {
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
async function toggleActiveTimer(timerId: string) {
|
||||
const timer = timers.find((entry) => entry.id === timerId);
|
||||
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updateActiveTimer(timerId, {
|
||||
phase: timer.phase === "running" ? "paused" : "running"
|
||||
});
|
||||
setTimers(result.timers);
|
||||
} catch (error) {
|
||||
toast.error("Timer konnte nicht aktualisiert werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser || currentUser.role !== "admin") {
|
||||
return;
|
||||
@@ -1026,14 +1130,14 @@ export function App() {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ticketNumber = rawTicketNumber.trim();
|
||||
const ticketNumber = rawTicketNumber.trim();
|
||||
|
||||
if (!ticketPattern.test(ticketNumber)) {
|
||||
if (!ticketPattern.test(ticketNumber)) {
|
||||
toast.error("Ticketnummer prüfen", {
|
||||
description: "Das Format muss Ticket#XXXXXX sein."
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const [ticketResult, recurringResult] = await Promise.all([
|
||||
lookupTicket(ticketNumber).catch(() => ({ ticket: null })),
|
||||
@@ -1041,7 +1145,9 @@ export function App() {
|
||||
]);
|
||||
|
||||
if (ticketHasRecurringBilling(ticketResult.ticket, recurringResult.recurringBillings) || isRecurringBillingTicketNumber(ticketNumber, recurringResult.recurringBillings)) {
|
||||
setTimers((current) => current.filter((timer) => timer.ticketNumber !== ticketNumber));
|
||||
const existingRecurringTimer = timers.find((timer) => timer.ticketNumber === ticketNumber);
|
||||
const result = existingRecurringTimer ? await deleteActiveTimer(existingRecurringTimer.id).catch(() => null) : null;
|
||||
setTimers((current) => result?.timers ?? current.filter((timer) => timer.ticketNumber !== ticketNumber));
|
||||
toast.error("Fixe Abrechnung", {
|
||||
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gestartet werden. Bitte die Definition bearbeiten."
|
||||
});
|
||||
@@ -1052,38 +1158,49 @@ export function App() {
|
||||
const now = Date.now();
|
||||
|
||||
if (existingTimer) {
|
||||
setTimers((current) =>
|
||||
current.map((timer) => (timer.id === existingTimer.id ? resumeEntry(timer, now) : pauseEntry(timer, now)))
|
||||
);
|
||||
setSelectedTimerId(existingTimer.id);
|
||||
toast.info("Bestehender Timer aktiviert", {
|
||||
description: ticketNumber
|
||||
});
|
||||
return true;
|
||||
try {
|
||||
const result = await updateActiveTimer(existingTimer.id, { phase: "running" });
|
||||
setTimers(result.timers);
|
||||
setSelectedTimerId(existingTimer.id);
|
||||
toast.info("Bestehender Timer aktiviert", {
|
||||
description: ticketNumber
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error("Timer konnte nicht aktiviert werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const newTimer: TimerEntry = {
|
||||
id: createClientId("timer"),
|
||||
ticketNumber,
|
||||
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
|
||||
workType: ticketResult.ticket?.work_type ?? null,
|
||||
isProject: Boolean(ticketResult.ticket?.is_project),
|
||||
projectName: ticketResult.ticket?.project_name ?? null,
|
||||
ticketLookupDone: true,
|
||||
startedAt: now,
|
||||
pausedTotalMs: 0,
|
||||
pausedAt: null,
|
||||
activity: "",
|
||||
billingStatus: null,
|
||||
phase: "running"
|
||||
};
|
||||
|
||||
setTimers((current) => [...current.map((timer) => pauseEntry(timer, now)), newTimer]);
|
||||
setSelectedTimerId(newTimer.id);
|
||||
toast.success("Timer gestartet", {
|
||||
description: ticketResult.ticket?.is_project ? `${ticketNumber} ist ein Projekt` : ticketNumber
|
||||
});
|
||||
return true;
|
||||
try {
|
||||
const result = await createActiveTimer({
|
||||
ticketNumber,
|
||||
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
|
||||
workType: ticketResult.ticket?.work_type ?? null,
|
||||
isProject: Boolean(ticketResult.ticket?.is_project),
|
||||
projectName: ticketResult.ticket?.project_name ?? null,
|
||||
ticketLookupDone: true,
|
||||
startedAt: now,
|
||||
pausedTotalMs: 0,
|
||||
pausedAt: null,
|
||||
activity: "",
|
||||
billingStatus: null,
|
||||
phase: "running"
|
||||
});
|
||||
setTimers(result.timers);
|
||||
setSelectedTimerId(result.timers.find((timer) => timer.ticketNumber === ticketNumber)?.id ?? null);
|
||||
toast.success("Timer gestartet", {
|
||||
description: ticketResult.ticket?.is_project ? `${ticketNumber} ist ein Projekt` : ticketNumber
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error("Timer konnte nicht gestartet werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
@@ -1281,8 +1398,8 @@ export function App() {
|
||||
timers={timers}
|
||||
selectedTimerId={selectedTimerId}
|
||||
setSelectedTimerId={setSelectedTimerId}
|
||||
setTimers={setTimers}
|
||||
tick={tick}
|
||||
onToggleTimer={(timerId) => void toggleActiveTimer(timerId)}
|
||||
onOpenTimer={() => navigate("/timer")}
|
||||
/>
|
||||
</>
|
||||
@@ -1380,7 +1497,7 @@ export function App() {
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton tooltip="Worklog">
|
||||
<FolderGit />
|
||||
<span className="text-xs text-muted-foreground">v1.0.1-rc1</span>
|
||||
<span className="text-xs text-muted-foreground">v1.0.10-rc1</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
+103
-5
@@ -19,11 +19,13 @@ import type {
|
||||
WorkType,
|
||||
ZammadSessionArticleQueueItem
|
||||
} from "./types";
|
||||
import type { TimerEntry, TimerPhase } from "./timers";
|
||||
|
||||
type CreateSessionPayload = {
|
||||
ticketNumber: string;
|
||||
organizationId: string;
|
||||
activity: string;
|
||||
zammadArticleBody?: string | null;
|
||||
workType: WorkType;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
@@ -35,6 +37,22 @@ type CreateSessionPayload = {
|
||||
zammadArticleTypeId?: number;
|
||||
};
|
||||
|
||||
type CreateTimerPayload = Omit<TimerEntry, "id"> & {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
type UpdateTimerPayload = Partial<{
|
||||
organizationName: string | null;
|
||||
workType: TimerEntry["workType"];
|
||||
isProject: boolean;
|
||||
projectName: string | null;
|
||||
ticketLookupDone: boolean;
|
||||
activity: string;
|
||||
zammadArticleBody?: string | null;
|
||||
billingStatus: TimerEntry["billingStatus"];
|
||||
phase: TimerPhase;
|
||||
}>;
|
||||
|
||||
type DeleteSessionResult = {
|
||||
deleted: {
|
||||
id: string;
|
||||
@@ -50,7 +68,7 @@ type DeleteSessionResult = {
|
||||
export type CreateSessionResult = {
|
||||
zammadArticle?: {
|
||||
requested: boolean;
|
||||
status: "not_requested" | "sent" | "queued";
|
||||
status: "not_requested" | "sent" | "failed";
|
||||
error: string | null;
|
||||
articleId: number | null;
|
||||
};
|
||||
@@ -106,6 +124,25 @@ export type ZammadSyncJob = {
|
||||
finishedAt: string | null;
|
||||
};
|
||||
|
||||
export type TicketZammadMailSessionItem = {
|
||||
articleId: number;
|
||||
ticketNumber: string;
|
||||
subject: string | null;
|
||||
activity: string;
|
||||
createdAt: string;
|
||||
endedAt: string;
|
||||
timeUnit: number;
|
||||
durationSeconds: number;
|
||||
};
|
||||
|
||||
export type TicketZammadMailSessionResult = {
|
||||
checked: number;
|
||||
created: number;
|
||||
skipped: number;
|
||||
items: TicketZammadMailSessionItem[];
|
||||
errors: Array<{ articleId: number; error: string }>;
|
||||
};
|
||||
|
||||
export type RestoreValidationResult = {
|
||||
valid: boolean;
|
||||
checkedTables: number;
|
||||
@@ -215,6 +252,7 @@ export function updateCurrentUser(payload: {
|
||||
zammadSessionNoteDefaultEnabled?: boolean;
|
||||
zammadTimeUnitRoundingEnabled?: boolean;
|
||||
zammadTimeUnitRoundingMinutes?: number;
|
||||
zammadAtossEmailSyncEnabled?: boolean;
|
||||
zammadArticleTypeId?: number;
|
||||
}) {
|
||||
return request<{ user: AuthUser }>("/api/auth/me", {
|
||||
@@ -479,6 +517,20 @@ export function applyOwnZammadConsultingTicketsFromPreview(previewJobId: string,
|
||||
}, onJobStarted);
|
||||
}
|
||||
|
||||
export function previewZammadMailSessions(daysBack: number, perPage: number, onJobStarted?: (job: ZammadSyncJob) => void) {
|
||||
return runZammadSyncJob("/api/zammad-sync/jobs/mail-sessions", {
|
||||
dryRun: true,
|
||||
daysBack,
|
||||
perPage
|
||||
}, onJobStarted);
|
||||
}
|
||||
|
||||
export function applyZammadMailSessionsFromPreview(previewJobId: string, articleIds: number[], onJobStarted?: (job: ZammadSyncJob) => void) {
|
||||
return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-mail-sessions`, {
|
||||
articleIds
|
||||
}, onJobStarted);
|
||||
}
|
||||
|
||||
export function getRecurringBillings() {
|
||||
return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings");
|
||||
}
|
||||
@@ -564,6 +616,7 @@ export function createProject(payload: {
|
||||
teamspaceEntries?: Array<{
|
||||
id?: string | null;
|
||||
teamspaceProject?: string | null;
|
||||
teamspaceDescription?: string | null;
|
||||
isDefault: boolean;
|
||||
hourBudgetMinutes?: number | null;
|
||||
budgetSyncDisabled?: boolean;
|
||||
@@ -592,6 +645,7 @@ export function updateProject(
|
||||
teamspaceEntries?: Array<{
|
||||
id?: string | null;
|
||||
teamspaceProject?: string | null;
|
||||
teamspaceDescription?: string | null;
|
||||
isDefault: boolean;
|
||||
hourBudgetMinutes?: number | null;
|
||||
budgetSyncDisabled?: boolean;
|
||||
@@ -699,6 +753,37 @@ export function createSession(payload: CreateSessionPayload) {
|
||||
});
|
||||
}
|
||||
|
||||
export function resendSessionZammadArticle(sessionId: string, payload: { zammadArticleTypeId?: number } = {}) {
|
||||
return request<CreateSessionResult>(`/api/sessions/${encodeURIComponent(sessionId)}/zammad-article`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function getActiveTimers() {
|
||||
return request<{ timers: TimerEntry[] }>("/api/timers");
|
||||
}
|
||||
|
||||
export function createActiveTimer(payload: CreateTimerPayload) {
|
||||
return request<{ timers: TimerEntry[] }>("/api/timers", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateActiveTimer(timerId: string, payload: UpdateTimerPayload) {
|
||||
return request<{ timers: TimerEntry[] }>(`/api/timers/${encodeURIComponent(timerId)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteActiveTimer(timerId: string) {
|
||||
return request<{ timers: TimerEntry[] }>(`/api/timers/${encodeURIComponent(timerId)}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
}
|
||||
|
||||
export function getZammadSessionArticleQueue() {
|
||||
return request<{ queue: ZammadSessionArticleQueueItem[] }>("/api/zammad/session-article-queue");
|
||||
}
|
||||
@@ -749,6 +834,7 @@ export function updateSessionDetails(
|
||||
payload: {
|
||||
organizationId: string;
|
||||
activity: string;
|
||||
zammadArticleBody?: string | null;
|
||||
workType: WorkType;
|
||||
removeProjectData?: boolean;
|
||||
projectTeamspaceEntryId?: string | null;
|
||||
@@ -762,10 +848,10 @@ export function updateSessionDetails(
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTicketDayBilling(ticketId: string, day: string, billedMinutes: number | null) {
|
||||
export function updateTicketDayBilling(ticketId: string, day: string, billedMinutes: number | null, projectTeamspaceEntryId?: string | null) {
|
||||
return request(`/api/tickets/${ticketId}/day-billings/${day}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ billedMinutes })
|
||||
body: JSON.stringify({ billedMinutes, projectTeamspaceEntryId: projectTeamspaceEntryId ?? null })
|
||||
});
|
||||
}
|
||||
|
||||
@@ -792,8 +878,8 @@ export function getPeriodOverview(type: PeriodType, period: string) {
|
||||
return request<PeriodOverview>(`/api/periods/${periodPath(type)}/${period}/overview`);
|
||||
}
|
||||
|
||||
export function getStatisticsOverview(month: string) {
|
||||
return request<StatisticsOverview>(`/api/statistics/months/${month}`);
|
||||
export function getStatisticsOverview(period: string, type: "month" | "week" = "month") {
|
||||
return request<StatisticsOverview>(`/api/statistics/${type === "week" ? "weeks" : "months"}/${period}`);
|
||||
}
|
||||
|
||||
export function getTicketPeriod(type: PeriodType, period: string, ticketId: string) {
|
||||
@@ -825,6 +911,18 @@ export function getTicketDetails(
|
||||
return request<TicketPeriod>(`/api/tickets/${ticketId}/details${params.size ? `?${params}` : ""}`);
|
||||
}
|
||||
|
||||
export function previewTicketZammadMailSessions(ticketId: string, daysBack: number) {
|
||||
const params = new URLSearchParams({ daysBack: String(daysBack) });
|
||||
return request<TicketZammadMailSessionResult>(`/api/tickets/${ticketId}/zammad-mail-sessions?${params}`);
|
||||
}
|
||||
|
||||
export function importTicketZammadMailSessions(ticketId: string, daysBack: number, articleIds: number[]) {
|
||||
return request<TicketZammadMailSessionResult>(`/api/tickets/${ticketId}/zammad-mail-sessions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ daysBack, articleIds })
|
||||
});
|
||||
}
|
||||
|
||||
export function updateSessionBilling(sessionId: string, billingStatus: BillingStatus) {
|
||||
return request(`/api/sessions/${sessionId}/billing`, {
|
||||
method: "PATCH",
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { Bold, Heading1, Heading2, Heading3, Italic, List, ListOrdered, RemoveFormatting, Strikethrough, Underline } from "lucide-react";
|
||||
import { type ClipboardEvent, type KeyboardEvent, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type RichActivityEditorProps = {
|
||||
id: string;
|
||||
value: string;
|
||||
htmlValue: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
onChange: (value: string, htmlValue: string) => void;
|
||||
};
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function textToEditorHtml(value: string) {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((line) => `<div>${line ? escapeHtml(line) : "<br>"}</div>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function fileToDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => resolve(String(reader.result ?? "")));
|
||||
reader.addEventListener("error", () => reject(reader.error ?? new Error("Bild konnte nicht gelesen werden")));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function currentSelectionRange() {
|
||||
const selection = window.getSelection();
|
||||
return selection && selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null;
|
||||
}
|
||||
|
||||
function restoreSelectionRange(range: Range | null) {
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
function editorPlainText(element: HTMLElement) {
|
||||
return element.innerText.replace(/\u00a0/g, " ");
|
||||
}
|
||||
|
||||
function hasInlineImage(element: HTMLElement) {
|
||||
return Boolean(element.querySelector("img[src^='data:image/']"));
|
||||
}
|
||||
|
||||
function normalizedEditorHtml(element: HTMLElement) {
|
||||
const html = element.innerHTML;
|
||||
return html && (editorPlainText(element).trim() || hasInlineImage(element)) ? html : "";
|
||||
}
|
||||
|
||||
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",
|
||||
active && "bg-zinc-700"
|
||||
);
|
||||
}
|
||||
|
||||
export function RichActivityEditor({
|
||||
id,
|
||||
value,
|
||||
htmlValue,
|
||||
disabled = false,
|
||||
placeholder = "Tätigkeit beschreiben, Screenshot mit Strg+V einfügen",
|
||||
onChange
|
||||
}: RichActivityEditorProps) {
|
||||
const editorRef = useRef<HTMLDivElement | null>(null);
|
||||
const [selectedImage, setSelectedImage] = useState<HTMLImageElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextHtml = htmlValue || (value ? textToEditorHtml(value) : "");
|
||||
|
||||
if (editor.innerHTML !== nextHtml) {
|
||||
editor.innerHTML = nextHtml;
|
||||
}
|
||||
}, [htmlValue, value]);
|
||||
|
||||
function clearSelectedImage() {
|
||||
selectedImage?.classList.remove("ring-2", "ring-ring", "ring-offset-2");
|
||||
setSelectedImage(null);
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
const editor = editorRef.current;
|
||||
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(editorPlainText(editor), normalizedEditorHtml(editor));
|
||||
}
|
||||
|
||||
function focusEditor() {
|
||||
editorRef.current?.focus();
|
||||
}
|
||||
|
||||
function runCommand(command: string, value?: string) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
focusEditor();
|
||||
document.execCommand("styleWithCSS", false, "false");
|
||||
document.execCommand(command, false, value);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function selectImage(image: HTMLImageElement) {
|
||||
clearSelectedImage();
|
||||
image.classList.add("ring-2", "ring-ring", "ring-offset-2");
|
||||
setSelectedImage(image);
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNode(image);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
function removeSelectedImage() {
|
||||
const image = selectedImage;
|
||||
|
||||
if (!image) {
|
||||
return;
|
||||
}
|
||||
|
||||
image.remove();
|
||||
setSelectedImage(null);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||
if ((event.key === "Delete" || event.key === "Backspace") && selectedImage) {
|
||||
event.preventDefault();
|
||||
removeSelectedImage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Tab" && !disabled) {
|
||||
event.preventDefault();
|
||||
document.execCommand("insertText", false, " ");
|
||||
emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaste(event: ClipboardEvent<HTMLDivElement>) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageFiles = Array.from(event.clipboardData.items)
|
||||
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => Boolean(file));
|
||||
|
||||
if (imageFiles.length === 0) {
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
|
||||
if (text) {
|
||||
event.preventDefault();
|
||||
document.execCommand("styleWithCSS", false, "false");
|
||||
document.execCommand("insertText", false, text);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
clearSelectedImage();
|
||||
|
||||
let insertionRange = currentSelectionRange();
|
||||
const text = event.clipboardData.getData("text/plain").trim();
|
||||
if (text) {
|
||||
document.execCommand("styleWithCSS", false, "false");
|
||||
document.execCommand("insertText", false, `${text}\n`);
|
||||
insertionRange = currentSelectionRange();
|
||||
}
|
||||
|
||||
try {
|
||||
for (const file of imageFiles) {
|
||||
const dataUrl = await fileToDataUrl(file);
|
||||
restoreSelectionRange(insertionRange);
|
||||
document.execCommand("insertHTML", false, `<img src="${dataUrl}" style="display:block;max-width:100%;height:auto;margin-top:0.5rem;border-radius:0.375rem;"><br>`);
|
||||
insertionRange = currentSelectionRange();
|
||||
}
|
||||
emitChange();
|
||||
} catch (error) {
|
||||
toast.error("Screenshot konnte nicht eingefügt werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Fett" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("bold")}>
|
||||
<Bold className="size-4" />
|
||||
</button>
|
||||
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Kursiv" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("italic")}>
|
||||
<Italic className="size-4" />
|
||||
</button>
|
||||
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Unterstrichen" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("underline")}>
|
||||
<Underline className="size-4" />
|
||||
</button>
|
||||
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Durchgestrichen" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("strikeThrough")}>
|
||||
<Strikethrough className="size-4" />
|
||||
</button>
|
||||
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Formatierung entfernen" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("removeFormat")}>
|
||||
<RemoveFormatting className="size-4" />
|
||||
</button>
|
||||
<span className="mx-1 h-6 w-px bg-zinc-700" aria-hidden="true" />
|
||||
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Überschrift 1" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("formatBlock", "h1")}>
|
||||
<Heading1 className="size-4" />
|
||||
</button>
|
||||
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Überschrift 2" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("formatBlock", "h2")}>
|
||||
<Heading2 className="size-4" />
|
||||
</button>
|
||||
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Überschrift 3" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("formatBlock", "h3")}>
|
||||
<Heading3 className="size-4" />
|
||||
</button>
|
||||
<span className="mx-1 h-6 w-px bg-zinc-700" aria-hidden="true" />
|
||||
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Nummerierte Liste" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("insertOrderedList")}>
|
||||
<ListOrdered className="size-4" />
|
||||
</button>
|
||||
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Liste" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("insertUnorderedList")}>
|
||||
<List className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id={id}
|
||||
ref={editorRef}
|
||||
role="textbox"
|
||||
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"
|
||||
data-placeholder={placeholder}
|
||||
onInput={emitChange}
|
||||
onBlur={() => {
|
||||
clearSelectedImage();
|
||||
emitChange();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
const target = event.target;
|
||||
|
||||
if (target instanceof HTMLImageElement) {
|
||||
selectImage(target);
|
||||
return;
|
||||
}
|
||||
|
||||
clearSelectedImage();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(event) => void handlePaste(event)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,17 @@ export function currentMonth() {
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function currentWeek() {
|
||||
const now = new Date();
|
||||
const date = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
|
||||
const weekday = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() + 4 - weekday);
|
||||
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
||||
const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);
|
||||
|
||||
return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function currentDay() {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||
|
||||
@@ -25,7 +25,7 @@ export const helpSections: HelpSection[] = [
|
||||
"Eine Session ist immer einem Benutzer zugeordnet. In den normalen Auswertungen sieht jeder Benutzer nur seine eigenen Sessions.",
|
||||
"Ticketdaten wie Organisation und Art können aus bestehenden Tickets übernommen werden, auch wenn ein anderer Benutzer das Ticket schon bearbeitet hat.",
|
||||
"Consulting-Projekte können einen oder mehrere Teamspace-Einträge haben. Einer davon ist immer der Standard-Eintrag.",
|
||||
"Tickets werden nicht abgeschlossen. Sie gruppieren Sessions. Relevant für den Abschluss ist nur, ob alle Sessions eines Monats bewertet sind.",
|
||||
"Tickets werden nicht abgeschlossen. Sie gruppieren Sessions. Relevant für den Abschluss ist nur, ob alle Sessions eines Monats final bewertet sind.",
|
||||
"Der Monatsabschluss sperrt den Monat gegen versehentliche Änderungen. Muss doch noch etwas korrigiert werden, kann der Monat wieder geöffnet werden."
|
||||
]
|
||||
},
|
||||
@@ -47,14 +47,14 @@ export const helpSections: HelpSection[] = [
|
||||
"Hat ein Consulting-Projekt mehrere Teamspace-Einträge, wählst du beim Speichern der Session den passenden Eintrag aus. Bei genau einem Teamspace-Eintrag ordnet TicketTracker automatisch zu.",
|
||||
"Vergessene Sessions trägst du auf der Seite Session starten im Bereich Session nachtragen nach. Wenn das Ticket bereits in der Ticketliste geöffnet ist, nutzt du in der Ticketdetailansicht ebenfalls Session nachtragen; dann ist die Zuordnung zum Ticket bereits vorbelegt.",
|
||||
"Unter Tickets wählst du Monat oder Tag, öffnest ein Ticket über Öffnen und siehst dort alle Sessions nach Tag gruppiert.",
|
||||
"Im Ticket bewertest du jede Session mit Abrechenbar, Abgerechnet oder Nicht abrechenbar. Ein erneuter Klick auf den aktiven Status nimmt die Bewertung zurück.",
|
||||
"Im Ticket setzt du je Session den Status Abrechenbar, Abgerechnet oder Nicht abrechenbar. Abrechenbar bleibt eine offene Bewertung, bis daraus Abgerechnet oder Nicht abrechenbar wird.",
|
||||
"Im Ticket steht pro Tag das Feld Teamspace. Dort trägst du die Stunden ein, die du für genau dieses Ticket an genau diesem Tag wirklich im CRM beziehungsweise Teamspace erfasst hast. Nach einer Änderung erscheint der Speichern-Button.",
|
||||
"Die Teamspace-Zeit ist ein Tageswert pro Ticket und Benutzer. Monats- und Tagesansicht zeigen denselben Wert; er muss also nur einmal gepflegt werden.",
|
||||
"Am Monatsende öffnest du Monatsabschluss. Dort findest du offene Bewertungen gruppiert nach Ticket, Tage ohne Teamspace-Wert und die Schaltfläche Monat abschließen.",
|
||||
"Offene Bewertungen sind Pflicht: Öffne das Ticket aus der Liste, bewerte die Sessions und kehre zum Monatsabschluss zurück.",
|
||||
"Tage ohne Teamspace-Wert sind eine Prüfliste. Wenn die Arbeit wirklich nicht in Teamspace eingetragen werden soll, nutzt du Zur Kenntnis. Mit Zur Kenntnis anzeigen blendest du solche Einträge wieder ein und kannst die Kenntnisnahme zurücknehmen.",
|
||||
"Kontrolliere vor dem Abschluss die Kennzahl Teamspace im Monatsabschluss. Idealerweise entspricht dieser Wert der Summe, die du im CRM beziehungsweise Teamspace für den Monat eingetragen hast.",
|
||||
"Wenn alle Sessions bewertet sind und die Teamspace-Kontrolle passt, schließt du den Monat mit Monat abschließen. Danach sind Bewertungen und Änderungen für den Monat geschützt, bis der Monat wieder geöffnet wird."
|
||||
"Wenn keine offenen Bewertungen mehr vorhanden sind und die Teamspace-Kontrolle passt, schließt du den Monat mit Monat abschließen. Danach sind Bewertungen und Änderungen für den Monat geschützt, bis der Monat wieder geöffnet wird."
|
||||
],
|
||||
workflow: [
|
||||
"Ticketfeld im Kopfbereich oder Session starten öffnen.",
|
||||
@@ -239,16 +239,16 @@ export const helpSections: HelpSection[] = [
|
||||
title: "Sessions Bewerten",
|
||||
description: "Bewertungen bestimmen, ob eine Session offen, abrechenbar, abgerechnet oder bewusst nicht abrechenbar ist.",
|
||||
quickItems: [
|
||||
"Jede Session braucht genau eine Bewertung.",
|
||||
"Jede Session braucht vor dem Monatsabschluss einen finalen Status.",
|
||||
"Abrechenbar, Abgerechnet und Nicht abrechenbar können durch erneuten Klick zurückgenommen werden.",
|
||||
"Offene Bewertungen blockieren den Monatsabschluss."
|
||||
],
|
||||
details: [
|
||||
"Abrechenbar bedeutet: Die Session ist bewertet, aber noch nicht als abgerechnet/Teamspace-relevant markiert.",
|
||||
"Abrechenbar bedeutet: Die Session ist als potenziell abrechenbar markiert, bleibt aber für Abschluss und offene Bewertungen noch offen.",
|
||||
"Abgerechnet bedeutet: Die Session wurde für die Abrechnung beziehungsweise im CRM berücksichtigt.",
|
||||
"Nicht abrechenbar bedeutet: Die Session gehört zum Arbeitsnachweis, wird aber nicht abgerechnet.",
|
||||
"Ein erneuter Klick auf den aktiven Bewertungsbutton entfernt die Bewertung und macht die Session wieder offen.",
|
||||
"Abrechenbare und nicht abrechenbare Sessions zählen als bewertet. Sie blockieren den Monatsabschluss nicht.",
|
||||
"Nur Abgerechnet und Nicht abrechenbar schließen die Bewertung ab. Abrechenbar blockiert den Monatsabschluss weiterhin.",
|
||||
"Wenn ein Monat abgeschlossen ist, sind Bewertungen gesperrt. Öffne den Monat zuerst wieder, wenn du etwas korrigieren musst.",
|
||||
"Die offenen Bewertungen findest du gebündelt im Monatsabschluss."
|
||||
]
|
||||
@@ -345,7 +345,7 @@ export const helpSections: HelpSection[] = [
|
||||
"Der Monat kann wieder geöffnet werden."
|
||||
],
|
||||
details: [
|
||||
"Ein Monat kann abgeschlossen werden, sobald alle Sessions dieses Monats bewertet sind.",
|
||||
"Ein Monat kann abgeschlossen werden, sobald keine offenen Bewertungen mehr vorhanden sind.",
|
||||
"Tage ohne Teamspace-Wert blockieren den Abschluss nicht automatisch. Sie sind eine Prüfliste für CRM-Abgleich und Nacharbeit.",
|
||||
"Wenn eine Session bewusst nicht in Teamspace eingetragen wird, kann der fehlende Tageswert zur Kenntnis genommen werden.",
|
||||
"Zur Kenntnis genommene Einträge können über die Checkbox Zur Kenntnis anzeigen wieder eingeblendet werden.",
|
||||
@@ -441,9 +441,9 @@ export const helpSections: HelpSection[] = [
|
||||
"Die Klärung erfolgt in der Ticketdetailansicht."
|
||||
],
|
||||
details: [
|
||||
"Offen umfasst alle Sessions des Monats ohne Bewertung.",
|
||||
"Diese Sessions müssen vor dem Monatsabschluss bewertet werden.",
|
||||
"Die offene Zeit zeigt, wie viele Minuten noch bewertet werden müssen.",
|
||||
"Offen umfasst alle Sessions des Monats ohne finalen Status sowie Sessions mit dem Zwischenstatus Abrechenbar.",
|
||||
"Diese Sessions müssen vor dem Monatsabschluss auf Abgerechnet oder Nicht abrechenbar gesetzt werden.",
|
||||
"Die offene Zeit zeigt, wie viele Minuten noch final bewertet werden müssen.",
|
||||
"Der Monatsabschluss listet offene Bewertungen gruppiert nach Ticket, damit du direkt in die passende Ticketdetailansicht springen kannst.",
|
||||
"Wird eine Bewertung zurückgenommen, steigt die offene Anzahl wieder."
|
||||
]
|
||||
@@ -540,7 +540,7 @@ export const helpSections: HelpSection[] = [
|
||||
"Der persönliche Zammad Token ist vom Admin-Token für den Organisations-Sync getrennt. Er wird für ticketbezogene Zammad-Abgleiche, Consulting-Importe und die Zuordnung zum Zammad-Owner verwendet.",
|
||||
"Die Bewertungsabfrage beim Timerabschluss kann im Profil ein- oder ausgeschaltet werden. Sie erscheint nur, wenn der Timer noch auf --- steht.",
|
||||
"Die interne Zammad-Notiz beim Timerabschluss steuert, ob die Checkbox im Abschlussdialog standardmäßig aktiv ist. Nur dann werden Standard-Typ und Zeitaufrundung angezeigt.",
|
||||
"Der Standard-Typ belegt die Zammad-type_id für interne Notizen vor und kann beim Timerabschluss oder Nachtragen noch geändert werden.",
|
||||
"Der Standard-Typ belegt die Zammad-Zeitbuchungskategorie für interne Notizen vor. Es gibt keine leere Auswahl; ohne gespeicherten Profilwert wird Nicht kategorisiert verwendet.",
|
||||
"Für die an Zammad gesendete time_unit kann optional eine Aufrundung auf 5, 10, 15 oder 30 Minuten aktiviert werden. Ohne Rundung wird die konkrete Zeit mit zwei Nachkommastellen gesendet.",
|
||||
"Geparkte Zammad-Notizen listet interne Notizen, die wegen eines Zammad-Fehlers noch nicht übertragen wurden. Über Senden werden sie gesammelt nach Zammad gepusht.",
|
||||
"Im Admin-Profil wird kein persönlicher Zammad-Token angezeigt.",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
Datensatz aus api/v1/ticket_articles/
|
||||
|
||||
{
|
||||
"id": 2,
|
||||
"ticket_id": 2,
|
||||
"type_id": 1,
|
||||
"sender_id": 1,
|
||||
"from": "",
|
||||
"to": "",
|
||||
"cc": null,
|
||||
"subject": "Test Bestellung",
|
||||
"reply_to": null,
|
||||
"message_id": "",
|
||||
"message_id_md5": "",
|
||||
"in_reply_to": null,
|
||||
"content_type": "text/html",
|
||||
"body": "",
|
||||
"internal": false,
|
||||
"preferences": {
|
||||
"send-auto-response": true,
|
||||
"is-auto-response": false
|
||||
},
|
||||
"updated_by_id": 3,
|
||||
"created_by_id": 3,
|
||||
"origin_by_id": null,
|
||||
"created_at": "2023-01-06T11:17:03.919Z",
|
||||
"updated_at": "2023-01-06T11:17:03.919Z",
|
||||
"detected_language": null,
|
||||
"ai_stored_result_ids": [],
|
||||
"attachments": [
|
||||
{
|
||||
"id": 3,
|
||||
"store_file_id": 3,
|
||||
"filename": "message.html",
|
||||
"size": "5733",
|
||||
"preferences": {
|
||||
"content-alternative": true,
|
||||
"original-format": true,
|
||||
"Mime-Type": "text/html",
|
||||
"Charset": "utf-8"
|
||||
}
|
||||
}
|
||||
],
|
||||
"body_rendering_error": false
|
||||
}
|
||||
|
||||
|
||||
Datensatz aus api/v1/ticket_articles/{id]}
|
||||
{
|
||||
"id": 2,
|
||||
"ticket_id": 2,
|
||||
"type_id": 1,
|
||||
"sender_id": 1,
|
||||
"from": "",
|
||||
"to": "",
|
||||
"cc": null,
|
||||
"subject": "",
|
||||
"reply_to": null,
|
||||
"message_id": "",
|
||||
"message_id_md5": "a9cc698b80f75fe08ca6d2dc6dec69ae",
|
||||
"in_reply_to": null,
|
||||
"content_type": "text/html",
|
||||
"body": "",
|
||||
"internal": false,
|
||||
"preferences": {
|
||||
"send-auto-response": true,
|
||||
"is-auto-response": false
|
||||
},
|
||||
"updated_by_id": 3,
|
||||
"created_by_id": 3,
|
||||
"origin_by_id": null,
|
||||
"created_at": "2023-01-06T11:17:03.919Z",
|
||||
"updated_at": "2023-01-06T11:17:03.919Z",
|
||||
"detected_language": null,
|
||||
"ai_stored_result_ids": [],
|
||||
"attachments": [
|
||||
{
|
||||
"id": 3,
|
||||
"store_file_id": 3,
|
||||
"filename": "message.html",
|
||||
"size": "5733",
|
||||
"preferences": {
|
||||
"content-alternative": true,
|
||||
"original-format": true,
|
||||
"Mime-Type": "text/html",
|
||||
"Charset": "utf-8"
|
||||
}
|
||||
}
|
||||
],
|
||||
"body_rendering_error": false,
|
||||
"created_by": "",
|
||||
"updated_by": "",
|
||||
"type": "email",
|
||||
"sender": "Agent",
|
||||
"ai_stored_results": [],
|
||||
"time_unit": null
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export type TimerEntry = {
|
||||
projectName?: string | null;
|
||||
ticketLookupDone?: boolean;
|
||||
activity: string;
|
||||
zammadArticleBody?: string;
|
||||
billingStatus: BillingStatus;
|
||||
startedAt: number;
|
||||
pausedTotalMs: number;
|
||||
@@ -66,6 +67,7 @@ export function normalizeTimers(timers: TimerEntry[]) {
|
||||
const normalizedTimer = {
|
||||
...timer,
|
||||
activity: typeof timer.activity === "string" ? timer.activity : "",
|
||||
zammadArticleBody: typeof timer.zammadArticleBody === "string" ? timer.zammadArticleBody : "",
|
||||
billingStatus: timer.billingStatus === "billed" ? "billable" : timer.billingStatus === "billable" || timer.billingStatus === "non_billable" ? timer.billingStatus : null,
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export type AuthUser = {
|
||||
zammad_session_note_default_enabled: boolean;
|
||||
zammad_time_unit_rounding_enabled: boolean;
|
||||
zammad_time_unit_rounding_minutes: number;
|
||||
zammad_atoss_email_sync_enabled: boolean;
|
||||
zammad_article_type_id: number;
|
||||
};
|
||||
|
||||
@@ -76,7 +77,7 @@ export type ZammadSessionArticleQueueItem = {
|
||||
zammad_ticket_id: string | null;
|
||||
activity: string;
|
||||
time_unit: number;
|
||||
zammad_article_type_id: number | null;
|
||||
zammad_article_type_id: number;
|
||||
attempt_count: number;
|
||||
last_error: string | null;
|
||||
last_attempt_at: string | null;
|
||||
@@ -210,6 +211,7 @@ export type ProjectTeamspaceEntry = {
|
||||
id: string;
|
||||
project_id?: string;
|
||||
teamspace_project: string | null;
|
||||
teamspace_description?: string | null;
|
||||
is_default: boolean;
|
||||
hour_budget_minutes: number | null;
|
||||
budget_sync_disabled?: boolean;
|
||||
@@ -313,6 +315,7 @@ export type PeriodOverview = {
|
||||
|
||||
export type TicketDayBilling = {
|
||||
day: string;
|
||||
project_teamspace_entry_id?: string | null;
|
||||
billed_minutes: number;
|
||||
};
|
||||
|
||||
@@ -327,6 +330,7 @@ export type SessionEntry = {
|
||||
organization_name: string | null;
|
||||
customer_name: string;
|
||||
activity: string;
|
||||
zammad_article_body?: string | null;
|
||||
work_type: WorkType;
|
||||
project_teamspace_entry_id?: string | null;
|
||||
project_teamspace_project?: string | null;
|
||||
@@ -340,9 +344,10 @@ export type SessionEntry = {
|
||||
recurring_billing_id: string | null;
|
||||
recurring_billing_slot_id: string | null;
|
||||
recurring_occurrence_date: string | null;
|
||||
zammad_source_article_id?: string | null;
|
||||
zammad_article_status?: "pending" | "sent" | null;
|
||||
zammad_article_id?: string | null;
|
||||
zammad_article_type_id?: number | null;
|
||||
zammad_article_type_id?: number;
|
||||
};
|
||||
|
||||
export type TicketPeriod = {
|
||||
@@ -473,7 +478,7 @@ export type StatisticsTeamspaceDay = {
|
||||
};
|
||||
|
||||
export type StatisticsOverview = {
|
||||
periodType: "month";
|
||||
periodType: "month" | "week";
|
||||
period: string;
|
||||
closed: boolean;
|
||||
closedAt: string | null;
|
||||
|
||||
@@ -1632,7 +1632,7 @@ function WidgetContent(props: WidgetContentProps) {
|
||||
/>
|
||||
) : null}
|
||||
<CompactList
|
||||
empty="Alles bewertet."
|
||||
empty="Keine offenen Bewertungen."
|
||||
items={sessions.map((session) => ({
|
||||
key: session.id,
|
||||
title: session.ticket_number,
|
||||
@@ -1722,9 +1722,9 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
|
||||
const checklistItems = [
|
||||
{
|
||||
key: "ratings",
|
||||
label: "Sessions bewertet",
|
||||
label: "Bewertungen final",
|
||||
done: todayOpenRatings.length === 0,
|
||||
action: todayOpenRatings.length === 0 ? "Keine offenen Bewertungen." : `${todayOpenRatings.length} heutige Bewertung(en) setzen.`
|
||||
action: todayOpenRatings.length === 0 ? "Keine offenen Bewertungen." : `${todayOpenRatings.length} heutige Bewertung(en) finalisieren.`
|
||||
},
|
||||
{
|
||||
key: "todos",
|
||||
@@ -1831,7 +1831,7 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
|
||||
items={afterworkTickets.slice(0, 4).map((ticket) => ({
|
||||
key: `today-ticket-${ticket.id}`,
|
||||
title: ticket.title || ticket.ticket_number,
|
||||
detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.billable_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`,
|
||||
detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`,
|
||||
note: dayControlTodaySessionReason(ticket),
|
||||
onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`)
|
||||
}))}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
||||
import { HelpLink } from "@/components/HelpLink";
|
||||
import { acknowledgeMissingTicketDayBilling, closePeriod, getStatisticsOverview, reopenPeriod } from "../api";
|
||||
import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format";
|
||||
import { currentMonth, currentWeek, formatDate, formatDateTime, formatMinutes } from "../format";
|
||||
import type { StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview, StatisticsTeamspaceDay } from "../types";
|
||||
|
||||
type MonthlyClosePageProps = {
|
||||
@@ -32,6 +32,17 @@ type MonthlyClosePageProps = {
|
||||
const trackedTextClass = "text-sky-700 dark:text-sky-300";
|
||||
const teamspaceTextClass = "text-emerald-700 dark:text-emerald-300";
|
||||
const showAcknowledgedMissingCrmStorageKey = "tickettracker.monthlyClose.showAcknowledgedMissingCrm";
|
||||
const monthlyCloseViewModeStorageKey = "tickettracker.monthlyClose.viewMode";
|
||||
|
||||
type MonthlyCloseViewMode = "month" | "week";
|
||||
|
||||
function readMonthlyCloseViewMode(): MonthlyCloseViewMode {
|
||||
if (typeof window === "undefined") {
|
||||
return "month";
|
||||
}
|
||||
|
||||
return window.localStorage.getItem(monthlyCloseViewModeStorageKey) === "week" ? "week" : "month";
|
||||
}
|
||||
|
||||
function formatSignedMinutes(minutes: number) {
|
||||
if (minutes === 0) {
|
||||
@@ -63,6 +74,7 @@ type OpenTicketGroup = {
|
||||
ticketId: string;
|
||||
ticketNumber: string;
|
||||
organizationName: string;
|
||||
month: string;
|
||||
openCount: number;
|
||||
totalMinutes: number;
|
||||
};
|
||||
@@ -83,6 +95,7 @@ function groupOpenSessions(sessions: StatisticsOpenSession[]) {
|
||||
ticketId: session.ticket_id,
|
||||
ticketNumber: session.ticket_number,
|
||||
organizationName: session.organization_name,
|
||||
month: session.started_at.slice(0, 7),
|
||||
openCount: 1,
|
||||
totalMinutes: session.rounded_minutes
|
||||
});
|
||||
@@ -91,7 +104,7 @@ function groupOpenSessions(sessions: StatisticsOpenSession[]) {
|
||||
return Array.from(groups.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" }));
|
||||
}
|
||||
|
||||
function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup; month: string; onNavigate: (to: string) => void }) {
|
||||
function OpenTicketItem({ group, onNavigate }: { group: OpenTicketGroup; onNavigate: (to: string) => void }) {
|
||||
return (
|
||||
<div className="rounded-md border bg-background px-3 py-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -104,7 +117,7 @@ function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup;
|
||||
<p className="truncate text-sm text-muted-foreground">{group.organizationName}</p>
|
||||
<p className={`text-xs font-medium ${trackedTextClass}`}>{formatMinutes(group.totalMinutes)} offen zu bewerten</p>
|
||||
</div>
|
||||
<Button className="h-8 shrink-0" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${month}`)}>
|
||||
<Button className="h-8 shrink-0" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${group.month}`)}>
|
||||
Ticket öffnen
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
@@ -185,7 +198,9 @@ function TeamspaceDayItem({ item, onNavigate }: { item: StatisticsTeamspaceDay;
|
||||
}
|
||||
|
||||
export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
const [viewMode, setViewMode] = useState<MonthlyCloseViewMode>(() => readMonthlyCloseViewMode());
|
||||
const [month, setMonth] = useState(currentMonth());
|
||||
const [week, setWeek] = useState(currentWeek());
|
||||
const [stats, setStats] = useState<StatisticsOverview | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [closing, setClosing] = useState(false);
|
||||
@@ -194,6 +209,11 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
const [showAcknowledgedMissingCrm, setShowAcknowledgedMissingCrm] = useState(false);
|
||||
const [showAcknowledgedMissingCrmLoaded, setShowAcknowledgedMissingCrmLoaded] = useState(false);
|
||||
const loadRequestId = useRef(0);
|
||||
const period = viewMode === "month" ? month : week;
|
||||
const periodLabel = viewMode === "month" ? "Monat" : "Woche";
|
||||
const periodAttentionLabel = viewMode === "month" ? "diesen Monat" : "diese Woche";
|
||||
const periodInLabel = viewMode === "month" ? "diesem Monat" : "dieser Woche";
|
||||
const isMonthView = viewMode === "month";
|
||||
|
||||
async function load() {
|
||||
const requestId = loadRequestId.current + 1;
|
||||
@@ -201,14 +221,14 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await getStatisticsOverview(month);
|
||||
const result = await getStatisticsOverview(period, viewMode);
|
||||
|
||||
if (requestId === loadRequestId.current) {
|
||||
setStats(result);
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId === loadRequestId.current) {
|
||||
toast.error("Monatsabschluss konnte nicht geladen werden", {
|
||||
toast.error("Abschlussansicht konnte nicht geladen werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
}
|
||||
@@ -221,7 +241,11 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [month]);
|
||||
}, [period, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(monthlyCloseViewModeStorageKey, viewMode);
|
||||
}, [viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setShowAcknowledgedMissingCrm(window.localStorage.getItem(showAcknowledgedMissingCrmStorageKey) === "true");
|
||||
@@ -241,6 +265,22 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
setMonth(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
|
||||
function changeWeekBy(delta: number) {
|
||||
const match = /^(\d{4})-W(\d{2})$/.exec(week);
|
||||
const year = match ? Number(match[1]) : new Date().getFullYear();
|
||||
const weekNumber = match ? Number(match[2]) : 1;
|
||||
const jan4 = new Date(Date.UTC(year, 0, 4));
|
||||
const jan4Weekday = jan4.getUTCDay() || 7;
|
||||
const date = new Date(jan4);
|
||||
date.setUTCDate(jan4.getUTCDate() - jan4Weekday + 1 + (weekNumber - 1 + delta) * 7);
|
||||
const weekday = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() + 4 - weekday);
|
||||
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
||||
const nextWeek = Math.ceil(((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);
|
||||
|
||||
setWeek(`${date.getUTCFullYear()}-W${String(nextWeek).padStart(2, "0")}`);
|
||||
}
|
||||
|
||||
async function finishMonth() {
|
||||
setClosing(true);
|
||||
try {
|
||||
@@ -308,14 +348,14 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
const days = showAcknowledgedMissingCrm ? [...missingCrmDays, ...acknowledgedMissingCrmDays] : [...missingCrmDays];
|
||||
return days.sort((left, right) => left.day.localeCompare(right.day) || left.ticket_number.localeCompare(right.ticket_number, "de", { numeric: true, sensitivity: "base" }));
|
||||
}, [acknowledgedMissingCrmDays, missingCrmDays, showAcknowledgedMissingCrm]);
|
||||
const canClose = Boolean(stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
|
||||
const canClose = Boolean(isMonthView && stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
|
||||
const checklist = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: "Offene Bewertungen",
|
||||
ok: openSessions.length === 0,
|
||||
value: openSessions.length,
|
||||
detail: openSessions.length === 0 ? "Alle Sessions sind bewertet." : `${openTicketGroups.length} Ticket(s) bearbeiten`
|
||||
detail: openSessions.length === 0 ? "Keine offenen Bewertungen." : `${openTicketGroups.length} Ticket(s) bearbeiten`
|
||||
},
|
||||
{
|
||||
label: "Teamspace geprüft",
|
||||
@@ -324,13 +364,13 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
detail: missingCrmDays.length === 0 ? "Keine offenen Teamspace-Prüfpunkte." : `${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`
|
||||
},
|
||||
{
|
||||
label: "Monat abschließbar",
|
||||
ok: Boolean(stats?.closed || canClose),
|
||||
value: stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen",
|
||||
detail: stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen."
|
||||
label: isMonthView ? "Monat abschließbar" : "Wochenansicht",
|
||||
ok: isMonthView ? Boolean(stats?.closed || canClose) : true,
|
||||
value: isMonthView ? (stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen") : "Ansicht",
|
||||
detail: isMonthView ? (stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen.") : "Abschluss bleibt monatlich."
|
||||
}
|
||||
],
|
||||
[acknowledgedMissingCrmDays.length, canClose, openSessions.length, openTicketGroups.length, missingCrmDays.length, stats?.closed]
|
||||
[acknowledgedMissingCrmDays.length, canClose, isMonthView, openSessions.length, openTicketGroups.length, missingCrmDays.length, stats?.closed]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -338,25 +378,46 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-normal">Monatsabschluss</h2>
|
||||
<p className="text-sm text-muted-foreground">Offene Bewertungen, Teamspace-Abgleich und der eigentliche Monatsabschluss.</p>
|
||||
<p className="text-sm text-muted-foreground">Offene Bewertungen und Teamspace-Abgleich für {periodAttentionLabel}; der Abschluss selbst bleibt monatlich.</p>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(220px,300px)_auto] sm:items-end">
|
||||
<div className="grid gap-2 sm:grid-cols-[auto_minmax(220px,300px)_auto] sm:items-end">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Ansicht</span>
|
||||
<div className="inline-flex h-9 rounded-md border bg-muted/20 p-1">
|
||||
<Button type="button" size="sm" variant={viewMode === "week" ? "default" : "ghost"} className="h-7 px-3" onClick={() => setViewMode("week")}>
|
||||
Woche
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant={viewMode === "month" ? "default" : "ghost"} className="h-7 px-3" onClick={() => setViewMode("month")}>
|
||||
Monat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="close-month">Monat</label>
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="close-period">{periodLabel}</label>
|
||||
{loading ? <span className="text-xs text-muted-foreground">lädt...</span> : null}
|
||||
</div>
|
||||
<div className="grid grid-cols-[2.25rem_minmax(0,1fr)_2.25rem] gap-1">
|
||||
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(-1)} aria-label="Monat zurück">
|
||||
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => (isMonthView ? changeMonthBy(-1) : changeWeekBy(-1))} aria-label={`${periodLabel} zurück`}>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Input id="close-month" className="h-9" type="month" value={month} onChange={(event) => setMonth(event.currentTarget.value)} />
|
||||
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(1)} aria-label="Monat vor">
|
||||
<Input
|
||||
id="close-period"
|
||||
className="h-9"
|
||||
type={isMonthView ? "month" : "week"}
|
||||
value={period}
|
||||
onChange={(event) => (isMonthView ? setMonth(event.currentTarget.value) : setWeek(event.currentTarget.value))}
|
||||
/>
|
||||
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => (isMonthView ? changeMonthBy(1) : changeWeekBy(1))} aria-label={`${periodLabel} vor`}>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{stats?.closed ? (
|
||||
{!isMonthView ? (
|
||||
<Badge variant="outline" className="h-9 justify-center px-3">
|
||||
Abschluss nur monatlich
|
||||
</Badge>
|
||||
) : stats?.closed ? (
|
||||
<Button size="sm" variant="secondary" disabled={reopening} onClick={reopenMonth}>
|
||||
<LockOpen className="size-4" />
|
||||
{reopening ? "Öffnet..." : "Monat öffnen"}
|
||||
@@ -370,7 +431,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats?.closed ? (
|
||||
{isMonthView && stats?.closed ? (
|
||||
<Alert variant="success" className="flex items-center gap-2 py-3">
|
||||
<CheckCircle2 className="size-4 shrink-0" />
|
||||
<span>Dieser Monat wurde am {formatDateTime(stats.closedAt!)} abgeschlossen.</span>
|
||||
@@ -382,7 +443,12 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
<StatusCard label="Teamspace" value={formatMinutes(stats?.totals.crmBilledMinutes ?? 0)} detail={`Differenz ${formatTeamspaceDelta(stats?.totals.crmDeltaMinutes ?? 0)}`} />
|
||||
<StatusCard label="Offene Bewertungen" value={openSessions.length} detail={`${openTicketGroups.length} Ticket(s)`} tone={openSessions.length > 0 ? "warn" : "ok"} />
|
||||
<StatusCard label="Ohne Teamspace" value={missingCrmDays.length} detail={`${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`} tone={missingCrmDays.length > 0 ? "warn" : "ok"} />
|
||||
<StatusCard label="Status" value={stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen"} detail={stats?.closed ? "Monat ist gesperrt" : canClose ? "kann abgeschlossen werden" : "Prüfpunkte bearbeiten"} tone={stats?.closed || canClose ? "ok" : "warn"} />
|
||||
<StatusCard
|
||||
label="Status"
|
||||
value={isMonthView ? (stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen") : "Ansicht"}
|
||||
detail={isMonthView ? (stats?.closed ? "Monat ist gesperrt" : canClose ? "kann abgeschlossen werden" : "Prüfpunkte bearbeiten") : "Abschluss bleibt monatlich"}
|
||||
tone={isMonthView ? (stats?.closed || canClose ? "ok" : "warn") : "neutral"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
@@ -413,7 +479,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>Aufräumen</CardTitle>
|
||||
<CardDescription>Alles, was für diesen Monat noch Aufmerksamkeit braucht.</CardDescription>
|
||||
<CardDescription>Alles, was für {periodAttentionLabel} noch Aufmerksamkeit braucht.</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="inline-flex h-8 items-center gap-2 rounded-md border bg-background px-3 text-sm">
|
||||
@@ -435,7 +501,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
Offene Bewertungen
|
||||
</div>
|
||||
{openTicketGroups.map((group) => (
|
||||
<OpenTicketItem key={group.ticketId} group={group} month={month} onNavigate={onNavigate} />
|
||||
<OpenTicketItem key={group.ticketId} group={group} onNavigate={onNavigate} />
|
||||
))}
|
||||
{openTicketGroups.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine offenen Bewertungen.</p> : null}
|
||||
</section>
|
||||
@@ -479,7 +545,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Für diesen Monat sind noch keine Teamspace-Werte zu abgerechneten Ticket-Tagen vorhanden.</p>
|
||||
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Für {periodAttentionLabel} sind noch keine Teamspace-Werte zu abgerechneten Ticket-Tagen vorhanden.</p>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -490,8 +556,8 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
<CardContent className="grid place-items-center gap-2 p-8 text-center">
|
||||
<Clock3 className="size-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">Keine Sessions in diesem Monat</p>
|
||||
<p className="text-sm text-muted-foreground">Ein Monatsabschluss ist erst sinnvoll, wenn Sessions vorhanden sind.</p>
|
||||
<p className="font-medium">Keine Sessions in {periodInLabel}</p>
|
||||
<p className="text-sm text-muted-foreground">Die Kontrollansicht ist erst sinnvoll, wenn Sessions vorhanden sind.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -25,6 +25,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
||||
const [zammadApiToken, setZammadApiToken] = useState("");
|
||||
const [zammadTokenDirty, setZammadTokenDirty] = useState(false);
|
||||
const [zammadEmailSessionWebhookEnabled, setZammadEmailSessionWebhookEnabled] = useState(currentUser.zammad_email_session_webhook_enabled);
|
||||
const [zammadAtossEmailSyncEnabled, setZammadAtossEmailSyncEnabled] = useState(currentUser.zammad_atoss_email_sync_enabled);
|
||||
const [sessionBillingPromptEnabled, setSessionBillingPromptEnabled] = useState(currentUser.session_billing_prompt_enabled);
|
||||
const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled);
|
||||
const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled);
|
||||
@@ -41,6 +42,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
||||
setZammadApiToken("");
|
||||
setZammadTokenDirty(false);
|
||||
setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled);
|
||||
setZammadAtossEmailSyncEnabled(currentUser.zammad_atoss_email_sync_enabled);
|
||||
setSessionBillingPromptEnabled(currentUser.session_billing_prompt_enabled);
|
||||
setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled);
|
||||
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
|
||||
@@ -125,6 +127,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
||||
...(currentUser.role === "user"
|
||||
? {
|
||||
zammadEmailSessionWebhookEnabled,
|
||||
zammadAtossEmailSyncEnabled,
|
||||
sessionBillingPromptEnabled,
|
||||
zammadSessionNoteDefaultEnabled,
|
||||
zammadTimeUnitRoundingEnabled,
|
||||
@@ -299,6 +302,22 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
||||
onCheckedChange={setZammadEmailSessionWebhookEnabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MailPlus className="size-4 text-muted-foreground" />
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="profile-zammad-atoss-email-sync">
|
||||
ATOSS eMails syncen
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">Wenn aus, werden eMails an support.atc@atoss.com nicht übernommen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="profile-zammad-atoss-email-sync"
|
||||
checked={zammadAtossEmailSyncEnabled}
|
||||
onCheckedChange={setZammadAtossEmailSyncEnabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardCheck className="size-4 text-muted-foreground" />
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
zammadPriorityFilterOptions,
|
||||
zammadPriorityLabel,
|
||||
zammadPrioritySortRank,
|
||||
zammadId,
|
||||
zammadStateId,
|
||||
ZammadStateBadge,
|
||||
zammadStateFilterOptions,
|
||||
zammadStateLabel,
|
||||
@@ -60,6 +62,7 @@ type ProjectTeamspaceEntryForm = {
|
||||
|
||||
type ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "consumption" | "consumptionPercent" | "remaining" | "budget";
|
||||
type ProjectSortDirection = "asc" | "desc";
|
||||
type ProjectGroupBy = "none" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "budgetState" | "teamspace";
|
||||
type ProjectFilterOption = MultiFilterOption;
|
||||
|
||||
type ProjectViewSettings = {
|
||||
@@ -67,6 +70,8 @@ type ProjectViewSettings = {
|
||||
filterValues: string[];
|
||||
organizationValues: string[];
|
||||
standardTeamspaceOnly: boolean;
|
||||
groupBy: ProjectGroupBy;
|
||||
groupDirection: ProjectSortDirection;
|
||||
sortBy: ProjectSortBy;
|
||||
sortDirection: ProjectSortDirection;
|
||||
};
|
||||
@@ -78,6 +83,8 @@ const defaultProjectViewSettings: ProjectViewSettings = {
|
||||
filterValues: ["open"],
|
||||
organizationValues: [],
|
||||
standardTeamspaceOnly: false,
|
||||
groupBy: "none",
|
||||
groupDirection: "asc",
|
||||
sortBy: "updated",
|
||||
sortDirection: "desc"
|
||||
};
|
||||
@@ -97,6 +104,7 @@ const projectStaticFilterOptions: ProjectFilterOption[] = [
|
||||
const projectStaticFilterValues = new Set(projectStaticFilterOptions.map((option) => option.value));
|
||||
|
||||
const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "zammadStatus", "zammadPriority", "consumption", "consumptionPercent", "remaining", "budget"];
|
||||
const projectGroupValues: ProjectGroupBy[] = ["none", "organization", "status", "zammadStatus", "zammadPriority", "budgetState", "teamspace"];
|
||||
|
||||
const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [
|
||||
{ value: "updated", label: "Aktualisierung" },
|
||||
@@ -112,6 +120,16 @@ const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [
|
||||
{ value: "budget", label: "Kontingent" }
|
||||
];
|
||||
|
||||
const projectGroupOptions: Array<{ value: ProjectGroupBy; label: string }> = [
|
||||
{ value: "none", label: "Keine Gruppierung" },
|
||||
{ value: "organization", label: "Organisation" },
|
||||
{ value: "status", label: "TT-Status" },
|
||||
{ value: "zammadStatus", label: "Zammad-Status" },
|
||||
{ value: "zammadPriority", label: "Zammad-Prio" },
|
||||
{ value: "budgetState", label: "Kontingentstatus" },
|
||||
{ value: "teamspace", label: "Standard-Teamspace" }
|
||||
];
|
||||
|
||||
function projectEntryId() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
@@ -262,6 +280,8 @@ function readStoredProjectViewSettings(): ProjectViewSettings {
|
||||
filterValues,
|
||||
organizationValues: legacyProjectOrganizationValues(parsed as Partial<ProjectViewSettings> & Record<string, unknown>),
|
||||
standardTeamspaceOnly: legacyStandardTeamspaceOnly(parsed as Partial<ProjectViewSettings> & Record<string, unknown>),
|
||||
groupBy: projectGroupValues.find((value) => value === parsed.groupBy) ?? defaultProjectViewSettings.groupBy,
|
||||
groupDirection: parsed.groupDirection === "desc" ? "desc" : defaultProjectViewSettings.groupDirection,
|
||||
sortBy: projectSortValues.find((value) => value === parsed.sortBy) ?? defaultProjectViewSettings.sortBy,
|
||||
sortDirection: parsed.sortDirection === "asc" ? "asc" : defaultProjectViewSettings.sortDirection
|
||||
};
|
||||
@@ -579,6 +599,117 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr
|
||||
return comparison * factor || compareText(left.ticket_number, right.ticket_number);
|
||||
}
|
||||
|
||||
type ProjectGroupMeta = {
|
||||
key: string;
|
||||
label: string;
|
||||
sortLabel: string;
|
||||
rank: number;
|
||||
};
|
||||
|
||||
type ProjectGroup = ProjectGroupMeta & {
|
||||
projects: ProjectSummary[];
|
||||
consumptionMinutes: number;
|
||||
remainingMinutes: number;
|
||||
};
|
||||
|
||||
function projectBudgetStateGroup(project: ProjectSummary, standardTeamspaceOnly: boolean): ProjectGroupMeta {
|
||||
const budgetMinutes = projectBudget(project, standardTeamspaceOnly) ?? 0;
|
||||
const remaining = projectRemaining(project, standardTeamspaceOnly);
|
||||
|
||||
if (budgetMinutes <= 0) {
|
||||
return { key: "budgetState:none", label: "Ohne Kontingent", sortLabel: "Ohne Kontingent", rank: 40 };
|
||||
}
|
||||
|
||||
if (remaining !== null && remaining < 0) {
|
||||
return { key: "budgetState:over", label: "Überzogen", sortLabel: "Überzogen", rank: 10 };
|
||||
}
|
||||
|
||||
if (remaining === 0) {
|
||||
return { key: "budgetState:used", label: "Aufgebraucht", sortLabel: "Aufgebraucht", rank: 30 };
|
||||
}
|
||||
|
||||
return { key: "budgetState:remaining", label: "Mit Rest", sortLabel: "Mit Rest", rank: 20 };
|
||||
}
|
||||
|
||||
function projectGroupMeta(project: ProjectSummary, groupBy: ProjectGroupBy, standardTeamspaceOnly: boolean): ProjectGroupMeta {
|
||||
if (groupBy === "organization") {
|
||||
const label = projectOrganizationLabel(project);
|
||||
return { key: `organization:${projectOrganizationKey(project)}`, label, sortLabel: label, rank: 0 };
|
||||
}
|
||||
|
||||
if (groupBy === "status") {
|
||||
return project.closed_at
|
||||
? { key: "status:closed", label: "Geschlossen", sortLabel: "Geschlossen", rank: 20 }
|
||||
: { key: "status:open", label: "Offen", sortLabel: "Offen", rank: 10 };
|
||||
}
|
||||
|
||||
if (groupBy === "zammadStatus") {
|
||||
const id = zammadStateId(project.zammad_state_id);
|
||||
const label = id ? zammadStateLabel(id) : "Ohne Zammad-Status";
|
||||
|
||||
return { key: `zammadStatus:${id ?? "none"}`, label, sortLabel: label, rank: zammadStateSortRank(id) };
|
||||
}
|
||||
|
||||
if (groupBy === "zammadPriority") {
|
||||
const id = zammadId(project.zammad_priority_id);
|
||||
const label = id ? zammadPriorityLabel(id) : "Ohne Zammad-Prio";
|
||||
|
||||
return { key: `zammadPriority:${id ?? "none"}`, label, sortLabel: label, rank: zammadPrioritySortRank(id) };
|
||||
}
|
||||
|
||||
if (groupBy === "budgetState") {
|
||||
return projectBudgetStateGroup(project, standardTeamspaceOnly);
|
||||
}
|
||||
|
||||
if (groupBy === "teamspace") {
|
||||
const label = defaultProjectTeamspaceEntry(project)?.teamspace_project || "Ohne Teamspace-ID";
|
||||
|
||||
return { key: `teamspace:${label.toLocaleLowerCase("de-DE")}`, label, sortLabel: label, rank: 0 };
|
||||
}
|
||||
|
||||
return { key: "all", label: "Alle Projekte", sortLabel: "Alle Projekte", rank: 0 };
|
||||
}
|
||||
|
||||
function groupProjects(projects: ProjectSummary[], groupBy: ProjectGroupBy, groupDirection: ProjectSortDirection, standardTeamspaceOnly: boolean): ProjectGroup[] {
|
||||
if (groupBy === "none") {
|
||||
return [
|
||||
{
|
||||
key: "all",
|
||||
label: "Alle Projekte",
|
||||
sortLabel: "Alle Projekte",
|
||||
rank: 0,
|
||||
projects,
|
||||
consumptionMinutes: projects.reduce((sum, project) => sum + projectConsumption(project, standardTeamspaceOnly), 0),
|
||||
remainingMinutes: projects.reduce((sum, project) => sum + Math.max(projectRemaining(project, standardTeamspaceOnly) ?? 0, 0), 0)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
const factor = groupDirection === "asc" ? 1 : -1;
|
||||
|
||||
const groups = new Map<string, ProjectGroup>();
|
||||
|
||||
for (const project of projects) {
|
||||
const meta = projectGroupMeta(project, groupBy, standardTeamspaceOnly);
|
||||
const group = groups.get(meta.key);
|
||||
|
||||
if (group) {
|
||||
group.projects.push(project);
|
||||
group.consumptionMinutes += projectConsumption(project, standardTeamspaceOnly);
|
||||
group.remainingMinutes += Math.max(projectRemaining(project, standardTeamspaceOnly) ?? 0, 0);
|
||||
} else {
|
||||
groups.set(meta.key, {
|
||||
...meta,
|
||||
projects: [project],
|
||||
consumptionMinutes: projectConsumption(project, standardTeamspaceOnly),
|
||||
remainingMinutes: Math.max(projectRemaining(project, standardTeamspaceOnly) ?? 0, 0)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groups.values()).sort((left, right) => ((left.rank - right.rank) || compareText(left.sortLabel, right.sortLabel)) * factor);
|
||||
}
|
||||
|
||||
function formFromProject(project: ProjectSummary): ProjectForm {
|
||||
const entries = projectTeamspaceEntries(project).map((entry, index) => ({
|
||||
clientId: entry.id || projectEntryId(),
|
||||
@@ -967,12 +1098,18 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
() => filteredProjects.reduce((sum, project) => sum + Math.max(projectRemaining(project, viewSettings.standardTeamspaceOnly) ?? 0, 0), 0),
|
||||
[filteredProjects, viewSettings.standardTeamspaceOnly]
|
||||
);
|
||||
const projectGroups = useMemo(
|
||||
() => groupProjects(filteredProjects, viewSettings.groupBy, viewSettings.groupDirection, viewSettings.standardTeamspaceOnly),
|
||||
[filteredProjects, viewSettings.groupBy, viewSettings.groupDirection, viewSettings.standardTeamspaceOnly]
|
||||
);
|
||||
const closedProjectsLoaded = projectShouldLoadClosed(viewSettings.filterValues);
|
||||
const activeFilterCount = [
|
||||
viewSettings.query.trim() !== "",
|
||||
JSON.stringify(viewSettings.filterValues) !== JSON.stringify(defaultProjectViewSettings.filterValues),
|
||||
viewSettings.organizationValues.length > 0,
|
||||
viewSettings.standardTeamspaceOnly,
|
||||
viewSettings.groupBy !== defaultProjectViewSettings.groupBy,
|
||||
viewSettings.groupDirection !== defaultProjectViewSettings.groupDirection,
|
||||
viewSettings.sortBy !== defaultProjectViewSettings.sortBy,
|
||||
viewSettings.sortDirection !== defaultProjectViewSettings.sortDirection
|
||||
].filter(Boolean).length;
|
||||
@@ -1286,7 +1423,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
||||
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-8">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="project-search">
|
||||
Suche
|
||||
@@ -1331,6 +1468,44 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
<span className="block truncate text-xs">Hauptbalken</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Gruppierung</label>
|
||||
<Select value={viewSettings.groupBy} onValueChange={(value) => setViewSettings((current) => ({ ...current, groupBy: value as ProjectGroupBy }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projectGroupOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Gruppenrichtung</label>
|
||||
<div className="grid grid-cols-2 rounded-md border bg-background p-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={viewSettings.groupDirection === "asc" ? "secondary" : "ghost"}
|
||||
className="h-7 px-2"
|
||||
onClick={() => setViewSettings((current) => ({ ...current, groupDirection: "asc" }))}
|
||||
>
|
||||
<ArrowUp className="size-3.5" />
|
||||
Auf
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={viewSettings.groupDirection === "desc" ? "secondary" : "ghost"}
|
||||
className="h-7 px-2"
|
||||
onClick={() => setViewSettings((current) => ({ ...current, groupDirection: "desc" }))}
|
||||
>
|
||||
<ArrowDown className="size-3.5" />
|
||||
Ab
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Sortierung</label>
|
||||
<Select value={viewSettings.sortBy} onValueChange={(value) => setViewSettings((current) => ({ ...current, sortBy: value as ProjectSortBy }))}>
|
||||
@@ -1345,7 +1520,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Richtung</label>
|
||||
<label className="text-xs font-medium text-muted-foreground">Sortierrichtung</label>
|
||||
<div className="grid grid-cols-2 rounded-md border bg-background p-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1432,18 +1607,28 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
{sortHeader("Verbrauch %", "consumptionPercent")}
|
||||
<span className="text-right">Aktion</span>
|
||||
</div>
|
||||
{filteredProjects.map((project) => {
|
||||
const isEditing = editingProjectId === project.id;
|
||||
const budgetedTeamspaceEntries = budgetedProjectTeamspaceEntries(project);
|
||||
const consumptionMinutes = projectConsumption(project, viewSettings.standardTeamspaceOnly);
|
||||
const budgetMinutes = projectBudget(project, viewSettings.standardTeamspaceOnly);
|
||||
const remaining = projectRemaining(project, viewSettings.standardTeamspaceOnly);
|
||||
const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly);
|
||||
const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1;
|
||||
const canEditProject = project.can_edit !== false;
|
||||
{projectGroups.map((group) => (
|
||||
<div key={group.key} className={cn(viewSettings.groupBy !== "none" ? "border-b last:border-b-0" : undefined)}>
|
||||
{viewSettings.groupBy !== "none" ? (
|
||||
<div className="flex flex-col gap-1 border-b bg-muted/15 px-4 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h4 className="min-w-0 truncate text-sm font-medium">{group.label}</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{group.projects.length} Projekt(e) · {formatMinutes(group.consumptionMinutes)} Verbrauch · {formatMinutes(group.remainingMinutes)} Rest offen
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{group.projects.map((project) => {
|
||||
const isEditing = editingProjectId === project.id;
|
||||
const budgetedTeamspaceEntries = budgetedProjectTeamspaceEntries(project);
|
||||
const consumptionMinutes = projectConsumption(project, viewSettings.standardTeamspaceOnly);
|
||||
const budgetMinutes = projectBudget(project, viewSettings.standardTeamspaceOnly);
|
||||
const remaining = projectRemaining(project, viewSettings.standardTeamspaceOnly);
|
||||
const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly);
|
||||
const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1;
|
||||
const canEditProject = project.can_edit !== false;
|
||||
|
||||
return (
|
||||
<div key={project.id} className="border-b last:border-b-0">
|
||||
return (
|
||||
<div key={project.id} className="border-b last:border-b-0">
|
||||
<div className="grid gap-3 px-4 py-3 xl:grid-cols-[minmax(220px,1.35fr)_minmax(170px,1fr)_105px_115px_105px_165px_90px_44px] xl:items-center">
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
@@ -1640,8 +1825,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
{!loading && filteredProjects.length === 0 ? (
|
||||
|
||||
@@ -430,7 +430,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
const nonBillableMinutes = totals?.nonBillableMinutes ?? 0;
|
||||
const openSessions = totals?.openSessions ?? 0;
|
||||
const openMinutes = totals?.openMinutes ?? 0;
|
||||
const evaluatedSessions = billedSessions + billableSessions + nonBillableSessions;
|
||||
const evaluatedSessions = billedSessions + nonBillableSessions;
|
||||
const crmCoverage = percentage(totals?.crmBilledMinutes ?? 0, totals?.minutes ?? 0);
|
||||
const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1);
|
||||
const topOrganization = stats?.organizations[0] ?? null;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { type ClipboardEvent, FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { FolderGit, Pause, Play, Plus, RotateCcw, Search, Square, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -11,11 +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 { createSession, getRecurringBillings, lookupTicket } from "../api";
|
||||
import { 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, createClientId, pauseEntry, resumeEntry, ticketPattern, type TimerEntry } from "../timers";
|
||||
import { activeElapsedMs, ticketPattern, type TimerEntry } from "../timers";
|
||||
import type { AuthUser, BillingStatus, RecurringBilling, TicketMeta, WorkType } from "../types";
|
||||
import { zammadArticleTypeFromValue, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
|
||||
|
||||
@@ -25,6 +26,7 @@ type ManualSessionFormState = {
|
||||
organizationName: string | null;
|
||||
projectTeamspaceEntryId: string;
|
||||
activity: string;
|
||||
zammadArticleBody: string;
|
||||
billingStatus: BillingStatus;
|
||||
createZammadArticle: boolean;
|
||||
zammadArticleTypeId: ZammadArticleTypeId;
|
||||
@@ -46,6 +48,7 @@ function emptyManualSession(): ManualSessionFormState {
|
||||
organizationName: null,
|
||||
projectTeamspaceEntryId: "",
|
||||
activity: "",
|
||||
zammadArticleBody: "",
|
||||
billingStatus: null,
|
||||
createZammadArticle: false,
|
||||
zammadArticleTypeId: 6,
|
||||
@@ -61,6 +64,7 @@ type FinishedSession = {
|
||||
ticketNumber: string;
|
||||
ticket: TicketMeta | null;
|
||||
activity: string;
|
||||
zammadArticleBody: string;
|
||||
billingStatus: BillingStatus;
|
||||
promptBillingStatus: boolean;
|
||||
createZammadArticle: boolean;
|
||||
@@ -81,6 +85,10 @@ function teamspaceEntryOptionLabel(entry: { teamspace_project: string | null; is
|
||||
return entry.is_default ? `${label} (Standard)` : label;
|
||||
}
|
||||
|
||||
function teamspaceEntryDescription(entry: { teamspace_description?: string | null } | null | undefined) {
|
||||
return entry?.teamspace_description?.trim() || "";
|
||||
}
|
||||
|
||||
function requiresTeamspaceEntry(ticket: TicketMeta | null | undefined) {
|
||||
return Boolean(ticket?.is_project && ticketTeamspaceEntries(ticket).length > 1);
|
||||
}
|
||||
@@ -126,13 +134,161 @@ function notifyZammadArticleResult(result: CreateSessionResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.zammadArticle?.status === "queued") {
|
||||
toast.warning("Zammad-Notiz geparkt", {
|
||||
description: result.zammadArticle.error ?? "Die Session wurde gespeichert. Die Notiz kann im Profil später erneut gesendet werden."
|
||||
if (result.zammadArticle?.status === "failed") {
|
||||
toast.error("Zammad-Notiz nicht übertragen", {
|
||||
description: result.zammadArticle.error ?? "Die Session wurde gespeichert, aber Zammad hat die Notiz nicht angenommen."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function textToEditorHtml(value: string) {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((line) => `<div>${line ? escapeHtml(line) : "<br>"}</div>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function fileToDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => resolve(String(reader.result ?? "")));
|
||||
reader.addEventListener("error", () => reject(reader.error ?? new Error("Bild konnte nicht gelesen werden")));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function currentSelectionRange() {
|
||||
const selection = window.getSelection();
|
||||
return selection && selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null;
|
||||
}
|
||||
|
||||
function restoreSelectionRange(range: Range | null) {
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
function editorPlainText(element: HTMLElement) {
|
||||
return element.innerText.replace(/\u00a0/g, " ").trim();
|
||||
}
|
||||
|
||||
function hasInlineImage(element: HTMLElement) {
|
||||
return Boolean(element.querySelector("img[src^='data:image/']"));
|
||||
}
|
||||
|
||||
function normalizedEditorHtml(element: HTMLElement) {
|
||||
const html = element.innerHTML.trim();
|
||||
return html && (editorPlainText(element) || hasInlineImage(element)) ? html : "";
|
||||
}
|
||||
|
||||
function InlineImageActivityEditor({
|
||||
id,
|
||||
value,
|
||||
htmlValue,
|
||||
onChange
|
||||
}: {
|
||||
id: string;
|
||||
value: string;
|
||||
htmlValue: string;
|
||||
onChange: (value: string, htmlValue: string) => void;
|
||||
}) {
|
||||
const editorRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextHtml = htmlValue || (value ? textToEditorHtml(value) : "");
|
||||
|
||||
if (editor.innerHTML !== nextHtml) {
|
||||
editor.innerHTML = nextHtml;
|
||||
}
|
||||
}, [htmlValue, value]);
|
||||
|
||||
function emitChange() {
|
||||
const editor = editorRef.current;
|
||||
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(editorPlainText(editor), normalizedEditorHtml(editor));
|
||||
}
|
||||
|
||||
async function handlePaste(event: ClipboardEvent<HTMLDivElement>) {
|
||||
const imageFiles = Array.from(event.clipboardData.items)
|
||||
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => Boolean(file));
|
||||
|
||||
if (imageFiles.length === 0) {
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
|
||||
if (text) {
|
||||
event.preventDefault();
|
||||
document.execCommand("insertText", false, text);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
let insertionRange = currentSelectionRange();
|
||||
const text = event.clipboardData.getData("text/plain").trim();
|
||||
if (text) {
|
||||
document.execCommand("insertText", false, `${text}\n`);
|
||||
insertionRange = currentSelectionRange();
|
||||
}
|
||||
|
||||
try {
|
||||
for (const file of imageFiles) {
|
||||
const dataUrl = await fileToDataUrl(file);
|
||||
restoreSelectionRange(insertionRange);
|
||||
document.execCommand("insertHTML", false, `<img src="${dataUrl}" style="display:block;max-width:100%;height:auto;margin-top:0.5rem;border-radius:0.375rem;"><br>`);
|
||||
insertionRange = currentSelectionRange();
|
||||
}
|
||||
emitChange();
|
||||
} catch (error) {
|
||||
toast.error("Screenshot konnte nicht eingefügt werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
ref={editorRef}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
className="min-h-24 w-full overflow-hidden rounded-lg border border-input 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:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm [&_img]:my-2 [&_img]:max-h-[28rem] [&_img]:max-w-full [&_img]:rounded-md"
|
||||
data-placeholder="Tätigkeit beschreiben, Screenshot mit Strg+V einfügen"
|
||||
onInput={emitChange}
|
||||
onBlur={emitChange}
|
||||
onPaste={(event) => void handlePaste(event)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimerId, tick, currentUser }: TimerPageProps) {
|
||||
const [ticketNumber, setTicketNumber] = useState("");
|
||||
const [finished, setFinished] = useState<FinishedSession | null>(null);
|
||||
@@ -148,6 +304,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
const [manualLookupTicket, setManualLookupTicket] = useState<TicketMeta | null>(null);
|
||||
const [zammadLookupBusy, setZammadLookupBusy] = useState<string | null>(null);
|
||||
const [recurringBillings, setRecurringBillings] = useState<RecurringBilling[]>([]);
|
||||
const activitySaveTimeouts = useRef(new Map<string, number>());
|
||||
const activityDrafts = useRef(new Map<string, { activity: string; zammadArticleBody: string }>());
|
||||
|
||||
const selectedTimer = timers.find((timer) => timer.id === selectedTimerId) ?? timers[0] ?? null;
|
||||
const elapsedSeconds = useMemo(() => Math.floor(activeElapsedMs(selectedTimer, tick) / 1000), [selectedTimer, tick]);
|
||||
@@ -157,6 +315,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
const manualTicketIsRecurringBilling =
|
||||
ticketHasRecurringBilling(manualLookupTicket, recurringBillings) || isRecurringBillingTicketNumber(manualSession.ticketNumber.trim(), recurringBillings);
|
||||
const runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
|
||||
const selectedManualTeamspaceEntry = ticketTeamspaceEntries(manualLookupTicket).find((entry) => entry.id === manualSession.projectTeamspaceEntryId) ?? null;
|
||||
const selectedFinishedTeamspaceEntry = ticketTeamspaceEntries(finished?.ticket).find((entry) => entry.id === projectTeamspaceEntryId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -261,6 +421,15 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
});
|
||||
}, [currentUser.zammad_article_type_id, currentUser.zammad_session_note_default_enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const timeout of activitySaveTimeouts.current.values()) {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
activitySaveTimeouts.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function addTimer(source: "local" | "zammad" = "local") {
|
||||
const normalizedTicketNumber = ticketNumber.trim();
|
||||
|
||||
@@ -312,25 +481,29 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const newTimer: TimerEntry = {
|
||||
id: createClientId("timer"),
|
||||
ticketNumber: normalizedTicketNumber,
|
||||
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
|
||||
workType: ticketResult.ticket?.work_type ?? null,
|
||||
isProject: Boolean(ticketResult.ticket?.is_project),
|
||||
projectName: ticketResult.ticket?.project_name ?? null,
|
||||
ticketLookupDone: true,
|
||||
startedAt: now,
|
||||
pausedTotalMs: 0,
|
||||
pausedAt: null,
|
||||
activity: "",
|
||||
billingStatus: null,
|
||||
phase: "running"
|
||||
};
|
||||
|
||||
setTimers((current) => [...current.map((timer) => pauseEntry(timer, now)), newTimer]);
|
||||
setSelectedTimerId(newTimer.id);
|
||||
setTicketNumber("");
|
||||
try {
|
||||
const result = await createActiveTimer({
|
||||
ticketNumber: normalizedTicketNumber,
|
||||
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
|
||||
workType: ticketResult.ticket?.work_type ?? null,
|
||||
isProject: Boolean(ticketResult.ticket?.is_project),
|
||||
projectName: ticketResult.ticket?.project_name ?? null,
|
||||
ticketLookupDone: true,
|
||||
startedAt: now,
|
||||
pausedTotalMs: 0,
|
||||
pausedAt: null,
|
||||
activity: "",
|
||||
billingStatus: null,
|
||||
phase: "running"
|
||||
});
|
||||
setTimers(result.timers);
|
||||
setSelectedTimerId(result.timers.find((timer) => timer.ticketNumber === normalizedTicketNumber)?.id ?? null);
|
||||
setTicketNumber("");
|
||||
} catch (error) {
|
||||
toast.error("Timer konnte nicht gestartet werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupManualTicketInZammad() {
|
||||
@@ -407,36 +580,67 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
}
|
||||
}
|
||||
|
||||
function activateTimer(timerId: string) {
|
||||
const now = Date.now();
|
||||
setTimers((current) =>
|
||||
current.map((timer) => {
|
||||
if (timer.id === timerId) {
|
||||
return resumeEntry(timer, now);
|
||||
}
|
||||
|
||||
return pauseEntry(timer, now);
|
||||
})
|
||||
);
|
||||
setSelectedTimerId(timerId);
|
||||
async function activateTimer(timerId: string) {
|
||||
try {
|
||||
const result = await updateActiveTimer(timerId, { phase: "running" });
|
||||
setTimers(result.timers);
|
||||
setSelectedTimerId(timerId);
|
||||
} catch (error) {
|
||||
toast.error("Timer konnte nicht aktiviert werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function pauseTimer(timerId: string) {
|
||||
const now = Date.now();
|
||||
setTimers((current) => current.map((timer) => (timer.id === timerId ? pauseEntry(timer, now) : timer)));
|
||||
setSelectedTimerId(timerId);
|
||||
async function pauseTimer(timerId: string) {
|
||||
try {
|
||||
const result = await updateActiveTimer(timerId, { phase: "paused" });
|
||||
setTimers(result.timers);
|
||||
setSelectedTimerId(timerId);
|
||||
} catch (error) {
|
||||
toast.error("Timer konnte nicht pausiert werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resetTimer(timerId: string) {
|
||||
setTimers((current) => current.filter((timer) => timer.id !== timerId));
|
||||
async function resetTimer(timerId: string) {
|
||||
try {
|
||||
const result = await deleteActiveTimer(timerId);
|
||||
setTimers(result.timers);
|
||||
} catch (error) {
|
||||
toast.error("Timer konnte nicht entfernt werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateTimerActivity(timerId: string, nextActivity: string) {
|
||||
setTimers((current) => current.map((timer) => (timer.id === timerId ? { ...timer, activity: nextActivity } : timer)));
|
||||
function updateTimerActivity(timerId: string, nextActivity: string, zammadArticleBody: string) {
|
||||
activityDrafts.current.set(timerId, { activity: nextActivity, zammadArticleBody });
|
||||
setTimers((current) => current.map((timer) => (timer.id === timerId ? { ...timer, activity: nextActivity, zammadArticleBody } : timer)));
|
||||
const existingTimeout = activitySaveTimeouts.current.get(timerId);
|
||||
|
||||
if (existingTimeout) {
|
||||
window.clearTimeout(existingTimeout);
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
activitySaveTimeouts.current.delete(timerId);
|
||||
void updateActiveTimer(timerId, { activity: nextActivity, zammadArticleBody: zammadArticleBody || null }).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}, 700);
|
||||
|
||||
activitySaveTimeouts.current.set(timerId, timeout);
|
||||
}
|
||||
|
||||
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) => {
|
||||
toast.error("Bewertung konnte nicht gespeichert werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function finishTimer(timerId: string) {
|
||||
@@ -446,31 +650,31 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
return;
|
||||
}
|
||||
|
||||
const timerActivity = timer.activity;
|
||||
|
||||
if (!timerActivity.trim()) {
|
||||
setSelectedTimerId(timer.id);
|
||||
toast.error("Tätigkeit erfassen", {
|
||||
description: "Bitte dokumentiere die Tätigkeit vor dem Beenden der Session."
|
||||
});
|
||||
return;
|
||||
}
|
||||
const activityDraft = activityDrafts.current.get(timer.id);
|
||||
const timerActivity = activityDraft?.activity ?? timer.activity;
|
||||
const timerZammadArticleBody = activityDraft?.zammadArticleBody ?? timer.zammadArticleBody ?? "";
|
||||
|
||||
const now = Date.now();
|
||||
const stoppedTimer = pauseEntry(timer, now);
|
||||
const durationSeconds = Math.floor(activeElapsedMs(stoppedTimer, now) / 1000);
|
||||
const durationSeconds = Math.floor(activeElapsedMs(timer, now) / 1000);
|
||||
const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60));
|
||||
|
||||
setTimers((current) => current.map((entry) => (entry.id === timerId ? pauseEntry(entry, now) : entry)));
|
||||
setSelectedTimerId(timer.id);
|
||||
setFinishingId(timer.id);
|
||||
|
||||
try {
|
||||
const timerResult = await updateActiveTimer(timer.id, {
|
||||
phase: "paused",
|
||||
activity: timerActivity,
|
||||
zammadArticleBody: timerZammadArticleBody || null
|
||||
});
|
||||
setTimers(timerResult.timers);
|
||||
setSelectedTimerId(timer.id);
|
||||
|
||||
const result = await lookupTicket(timer.ticketNumber);
|
||||
const ticket = result.ticket;
|
||||
|
||||
if (ticketHasRecurringBilling(ticket, recurringBillings) || isRecurringBillingTicketNumber(timer.ticketNumber, recurringBillings)) {
|
||||
setTimers((current) => current.filter((entry) => entry.id !== timer.id));
|
||||
const timerResult = await deleteActiveTimer(timer.id).catch(() => null);
|
||||
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."
|
||||
});
|
||||
@@ -486,11 +690,12 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
ticketNumber: timer.ticketNumber,
|
||||
ticket,
|
||||
activity: timerActivity,
|
||||
zammadArticleBody: timerZammadArticleBody,
|
||||
billingStatus: timer.billingStatus,
|
||||
promptBillingStatus: currentUser.session_billing_prompt_enabled && timer.billingStatus === null,
|
||||
createZammadArticle: currentUser.zammad_session_note_default_enabled,
|
||||
createZammadArticle: currentUser.zammad_session_note_default_enabled && timerActivity.trim().length > 0,
|
||||
zammadArticleTypeId: currentUser.zammad_article_type_id,
|
||||
startedAt: new Date(stoppedTimer.startedAt).toISOString(),
|
||||
startedAt: new Date(timer.startedAt).toISOString(),
|
||||
endedAt: new Date(now).toISOString(),
|
||||
durationSeconds,
|
||||
roundedMinutes
|
||||
@@ -511,13 +716,6 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
return;
|
||||
}
|
||||
|
||||
if (!finished.activity.trim()) {
|
||||
toast.error("Tätigkeit erfassen", {
|
||||
description: "Bitte dokumentiere die Tätigkeit vor dem Speichern der Session."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (ticketHasRecurringBilling(finished.ticket, recurringBillings) || isRecurringBillingTicketNumber(finished.ticketNumber, recurringBillings)) {
|
||||
toast.error("Fixe Abrechnung", {
|
||||
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gespeichert werden. Bitte die Definition bearbeiten."
|
||||
@@ -555,11 +753,12 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
durationSeconds: finished.durationSeconds,
|
||||
organizationId: effectiveOrganizationId,
|
||||
activity: finished.activity,
|
||||
zammadArticleBody: finished.zammadArticleBody || null,
|
||||
workType: effectiveWorkType,
|
||||
zammadLookup: Boolean(finished.ticket?.zammad_ticket_id),
|
||||
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null,
|
||||
billingStatus: finished.billingStatus,
|
||||
createZammadArticle: finished.createZammadArticle,
|
||||
createZammadArticle: finished.createZammadArticle && (finished.activity.trim().length > 0 || Boolean(finished.zammadArticleBody)),
|
||||
zammadArticleTypeId: finished.zammadArticleTypeId
|
||||
});
|
||||
|
||||
@@ -567,7 +766,15 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
description: `${finished.ticketNumber} wurde mit ${finished.roundedMinutes} Minute(n) erfasst.`
|
||||
});
|
||||
notifyZammadArticleResult(result);
|
||||
setTimers((current) => current.filter((timer) => timer.id !== finished.timerId));
|
||||
try {
|
||||
const timerResult = await deleteActiveTimer(finished.timerId);
|
||||
setTimers(timerResult.timers);
|
||||
} catch (error) {
|
||||
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."
|
||||
});
|
||||
}
|
||||
setFinished(null);
|
||||
setOrganizationId("");
|
||||
setOrganizationName(null);
|
||||
@@ -619,8 +826,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
return;
|
||||
}
|
||||
|
||||
if (endedAt <= startedAt) {
|
||||
toast.error("Ende muss nach Beginn liegen");
|
||||
if (endedAt < startedAt) {
|
||||
toast.error("Ende darf nicht vor Beginn liegen");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -633,6 +840,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
ticketNumber: manualSession.ticketNumber.trim(),
|
||||
organizationId: manualSession.organizationId,
|
||||
activity: manualSession.activity,
|
||||
zammadArticleBody: manualSession.zammadArticleBody || null,
|
||||
workType: manualSession.workType,
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: endedAt.toISOString(),
|
||||
@@ -750,13 +958,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
<label className="text-sm font-medium" htmlFor="timer-activity">
|
||||
Tätigkeit
|
||||
</label>
|
||||
<Textarea
|
||||
<RichActivityEditor
|
||||
id="timer-activity"
|
||||
className="min-h-32"
|
||||
value={selectedTimer?.activity ?? ""}
|
||||
htmlValue={selectedTimer?.zammadArticleBody ?? ""}
|
||||
disabled={!selectedTimer}
|
||||
placeholder={selectedTimer ? "Beschreibe, woran du in dieser Session arbeitest." : "Timer auswählen oder starten."}
|
||||
onChange={(event) => selectedTimer && updateTimerActivity(selectedTimer.id, event.currentTarget.value)}
|
||||
onChange={(activity, zammadArticleBody) => selectedTimer && updateTimerActivity(selectedTimer.id, activity, zammadArticleBody)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -780,12 +988,12 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
||||
{selectedTimer?.phase === "running" ? (
|
||||
<Button variant="secondary" disabled={!selectedTimer} onClick={() => selectedTimer && pauseTimer(selectedTimer.id)}>
|
||||
<Button variant="secondary" disabled={!selectedTimer} onClick={() => selectedTimer && void pauseTimer(selectedTimer.id)}>
|
||||
<Pause className="size-4" />
|
||||
Pausieren
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled={!selectedTimer} onClick={() => selectedTimer && activateTimer(selectedTimer.id)}>
|
||||
<Button disabled={!selectedTimer} onClick={() => selectedTimer && void activateTimer(selectedTimer.id)}>
|
||||
<Play className="size-4" />
|
||||
Aktivieren
|
||||
</Button>
|
||||
@@ -795,7 +1003,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
<Square className="size-4" />
|
||||
{finishingId === selectedTimer?.id ? "Prüft..." : "Beenden"}
|
||||
</Button>
|
||||
<Button variant="ghost" disabled={!selectedTimer} onClick={() => selectedTimer && resetTimer(selectedTimer.id)}>
|
||||
<Button variant="ghost" disabled={!selectedTimer} onClick={() => selectedTimer && void resetTimer(selectedTimer.id)}>
|
||||
<RotateCcw className="size-4" />
|
||||
Entfernen
|
||||
</Button>
|
||||
@@ -903,6 +1111,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{teamspaceEntryDescription(selectedManualTeamspaceEntry) ? (
|
||||
<p className="text-xs text-muted-foreground">{teamspaceEntryDescription(selectedManualTeamspaceEntry)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -948,12 +1159,11 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="manual-activity">Tätigkeit</label>
|
||||
<Textarea
|
||||
<RichActivityEditor
|
||||
id="manual-activity"
|
||||
className="min-h-20"
|
||||
value={manualSession.activity}
|
||||
onChange={(event) => setManualSession({ ...manualSession, activity: event.currentTarget.value })}
|
||||
required
|
||||
htmlValue={manualSession.zammadArticleBody}
|
||||
onChange={(activity, zammadArticleBody) => setManualSession((current) => ({ ...current, activity, zammadArticleBody }))}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex min-h-10 items-start gap-2 rounded-md border bg-background p-3 text-sm lg:col-span-5">
|
||||
@@ -1044,36 +1254,50 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
<label className="text-sm font-medium" htmlFor="finished-activity">
|
||||
Tätigkeit
|
||||
</label>
|
||||
<Textarea
|
||||
<RichActivityEditor
|
||||
id="finished-activity"
|
||||
className="min-h-24"
|
||||
value={finished?.activity ?? ""}
|
||||
onChange={(event) => {
|
||||
const nextActivity = event.currentTarget.value;
|
||||
setFinished((current) => (current ? { ...current, activity: nextActivity } : current));
|
||||
htmlValue={finished?.zammadArticleBody ?? ""}
|
||||
onChange={(activity, zammadArticleBody) => {
|
||||
setFinished((current) => current ? {
|
||||
...current,
|
||||
activity,
|
||||
zammadArticleBody,
|
||||
createZammadArticle: activity.trim().length > 0 || zammadArticleBody ? current.createZammadArticle : false
|
||||
} : current);
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{finished ? (
|
||||
<div className="space-y-3 rounded-md border bg-muted/30 p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{(() => {
|
||||
const hasZammadContent = finished.activity.trim().length > 0 || Boolean(finished.zammadArticleBody);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Checkbox
|
||||
id="finished-zammad-article"
|
||||
checked={finished.createZammadArticle}
|
||||
onCheckedChange={(checked) => setFinished((current) => (current ? { ...current, createZammadArticle: checked === true } : current))}
|
||||
checked={finished.createZammadArticle && hasZammadContent}
|
||||
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">
|
||||
Tätigkeit als interne Zammad-Notiz übertragen
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Erstellt einen internen Zammad-Article mit Tätigkeit und getrackter Zeit.
|
||||
{hasZammadContent
|
||||
? "Erstellt einen internen Zammad-Article mit Tätigkeit und getrackter Zeit."
|
||||
: "Ohne Tätigkeit wird keine Zammad-Notiz erstellt."}
|
||||
</p>
|
||||
{!finished.ticket?.zammad_ticket_id ? (
|
||||
<p className="text-xs text-muted-foreground">Falls die Zammad-ID noch fehlt, wird sie beim Speichern oder späteren Nachsenden erneut gesucht.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{finished.createZammadArticle ? (
|
||||
<div className="space-y-2">
|
||||
@@ -1142,6 +1366,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{teamspaceEntryDescription(selectedFinishedTeamspaceEntry) ? (
|
||||
<p className="text-xs text-muted-foreground">{teamspaceEntryDescription(selectedFinishedTeamspaceEntry)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-end">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { AlertCircle, CheckCircle2, DatabaseZap, DownloadCloud, RefreshCw } from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2, DatabaseZap, DownloadCloud, MailPlus, RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
@@ -7,13 +7,16 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
applyExistingZammadTicketsFromPreview,
|
||||
applyOwnZammadConsultingTicketsFromPreview,
|
||||
applyZammadMailSessionsFromPreview,
|
||||
previewExistingZammadTickets,
|
||||
previewOwnZammadConsultingTickets,
|
||||
previewZammadMailSessions,
|
||||
waitForZammadSyncJob,
|
||||
type ZammadSyncExistingFields,
|
||||
type ZammadSyncChange,
|
||||
@@ -61,10 +64,18 @@ type ZammadSyncPageState = {
|
||||
importPreviewJobId: string | null;
|
||||
importRunningJobId: string | null;
|
||||
importRunningJobKind: RunningJobKind | null;
|
||||
mailResult: ZammadSyncResult | null;
|
||||
mailPreviewJobId: string | null;
|
||||
mailRunningJobId: string | null;
|
||||
mailRunningJobKind: RunningJobKind | null;
|
||||
mailDaysBack: number;
|
||||
mailPerPage: number;
|
||||
existingSelection: SelectionMap;
|
||||
importSelection: SelectionMap;
|
||||
mailSelection: SelectionMap;
|
||||
syncingExisting: boolean;
|
||||
importingConsulting: boolean;
|
||||
syncingMail: boolean;
|
||||
};
|
||||
|
||||
function emptyZammadSyncPageState(): ZammadSyncPageState {
|
||||
@@ -78,10 +89,18 @@ function emptyZammadSyncPageState(): ZammadSyncPageState {
|
||||
importPreviewJobId: null,
|
||||
importRunningJobId: null,
|
||||
importRunningJobKind: null,
|
||||
mailResult: null,
|
||||
mailPreviewJobId: null,
|
||||
mailRunningJobId: null,
|
||||
mailRunningJobKind: null,
|
||||
mailDaysBack: 30,
|
||||
mailPerPage: 300,
|
||||
existingSelection: {},
|
||||
importSelection: {},
|
||||
mailSelection: {},
|
||||
syncingExisting: false,
|
||||
importingConsulting: false
|
||||
importingConsulting: false,
|
||||
syncingMail: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,6 +181,40 @@ function runningJobKind(value: unknown): RunningJobKind | null {
|
||||
return value === "preview" || value === "apply" ? value : null;
|
||||
}
|
||||
|
||||
function parseMailDaysBack(value: unknown) {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 3650 ? parsed : 30;
|
||||
}
|
||||
|
||||
function berlinDateParts(date: Date) {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit"
|
||||
}).formatToParts(date);
|
||||
const value = (type: string) => Number(parts.find((part) => part.type === type)?.value);
|
||||
|
||||
return {
|
||||
year: value("year"),
|
||||
month: value("month"),
|
||||
day: value("day")
|
||||
};
|
||||
}
|
||||
|
||||
function mailSyncCutoffDate(daysBack: number) {
|
||||
const current = berlinDateParts(new Date());
|
||||
const date = new Date(Date.UTC(current.year, current.month - 1, current.day));
|
||||
date.setUTCDate(date.getUTCDate() - Math.max(0, daysBack - 1));
|
||||
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function parseMailPerPage(value: unknown) {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 1000 ? parsed : 300;
|
||||
}
|
||||
|
||||
function loadZammadSyncPageState(userId: string): ZammadSyncPageState | null {
|
||||
const raw = getLocalStorageValue(zammadSyncStorageKey(userId));
|
||||
|
||||
@@ -180,6 +233,8 @@ function loadZammadSyncPageState(userId: string): ZammadSyncPageState | null {
|
||||
const existingRunningJobKind = runningJobKind(parsed.existingRunningJobKind);
|
||||
const importRunningJobId = nullableString(parsed.importRunningJobId);
|
||||
const importRunningJobKind = runningJobKind(parsed.importRunningJobKind);
|
||||
const mailRunningJobId = nullableString(parsed.mailRunningJobId);
|
||||
const mailRunningJobKind = runningJobKind(parsed.mailRunningJobKind);
|
||||
|
||||
return {
|
||||
fields: syncFields(parsed.fields),
|
||||
@@ -191,10 +246,18 @@ function loadZammadSyncPageState(userId: string): ZammadSyncPageState | null {
|
||||
importPreviewJobId: nullableString(parsed.importPreviewJobId),
|
||||
importRunningJobId,
|
||||
importRunningJobKind,
|
||||
mailResult: syncResult(parsed.mailResult),
|
||||
mailPreviewJobId: nullableString(parsed.mailPreviewJobId),
|
||||
mailRunningJobId,
|
||||
mailRunningJobKind,
|
||||
mailDaysBack: parseMailDaysBack(parsed.mailDaysBack),
|
||||
mailPerPage: parseMailPerPage(parsed.mailPerPage),
|
||||
existingSelection: selectionMap(parsed.existingSelection),
|
||||
importSelection: selectionMap(parsed.importSelection),
|
||||
mailSelection: selectionMap(parsed.mailSelection),
|
||||
syncingExisting: Boolean(existingRunningJobId && existingRunningJobKind),
|
||||
importingConsulting: Boolean(importRunningJobId && importRunningJobKind)
|
||||
importingConsulting: Boolean(importRunningJobId && importRunningJobKind),
|
||||
syncingMail: Boolean(mailRunningJobId && mailRunningJobKind)
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -213,12 +276,17 @@ function selectableItems(result: ZammadSyncResult) {
|
||||
return result.items.filter((item) => item.action === "update" || item.action === "create");
|
||||
}
|
||||
|
||||
function selectionKey(item: ZammadSyncItem) {
|
||||
const articleId = item.zammad?.articleId;
|
||||
return typeof articleId === "number" || typeof articleId === "string" ? `article:${articleId}` : item.ticketNumber;
|
||||
}
|
||||
|
||||
function itemIsSelected(item: ZammadSyncItem, selection: SelectionMap) {
|
||||
return (item.action === "update" || item.action === "create") && selection[item.ticketNumber] !== false;
|
||||
return (item.action === "update" || item.action === "create") && selection[selectionKey(item)] !== false;
|
||||
}
|
||||
|
||||
function selectionFromResult(result: ZammadSyncResult): SelectionMap {
|
||||
return Object.fromEntries(selectableItems(result).map((item) => [item.ticketNumber, true]));
|
||||
return Object.fromEntries(selectableItems(result).map((item) => [selectionKey(item), true]));
|
||||
}
|
||||
|
||||
function selectedTicketNumbers(result: ZammadSyncResult | null, selection: SelectionMap) {
|
||||
@@ -231,6 +299,25 @@ function selectedTicketNumbers(result: ZammadSyncResult | null, selection: Selec
|
||||
.map((item) => item.ticketNumber);
|
||||
}
|
||||
|
||||
function selectedArticleIds(result: ZammadSyncResult | null, selection: SelectionMap) {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return selectableItems(result)
|
||||
.filter((item) => itemIsSelected(item, selection))
|
||||
.map((item) => Number(item.zammad?.articleId))
|
||||
.filter((articleId) => Number.isInteger(articleId) && articleId > 0);
|
||||
}
|
||||
|
||||
function existingApplyDescription(result: ZammadSyncResult) {
|
||||
return `${result.updated} aktualisiert, ${result.skipped} ohne Transfer.`;
|
||||
}
|
||||
|
||||
function importApplyDescription(result: ZammadSyncResult) {
|
||||
return `${result.created} angelegt, ${result.skipped} ohne Transfer.`;
|
||||
}
|
||||
|
||||
function formatMinutes(value: unknown) {
|
||||
if (typeof value !== "number") {
|
||||
return formatValue(value);
|
||||
@@ -243,6 +330,28 @@ function formatMinutes(value: unknown) {
|
||||
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: value < 600 ? 1 : 0 }).format(value / 60)} h`;
|
||||
}
|
||||
|
||||
function formatDurationSeconds(value: unknown) {
|
||||
const seconds = Number(value);
|
||||
|
||||
if (!Number.isFinite(seconds)) {
|
||||
return formatValue(value);
|
||||
}
|
||||
|
||||
const minutes = Math.round(seconds / 60);
|
||||
return `${new Intl.NumberFormat("de-DE").format(minutes)} min`;
|
||||
}
|
||||
|
||||
function formatTimeUnit(value: unknown) {
|
||||
const hours = Number(value);
|
||||
|
||||
if (!Number.isFinite(hours)) {
|
||||
return formatValue(value);
|
||||
}
|
||||
|
||||
const minutes = Math.round(hours * 60);
|
||||
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(hours)} h / ${new Intl.NumberFormat("de-DE").format(minutes)} min`;
|
||||
}
|
||||
|
||||
function numericValue(value: unknown) {
|
||||
const number = Number(value);
|
||||
return Number.isInteger(number) ? number : null;
|
||||
@@ -316,6 +425,10 @@ function formatValue(value: unknown): string {
|
||||
}
|
||||
|
||||
function formatChangeValue(field: string, value: unknown) {
|
||||
if (field === "Dauer") {
|
||||
return formatDurationSeconds(value);
|
||||
}
|
||||
|
||||
if (field === "Teamspace-Budget") {
|
||||
return formatMinutes(value);
|
||||
}
|
||||
@@ -725,6 +838,17 @@ function zammadDetails(item: ZammadSyncItem) {
|
||||
return ["kein Treffer"];
|
||||
}
|
||||
|
||||
if (zammad.articleId) {
|
||||
return [
|
||||
`Article ${formatValue(zammad.articleId)}`,
|
||||
`Ticket-ID ${formatValue(zammad.ticketId)}`,
|
||||
`Typ eMail`,
|
||||
`Erstellt ${formatValue(zammad.createdAt)}`,
|
||||
`time_unit ${formatTimeUnit(zammad.timeUnit)}`,
|
||||
`Betreff ${formatValue(zammad.subject)}`
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
`ID ${formatValue(zammad.ticketId)}`,
|
||||
`Gruppe ${formatZammadGroup(zammad.groupId)}`,
|
||||
@@ -795,18 +919,25 @@ function ResultSummary({
|
||||
) : null}
|
||||
|
||||
<div className="max-h-[520px] overflow-auto rounded-md border">
|
||||
<Table>
|
||||
<Table className="w-full table-fixed">
|
||||
<colgroup>
|
||||
{selection ? <col className="w-12" /> : null}
|
||||
<col className="w-40" />
|
||||
<col className="w-64" />
|
||||
<col className="w-[34rem]" />
|
||||
<col className="w-[36rem]" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{selection ? (
|
||||
<TableHead className="w-10">
|
||||
<TableHead>
|
||||
<Checkbox checked={allSelected} disabled={selectable.length === 0} onCheckedChange={(checked) => onToggleAll?.(checked === true)} />
|
||||
</TableHead>
|
||||
) : null}
|
||||
<TableHead className="min-w-32">Ticket</TableHead>
|
||||
<TableHead className="min-w-32">Aktion</TableHead>
|
||||
<TableHead className="min-w-72">Daten aus Zammad</TableHead>
|
||||
<TableHead className="min-w-80">Änderungen in TicketTracker</TableHead>
|
||||
<TableHead>Ticket</TableHead>
|
||||
<TableHead>Aktion</TableHead>
|
||||
<TableHead>Daten aus Zammad</TableHead>
|
||||
<TableHead>Änderungen in TicketTracker</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -823,27 +954,27 @@ function ResultSummary({
|
||||
const visibleChanges = visibleTicketTrackerChanges(item);
|
||||
|
||||
return (
|
||||
<TableRow key={`${result.mode}-${item.ticketNumber}-${item.action}`}>
|
||||
<TableRow key={`${result.mode}-${selectionKey(item)}-${item.action}`}>
|
||||
{selection ? (
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={itemIsSelected(item, selection)}
|
||||
disabled={!isSelectable}
|
||||
onCheckedChange={(checked) => onToggle?.(item.ticketNumber, checked === true)}
|
||||
onCheckedChange={(checked) => onToggle?.(selectionKey(item), checked === true)}
|
||||
/>
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell className="font-medium">{item.ticketNumber}</TableCell>
|
||||
<TableCell className="truncate font-medium" title={item.ticketNumber}>{item.ticketNumber}</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<Badge variant={action.variant}>{action.label}</Badge>
|
||||
{item.reason ? <p className="text-xs text-muted-foreground">{item.reason}</p> : null}
|
||||
{item.reason ? <p className="truncate text-xs text-muted-foreground" title={item.reason}>{item.reason}</p> : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-xl space-y-1 text-xs text-muted-foreground">
|
||||
<div className="min-w-0 space-y-1 text-xs text-muted-foreground">
|
||||
{zammadDetails(item).map((detail) => (
|
||||
<div key={detail} className="break-words">
|
||||
<div key={detail} className="truncate" title={detail}>
|
||||
{detail}
|
||||
</div>
|
||||
))}
|
||||
@@ -851,15 +982,21 @@ function ResultSummary({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{visibleChanges.length > 0 ? (
|
||||
<div className="space-y-1 text-xs">
|
||||
{visibleChanges.map((change) => (
|
||||
<div key={`${item.ticketNumber}-${change.field}`} className="break-words">
|
||||
<span className="font-medium">{change.field}:</span>{" "}
|
||||
<span className="text-muted-foreground">{formatChangeValue(change.field, change.from)}</span>
|
||||
<span className="text-muted-foreground"> -> </span>
|
||||
<span>{formatChangeValue(change.field, change.to)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="min-w-0 space-y-1 text-xs">
|
||||
{visibleChanges.map((change) => {
|
||||
const fromValue = formatChangeValue(change.field, change.from);
|
||||
const toValue = formatChangeValue(change.field, change.to);
|
||||
const title = `${change.field}: ${fromValue} -> ${toValue}`;
|
||||
|
||||
return (
|
||||
<div key={`${item.ticketNumber}-${change.field}`} className="truncate" title={title}>
|
||||
<span className="font-medium">{change.field}:</span>{" "}
|
||||
<span className="text-muted-foreground">{fromValue}</span>
|
||||
<span className="text-muted-foreground"> -> </span>
|
||||
<span>{toValue}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Keine Änderung.</span>
|
||||
@@ -894,15 +1031,23 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
importResult,
|
||||
importRunningJobId,
|
||||
importRunningJobKind,
|
||||
mailResult,
|
||||
mailRunningJobId,
|
||||
mailRunningJobKind,
|
||||
mailDaysBack,
|
||||
mailPerPage,
|
||||
existingSelection,
|
||||
importSelection,
|
||||
mailSelection,
|
||||
syncingExisting,
|
||||
importingConsulting
|
||||
importingConsulting,
|
||||
syncingMail
|
||||
} = pageState;
|
||||
const [storageReady, setStorageReady] = useState(false);
|
||||
const displayedExistingResult = useMemo(() => applyFieldsToExistingPreview(existingResult, fields), [existingResult, fields]);
|
||||
const selectedExistingCount = useMemo(() => selectedTicketNumbers(displayedExistingResult, existingSelection).length, [displayedExistingResult, existingSelection]);
|
||||
const selectedImportCount = useMemo(() => selectedTicketNumbers(importResult, importSelection).length, [importResult, importSelection]);
|
||||
const selectedMailCount = useMemo(() => selectedArticleIds(mailResult, mailSelection).length, [mailResult, mailSelection]);
|
||||
const zammadReady = currentUser.has_zammad_api_token && Boolean(currentUser.zammad_user_id);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -952,7 +1097,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
}));
|
||||
} else {
|
||||
setZammadSyncPageState({
|
||||
existingResult: result,
|
||||
existingResult: null,
|
||||
existingPreviewJobId: null,
|
||||
existingSelection: {},
|
||||
existingRunningJobId: null,
|
||||
@@ -1005,7 +1150,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
}));
|
||||
} else {
|
||||
setZammadSyncPageState({
|
||||
importResult: result,
|
||||
importResult: null,
|
||||
importPreviewJobId: null,
|
||||
importSelection: {},
|
||||
importRunningJobId: null,
|
||||
@@ -1034,6 +1179,59 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
};
|
||||
}, [importRunningJobId, importRunningJobKind, storageReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!storageReady || !mailRunningJobId || !mailRunningJobKind) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void waitForZammadSyncJob(mailRunningJobId)
|
||||
.then((result) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mailRunningJobKind === "preview") {
|
||||
setZammadSyncPageState((current) => ({
|
||||
mailResult: result,
|
||||
mailPreviewJobId: mailRunningJobId,
|
||||
mailSelection: Object.keys(current.mailSelection).length ? current.mailSelection : selectionFromResult(result),
|
||||
mailRunningJobId: null,
|
||||
mailRunningJobKind: null,
|
||||
syncingMail: false
|
||||
}));
|
||||
} else {
|
||||
setZammadSyncPageState({
|
||||
mailResult: null,
|
||||
mailPreviewJobId: null,
|
||||
mailSelection: {},
|
||||
mailRunningJobId: null,
|
||||
mailRunningJobKind: null,
|
||||
syncingMail: false
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setZammadSyncPageState({
|
||||
mailRunningJobId: null,
|
||||
mailRunningJobKind: null,
|
||||
syncingMail: false
|
||||
});
|
||||
toast.error("Zammad-Mail-Sync konnte nicht fortgesetzt werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mailRunningJobId, mailRunningJobKind, storageReady]);
|
||||
|
||||
function setField(key: keyof ZammadSyncExistingFields, checked: boolean) {
|
||||
setZammadSyncPageState((current) => ({
|
||||
fields: { ...current.fields, [key]: checked, ...(key === "budget" && !checked ? { overrideBudget: false } : {}) }
|
||||
@@ -1044,10 +1242,13 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
setZammadSyncPageState({
|
||||
syncingExisting: false,
|
||||
importingConsulting: false,
|
||||
syncingMail: false,
|
||||
existingRunningJobId: null,
|
||||
existingRunningJobKind: null,
|
||||
importRunningJobId: null,
|
||||
importRunningJobKind: null
|
||||
importRunningJobKind: null,
|
||||
mailRunningJobId: null,
|
||||
mailRunningJobKind: null
|
||||
});
|
||||
toast.info("Laufstatus zurückgesetzt");
|
||||
}
|
||||
@@ -1058,7 +1259,14 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
}
|
||||
|
||||
const syncFields = zammadSyncPageState.fields;
|
||||
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: null, existingRunningJobKind: null });
|
||||
setZammadSyncPageState({
|
||||
existingResult: null,
|
||||
existingPreviewJobId: null,
|
||||
existingSelection: {},
|
||||
syncingExisting: true,
|
||||
existingRunningJobId: null,
|
||||
existingRunningJobKind: null
|
||||
});
|
||||
try {
|
||||
const { jobId, result } = await previewExistingZammadTickets(syncFields, (job: ZammadSyncJob) => {
|
||||
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: job.id, existingRunningJobKind: "preview" });
|
||||
@@ -1107,14 +1315,14 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: job.id, existingRunningJobKind: "apply" });
|
||||
});
|
||||
setZammadSyncPageState({
|
||||
existingResult: result,
|
||||
existingResult: null,
|
||||
existingPreviewJobId: null,
|
||||
existingSelection: {},
|
||||
existingRunningJobId: null,
|
||||
existingRunningJobKind: null
|
||||
});
|
||||
toast.success("Auswahl synchronisiert", {
|
||||
description: `${result.updated} aktualisiert, ${result.skipped} ohne Transfer.`
|
||||
description: existingApplyDescription(result)
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Zammad-Sync fehlgeschlagen", {
|
||||
@@ -1130,7 +1338,14 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: null, importRunningJobKind: null });
|
||||
setZammadSyncPageState({
|
||||
importResult: null,
|
||||
importPreviewJobId: null,
|
||||
importSelection: {},
|
||||
importingConsulting: true,
|
||||
importRunningJobId: null,
|
||||
importRunningJobKind: null
|
||||
});
|
||||
try {
|
||||
const { jobId, result } = await previewOwnZammadConsultingTickets((job: ZammadSyncJob) => {
|
||||
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: job.id, importRunningJobKind: "preview" });
|
||||
@@ -1177,14 +1392,14 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: job.id, importRunningJobKind: "apply" });
|
||||
});
|
||||
setZammadSyncPageState({
|
||||
importResult: result,
|
||||
importResult: null,
|
||||
importPreviewJobId: null,
|
||||
importSelection: {},
|
||||
importRunningJobId: null,
|
||||
importRunningJobKind: null
|
||||
});
|
||||
toast.success("Auswahl importiert", {
|
||||
description: `${result.created} angelegt, ${result.skipped} ohne Transfer.`
|
||||
description: importApplyDescription(result)
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Zammad-Import fehlgeschlagen", {
|
||||
@@ -1195,6 +1410,87 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function previewMailSync() {
|
||||
if (zammadSyncPageState.syncingMail) {
|
||||
return;
|
||||
}
|
||||
|
||||
const daysBack = parseMailDaysBack(zammadSyncPageState.mailDaysBack);
|
||||
const perPage = parseMailPerPage(zammadSyncPageState.mailPerPage);
|
||||
setZammadSyncPageState({
|
||||
mailResult: null,
|
||||
mailPreviewJobId: null,
|
||||
mailSelection: {},
|
||||
mailDaysBack: daysBack,
|
||||
mailPerPage: perPage,
|
||||
syncingMail: true,
|
||||
mailRunningJobId: null,
|
||||
mailRunningJobKind: null
|
||||
});
|
||||
try {
|
||||
const { jobId, result } = await previewZammadMailSessions(daysBack, perPage, (job: ZammadSyncJob) => {
|
||||
setZammadSyncPageState({ syncingMail: true, mailRunningJobId: job.id, mailRunningJobKind: "preview" });
|
||||
});
|
||||
setZammadSyncPageState({
|
||||
mailResult: result,
|
||||
mailPreviewJobId: jobId,
|
||||
mailSelection: selectionFromResult(result),
|
||||
mailRunningJobId: null,
|
||||
mailRunningJobKind: null
|
||||
});
|
||||
toast.success("Mail-Vorschau erstellt", {
|
||||
description: `${result.created} Mail-Session(s) können angelegt werden.`
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Mail-Vorschau fehlgeschlagen", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setZammadSyncPageState({ syncingMail: false, mailRunningJobId: null, mailRunningJobKind: null });
|
||||
}
|
||||
}
|
||||
|
||||
async function applyMailSync() {
|
||||
if (zammadSyncPageState.syncingMail) {
|
||||
return;
|
||||
}
|
||||
|
||||
const articleIds = selectedArticleIds(zammadSyncPageState.mailResult, zammadSyncPageState.mailSelection);
|
||||
|
||||
if (articleIds.length === 0) {
|
||||
toast.info("Keine Mail-Sessions ausgewählt");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!zammadSyncPageState.mailPreviewJobId) {
|
||||
toast.error("Keine Mail-Vorschau im Zwischenspeicher");
|
||||
return;
|
||||
}
|
||||
|
||||
setZammadSyncPageState({ syncingMail: true, mailRunningJobId: null, mailRunningJobKind: null });
|
||||
try {
|
||||
const { result } = await applyZammadMailSessionsFromPreview(zammadSyncPageState.mailPreviewJobId, articleIds, (job: ZammadSyncJob) => {
|
||||
setZammadSyncPageState({ syncingMail: true, mailRunningJobId: job.id, mailRunningJobKind: "apply" });
|
||||
});
|
||||
setZammadSyncPageState({
|
||||
mailResult: null,
|
||||
mailPreviewJobId: null,
|
||||
mailSelection: {},
|
||||
mailRunningJobId: null,
|
||||
mailRunningJobKind: null
|
||||
});
|
||||
toast.success("Mail-Sessions importiert", {
|
||||
description: `${result.created} angelegt, ${result.skipped} ohne Transfer.`
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Mail-Sync fehlgeschlagen", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setZammadSyncPageState({ syncingMail: false, mailRunningJobId: null, mailRunningJobKind: null });
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExisting(ticketNumber: string, checked: boolean) {
|
||||
setZammadSyncPageState((current) => ({
|
||||
existingSelection: { ...current.existingSelection, [ticketNumber]: checked }
|
||||
@@ -1209,7 +1505,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
}
|
||||
|
||||
setZammadSyncPageState({
|
||||
existingSelection: Object.fromEntries(selectableItems(currentResult).map((item) => [item.ticketNumber, checked]))
|
||||
existingSelection: Object.fromEntries(selectableItems(currentResult).map((item) => [selectionKey(item), checked]))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1225,7 +1521,23 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
}
|
||||
|
||||
setZammadSyncPageState({
|
||||
importSelection: Object.fromEntries(selectableItems(zammadSyncPageState.importResult).map((item) => [item.ticketNumber, checked]))
|
||||
importSelection: Object.fromEntries(selectableItems(zammadSyncPageState.importResult).map((item) => [selectionKey(item), checked]))
|
||||
});
|
||||
}
|
||||
|
||||
function toggleMail(key: string, checked: boolean) {
|
||||
setZammadSyncPageState((current) => ({
|
||||
mailSelection: { ...current.mailSelection, [key]: checked }
|
||||
}));
|
||||
}
|
||||
|
||||
function toggleMailAll(checked: boolean) {
|
||||
if (!zammadSyncPageState.mailResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
setZammadSyncPageState({
|
||||
mailSelection: Object.fromEntries(selectableItems(zammadSyncPageState.mailResult).map((item) => [selectionKey(item), checked]))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1340,6 +1652,64 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MailPlus className="size-5 text-muted-foreground" />
|
||||
Mail-Sync
|
||||
</CardTitle>
|
||||
<CardDescription>Legt fest, wie weit und mit welcher Seitengröße Mail-Session-Importe suchen.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 px-4 pb-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="grid w-48 gap-1 text-sm font-medium">
|
||||
Tage rückwirkend
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={3650}
|
||||
value={mailDaysBack}
|
||||
onChange={(event) => setZammadSyncPageState({ mailDaysBack: parseMailDaysBack(event.currentTarget.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid w-48 gap-1 text-sm font-medium">
|
||||
Abgleich ab
|
||||
<Input type="date" value={mailSyncCutoffDate(mailDaysBack)} readOnly tabIndex={-1} />
|
||||
</label>
|
||||
<label className="grid w-48 gap-1 text-sm font-medium">
|
||||
PER_PAGE
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={mailPerPage}
|
||||
onChange={(event) => setZammadSyncPageState({ mailPerPage: parseMailPerPage(event.currentTarget.value) })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{syncingMail ? (
|
||||
<Button type="button" variant="outline" onClick={resetRunningState}>
|
||||
Laufstatus zurücksetzen
|
||||
</Button>
|
||||
) : null}
|
||||
{mailResult?.mode === "preview" ? (
|
||||
<Button type="button" disabled={!zammadReady || syncingMail || selectedMailCount === 0} onClick={() => void applyMailSync()}>
|
||||
<CheckCircle2 className="size-4" />
|
||||
{syncingMail ? "Importiert..." : `Auswahl übernehmen (${selectedMailCount})`}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant={mailResult?.mode === "preview" ? "outline" : "default"} disabled={!zammadReady || syncingMail} onClick={() => void previewMailSync()}>
|
||||
<MailPlus className="size-4" />
|
||||
{syncingMail ? "Bereitet vor..." : "Vorschau erstellen"}
|
||||
</Button>
|
||||
</div>
|
||||
{mailResult ? (
|
||||
<ResultSummary result={mailResult} selection={mailResult.mode === "preview" ? mailSelection : undefined} onToggle={toggleMail} onToggleAll={toggleMailAll} />
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user