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: "Abgerechnet", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 },
{ 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 }
];
const tickets = overview?.tickets ?? [];
+286 -272
View File
@@ -395,7 +395,7 @@ function BillingButtons({
const status = session.billing_status;
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
type="button"
size="sm"
@@ -790,6 +790,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const [mailSelection, setMailSelection] = useState<Record<number, boolean>>({});
const [loadingMailPreview, setLoadingMailPreview] = useState(false);
const [importingMailSessions, setImportingMailSessions] = useState(false);
const [mailImportOpen, setMailImportOpen] = useState(false);
async function load() {
try {
@@ -1177,6 +1178,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const result = await importTicketZammadMailSessions(ticket.id, mailDaysBack, selectedMailArticleIds);
setMailPreview(null);
setMailSelection({});
setMailImportOpen(false);
const firstError = result.errors[0];
const description = firstError
? `${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
</Button>
) : 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 ? (
<Button type="button" size="sm" variant={isFavorite ? "default" : "secondary"} disabled={savingFavorite} onClick={() => void toggleTicketFavorite()}>
<Star className={cn("size-4", isFavorite && "fill-current")} />
@@ -1754,51 +1762,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</Alert>
) : null}
<div className={cn("grid gap-4", isGlobalTicket && "details:grid-cols-2")}>
{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}
<div className="grid gap-4">
<Card>
<CardHeader className="p-4">
<CardHeader className="p-4 pb-2">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Ticketdaten</CardTitle>
@@ -1811,8 +1777,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<HelpLink anchor="ticketdaten" label="Hilfe zu Ticketdaten" />
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
<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}>
<CardContent className="px-4 pb-3">
<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">
<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 />
@@ -1850,7 +1816,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
required
/>
</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" />
{savingTicket ? "Speichert..." : "Speichern"}
</Button>
@@ -2124,76 +2090,6 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</Card>
</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>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
@@ -2213,6 +2109,56 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</div>
</CardHeader>
<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 ? (
<div className="mb-3 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
{teamspaceTabs.length > 0 ? (
@@ -2253,29 +2199,43 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<div className="space-y-2">
{sessionGroups.map((group) => (
<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">
<p className="truncate font-medium">{group.label}</p>
<p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
</div>
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 lg:justify-end">
<Button
type="button"
size="sm"
variant="secondary"
className="h-7 min-w-32 justify-center"
title="Teamspace-Aufbereitung"
onClick={() => openTeamspacePreparation(group)}
>
<ClipboardList className="size-3.5" />
TS
</Button>
<Button
type="button"
size="sm"
variant="secondary"
className="h-8 min-w-32 justify-center justify-self-start"
title="Teamspace-Aufbereitung"
onClick={() => openTeamspacePreparation(group)}
>
<ClipboardList className="size-3.5" />
TS
</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">
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
<Input
id={`crm-billing-${group.key}`}
type="text"
className="h-7 w-16 bg-background text-right"
className="h-8 w-20 bg-background text-right"
autoComplete="off"
inputMode="decimal"
value={dayBillingInputs[group.key] ?? ""}
@@ -2315,7 +2275,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</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) => {
const expanded = expandedSessionIds.includes(session.id);
const zammadSent = session.zammad_article_status === "sent";
@@ -2325,8 +2285,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const sendingZammad = sendingZammadSessionId === session.id;
return (
<div key={session.id} className="ml-3 rounded-md border bg-card/40 sm:ml-6">
<div className="grid gap-2 p-2 lg:grid-cols-[220px_minmax(0,150px)_minmax(260px,1fr)_auto] lg:items-center">
<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)_220px] lg:items-center">
<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"
@@ -2427,25 +2387,6 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</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>
))}
@@ -2473,132 +2414,123 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
<form className="space-y-4" onSubmit={submitTicketSession}>
{ticket?.organization_id && ticket.work_type ? (
<div className="grid gap-2 rounded-md border bg-muted/30 p-3 text-sm sm:grid-cols-[1fr_auto] sm:items-center">
<span className="font-medium">{ticket.organization_name ?? ticket.customer_name}</span>
<Badge variant="outline">{ticket.work_type === "support" ? "Support" : "Consulting"}</Badge>
</div>
) : (
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_180px]">
<form className="grid gap-4 xl:grid-cols-2" onSubmit={submitTicketSession}>
<div className="space-y-4">
{ticket?.organization_id && ticket.work_type ? (
<div className="grid gap-2 rounded-md border bg-muted/30 p-3 text-sm sm:grid-cols-[1fr_auto] sm:items-center">
<span className="min-w-0 truncate font-medium">{ticket.organization_name ?? ticket.customer_name}</span>
<Badge variant="outline">{ticket.work_type === "support" ? "Support" : "Consulting"}</Badge>
</div>
) : (
<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">
<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>
<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}
<OrganizationSelect
value={ticketSession.organizationId}
selectedName={ticketSession.organizationName}
onChange={(organization) =>
setTicketSession({
...ticketSession,
organizationId: organization.id,
organizationName: organization.name
})
}
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<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-work-type">
Art
<label className="text-sm font-medium" htmlFor="ticket-session-end">
Bis
</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>
<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-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>
)}
{requiresProjectTeamspaceEntry ? (
<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">
<label className="flex min-h-10 items-start gap-2 rounded-md border bg-background p-3 text-sm">
<Checkbox
checked={ticketSession.createZammadArticle}
onCheckedChange={(checked) => setTicketSession({ ...ticketSession, createZammadArticle: checked === true })}
@@ -2609,7 +2541,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</span>
</label>
{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">
Zammad-Typ
</label>
@@ -2627,6 +2559,21 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</select>
</div>
) : 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}>
<Save className="size-4" />
{savingTicketSession ? "Speichert..." : "Nachtragen"}
@@ -2678,6 +2625,74 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</DialogContent>
</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()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
@@ -2751,7 +2766,6 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="session-activity">Tätigkeit</label>
<RichActivityEditor
key={editingSession?.id ?? "session-activity-empty"}
id="session-activity"
+131 -123
View File
@@ -1036,80 +1036,83 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</div>
</CardHeader>
<CardContent className="p-4">
<form className="space-y-4" onSubmit={submitManualSession}>
<div className="grid gap-3 lg:grid-cols-[160px_minmax(180px,1fr)_150px]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-ticket-number">Ticket</label>
<Input
id="manual-ticket-number"
placeholder="Ticket#123456"
value={manualSession.ticketNumber}
onChange={(event) =>
setManualSession({
...manualSession,
ticketNumber: event.currentTarget.value,
organizationId: "",
organizationName: null,
projectTeamspaceEntryId: "",
workType: "support"
})
}
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}
{manualLookupStatus === "found" && manualTicketDefaultsLocked ? (
<p className="text-xs text-muted-foreground">
{manualLookupTicket?.is_project ? "Projekt gefunden. Organisation und Consulting wurden übernommen." : "Ticket gefunden. Organisation und Art wurden übernommen."}
</p>
) : null}
{manualLookupStatus === "found" && !manualTicketDefaultsLocked ? (
<p className="text-xs text-muted-foreground">Ticket gefunden, aber Stammdaten sind unvollständig. Bitte ergänzen.</p>
) : null}
{manualTicketIsRecurringBilling ? (
<p className="text-xs text-destructive">Fixe Abrechnung: Sessions kommen ausschließlich aus der Definition.</p>
) : null}
{manualLookupStatus === "not-found" ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Neues Ticket. Organisation und Art bitte wählen oder aus Zammad übernehmen.</p>
<Button type="button" size="sm" variant="secondary" disabled={zammadLookupBusy === "manual"} onClick={() => void lookupManualTicketInZammad()}>
<Search className="size-3.5" />
{zammadLookupBusy === "manual" ? "Sucht..." : "In Zammad suchen"}
</Button>
</div>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Organisation</label>
<OrganizationSelect
value={manualSession.organizationId}
selectedName={manualSession.organizationName}
disabled={manualTicketDefaultsLocked}
onChange={(organization) =>
setManualSession({
...manualSession,
organizationId: organization.id,
organizationName: organization.name
})
}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-work-type">Art</label>
<select
id="manual-work-type"
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={manualSession.workType}
disabled={manualTicketDefaultsLocked}
onChange={(event) => setManualSession({ ...manualSession, workType: event.currentTarget.value as WorkType })}
>
<option value="support">Support</option>
<option value="consulting">Consulting</option>
</select>
<form className="grid gap-4 xl:grid-cols-2" onSubmit={submitManualSession}>
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-[160px_minmax(0,1fr)_150px]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-ticket-number">Ticket</label>
<Input
id="manual-ticket-number"
placeholder="Ticket#123456"
value={manualSession.ticketNumber}
onChange={(event) =>
setManualSession({
...manualSession,
ticketNumber: event.currentTarget.value,
organizationId: "",
organizationName: null,
projectTeamspaceEntryId: "",
workType: "support"
})
}
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}
{manualLookupStatus === "found" && manualTicketDefaultsLocked ? (
<p className="text-xs text-muted-foreground">
{manualLookupTicket?.is_project ? "Projekt gefunden. Organisation und Consulting wurden übernommen." : "Ticket gefunden. Organisation und Art wurden übernommen."}
</p>
) : null}
{manualLookupStatus === "found" && !manualTicketDefaultsLocked ? (
<p className="text-xs text-muted-foreground">Ticket gefunden, aber Stammdaten sind unvollständig. Bitte ergänzen.</p>
) : null}
{manualTicketIsRecurringBilling ? (
<p className="text-xs text-destructive">Fixe Abrechnung: Sessions kommen ausschließlich aus der Definition.</p>
) : null}
{manualLookupStatus === "not-found" ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Neues Ticket. Organisation und Art bitte wählen oder aus Zammad übernehmen.</p>
<Button type="button" size="sm" variant="secondary" disabled={zammadLookupBusy === "manual"} onClick={() => void lookupManualTicketInZammad()}>
<Search className="size-3.5" />
{zammadLookupBusy === "manual" ? "Sucht..." : "In Zammad suchen"}
</Button>
</div>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Organisation</label>
<OrganizationSelect
value={manualSession.organizationId}
selectedName={manualSession.organizationName}
disabled={manualTicketDefaultsLocked}
onChange={(organization) =>
setManualSession({
...manualSession,
organizationId: organization.id,
organizationName: organization.name
})
}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-work-type">Art</label>
<select
id="manual-work-type"
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={manualSession.workType}
disabled={manualTicketDefaultsLocked}
onChange={(event) => setManualSession({ ...manualSession, workType: event.currentTarget.value as WorkType })}
>
<option value="support">Support</option>
<option value="consulting">Consulting</option>
</select>
</div>
</div>
{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>
<select
id="manual-teamspace-entry"
@@ -1130,58 +1133,49 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
) : null}
</div>
) : null}
</div>
<div className="grid gap-3 lg:grid-cols-[160px_120px_120px_160px_minmax(220px,1fr)_auto] lg:items-end">
<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 className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-start">Von</label>
<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-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 className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-start">Von</label>
<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">
<label className="flex min-h-10 items-start gap-2 rounded-md border bg-background p-3 text-sm">
<Checkbox
checked={manualSession.createZammadArticle}
onCheckedChange={(checked) => setManualSession({ ...manualSession, createZammadArticle: checked === true })}
@@ -1192,7 +1186,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</span>
</label>
{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">
Zammad-Typ
</label>
@@ -1210,6 +1204,20 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</select>
</div>
) : 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}>
<Upload className="size-4" />
{savingManualSession ? "Speichert..." : "Nachtragen"}