This commit is contained in:
michael committed 2026-09-29 14:17:08 +02:00
1 parent 62794f72f1
commit 39690b1d2b
3 files changed
+417 -396

No files matched your search

-1
View File
@@ -444,7 +444,6 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users }, { label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
{ label: "Abgerechnet", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 }, { label: "Abgerechnet", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 },
{ label: "Abrechenbar", value: formatMinutes(totals?.billableMinutes ?? 0), icon: UserCheck }, { label: "Abrechenbar", value: formatMinutes(totals?.billableMinutes ?? 0), icon: UserCheck },
{ label: "Nicht abrechenbar", value: formatMinutes(totals?.nonBillableMinutes ?? 0), icon: UserCheck },
{ label: "Offen", value: totals?.openSessions ?? 0, detail: formatMinutes(totals?.openMinutes ?? 0), icon: UserCheck } { label: "Offen", value: totals?.openSessions ?? 0, detail: formatMinutes(totals?.openMinutes ?? 0), icon: UserCheck }
]; ];
const tickets = overview?.tickets ?? []; const tickets = overview?.tickets ?? [];
+286 -272
View File
@@ -395,7 +395,7 @@ function BillingButtons({
const status = session.billing_status; const status = session.billing_status;
return ( return (
<div className="grid grid-cols-3 gap-1.5 sm:inline-grid"> <div className="grid grid-cols-3 gap-2 sm:inline-grid lg:gap-3">
<Button <Button
type="button" type="button"
size="sm" size="sm"
@@ -790,6 +790,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const [mailSelection, setMailSelection] = useState<Record<number, boolean>>({}); const [mailSelection, setMailSelection] = useState<Record<number, boolean>>({});
const [loadingMailPreview, setLoadingMailPreview] = useState(false); const [loadingMailPreview, setLoadingMailPreview] = useState(false);
const [importingMailSessions, setImportingMailSessions] = useState(false); const [importingMailSessions, setImportingMailSessions] = useState(false);
const [mailImportOpen, setMailImportOpen] = useState(false);
async function load() { async function load() {
try { try {
@@ -1177,6 +1178,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const result = await importTicketZammadMailSessions(ticket.id, mailDaysBack, selectedMailArticleIds); const result = await importTicketZammadMailSessions(ticket.id, mailDaysBack, selectedMailArticleIds);
setMailPreview(null); setMailPreview(null);
setMailSelection({}); setMailSelection({});
setMailImportOpen(false);
const firstError = result.errors[0]; const firstError = result.errors[0];
const description = firstError const description = firstError
? `${result.created} angelegt, ${result.skipped} ohne Transfer. Article ${firstError.articleId}: ${firstError.error}` ? `${result.created} angelegt, ${result.skipped} ohne Transfer. Article ${firstError.articleId}: ${firstError.error}`
@@ -1736,6 +1738,12 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
Timer starten Timer starten
</Button> </Button>
) : null} ) : null}
{ticket?.ticket_number ? (
<Button type="button" size="sm" variant="secondary" onClick={() => setMailImportOpen(true)}>
<MailPlus className="size-4" />
Mails importieren
</Button>
) : null}
{ticket?.ticket_number ? ( {ticket?.ticket_number ? (
<Button type="button" size="sm" variant={isFavorite ? "default" : "secondary"} disabled={savingFavorite} onClick={() => void toggleTicketFavorite()}> <Button type="button" size="sm" variant={isFavorite ? "default" : "secondary"} disabled={savingFavorite} onClick={() => void toggleTicketFavorite()}>
<Star className={cn("size-4", isFavorite && "fill-current")} /> <Star className={cn("size-4", isFavorite && "fill-current")} />
@@ -1754,51 +1762,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</Alert> </Alert>
) : null} ) : null}
<div className={cn("grid gap-4", isGlobalTicket && "details:grid-cols-2")}> <div className="grid gap-4">
{isGlobalTicket ? (
<Card>
<CardHeader className="p-4">
<CardTitle>Filter</CardTitle>
<CardDescription>Grenzt die Sessionliste dieses Tickets ein. Die Ticketdaten selbst bleiben global.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-month-filter">Monat</label>
<select
id="ticket-month-filter"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={globalMonthFilter}
onChange={(event) => setGlobalMonthFilter(event.currentTarget.value)}
>
<option value="all">Alle Monate</option>
{globalMonthFilter !== "all" && !selectedMonthHasSessions ? <option value={globalMonthFilter}>{globalMonthFilter} (0)</option> : null}
{availableMonths.map((month) => (
<option key={month.month} value={month.month}>
{month.month} ({month.session_count})
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-status-filter">Bewertung</label>
<select
id="ticket-status-filter"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={globalStatusFilter}
onChange={(event) => setGlobalStatusFilter(event.currentTarget.value as typeof globalStatusFilter)}
>
<option value="all">Alle Bewertungen</option>
<option value="open">Offen</option>
<option value="billable">Abrechenbar</option>
<option value="non_billable">Nicht abrechenbar</option>
</select>
</div>
</CardContent>
</Card>
) : null}
<Card> <Card>
<CardHeader className="p-4"> <CardHeader className="p-4 pb-2">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<CardTitle>Ticketdaten</CardTitle> <CardTitle>Ticketdaten</CardTitle>
@@ -1811,8 +1777,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<HelpLink anchor="ticketdaten" label="Hilfe zu Ticketdaten" /> <HelpLink anchor="ticketdaten" label="Hilfe zu Ticketdaten" />
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="px-4 pb-4"> <CardContent className="px-4 pb-3">
<form className="grid grid-cols-2 gap-3 [@media_(min-width:85rem)_and_(max-width:108.124rem)]:grid-cols-1 xxl:grid-cols-2" onSubmit={saveTicketData}> <form className="grid gap-3 md:grid-cols-2 xl:grid-cols-[140px_145px_minmax(220px,1fr)_minmax(240px,1.2fr)_150px] xl:items-end" onSubmit={saveTicketData}>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="edit-ticket-number">Ticketnummer</label> <label className="text-sm font-medium" htmlFor="edit-ticket-number">Ticketnummer</label>
<Input id="edit-ticket-number" value={editTicketNumber} disabled={!canEditTicketData} onChange={(event) => setEditTicketNumber(event.currentTarget.value)} required /> <Input id="edit-ticket-number" value={editTicketNumber} disabled={!canEditTicketData} onChange={(event) => setEditTicketNumber(event.currentTarget.value)} required />
@@ -1850,7 +1816,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
required required
/> />
</div> </div>
<Button type="submit" className="col-span-2 justify-self-end [@media_(min-width:85rem)_and_(max-width:108.124rem)]:col-span-1 xxl:col-span-2" disabled={!canEditTicketData || savingTicket}> <Button type="submit" className="justify-self-end md:col-span-2 xl:col-span-1 xl:mb-0 xl:w-full" disabled={!canEditTicketData || savingTicket}>
<Save className="size-4" /> <Save className="size-4" />
{savingTicket ? "Speichert..." : "Speichern"} {savingTicket ? "Speichert..." : "Speichern"}
</Button> </Button>
@@ -2124,76 +2090,6 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</Card> </Card>
</div> </div>
<Card className="overflow-hidden">
<CardHeader className="flex flex-col gap-2 p-2.5 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div className="min-w-0">
<CardTitle className="flex items-center gap-2 text-base leading-tight">
<MailPlus className="size-4 text-muted-foreground" />
Zammad-Mails importieren
</CardTitle>
<CardDescription className="text-xs leading-tight">Eigene eMail-Artikel übernehmen. Rückblick: {mailDaysBack} Tag(e).</CardDescription>
</div>
<div className="flex shrink-0 flex-wrap justify-end gap-2 sm:ml-auto sm:flex-nowrap">
{mailPreview?.items.length ? (
<Button type="button" size="sm" disabled={importingMailSessions || selectedMailArticleIds.length === 0} onClick={() => void importTicketMailSessions()}>
<CheckCircle2 className="size-3.5" />
{importingMailSessions ? "Importiert..." : `Auswahl übernehmen (${selectedMailArticleIds.length})`}
</Button>
) : null}
<Button type="button" size="sm" variant={mailPreview ? "outline" : "default"} disabled={!canPreviewMailSessions || loadingMailPreview || importingMailSessions} onClick={() => void previewTicketMailSessions()}>
<MailPlus className="size-3.5" />
{loadingMailPreview ? "Lädt..." : "Vorschau"}
</Button>
</div>
</CardHeader>
{!canPreviewMailSessions || mailPreview ? (
<CardContent className="space-y-2 px-2.5 pb-2.5">
{!canPreviewMailSessions ? (
<Alert variant="warning">
<AlertCircle className="size-4" />
<AlertTitle>Mail-Sync nicht bereit</AlertTitle>
<AlertDescription>Dieses Ticket braucht eine Support/Consulting-Art und dein Profil einen gültigen Zammad-Token.</AlertDescription>
</Alert>
) : null}
{mailPreview ? (
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-muted/20 px-2.5 py-1.5">
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<Checkbox checked={allMailSelected} disabled={mailPreview.items.length === 0} onCheckedChange={(checked) => toggleAllMailSelection(checked === true)} />
{selectedMailArticleIds.length} von {mailPreview.items.length} Mail-Session(s) ausgewählt.
</label>
{mailPreview.skipped > 0 || mailPreview.errors.length > 0 ? (
<span className="text-xs text-muted-foreground">
{mailPreview.skipped} ohne Transfer, {mailPreview.errors.length} Fehler
</span>
) : null}
</div>
{mailPreview.items.length === 0 ? (
<p className="rounded-md border bg-muted/10 px-3 py-4 text-center text-sm text-muted-foreground">Keine importierbaren Mails für dieses Ticket gefunden.</p>
) : (
<div className="space-y-1.5">
{mailPreview.items.map((item: TicketZammadMailSessionItem) => (
<label key={item.articleId} className="grid gap-2 rounded-md border bg-background p-2 sm:grid-cols-[auto_minmax(0,1fr)_auto] sm:items-center">
<Checkbox checked={mailSelection[item.articleId] !== false} onCheckedChange={(checked) => toggleMailSelection(item.articleId, checked === true)} />
<span className="min-w-0">
<span className="block truncate text-sm font-medium">{item.subject || "eMail aus Zammad"}</span>
<span className="block text-xs text-muted-foreground">
Article {item.articleId} · {formatDateTime(item.createdAt)} · {formatTimeRange(item.createdAt, item.endedAt)}
</span>
</span>
<Badge variant="outline" className="w-fit justify-self-start sm:justify-self-end">{formatMinutes(Math.round(item.durationSeconds / 60))}</Badge>
</label>
))}
</div>
)}
</div>
) : null}
</CardContent>
) : null}
</Card>
<Card> <Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0"> <CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div> <div>
@@ -2213,6 +2109,56 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="px-4 pb-4"> <CardContent className="px-4 pb-4">
{isGlobalTicket ? (
<div className="mb-3 grid gap-3 rounded-md border bg-muted/15 p-3 md:grid-cols-3">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor="ticket-month-filter">Monat</label>
<select
id="ticket-month-filter"
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={globalMonthFilter}
onChange={(event) => setGlobalMonthFilter(event.currentTarget.value)}
>
<option value="all">Alle Monate</option>
{globalMonthFilter !== "all" && !selectedMonthHasSessions ? <option value={globalMonthFilter}>{globalMonthFilter} (0)</option> : null}
{availableMonths.map((month) => (
<option key={month.month} value={month.month}>
{month.month} ({month.session_count})
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor="ticket-status-filter">Bewertung</label>
<select
id="ticket-status-filter"
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={globalStatusFilter}
onChange={(event) => setGlobalStatusFilter(event.currentTarget.value as typeof globalStatusFilter)}
>
<option value="all">Alle Bewertungen</option>
<option value="open">Offen</option>
<option value="billable">Abrechenbar</option>
<option value="billed">Abgerechnet</option>
<option value="non_billable">Nicht abrechenbar</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor="ticket-source-filter">Quelle</label>
<select
id="ticket-source-filter"
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={globalSourceFilter}
onChange={(event) => setGlobalSourceFilter(event.currentTarget.value as typeof globalSourceFilter)}
>
<option value="all">Alle Quellen</option>
<option value="manual">Manuell</option>
<option value="recurring">Fix</option>
</select>
</div>
</div>
) : null}
{data?.sessions.length ? ( {data?.sessions.length ? (
<div className="mb-3 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <div className="mb-3 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
{teamspaceTabs.length > 0 ? ( {teamspaceTabs.length > 0 ? (
@@ -2253,29 +2199,43 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<div className="space-y-2"> <div className="space-y-2">
{sessionGroups.map((group) => ( {sessionGroups.map((group) => (
<div key={group.key} className="rounded-md border bg-background"> <div key={group.key} className="rounded-md border bg-background">
<div className="grid min-w-0 gap-2 border-b bg-muted/25 px-3 py-2 lg:grid-cols-[minmax(170px,1fr)_minmax(0,auto)] lg:items-center"> <div className="grid min-w-0 gap-2 border-b bg-muted/25 px-3 py-2 lg:grid-cols-[220px_minmax(0,150px)_minmax(260px,1fr)_220px] lg:items-center">
<div className="min-w-0 text-left"> <div className="min-w-0 text-left">
<p className="truncate font-medium">{group.label}</p> <p className="truncate font-medium">{group.label}</p>
<p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p> <p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
</div> </div>
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 lg:justify-end"> <Button
<Button type="button"
type="button" size="sm"
size="sm" variant="secondary"
variant="secondary" className="h-8 min-w-32 justify-center justify-self-start"
className="h-7 min-w-32 justify-center" title="Teamspace-Aufbereitung"
title="Teamspace-Aufbereitung" onClick={() => openTeamspacePreparation(group)}
onClick={() => openTeamspacePreparation(group)} >
> <ClipboardList className="size-3.5" />
<ClipboardList className="size-3.5" /> TS
TS </Button>
</Button> <div className="grid min-w-0 grid-cols-3 gap-2 lg:gap-3">
<Badge variant="outline" className="flex h-8 min-w-0 justify-center gap-1 whitespace-nowrap px-2" title="Abrechenbar">
<Play className="size-3.5 shrink-0" />
<span className="truncate">{formatMinutes(group.billableMinutes)}</span>
</Badge>
<Badge variant="outline" className="flex h-8 min-w-0 justify-center gap-1 whitespace-nowrap px-2" title="Abgerechnet">
<CheckCircle2 className="size-3.5 shrink-0" />
<span className="truncate">{formatMinutes(group.totalMinutes)}</span>
</Badge>
<Badge variant="secondary" className="flex h-8 min-w-0 justify-center gap-1 whitespace-nowrap px-2" title="Nicht abrechenbar">
<EyeOff className="size-3.5 shrink-0" />
<span className="truncate">{formatMinutes(group.nonBillableMinutes)}</span>
</Badge>
</div>
<div className="flex min-w-0 items-center justify-start gap-2 lg:justify-end">
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label> <label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
<Input <Input
id={`crm-billing-${group.key}`} id={`crm-billing-${group.key}`}
type="text" type="text"
className="h-7 w-16 bg-background text-right" className="h-8 w-20 bg-background text-right"
autoComplete="off" autoComplete="off"
inputMode="decimal" inputMode="decimal"
value={dayBillingInputs[group.key] ?? ""} value={dayBillingInputs[group.key] ?? ""}
@@ -2315,7 +2275,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</div> </div>
</div> </div>
</div> </div>
<div className="space-y-1.5 p-2 sm:p-3"> <div className="space-y-1.5 p-2 sm:p-3 lg:px-0">
{group.sessions.map((session) => { {group.sessions.map((session) => {
const expanded = expandedSessionIds.includes(session.id); const expanded = expandedSessionIds.includes(session.id);
const zammadSent = session.zammad_article_status === "sent"; const zammadSent = session.zammad_article_status === "sent";
@@ -2325,8 +2285,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const sendingZammad = sendingZammadSessionId === session.id; const sendingZammad = sendingZammadSessionId === session.id;
return ( return (
<div key={session.id} className="ml-3 rounded-md border bg-card/40 sm:ml-6"> <div key={session.id} className="rounded-md border bg-card/40">
<div className="grid gap-2 p-2 lg:grid-cols-[220px_minmax(0,150px)_minmax(260px,1fr)_auto] lg:items-center"> <div className="grid gap-2 p-2 lg:grid-cols-[220px_minmax(0,150px)_minmax(260px,1fr)_220px] lg:items-center">
<button <button
type="button" type="button"
className="flex min-w-0 items-center gap-2 rounded-md text-left text-sm text-muted-foreground hover:cursor-pointer hover:text-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50" className="flex min-w-0 items-center gap-2 rounded-md text-left text-sm text-muted-foreground hover:cursor-pointer hover:text-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
@@ -2427,25 +2387,6 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</div> </div>
); );
})} })}
<div className="grid gap-2 border-t px-2 pt-2 lg:grid-cols-[220px_minmax(0,150px)_minmax(260px,1fr)_auto] lg:items-center">
<div className="hidden lg:block" aria-hidden="true" />
<div className="hidden lg:block" aria-hidden="true" />
<div className="grid grid-cols-3 gap-1.5">
<Badge variant="outline" className="flex h-8 min-w-0 justify-center gap-1 whitespace-nowrap px-2" title="Abrechenbar">
<Play className="size-3.5 shrink-0" />
<span className="truncate">{formatMinutes(group.billableMinutes)}</span>
</Badge>
<Badge variant="outline" className="flex h-8 min-w-0 justify-center gap-1 whitespace-nowrap px-2" title="Abgerechnet">
<CheckCircle2 className="size-3.5 shrink-0" />
<span className="truncate">{formatMinutes(group.totalMinutes)}</span>
</Badge>
<Badge variant="secondary" className="flex h-8 min-w-0 justify-center gap-1 whitespace-nowrap px-2" title="Nicht abrechenbar">
<EyeOff className="size-3.5 shrink-0" />
<span className="truncate">{formatMinutes(group.nonBillableMinutes)}</span>
</Badge>
</div>
<div className="hidden w-28 lg:block" aria-hidden="true" />
</div>
</div> </div>
</div> </div>
))} ))}
@@ -2473,132 +2414,123 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="px-4 pb-4"> <CardContent className="px-4 pb-4">
<form className="space-y-4" onSubmit={submitTicketSession}> <form className="grid gap-4 xl:grid-cols-2" onSubmit={submitTicketSession}>
{ticket?.organization_id && ticket.work_type ? ( <div className="space-y-4">
<div className="grid gap-2 rounded-md border bg-muted/30 p-3 text-sm sm:grid-cols-[1fr_auto] sm:items-center"> {ticket?.organization_id && ticket.work_type ? (
<span className="font-medium">{ticket.organization_name ?? ticket.customer_name}</span> <div className="grid gap-2 rounded-md border bg-muted/30 p-3 text-sm sm:grid-cols-[1fr_auto] sm:items-center">
<Badge variant="outline">{ticket.work_type === "support" ? "Support" : "Consulting"}</Badge> <span className="min-w-0 truncate font-medium">{ticket.organization_name ?? ticket.customer_name}</span>
</div> <Badge variant="outline">{ticket.work_type === "support" ? "Support" : "Consulting"}</Badge>
) : ( </div>
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_180px]"> ) : (
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_160px]">
<div className="space-y-2">
<label className="text-sm font-medium">Organisation</label>
{ticket?.customer_name && !ticket.organization_id ? (
<p className="text-xs text-muted-foreground">Bisheriger Freitext: {ticket.customer_name}</p>
) : null}
<OrganizationSelect
value={ticketSession.organizationId}
selectedName={ticketSession.organizationName}
onChange={(organization) =>
setTicketSession({
...ticketSession,
organizationId: organization.id,
organizationName: organization.name
})
}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-work-type">
Art
</label>
<select
id="ticket-session-work-type"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={ticketSession.workType}
onChange={(event) => setTicketSession({ ...ticketSession, workType: event.currentTarget.value as WorkType })}
>
<option value="support">Support</option>
<option value="consulting">Consulting</option>
</select>
</div>
</div>
)}
{requiresProjectTeamspaceEntry ? (
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Organisation</label> <label className="text-sm font-medium" htmlFor="ticket-session-teamspace-entry">Teamspace-Eintrag</label>
{ticket?.customer_name && !ticket.organization_id ? ( <select
<p className="text-xs text-muted-foreground">Bisheriger Freitext: {ticket.customer_name}</p> id="ticket-session-teamspace-entry"
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={ticketSession.projectTeamspaceEntryId}
onChange={(event) => setTicketSession({ ...ticketSession, projectTeamspaceEntryId: event.currentTarget.value })}
required
>
<option value="">Bitte wählen</option>
{projectEntries.map((entry) => (
<option key={entry.id} value={entry.id}>
{teamspaceEntryOptionLabel(entry)}
</option>
))}
</select>
{teamspaceEntryDescription(selectedTicketSessionTeamspaceEntry) ? (
<p className="text-xs text-muted-foreground">{teamspaceEntryDescription(selectedTicketSessionTeamspaceEntry)}</p>
) : null} ) : null}
<OrganizationSelect </div>
value={ticketSession.organizationId} ) : null}
selectedName={ticketSession.organizationName}
onChange={(organization) => <div className="grid gap-3 sm:grid-cols-2">
setTicketSession({ <div className="space-y-2">
...ticketSession, <label className="text-sm font-medium" htmlFor="ticket-session-start">
organizationId: organization.id, Von
organizationName: organization.name </label>
}) <Input
} id="ticket-session-start"
type="time"
value={ticketSession.startTime}
onChange={(event) => setTicketSession({ ...ticketSession, startTime: event.currentTarget.value })}
required required
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-work-type"> <label className="text-sm font-medium" htmlFor="ticket-session-end">
Art Bis
</label> </label>
<select <Input
id="ticket-session-work-type" id="ticket-session-end"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50" type="time"
value={ticketSession.workType} value={ticketSession.endTime}
onChange={(event) => setTicketSession({ ...ticketSession, workType: event.currentTarget.value as WorkType })} onChange={(event) => setTicketSession({ ...ticketSession, endTime: event.currentTarget.value })}
> required
<option value="support">Support</option> />
<option value="consulting">Consulting</option> </div>
</select> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-day">
Datum
</label>
<Input
id="ticket-session-day"
type="date"
value={ticketSession.day}
onChange={(event) => setTicketSession({ ...ticketSession, day: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-billing-status">
Bewertung
</label>
<BillingCaptureSelect
id="ticket-session-billing-status"
value={ticketSession.billingStatus}
onChange={(billingStatus) => setTicketSession({ ...ticketSession, billingStatus })}
/>
</div> </div>
</div> </div>
)}
{requiresProjectTeamspaceEntry ? ( <label className="flex min-h-10 items-start gap-2 rounded-md border bg-background p-3 text-sm">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-teamspace-entry">Teamspace-Eintrag</label>
<select
id="ticket-session-teamspace-entry"
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={ticketSession.projectTeamspaceEntryId}
onChange={(event) => setTicketSession({ ...ticketSession, projectTeamspaceEntryId: event.currentTarget.value })}
required
>
<option value="">Bitte wählen</option>
{projectEntries.map((entry) => (
<option key={entry.id} value={entry.id}>
{teamspaceEntryOptionLabel(entry)}
</option>
))}
</select>
{teamspaceEntryDescription(selectedTicketSessionTeamspaceEntry) ? (
<p className="text-xs text-muted-foreground">{teamspaceEntryDescription(selectedTicketSessionTeamspaceEntry)}</p>
) : null}
</div>
) : null}
<div className="grid gap-3 lg:grid-cols-[150px_120px_120px_160px_minmax(220px,1fr)_auto] lg:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-day">
Datum
</label>
<Input
id="ticket-session-day"
type="date"
value={ticketSession.day}
onChange={(event) => setTicketSession({ ...ticketSession, day: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-start">
Von
</label>
<Input
id="ticket-session-start"
type="time"
value={ticketSession.startTime}
onChange={(event) => setTicketSession({ ...ticketSession, startTime: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-end">
Bis
</label>
<Input
id="ticket-session-end"
type="time"
value={ticketSession.endTime}
onChange={(event) => setTicketSession({ ...ticketSession, endTime: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-billing-status">
Bewertung
</label>
<BillingCaptureSelect
id="ticket-session-billing-status"
value={ticketSession.billingStatus}
onChange={(billingStatus) => setTicketSession({ ...ticketSession, billingStatus })}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-activity">
Tätigkeit
</label>
<RichActivityEditor
key={`ticket-session-${ticketId}`}
id="ticket-session-activity"
value={ticketSession.activity}
htmlValue={ticketSession.zammadArticleBody}
onChange={(activity, zammadArticleBody) => setTicketSession((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">
<Checkbox <Checkbox
checked={ticketSession.createZammadArticle} checked={ticketSession.createZammadArticle}
onCheckedChange={(checked) => setTicketSession({ ...ticketSession, createZammadArticle: checked === true })} onCheckedChange={(checked) => setTicketSession({ ...ticketSession, createZammadArticle: checked === true })}
@@ -2609,7 +2541,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</span> </span>
</label> </label>
{ticketSession.createZammadArticle ? ( {ticketSession.createZammadArticle ? (
<div className="space-y-2 lg:col-span-5"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-zammad-article-type"> <label className="text-sm font-medium" htmlFor="ticket-session-zammad-article-type">
Zammad-Typ Zammad-Typ
</label> </label>
@@ -2627,6 +2559,21 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</select> </select>
</div> </div>
) : null} ) : null}
</div>
<div className="flex min-h-0 flex-col space-y-2">
<div className="min-h-72 flex-1 [&>div]:flex [&>div]:h-full [&>div]:flex-col [&_[role=textbox]]:min-h-72 xl:[&_[role=textbox]]:flex-1">
<RichActivityEditor
key={`ticket-session-${ticketId}`}
id="ticket-session-activity"
value={ticketSession.activity}
htmlValue={ticketSession.zammadArticleBody}
onChange={(activity, zammadArticleBody) => setTicketSession((current) => ({ ...current, activity, zammadArticleBody }))}
/>
</div>
</div>
<div className="flex justify-end xl:col-start-2">
<Button type="submit" disabled={savingTicketSession}> <Button type="submit" disabled={savingTicketSession}>
<Save className="size-4" /> <Save className="size-4" />
{savingTicketSession ? "Speichert..." : "Nachtragen"} {savingTicketSession ? "Speichert..." : "Nachtragen"}
@@ -2678,6 +2625,74 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={mailImportOpen} onOpenChange={setMailImportOpen}>
<DialogContent className="max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] overflow-y-auto p-5 sm:max-w-3xl">
<DialogHeader className="pr-8">
<DialogTitle>Zammad-Mails importieren</DialogTitle>
<DialogDescription>Eigene eMail-Artikel übernehmen. Rückblick: {mailDaysBack} Tag(e).</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="flex flex-wrap justify-end gap-2">
{mailPreview?.items.length ? (
<Button type="button" size="sm" disabled={importingMailSessions || selectedMailArticleIds.length === 0} onClick={() => void importTicketMailSessions()}>
<CheckCircle2 className="size-3.5" />
{importingMailSessions ? "Importiert..." : `Auswahl übernehmen (${selectedMailArticleIds.length})`}
</Button>
) : null}
<Button type="button" size="sm" variant={mailPreview ? "outline" : "default"} disabled={!canPreviewMailSessions || loadingMailPreview || importingMailSessions} onClick={() => void previewTicketMailSessions()}>
<MailPlus className="size-3.5" />
{loadingMailPreview ? "Lädt..." : "Vorschau laden"}
</Button>
</div>
{!canPreviewMailSessions ? (
<Alert variant="warning">
<AlertCircle className="size-4" />
<AlertTitle>Mail-Sync nicht bereit</AlertTitle>
<AlertDescription>Dieses Ticket braucht eine Support/Consulting-Art und dein Profil einen gültigen Zammad-Token.</AlertDescription>
</Alert>
) : null}
{mailPreview ? (
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-muted/20 px-3 py-2">
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<Checkbox checked={allMailSelected} disabled={mailPreview.items.length === 0} onCheckedChange={(checked) => toggleAllMailSelection(checked === true)} />
{selectedMailArticleIds.length} von {mailPreview.items.length} Mail-Session(s) ausgewählt.
</label>
{mailPreview.skipped > 0 || mailPreview.errors.length > 0 ? (
<span className="text-xs text-muted-foreground">
{mailPreview.skipped} ohne Transfer, {mailPreview.errors.length} Fehler
</span>
) : null}
</div>
{mailPreview.items.length === 0 ? (
<p className="rounded-md border bg-muted/10 px-3 py-4 text-center text-sm text-muted-foreground">Keine importierbaren Mails für dieses Ticket gefunden.</p>
) : (
<div className="space-y-1.5">
{mailPreview.items.map((item: TicketZammadMailSessionItem) => (
<label key={item.articleId} className="grid gap-2 rounded-md border bg-background p-2 sm:grid-cols-[auto_minmax(0,1fr)_auto] sm:items-center">
<Checkbox checked={mailSelection[item.articleId] !== false} onCheckedChange={(checked) => toggleMailSelection(item.articleId, checked === true)} />
<span className="min-w-0">
<span className="block truncate text-sm font-medium">{item.subject || "eMail aus Zammad"}</span>
<span className="block text-xs text-muted-foreground">
Article {item.articleId} · {formatDateTime(item.createdAt)} · {formatTimeRange(item.createdAt, item.endedAt)}
</span>
</span>
<Badge variant="outline" className="w-fit justify-self-start sm:justify-self-end">{formatMinutes(Math.round(item.durationSeconds / 60))}</Badge>
</label>
))}
</div>
)}
</div>
) : canPreviewMailSessions ? (
<p className="rounded-md border bg-muted/10 px-3 py-6 text-center text-sm text-muted-foreground">Lade zuerst die Vorschau, um importierbare Mails auszuwählen.</p>
) : null}
</div>
</DialogContent>
</Dialog>
<Dialog open={Boolean(pendingProjectDelete)} onOpenChange={(open) => !open && void keepEmptyProject()}> <Dialog open={Boolean(pendingProjectDelete)} onOpenChange={(open) => !open && void keepEmptyProject()}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
@@ -2751,7 +2766,6 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="session-activity">Tätigkeit</label>
<RichActivityEditor <RichActivityEditor
key={editingSession?.id ?? "session-activity-empty"} key={editingSession?.id ?? "session-activity-empty"}
id="session-activity" id="session-activity"
+131 -123
View File
@@ -1036,80 +1036,83 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<form className="space-y-4" onSubmit={submitManualSession}> <form className="grid gap-4 xl:grid-cols-2" onSubmit={submitManualSession}>
<div className="grid gap-3 lg:grid-cols-[160px_minmax(180px,1fr)_150px]"> <div className="space-y-4">
<div className="space-y-2"> <div className="grid gap-3 sm:grid-cols-[160px_minmax(0,1fr)_150px]">
<label className="text-sm font-medium" htmlFor="manual-ticket-number">Ticket</label> <div className="space-y-2">
<Input <label className="text-sm font-medium" htmlFor="manual-ticket-number">Ticket</label>
id="manual-ticket-number" <Input
placeholder="Ticket#123456" id="manual-ticket-number"
value={manualSession.ticketNumber} placeholder="Ticket#123456"
onChange={(event) => value={manualSession.ticketNumber}
setManualSession({ onChange={(event) =>
...manualSession, setManualSession({
ticketNumber: event.currentTarget.value, ...manualSession,
organizationId: "", ticketNumber: event.currentTarget.value,
organizationName: null, organizationId: "",
projectTeamspaceEntryId: "", organizationName: null,
workType: "support" projectTeamspaceEntryId: "",
}) workType: "support"
} })
required }
/> required
{manualSession.ticketNumber.length > 0 && !isManualTicketValid ? <p className="text-xs text-destructive">Format: Ticket#XXXXXX</p> : null} />
{manualLookupStatus === "loading" ? <p className="text-xs text-muted-foreground">Prüfe vorhandenes Ticket...</p> : null} {manualSession.ticketNumber.length > 0 && !isManualTicketValid ? <p className="text-xs text-destructive">Format: Ticket#XXXXXX</p> : null}
{manualLookupStatus === "found" && manualTicketDefaultsLocked ? ( {manualLookupStatus === "loading" ? <p className="text-xs text-muted-foreground">Prüfe vorhandenes Ticket...</p> : null}
<p className="text-xs text-muted-foreground"> {manualLookupStatus === "found" && manualTicketDefaultsLocked ? (
{manualLookupTicket?.is_project ? "Projekt gefunden. Organisation und Consulting wurden übernommen." : "Ticket gefunden. Organisation und Art wurden übernommen."} <p className="text-xs text-muted-foreground">
</p> {manualLookupTicket?.is_project ? "Projekt gefunden. Organisation und Consulting wurden übernommen." : "Ticket gefunden. Organisation und Art wurden übernommen."}
) : null} </p>
{manualLookupStatus === "found" && !manualTicketDefaultsLocked ? ( ) : null}
<p className="text-xs text-muted-foreground">Ticket gefunden, aber Stammdaten sind unvollständig. Bitte ergänzen.</p> {manualLookupStatus === "found" && !manualTicketDefaultsLocked ? (
) : null} <p className="text-xs text-muted-foreground">Ticket gefunden, aber Stammdaten sind unvollständig. Bitte ergänzen.</p>
{manualTicketIsRecurringBilling ? ( ) : null}
<p className="text-xs text-destructive">Fixe Abrechnung: Sessions kommen ausschließlich aus der Definition.</p> {manualTicketIsRecurringBilling ? (
) : null} <p className="text-xs text-destructive">Fixe Abrechnung: Sessions kommen ausschließlich aus der Definition.</p>
{manualLookupStatus === "not-found" ? ( ) : null}
<div className="space-y-2"> {manualLookupStatus === "not-found" ? (
<p className="text-xs text-muted-foreground">Neues Ticket. Organisation und Art bitte wählen oder aus Zammad übernehmen.</p> <div className="space-y-2">
<Button type="button" size="sm" variant="secondary" disabled={zammadLookupBusy === "manual"} onClick={() => void lookupManualTicketInZammad()}> <p className="text-xs text-muted-foreground">Neues Ticket. Organisation und Art bitte wählen oder aus Zammad übernehmen.</p>
<Search className="size-3.5" /> <Button type="button" size="sm" variant="secondary" disabled={zammadLookupBusy === "manual"} onClick={() => void lookupManualTicketInZammad()}>
{zammadLookupBusy === "manual" ? "Sucht..." : "In Zammad suchen"} <Search className="size-3.5" />
</Button> {zammadLookupBusy === "manual" ? "Sucht..." : "In Zammad suchen"}
</div> </Button>
) : null} </div>
</div> ) : null}
<div className="space-y-2"> </div>
<label className="text-sm font-medium">Organisation</label> <div className="space-y-2">
<OrganizationSelect <label className="text-sm font-medium">Organisation</label>
value={manualSession.organizationId} <OrganizationSelect
selectedName={manualSession.organizationName} value={manualSession.organizationId}
disabled={manualTicketDefaultsLocked} selectedName={manualSession.organizationName}
onChange={(organization) => disabled={manualTicketDefaultsLocked}
setManualSession({ onChange={(organization) =>
...manualSession, setManualSession({
organizationId: organization.id, ...manualSession,
organizationName: organization.name organizationId: organization.id,
}) organizationName: organization.name
} })
required }
/> required
</div> />
<div className="space-y-2"> </div>
<label className="text-sm font-medium" htmlFor="manual-work-type">Art</label> <div className="space-y-2">
<select <label className="text-sm font-medium" htmlFor="manual-work-type">Art</label>
id="manual-work-type" <select
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50" id="manual-work-type"
value={manualSession.workType} className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
disabled={manualTicketDefaultsLocked} value={manualSession.workType}
onChange={(event) => setManualSession({ ...manualSession, workType: event.currentTarget.value as WorkType })} disabled={manualTicketDefaultsLocked}
> onChange={(event) => setManualSession({ ...manualSession, workType: event.currentTarget.value as WorkType })}
<option value="support">Support</option> >
<option value="consulting">Consulting</option> <option value="support">Support</option>
</select> <option value="consulting">Consulting</option>
</select>
</div>
</div> </div>
{requiresTeamspaceEntry(manualLookupTicket) ? ( {requiresTeamspaceEntry(manualLookupTicket) ? (
<div className="space-y-2 lg:col-span-3"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-teamspace-entry">Teamspace-Eintrag</label> <label className="text-sm font-medium" htmlFor="manual-teamspace-entry">Teamspace-Eintrag</label>
<select <select
id="manual-teamspace-entry" id="manual-teamspace-entry"
@@ -1130,58 +1133,49 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
) : null} ) : null}
</div> </div>
) : null} ) : null}
</div>
<div className="grid gap-3 lg:grid-cols-[160px_120px_120px_160px_minmax(220px,1fr)_auto] lg:items-end"> <div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-day">Datum</label> <label className="text-sm font-medium" htmlFor="manual-start">Von</label>
<Input <Input
id="manual-day" id="manual-start"
type="date" type="time"
value={manualSession.day} value={manualSession.startTime}
onChange={(event) => setManualSession({ ...manualSession, day: event.currentTarget.value })} onChange={(event) => setManualSession({ ...manualSession, startTime: event.currentTarget.value })}
required required
/> />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-end">Bis</label>
<Input
id="manual-end"
type="time"
value={manualSession.endTime}
onChange={(event) => setManualSession({ ...manualSession, endTime: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-day">Datum</label>
<Input
id="manual-day"
type="date"
value={manualSession.day}
onChange={(event) => setManualSession({ ...manualSession, day: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-billing-status">Bewertung</label>
<BillingStatusSelect
id="manual-billing-status"
value={manualSession.billingStatus}
onChange={(billingStatus) => setManualSession({ ...manualSession, billingStatus })}
/>
</div>
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-start">Von</label> <label className="flex min-h-10 items-start gap-2 rounded-md border bg-background p-3 text-sm">
<Input
id="manual-start"
type="time"
value={manualSession.startTime}
onChange={(event) => setManualSession({ ...manualSession, startTime: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-end">Bis</label>
<Input
id="manual-end"
type="time"
value={manualSession.endTime}
onChange={(event) => setManualSession({ ...manualSession, endTime: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-billing-status">Bewertung</label>
<BillingStatusSelect
id="manual-billing-status"
value={manualSession.billingStatus}
onChange={(billingStatus) => setManualSession({ ...manualSession, billingStatus })}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-activity">Tätigkeit</label>
<RichActivityEditor
key="manual-activity"
id="manual-activity"
value={manualSession.activity}
htmlValue={manualSession.zammadArticleBody}
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">
<Checkbox <Checkbox
checked={manualSession.createZammadArticle} checked={manualSession.createZammadArticle}
onCheckedChange={(checked) => setManualSession({ ...manualSession, createZammadArticle: checked === true })} onCheckedChange={(checked) => setManualSession({ ...manualSession, createZammadArticle: checked === true })}
@@ -1192,7 +1186,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</span> </span>
</label> </label>
{manualSession.createZammadArticle ? ( {manualSession.createZammadArticle ? (
<div className="space-y-2 lg:col-span-5"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-zammad-article-type"> <label className="text-sm font-medium" htmlFor="manual-zammad-article-type">
Zammad-Typ Zammad-Typ
</label> </label>
@@ -1210,6 +1204,20 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</select> </select>
</div> </div>
) : null} ) : null}
</div>
<div className="flex min-h-0 flex-col space-y-2">
<div className="min-h-72 flex-1 [&>div]:flex [&>div]:h-full [&>div]:flex-col [&_[role=textbox]]:min-h-72 xl:[&_[role=textbox]]:flex-1">
<RichActivityEditor
key="manual-activity"
id="manual-activity"
value={manualSession.activity}
htmlValue={manualSession.zammadArticleBody}
onChange={(activity, zammadArticleBody) => setManualSession((current) => ({ ...current, activity, zammadArticleBody }))}
/>
</div>
</div>
<div className="flex justify-end xl:col-start-2">
<Button type="submit" disabled={savingManualSession || manualTicketIsRecurringBilling}> <Button type="submit" disabled={savingManualSession || manualTicketIsRecurringBilling}>
<Upload className="size-4" /> <Upload className="size-4" />
{savingManualSession ? "Speichert..." : "Nachtragen"} {savingManualSession ? "Speichert..." : "Nachtragen"}