Update
This commit is contained in:
1 parent
62794f72f1
commit
39690b1d2b
3 files changed
+222
-201
No files matched your search
@@ -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 ?? [];
|
||||||
|
|||||||
@@ -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>
|
<Card>
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4 pb-2">
|
||||||
<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>
|
|
||||||
<CardHeader className="p-4">
|
|
||||||
<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-7 min-w-32 justify-center"
|
className="h-8 min-w-32 justify-center justify-self-start"
|
||||||
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,14 +2414,15 @@ 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}>
|
||||||
|
<div className="space-y-4">
|
||||||
{ticket?.organization_id && ticket.work_type ? (
|
{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">
|
<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>
|
<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>
|
<Badge variant="outline">{ticket.work_type === "support" ? "Support" : "Consulting"}</Badge>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Organisation</label>
|
<label className="text-sm font-medium">Organisation</label>
|
||||||
{ticket?.customer_name && !ticket.organization_id ? (
|
{ticket?.customer_name && !ticket.organization_id ? (
|
||||||
@@ -2539,19 +2481,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-3 lg:grid-cols-[150px_120px_120px_160px_minmax(220px,1fr)_auto] lg:items-end">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<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">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor="ticket-session-start">
|
<label className="text-sm font-medium" htmlFor="ticket-session-start">
|
||||||
Von
|
Von
|
||||||
@@ -2576,6 +2506,18 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor="ticket-session-billing-status">
|
<label className="text-sm font-medium" htmlFor="ticket-session-billing-status">
|
||||||
Bewertung
|
Bewertung
|
||||||
@@ -2586,19 +2528,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
onChange={(billingStatus) => setTicketSession({ ...ticketSession, billingStatus })}
|
onChange={(billingStatus) => setTicketSession({ ...ticketSession, billingStatus })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</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
|
<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"
|
||||||
|
|||||||
@@ -1036,8 +1036,9 @@ 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="grid gap-3 sm:grid-cols-[160px_minmax(0,1fr)_150px]">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor="manual-ticket-number">Ticket</label>
|
<label className="text-sm font-medium" htmlFor="manual-ticket-number">Ticket</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -1108,8 +1109,10 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
<option value="consulting">Consulting</option>
|
<option value="consulting">Consulting</option>
|
||||||
</select>
|
</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,19 +1133,8 @@ 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">
|
|
||||||
<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">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor="manual-start">Von</label>
|
<label className="text-sm font-medium" htmlFor="manual-start">Von</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -1163,6 +1155,16 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor="manual-billing-status">Bewertung</label>
|
<label className="text-sm font-medium" htmlFor="manual-billing-status">Bewertung</label>
|
||||||
<BillingStatusSelect
|
<BillingStatusSelect
|
||||||
@@ -1171,17 +1173,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
onChange={(billingStatus) => setManualSession({ ...manualSession, billingStatus })}
|
onChange={(billingStatus) => setManualSession({ ...manualSession, billingStatus })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</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
|
<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"}
|
||||||
|
|||||||
Reference in new issue
Block a user