This commit is contained in:
michael committed 2026-09-29 07:46:21 +02:00
1 parent 2105a0c5f7
commit c89d4825e2
30 files changed
+5435 -627

No files matched your search

+4 -4
View File
@@ -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()}`)
}))}
+93 -27
View File
@@ -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>
+19
View File
@@ -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" />
+202 -15
View File
@@ -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 ? (
+1 -1
View File
@@ -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
+318 -91
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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">
+409 -39
View File
@@ -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"> -&gt; </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"> -&gt; </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>
);
}