Update
This commit is contained in:
1 parent
62794f72f1
commit
39690b1d2b
3 files changed
+417
-396
No files matched your search
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user