Update
This commit is contained in:
1 parent
2105a0c5f7
commit
c89d4825e2
30 files changed
+5435
-627
No files matched your search
@@ -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