@dxv-systems/helpdesk 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +279 -3
  2. package/dist/autosend.d.ts +43 -0
  3. package/dist/autosend.js +83 -0
  4. package/dist/bounce.d.ts +7 -0
  5. package/dist/bounce.js +41 -0
  6. package/dist/cleanup.d.ts +7 -0
  7. package/dist/cleanup.js +39 -0
  8. package/dist/controls.d.ts +13 -0
  9. package/dist/controls.js +87 -0
  10. package/dist/draft-body.d.ts +5 -0
  11. package/dist/draft-body.js +52 -0
  12. package/dist/drafting.d.ts +19 -0
  13. package/dist/drafting.js +311 -0
  14. package/dist/errors.d.ts +63 -0
  15. package/dist/errors.js +139 -0
  16. package/dist/inbox/handlers.d.ts +36 -0
  17. package/dist/inbox/handlers.js +535 -0
  18. package/dist/inbox/messages.d.ts +35 -0
  19. package/dist/inbox/messages.js +57 -0
  20. package/dist/inbox/plain-text.d.ts +1 -0
  21. package/dist/inbox/plain-text.js +58 -0
  22. package/dist/inbox/types.d.ts +108 -0
  23. package/dist/inbox/types.js +2 -0
  24. package/dist/index.d.ts +13 -0
  25. package/dist/index.js +71 -0
  26. package/dist/ingest.d.ts +25 -0
  27. package/dist/ingest.js +335 -0
  28. package/dist/internal.d.ts +8 -0
  29. package/dist/internal.js +44 -0
  30. package/dist/noise.d.ts +17 -0
  31. package/dist/noise.js +77 -0
  32. package/dist/react.d.ts +57 -0
  33. package/dist/react.js +431 -0
  34. package/dist/reply-attachments.d.ts +28 -0
  35. package/dist/reply-attachments.js +119 -0
  36. package/dist/sending.d.ts +33 -0
  37. package/dist/sending.js +291 -0
  38. package/dist/server.d.ts +6 -0
  39. package/dist/server.js +10 -0
  40. package/dist/states.d.ts +13 -0
  41. package/dist/states.js +63 -0
  42. package/dist/subject.d.ts +2 -0
  43. package/dist/subject.js +30 -0
  44. package/dist/testing/index.d.ts +99 -0
  45. package/dist/testing/index.js +489 -0
  46. package/dist/threading.d.ts +18 -0
  47. package/dist/threading.js +66 -0
  48. package/dist/types.d.ts +345 -0
  49. package/dist/types.js +2 -0
  50. package/package.json +73 -6
@@ -0,0 +1,17 @@
1
+ import type { InternetMessageHeader, NoiseReason } from "./types";
2
+ export interface NoiseInput {
3
+ from: string;
4
+ subject?: string;
5
+ headers?: InternetMessageHeader[] | Record<string, string | undefined>;
6
+ replyTo?: string[];
7
+ ownAddresses: string[];
8
+ connectedMailboxes: string[];
9
+ }
10
+ export type NoiseVerdict = {
11
+ action: "ignore" | "process";
12
+ reason: NoiseReason;
13
+ detail?: string;
14
+ customerAddress?: string;
15
+ suppressAutoResponse?: boolean;
16
+ };
17
+ export declare function classifyNoise(input: NoiseInput): NoiseVerdict;
package/dist/noise.js ADDED
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.classifyNoise = classifyNoise;
4
+ const MACHINE_LOCAL = /^(?:no[-_.]?reply|do[-_.]?not[-_.]?reply|mailer-daemon|postmaster|bounces?)(?:[+._-].*)?$/i;
5
+ const EXCHANGE_SYSTEM_LOCAL = /^microsoftexchange[0-9a-f]{32}$/i;
6
+ const OOO_SUBJECT = /^\s*(?:automatic reply|automatisch antwoord|auto[- ]?reply|autoreply|out of office|afwezigheidsmelding|abwesenheitsnotiz)\b/i;
7
+ function headerMap(headers) {
8
+ const map = new Map();
9
+ if (!headers)
10
+ return map;
11
+ const entries = Array.isArray(headers)
12
+ ? headers.map((h) => [h.name, h.value])
13
+ : Object.entries(headers);
14
+ for (const [name, value] of entries) {
15
+ if (value === undefined || value === null)
16
+ continue;
17
+ const key = name.trim().toLowerCase();
18
+ map.set(key, map.has(key) ? `${map.get(key)}, ${String(value)}` : String(value));
19
+ }
20
+ return map;
21
+ }
22
+ function keyword(value) {
23
+ return (value ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
24
+ }
25
+ function realReplyTo(input, from) {
26
+ const own = [...input.ownAddresses, ...input.connectedMailboxes].map((a) => a.trim().toLowerCase());
27
+ for (const raw of input.replyTo ?? []) {
28
+ const a = raw.trim().toLowerCase();
29
+ const local = a.split("@")[0] ?? "";
30
+ if (a.includes("@") && a !== from && !own.includes(a) && !MACHINE_LOCAL.test(local))
31
+ return a;
32
+ }
33
+ return null;
34
+ }
35
+ function classifyNoise(input) {
36
+ const from = input.from.trim().toLowerCase();
37
+ const h = headerMap(input.headers);
38
+ const ignore = (reason, detail) => ({
39
+ action: "ignore",
40
+ reason,
41
+ ...(detail ? { detail } : {}),
42
+ });
43
+ if (input.ownAddresses.some((a) => a.trim().toLowerCase() === from))
44
+ return ignore("own_address");
45
+ if (input.connectedMailboxes.some((a) => a.trim().toLowerCase() === from)) {
46
+ return ignore("connected_mailbox");
47
+ }
48
+ const local = from.split("@")[0] ?? "";
49
+ const contentType = (h.get("content-type") ?? "").toLowerCase();
50
+ const isNdrReport = contentType.includes("report-type=delivery-status") || contentType.includes("delivery-status");
51
+ if (isNdrReport || h.has("x-ms-exchange-message-is-ndr"))
52
+ return ignore("bounce", "ndr");
53
+ if (/^(?:mailer-daemon|postmaster)$/i.test(local))
54
+ return ignore("bounce", local);
55
+ if (EXCHANGE_SYSTEM_LOCAL.test(local))
56
+ return ignore("bounce", "exchange");
57
+ const auto = keyword(h.get("auto-submitted"));
58
+ if (auto && auto !== "no")
59
+ return ignore("auto_submitted", auto);
60
+ const precedence = keyword(h.get("precedence"));
61
+ if (["bulk", "junk", "list", "auto_reply"].includes(precedence))
62
+ return ignore("precedence", precedence);
63
+ if (h.has("x-autoreply") || h.has("x-autorespond") || OOO_SUBJECT.test(input.subject ?? "")) {
64
+ return ignore("out_of_office");
65
+ }
66
+ if (h.has("list-id") || h.has("list-unsubscribe"))
67
+ return ignore("mailing_list");
68
+ const suppressAutoResponse = (h.get("x-auto-response-suppress") ?? "").trim() !== "";
69
+ const flags = suppressAutoResponse ? { suppressAutoResponse } : {};
70
+ if (MACHINE_LOCAL.test(local)) {
71
+ const customer = realReplyTo(input, from);
72
+ if (customer)
73
+ return { action: "process", reason: "none", customerAddress: customer, ...flags };
74
+ return ignore("machine_sender", local);
75
+ }
76
+ return { action: "process", reason: "none", ...flags };
77
+ }
@@ -0,0 +1,57 @@
1
+ import { type ReactNode } from "react";
2
+ import type { InboxThreadDetail } from "./inbox/types";
3
+ export type { InboxActionResult, InboxThreadDetail, InboxThreadList, InboxThreadSummary, } from "./inbox/types";
4
+ export interface HelpdeskInboxClassNames {
5
+ root: string;
6
+ layout: string;
7
+ listPane: string;
8
+ detailPane: string;
9
+ hiddenOnMobile: string;
10
+ title: string;
11
+ filters: string;
12
+ field: string;
13
+ label: string;
14
+ select: string;
15
+ list: string;
16
+ listItem: string;
17
+ listItemCurrent: string;
18
+ listItemTitle: string;
19
+ listItemMeta: string;
20
+ card: string;
21
+ heading: string;
22
+ subheading: string;
23
+ text: string;
24
+ muted: string;
25
+ meta: string;
26
+ badges: string;
27
+ badge: string;
28
+ badgeAttention: string;
29
+ badgeDone: string;
30
+ messages: string;
31
+ message: string;
32
+ messageHeader: string;
33
+ messageBody: string;
34
+ textarea: string;
35
+ actions: string;
36
+ primaryButton: string;
37
+ secondaryButton: string;
38
+ dangerButton: string;
39
+ linkButton: string;
40
+ banner: string;
41
+ success: string;
42
+ warning: string;
43
+ error: string;
44
+ info: string;
45
+ context: string;
46
+ }
47
+ export declare const DEFAULT_HELPDESK_INBOX_CLASSNAMES: HelpdeskInboxClassNames;
48
+ export interface HelpdeskInboxProps {
49
+ apiBasePath?: string;
50
+ classNames?: Partial<HelpdeskInboxClassNames>;
51
+ pollIntervalMs?: number;
52
+ fetch?: typeof fetch;
53
+ renderCustomerContext?: (thread: InboxThreadDetail["thread"]) => ReactNode;
54
+ initialThreadId?: string;
55
+ onThreadChange?: (threadId: string | null) => void;
56
+ }
57
+ export declare function HelpdeskInbox({ apiBasePath, classNames, pollIntervalMs, fetch: fetchImpl, renderCustomerContext, initialThreadId, onThreadChange, }: HelpdeskInboxProps): import("react").JSX.Element;
package/dist/react.js ADDED
@@ -0,0 +1,431 @@
1
+ "use strict";
2
+ "use client";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.DEFAULT_HELPDESK_INBOX_CLASSNAMES = void 0;
5
+ exports.HelpdeskInbox = HelpdeskInbox;
6
+ const jsx_runtime_1 = require("react/jsx-runtime");
7
+ const react_1 = require("react");
8
+ const messages_1 = require("./inbox/messages");
9
+ exports.DEFAULT_HELPDESK_INBOX_CLASSNAMES = {
10
+ root: "flex w-full flex-col gap-4 text-foreground",
11
+ layout: "flex w-full flex-col gap-6 md:flex-row md:items-start",
12
+ listPane: "flex min-w-0 flex-col gap-3 md:w-96 md:flex-none",
13
+ detailPane: "flex min-w-0 flex-1 flex-col gap-4",
14
+ hiddenOnMobile: "max-md:hidden",
15
+ title: "text-2xl font-bold leading-tight",
16
+ filters: "grid grid-cols-1 gap-3 sm:grid-cols-3 md:grid-cols-1",
17
+ field: "flex flex-col gap-1.5",
18
+ label: "text-sm font-semibold",
19
+ select: "h-11 w-full min-w-0 rounded-md border border-input bg-background px-3 text-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring",
20
+ list: "flex flex-col gap-2",
21
+ listItem: "flex min-h-11 w-full flex-col gap-1 rounded-md border border-border bg-card px-4 py-3 text-left text-card-foreground hover:bg-accent",
22
+ listItemCurrent: "flex min-h-11 w-full flex-col gap-1 rounded-md border border-primary bg-accent px-4 py-3 text-left text-card-foreground",
23
+ listItemTitle: "font-semibold break-words",
24
+ listItemMeta: "flex flex-wrap items-center gap-2 text-sm text-muted-foreground",
25
+ card: "flex flex-col gap-4 rounded-lg border border-border bg-card p-4 text-card-foreground sm:p-6",
26
+ heading: "text-xl font-bold break-words",
27
+ subheading: "text-base font-semibold",
28
+ text: "",
29
+ muted: "text-sm text-muted-foreground",
30
+ meta: "flex flex-col gap-1 text-sm text-muted-foreground break-words",
31
+ badges: "flex flex-wrap items-center gap-2",
32
+ badge: "whitespace-nowrap rounded-full bg-muted px-2.5 py-0.5 text-sm font-semibold",
33
+ badgeAttention: "whitespace-nowrap rounded-full bg-amber-50 px-2.5 py-0.5 text-sm font-semibold text-amber-900 dark:bg-amber-950 dark:text-amber-100",
34
+ badgeDone: "whitespace-nowrap rounded-full bg-emerald-50 px-2.5 py-0.5 text-sm font-semibold text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200",
35
+ messages: "flex flex-col gap-3",
36
+ message: "flex flex-col gap-2 rounded-md border border-border p-4",
37
+ messageHeader: "flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 text-sm",
38
+ messageBody: "whitespace-pre-wrap break-words text-sm leading-relaxed",
39
+ textarea: "min-h-48 w-full rounded-md border border-input bg-background p-3 text-base leading-relaxed focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring read-only:bg-muted",
40
+ actions: "flex flex-wrap items-center gap-3",
41
+ primaryButton: "inline-flex min-h-11 items-center justify-center rounded-md bg-primary px-4 font-semibold text-primary-foreground hover:bg-primary/90 disabled:opacity-50",
42
+ secondaryButton: "inline-flex min-h-11 items-center justify-center rounded-md border border-border bg-background px-4 font-semibold hover:bg-accent disabled:opacity-50",
43
+ dangerButton: "inline-flex min-h-11 items-center justify-center rounded-md border border-destructive bg-background px-4 font-semibold text-destructive hover:bg-destructive/10 disabled:opacity-50",
44
+ linkButton: "inline-flex min-h-11 items-center font-semibold text-primary underline-offset-4 hover:underline",
45
+ banner: "rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100",
46
+ success: "rounded-md bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200",
47
+ warning: "rounded-md bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:bg-amber-950 dark:text-amber-100",
48
+ error: "rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive",
49
+ info: "rounded-md bg-muted px-4 py-3 text-sm",
50
+ context: "flex flex-col gap-2",
51
+ };
52
+ class ApiError extends Error {
53
+ constructor(code, message, status) {
54
+ super(message);
55
+ this.code = code;
56
+ this.status = status;
57
+ }
58
+ }
59
+ const GENERIC_ERROR = "Er ging iets mis. Probeer het opnieuw.";
60
+ const errorText = (error) => (error instanceof ApiError ? error.message : GENERIC_ERROR);
61
+ function useApi(basePath, fetchImpl) {
62
+ return (0, react_1.useCallback)(async (path, init = {}) => {
63
+ const headers = { Accept: "application/json" };
64
+ if (init.body !== undefined)
65
+ headers["Content-Type"] = "application/json";
66
+ const doFetch = fetchImpl ?? fetch;
67
+ const response = await doFetch(`${basePath.replace(/\/$/, "")}/${path}`, {
68
+ method: init.method ?? "GET",
69
+ headers,
70
+ credentials: "same-origin",
71
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
72
+ });
73
+ const body = (await response.json().catch(() => ({})));
74
+ if (!response.ok) {
75
+ const code = typeof body.error === "string" ? body.error : "error";
76
+ const message = typeof body.message === "string" ? body.message : GENERIC_ERROR;
77
+ throw new ApiError(code, message, response.status);
78
+ }
79
+ return { status: response.status, body: body };
80
+ }, [basePath, fetchImpl]);
81
+ }
82
+ const VIEWS = [
83
+ { value: "review", label: "Te beoordelen", states: ["review"] },
84
+ { value: "new", label: "Nieuw", states: ["new", "drafted"] },
85
+ { value: "sent", label: "Verstuurd en wachtend", states: ["sent", "waiting"] },
86
+ { value: "closed", label: "Gesloten", states: ["closed"] },
87
+ { value: "ignored", label: "Genegeerd", states: ["ignored"] },
88
+ { value: "all", label: "Alles", states: null },
89
+ ];
90
+ const ASSIGNEE_OPTIONS = [
91
+ { value: "", label: "Iedereen" },
92
+ { value: "me", label: "Aan mij" },
93
+ { value: "none", label: "Niet toegewezen" },
94
+ ];
95
+ const dateTime = new Intl.DateTimeFormat("nl-NL", { dateStyle: "medium", timeStyle: "short" });
96
+ const formatDate = (iso) => dateTime.format(new Date(iso));
97
+ function stateBadgeClass(cls, state) {
98
+ if (state === "review")
99
+ return cls.badgeAttention;
100
+ if (state === "sent" || state === "waiting" || state === "closed")
101
+ return cls.badgeDone;
102
+ return cls.badge;
103
+ }
104
+ const syncSince = new Intl.DateTimeFormat("nl-NL", { weekday: "long", hour: "2-digit", minute: "2-digit" });
105
+ function SyncIssueBanner({ cls, issues }) {
106
+ const since = issues.map((i) => i.failingSince).sort()[0];
107
+ return ((0, jsx_runtime_1.jsxs)("div", { role: "status", className: cls.warning, children: ["Microsoft 365 is niet bereikbaar sinds ", syncSince.format(new Date(since)), issues.length > 1 ? "" : ` (${issues[0].mailbox})`, ". Nieuwe mail komt binnen zodra het weer werkt; er gaat niets verloren."] }));
108
+ }
109
+ function PausedBanner({ cls }) {
110
+ return ((0, jsx_runtime_1.jsx)("div", { role: "status", className: cls.banner, "data-helpdesk-paused": "", children: "De helpdesk staat op pauze. Er wordt geen mail opgehaald, geen concept geschreven en niets verstuurd. Je kunt tickets wel bekijken." }));
111
+ }
112
+ function HelpdeskInbox({ apiBasePath = messages_1.HELPDESK_BASE_PATH, classNames, pollIntervalMs = 30000, fetch: fetchImpl, renderCustomerContext, initialThreadId, onThreadChange, }) {
113
+ const cls = (0, react_1.useMemo)(() => ({ ...exports.DEFAULT_HELPDESK_INBOX_CLASSNAMES, ...classNames }), [classNames]);
114
+ const api = useApi(apiBasePath, fetchImpl);
115
+ const id = (0, react_1.useId)();
116
+ const [view, setView] = (0, react_1.useState)("review");
117
+ const [assignee, setAssignee] = (0, react_1.useState)("");
118
+ const [mailbox, setMailbox] = (0, react_1.useState)("");
119
+ const [list, setList] = (0, react_1.useState)(null);
120
+ const [pages, setPages] = (0, react_1.useState)(1);
121
+ const [listError, setListError] = (0, react_1.useState)(null);
122
+ const [loadingMore, setLoadingMore] = (0, react_1.useState)(false);
123
+ const [selected, setSelected] = (0, react_1.useState)(initialThreadId ?? null);
124
+ const [detailPaused, setDetailPaused] = (0, react_1.useState)(null);
125
+ const interval = Math.max(5000, pollIntervalMs);
126
+ const query = (0, react_1.useCallback)((cursor) => {
127
+ const params = new URLSearchParams();
128
+ const states = VIEWS.find((v) => v.value === view)?.states;
129
+ if (states)
130
+ params.set("state", states.join(","));
131
+ if (assignee)
132
+ params.set("assignee", assignee);
133
+ if (mailbox)
134
+ params.set("mailbox", mailbox);
135
+ if (cursor)
136
+ params.set("cursor", cursor);
137
+ const qs = params.toString();
138
+ return qs ? `threads?${qs}` : "threads";
139
+ }, [view, assignee, mailbox]);
140
+ const loadFirst = (0, react_1.useCallback)(async () => {
141
+ try {
142
+ const { body } = await api(query(null));
143
+ setList(body);
144
+ setPages(1);
145
+ setListError(null);
146
+ }
147
+ catch (error) {
148
+ setListError(errorText(error));
149
+ }
150
+ }, [api, query]);
151
+ (0, react_1.useEffect)(() => {
152
+ void loadFirst();
153
+ }, [loadFirst]);
154
+ (0, react_1.useEffect)(() => {
155
+ if (pages > 1)
156
+ return;
157
+ const timer = setInterval(() => void loadFirst(), interval);
158
+ return () => clearInterval(timer);
159
+ }, [loadFirst, interval, pages]);
160
+ const loadMore = async () => {
161
+ if (!list?.nextCursor)
162
+ return;
163
+ setLoadingMore(true);
164
+ try {
165
+ const { body } = await api(query(list.nextCursor));
166
+ setList({ ...body, threads: [...list.threads, ...body.threads] });
167
+ setPages((p) => p + 1);
168
+ }
169
+ catch (error) {
170
+ setListError(errorText(error));
171
+ }
172
+ finally {
173
+ setLoadingMore(false);
174
+ }
175
+ };
176
+ const select = (threadId) => {
177
+ setSelected(threadId);
178
+ if (threadId === null)
179
+ setDetailPaused(null);
180
+ onThreadChange?.(threadId);
181
+ };
182
+ const viewStates = (0, react_1.useRef)(null);
183
+ viewStates.current = VIEWS.find((v) => v.value === view)?.states ?? null;
184
+ const onThreadUpdated = (0, react_1.useCallback)((detail) => {
185
+ setDetailPaused(detail.pilot.paused);
186
+ const { id: threadId, state, reviewReason, assignee: owner } = detail.thread;
187
+ const outOfView = viewStates.current !== null && !viewStates.current.includes(state);
188
+ setList((current) => current
189
+ ? {
190
+ ...current,
191
+ threads: current.threads.flatMap((t) => t.id !== threadId ? [t] : outOfView ? [] : [{ ...t, state, reviewReason, assignee: owner }]),
192
+ }
193
+ : current);
194
+ }, []);
195
+ const paused = detailPaused ?? list?.pilot.paused ?? false;
196
+ const mailboxes = list?.pilot.mailboxes ?? [];
197
+ return ((0, jsx_runtime_1.jsxs)("div", { className: cls.root, "data-helpdesk-inbox": "", children: [paused && (0, jsx_runtime_1.jsx)(PausedBanner, { cls: cls }), (list?.syncIssues ?? []).length > 0 && (0, jsx_runtime_1.jsx)(SyncIssueBanner, { cls: cls, issues: list.syncIssues }), (0, jsx_runtime_1.jsxs)("div", { className: cls.layout, children: [(0, jsx_runtime_1.jsxs)("section", { "aria-labelledby": `${id}-list`, className: `${cls.listPane} ${selected ? cls.hiddenOnMobile : ""}`.trim(), children: [(0, jsx_runtime_1.jsx)("h1", { id: `${id}-list`, className: cls.title, children: "Helpdesk" }), (0, jsx_runtime_1.jsxs)("div", { className: cls.filters, children: [(0, jsx_runtime_1.jsxs)("div", { className: cls.field, children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: `${id}-view`, className: cls.label, children: "Tickets" }), (0, jsx_runtime_1.jsx)("select", { id: `${id}-view`, className: cls.select, value: view, onChange: (e) => setView(e.target.value), children: VIEWS.map((v) => ((0, jsx_runtime_1.jsx)("option", { value: v.value, children: v.label }, v.value))) })] }), (0, jsx_runtime_1.jsxs)("div", { className: cls.field, children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: `${id}-assignee`, className: cls.label, children: "Toegewezen aan" }), (0, jsx_runtime_1.jsx)("select", { id: `${id}-assignee`, className: cls.select, value: assignee, onChange: (e) => setAssignee(e.target.value), children: ASSIGNEE_OPTIONS.map((o) => ((0, jsx_runtime_1.jsx)("option", { value: o.value, children: o.label }, o.value))) })] }), mailboxes.length > 1 && ((0, jsx_runtime_1.jsxs)("div", { className: cls.field, children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: `${id}-mailbox`, className: cls.label, children: "Mailbox" }), (0, jsx_runtime_1.jsxs)("select", { id: `${id}-mailbox`, className: cls.select, value: mailbox, onChange: (e) => setMailbox(e.target.value), children: [(0, jsx_runtime_1.jsx)("option", { value: "", children: "Alle mailboxen" }), mailboxes.map((m) => ((0, jsx_runtime_1.jsx)("option", { value: m.address, children: m.address }, m.address)))] })] }))] }), listError && ((0, jsx_runtime_1.jsx)("p", { role: "alert", className: cls.error, children: listError })), !list && !listError && ((0, jsx_runtime_1.jsx)("p", { className: cls.muted, "aria-live": "polite", children: "Laden\u2026" })), list && list.threads.length === 0 && (0, jsx_runtime_1.jsx)("p", { className: cls.muted, children: "Geen tickets in deze weergave." }), list && list.threads.length > 0 && ((0, jsx_runtime_1.jsx)("ul", { "aria-label": "Tickets", className: cls.list, children: list.threads.map((t) => ((0, jsx_runtime_1.jsx)(ThreadListItem, { cls: cls, thread: t, current: t.id === selected, showMailbox: mailboxes.length > 1, onSelect: select }, t.id))) })), list?.nextCursor && ((0, jsx_runtime_1.jsx)("div", { className: cls.actions, children: (0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.secondaryButton, disabled: loadingMore, onClick: () => void loadMore(), children: loadingMore ? "Laden…" : "Meer laden" }) }))] }), (0, jsx_runtime_1.jsx)("div", { className: `${cls.detailPane} ${selected ? "" : cls.hiddenOnMobile}`.trim(), children: selected ? ((0, jsx_runtime_1.jsx)(ThreadView, { cls: cls, api: api, fetchImpl: fetchImpl, threadId: selected, pollIntervalMs: interval, renderCustomerContext: renderCustomerContext, onBack: () => select(null), onUpdated: onThreadUpdated }, selected)) : ((0, jsx_runtime_1.jsx)("p", { className: cls.muted, children: "Kies een ticket om het te bekijken." })) })] })] }));
198
+ }
199
+ function ThreadListItem({ cls, thread, current, showMailbox, onSelect, }) {
200
+ return ((0, jsx_runtime_1.jsx)("li", { children: (0, jsx_runtime_1.jsxs)("button", { type: "button", className: current ? cls.listItemCurrent : cls.listItem, "aria-current": current ? "true" : undefined, onClick: () => onSelect(thread.id), children: [(0, jsx_runtime_1.jsx)("span", { className: cls.listItemTitle, children: thread.subject || "(geen onderwerp)" }), (0, jsx_runtime_1.jsxs)("span", { className: cls.listItemMeta, children: [(0, jsx_runtime_1.jsx)("span", { children: thread.customerAddress }), (0, jsx_runtime_1.jsx)("span", { "aria-hidden": "true", children: "\u00B7" }), (0, jsx_runtime_1.jsx)("span", { children: formatDate(thread.lastActivityAt) })] }), (0, jsx_runtime_1.jsxs)("span", { className: cls.listItemMeta, children: [(0, jsx_runtime_1.jsx)("span", { className: stateBadgeClass(cls, thread.state), children: messages_1.STATE_LABELS[thread.state] }), thread.state === "review" && thread.reviewReason && (0, jsx_runtime_1.jsx)("span", { children: messages_1.REVIEW_REASON_LABELS[thread.reviewReason] }), thread.assignee && (0, jsx_runtime_1.jsxs)("span", { children: ["Bij ", thread.assignee.name] }), showMailbox && (0, jsx_runtime_1.jsx)("span", { children: thread.mailbox })] })] }) }));
201
+ }
202
+ function ThreadView({ cls, api, fetchImpl, threadId, pollIntervalMs, renderCustomerContext, onBack, onUpdated }) {
203
+ const id = (0, react_1.useId)();
204
+ const headingRef = (0, react_1.useRef)(null);
205
+ const [detail, setDetail] = (0, react_1.useState)(null);
206
+ const [loadError, setLoadError] = (0, react_1.useState)(null);
207
+ const [notice, setNotice] = (0, react_1.useState)(null);
208
+ const [busy, setBusy] = (0, react_1.useState)(false);
209
+ const [text, setText] = (0, react_1.useState)("");
210
+ const [dirty, setDirty] = (0, react_1.useState)(false);
211
+ const bodies = (0, react_1.useRef)(new Map());
212
+ const [, setBodiesVersion] = (0, react_1.useState)(0);
213
+ const apply = (0, react_1.useCallback)((next, keepText) => {
214
+ setDetail(next);
215
+ onUpdated(next);
216
+ if (!keepText) {
217
+ setText(next.draft?.text ?? "");
218
+ setDirty(false);
219
+ }
220
+ }, [onUpdated]);
221
+ const dirtyRef = (0, react_1.useRef)(dirty);
222
+ dirtyRef.current = dirty;
223
+ const detailRef = (0, react_1.useRef)(detail);
224
+ detailRef.current = detail;
225
+ const load = (0, react_1.useCallback)(async () => {
226
+ try {
227
+ const { body } = await api(`threads/${encodeURIComponent(threadId)}`);
228
+ apply(body, dirtyRef.current);
229
+ setLoadError(null);
230
+ }
231
+ catch (error) {
232
+ setLoadError(errorText(error));
233
+ }
234
+ }, [api, threadId, apply]);
235
+ (0, react_1.useEffect)(() => {
236
+ void load();
237
+ }, [load]);
238
+ (0, react_1.useEffect)(() => {
239
+ const timer = setInterval(() => void load(), pollIntervalMs);
240
+ return () => clearInterval(timer);
241
+ }, [load, pollIntervalMs]);
242
+ const loaded = detail !== null;
243
+ (0, react_1.useEffect)(() => {
244
+ if (loaded)
245
+ headingRef.current?.focus();
246
+ }, [loaded]);
247
+ const messageIds = detail?.messages.map((m) => m.id).join(",") ?? "";
248
+ (0, react_1.useEffect)(() => {
249
+ if (!messageIds)
250
+ return;
251
+ let cancelled = false;
252
+ void (async () => {
253
+ for (const messageId of messageIds.split(",")) {
254
+ const cached = bodies.current.get(messageId);
255
+ if (cached && "text" in cached)
256
+ continue;
257
+ try {
258
+ const { body } = await api(`threads/${encodeURIComponent(threadId)}/messages/${encodeURIComponent(messageId)}`);
259
+ bodies.current.set(messageId, { text: body.text, attachments: body.attachments ?? [] });
260
+ }
261
+ catch (error) {
262
+ bodies.current.set(messageId, { error: errorText(error) });
263
+ }
264
+ if (cancelled)
265
+ return;
266
+ setBodiesVersion((v) => v + 1);
267
+ }
268
+ })();
269
+ return () => {
270
+ cancelled = true;
271
+ };
272
+ }, [api, threadId, messageIds]);
273
+ const act = (0, react_1.useCallback)(async (path, init = {}) => {
274
+ setBusy(true);
275
+ setNotice(null);
276
+ try {
277
+ const { body } = await api(`threads/${encodeURIComponent(threadId)}/${path}`, {
278
+ method: init.method ?? "POST",
279
+ body: init.body,
280
+ });
281
+ const draftUnchanged = (body.detail.draft?.text ?? null) === (detailRef.current?.draft?.text ?? null);
282
+ apply(body.detail, dirtyRef.current && draftUnchanged);
283
+ const kind = body.outcome === "unknown_outcome" || body.outcome === "in_progress" ? "warning" : "success";
284
+ setNotice({ kind, text: body.message });
285
+ return body;
286
+ }
287
+ catch (error) {
288
+ setNotice({ kind: "error", text: errorText(error) });
289
+ if (error instanceof ApiError && error.code !== "invalid_input")
290
+ void load();
291
+ throw error;
292
+ }
293
+ finally {
294
+ setBusy(false);
295
+ }
296
+ }, [api, threadId, apply, load]);
297
+ const run = (path, init) => void act(path, init).catch(() => undefined);
298
+ const uploadFile = async (file) => {
299
+ const thread = encodeURIComponent(threadId);
300
+ let target;
301
+ try {
302
+ ({ body: target } = await api(`threads/${thread}/attachments/upload`, {
303
+ method: "POST",
304
+ body: { name: file.name, contentType: file.type || "application/octet-stream", size: file.size },
305
+ }));
306
+ const put = await (fetchImpl ?? fetch)(target.uploadUrl, { method: "PUT", headers: { "Content-Type": target.contentType }, body: file });
307
+ if (!put.ok)
308
+ throw new ApiError("upload_failed", `${file.name} kon niet worden geüpload. Probeer het opnieuw.`, put.status);
309
+ }
310
+ catch (error) {
311
+ setNotice({ kind: "error", text: errorText(error) });
312
+ return;
313
+ }
314
+ await act("attachments", { body: { uploadToken: target.uploadToken } }).catch(() => undefined);
315
+ };
316
+ if (!detail) {
317
+ return ((0, jsx_runtime_1.jsxs)("div", { className: cls.card, children: [(0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.linkButton, onClick: onBack, children: "Terug naar tickets" }) }), loadError ? ((0, jsx_runtime_1.jsx)("p", { role: "alert", className: cls.error, children: loadError })) : ((0, jsx_runtime_1.jsx)("p", { className: cls.muted, "aria-live": "polite", children: "Ticket laden\u2026" }))] }));
318
+ }
319
+ const { thread, pilot, actions } = detail;
320
+ const can = (action) => actions.includes(action);
321
+ const isMine = thread.assignee?.id === detail.viewer.id;
322
+ return ((0, jsx_runtime_1.jsxs)("article", { "aria-labelledby": `${id}-subject`, className: cls.detailPane, "data-thread-state": thread.state, "data-write-mode": pilot.writeMode, children: [(0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.linkButton, onClick: onBack, children: "Terug naar tickets" }) }), (0, jsx_runtime_1.jsxs)("section", { className: cls.card, children: [(0, jsx_runtime_1.jsx)("h2", { id: `${id}-subject`, ref: headingRef, tabIndex: -1, className: cls.heading, children: thread.subject || "(geen onderwerp)" }), (0, jsx_runtime_1.jsxs)("div", { className: cls.badges, children: [(0, jsx_runtime_1.jsx)("span", { className: stateBadgeClass(cls, thread.state), children: messages_1.STATE_LABELS[thread.state] }), thread.state === "review" && thread.reviewReason && ((0, jsx_runtime_1.jsx)("span", { className: cls.badge, children: messages_1.REVIEW_REASON_LABELS[thread.reviewReason] })), (0, jsx_runtime_1.jsx)("span", { className: cls.badge, children: messages_1.WRITE_MODE_LABELS[pilot.writeMode] })] }), (0, jsx_runtime_1.jsxs)("div", { className: cls.meta, children: [(0, jsx_runtime_1.jsxs)("span", { children: ["Klant: ", thread.customerAddress] }), (0, jsx_runtime_1.jsxs)("span", { children: ["Mailbox: ", thread.mailbox] }), (0, jsx_runtime_1.jsxs)("span", { children: ["Laatste activiteit: ", formatDate(thread.lastActivityAt)] }), thread.relatedThreadId && (0, jsx_runtime_1.jsx)("span", { children: "Vervolg op een eerder ticket" }), thread.state === "ignored" && thread.dismissedBy && ((0, jsx_runtime_1.jsxs)("span", { children: ["Geen antwoord nodig, gemarkeerd door ", thread.dismissedBy === detail.viewer.id ? "jou" : "een collega"] }))] }), (0, jsx_runtime_1.jsxs)("div", { className: cls.actions, children: [(0, jsx_runtime_1.jsx)("span", { className: cls.muted, children: thread.assignee ? `Toegewezen aan ${isMine ? "jou" : thread.assignee.name}` : "Niet toegewezen" }), !isMine && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.secondaryButton, disabled: busy, onClick: () => run("assign"), children: "Aan mij toewijzen" })), thread.assignee && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.secondaryButton, disabled: busy, onClick: () => run("assign", { method: "DELETE" }), children: "Toewijzing weghalen" }))] })] }), (0, jsx_runtime_1.jsx)("div", { "aria-live": "polite", role: "status", children: notice && notice.kind !== "error" && (0, jsx_runtime_1.jsx)("p", { className: notice.kind === "success" ? cls.success : cls.warning, children: notice.text }) }), notice?.kind === "error" && ((0, jsx_runtime_1.jsx)("p", { role: "alert", className: cls.error, children: notice.text })), renderCustomerContext && (0, jsx_runtime_1.jsx)("section", { className: cls.context, children: renderCustomerContext(thread) }), (0, jsx_runtime_1.jsxs)("section", { "aria-labelledby": `${id}-messages`, className: cls.card, children: [(0, jsx_runtime_1.jsx)("h3", { id: `${id}-messages`, className: cls.subheading, children: "Berichten" }), (0, jsx_runtime_1.jsx)("ol", { className: cls.messages, children: detail.messages.map((m) => ((0, jsx_runtime_1.jsx)(MessageItem, { cls: cls, message: m, body: bodies.current.get(m.id) }, m.id))) })] }), (0, jsx_runtime_1.jsx)(DraftSection, { cls: cls, detail: detail, busy: busy, text: text, dirty: dirty, onText: (value) => {
323
+ setText(value);
324
+ setDirty(true);
325
+ }, act: act, run: run, can: can, uploadFile: uploadFile }), (0, jsx_runtime_1.jsx)(OtherActions, { cls: cls, busy: busy, can: can, run: run, draftInOutlook: pilot.writeMode === "drafts_in_outlook" && Boolean(detail.draft?.inOutlook) })] }));
326
+ }
327
+ function formatSize(bytes) {
328
+ if (bytes < 1024)
329
+ return `${bytes} B`;
330
+ if (bytes < 1024 * 1024)
331
+ return `${Math.round(bytes / 1024)} kB`;
332
+ return `${(bytes / (1024 * 1024)).toFixed(1).replace(".", ",")} MB`;
333
+ }
334
+ const UNAVAILABLE_TEXT = {
335
+ item: "bijgevoegde mail, open deze in Outlook",
336
+ reference: "link naar een gedeeld bestand, open deze in Outlook",
337
+ too_large: "te groot om hier te openen, open de mail in Outlook",
338
+ };
339
+ function AttachmentList({ cls, attachments }) {
340
+ if (attachments.length === 0)
341
+ return null;
342
+ return ((0, jsx_runtime_1.jsxs)("div", { className: cls.field, children: [(0, jsx_runtime_1.jsx)("span", { className: cls.label, children: attachments.length === 1 ? "Bijlage" : `Bijlagen (${attachments.length})` }), (0, jsx_runtime_1.jsx)("ul", { className: cls.list, children: attachments.map((a) => ((0, jsx_runtime_1.jsxs)("li", { children: [a.href ? ((0, jsx_runtime_1.jsx)("a", { href: a.href, download: a.name, className: cls.linkButton, children: a.name })) : ((0, jsx_runtime_1.jsx)("span", { children: a.name })), " ", (0, jsx_runtime_1.jsxs)("span", { className: cls.muted, children: ["(", formatSize(a.size), a.unavailable ? `, ${UNAVAILABLE_TEXT[a.unavailable]}` : "", ")"] })] }, a.id))) })] }));
343
+ }
344
+ function MessageItem({ cls, message, body, }) {
345
+ return ((0, jsx_runtime_1.jsxs)("li", { className: cls.message, children: [(0, jsx_runtime_1.jsxs)("div", { className: cls.messageHeader, children: [(0, jsx_runtime_1.jsx)("span", { className: cls.subheading, children: message.fromAddress }), (0, jsx_runtime_1.jsx)("span", { className: cls.muted, children: formatDate(message.receivedAt) })] }), (message.noise || message.removed) && ((0, jsx_runtime_1.jsxs)("div", { className: cls.badges, children: [message.noise && (0, jsx_runtime_1.jsx)("span", { className: cls.badge, children: "Automatisch bericht" }), message.removed && (0, jsx_runtime_1.jsx)("span", { className: cls.badge, children: "Niet meer in de Inbox" })] })), body === undefined && (0, jsx_runtime_1.jsx)("p", { className: cls.muted, children: "Bericht laden\u2026" }), body && "error" in body && (0, jsx_runtime_1.jsx)("p", { className: cls.muted, children: body.error }), body && "text" in body && (0, jsx_runtime_1.jsx)("div", { className: cls.messageBody, children: body.text || "(leeg bericht)" }), body && "text" in body && (0, jsx_runtime_1.jsx)(AttachmentList, { cls: cls, attachments: body.attachments })] }));
346
+ }
347
+ function modeExplanation(mode, mailbox) {
348
+ switch (mode) {
349
+ case "read_only":
350
+ return "Deze mailbox staat op alleen lezen. Je kunt het concept lezen en aanpassen, maar er gaat niets naar Outlook en er wordt niets verstuurd.";
351
+ case "drafts_in_app":
352
+ return `Dit concept staat alleen in de app. Het gaat pas naar Outlook als je het goedkeurt, en wordt dan meteen verstuurd vanaf ${mailbox}.`;
353
+ case "drafts_in_outlook":
354
+ return `Dit concept staat ook in Outlook bij Concepten van ${mailbox}. Versturen gebeurt pas als je het goedkeurt.`;
355
+ }
356
+ }
357
+ function ReplyAttachments({ cls, attachments, busy, editable, run, uploadFile, }) {
358
+ const id = (0, react_1.useId)();
359
+ const [uploading, setUploading] = (0, react_1.useState)([]);
360
+ if (!editable && attachments.length === 0)
361
+ return null;
362
+ const choose = async (files) => {
363
+ const list = files ? Array.from(files) : [];
364
+ setUploading(list.map((f) => f.name));
365
+ for (const file of list)
366
+ await uploadFile(file);
367
+ setUploading([]);
368
+ };
369
+ return ((0, jsx_runtime_1.jsxs)("div", { className: cls.field, children: [(0, jsx_runtime_1.jsx)("span", { className: cls.label, children: "Bijlagen" }), attachments.length > 0 && ((0, jsx_runtime_1.jsx)("ul", { className: cls.list, children: attachments.map((a) => ((0, jsx_runtime_1.jsxs)("li", { children: [(0, jsx_runtime_1.jsx)("span", { children: a.name }), " ", (0, jsx_runtime_1.jsxs)("span", { className: cls.muted, children: ["(", formatSize(a.size), ")"] }), " ", editable && !a.sent && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.linkButton, disabled: busy || uploading.length > 0, "aria-label": `${a.name} verwijderen`, onClick: () => run(`attachments/${encodeURIComponent(a.id)}`, { method: "DELETE" }), children: "Verwijderen" }))] }, a.id))) })), uploading.length > 0 && ((0, jsx_runtime_1.jsxs)("p", { className: cls.muted, "aria-live": "polite", children: ["Uploaden: ", uploading.join(", "), "\u2026"] })), editable && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: `${id}-file`, className: cls.secondaryButton, children: "Bijlage toevoegen" }), (0, jsx_runtime_1.jsx)("input", { id: `${id}-file`, type: "file", multiple: true, className: "sr-only", disabled: busy || uploading.length > 0, onChange: (e) => {
370
+ const input = e.currentTarget;
371
+ void choose(input.files).finally(() => {
372
+ input.value = "";
373
+ });
374
+ } }), (0, jsx_runtime_1.jsx)("p", { className: cls.muted, children: "Samen hoogstens 20 MB en 10 bestanden. Ze gaan mee als je het antwoord goedkeurt." })] }))] }));
375
+ }
376
+ function attachmentNote(detail) {
377
+ const n = (detail.replyAttachments ?? []).filter((a) => !a.sent).length;
378
+ return n === 0 ? "" : n === 1 ? ", met 1 bijlage" : `, met ${n} bijlagen`;
379
+ }
380
+ function DraftSection({ cls, detail, busy, text, dirty, onText, act, run, can, uploadFile }) {
381
+ const id = (0, react_1.useId)();
382
+ const [confirming, setConfirming] = (0, react_1.useState)(null);
383
+ const { thread, pilot, draft, send } = detail;
384
+ if (!draft && thread.state !== "review")
385
+ return null;
386
+ const writing = draft?.status === "requested" || draft?.status === "started";
387
+ const sendBlockedReason = pilot.paused
388
+ ? "De helpdesk staat op pauze, daarom kan er nu niets worden verstuurd."
389
+ : pilot.writeMode === "read_only"
390
+ ? "Versturen staat uit: deze mailbox staat op alleen lezen."
391
+ : !draft?.text
392
+ ? "Er is nog geen concept om te versturen."
393
+ : send?.status === "unknown"
394
+ ? "Geef eerst aan of het vorige antwoord is verstuurd."
395
+ : null;
396
+ const sentView = send?.status === "sent" && !draft?.stale;
397
+ const showSend = thread.state === "review" && !sentView;
398
+ const canSend = can("send") && text.trim() !== "";
399
+ const sendNow = async (acceptStale) => {
400
+ try {
401
+ if (dirty)
402
+ await act("draft", { method: "PUT", body: { text } });
403
+ await act("send", { body: acceptStale ? { acceptStale: true } : {} });
404
+ setConfirming(null);
405
+ }
406
+ catch (error) {
407
+ setConfirming(error instanceof ApiError && error.code === "stale_draft" ? "stale" : null);
408
+ }
409
+ };
410
+ return ((0, jsx_runtime_1.jsxs)("section", { "aria-labelledby": `${id}-h`, className: cls.card, children: [(0, jsx_runtime_1.jsx)("h3", { id: `${id}-h`, className: cls.subheading, children: "Antwoord" }), writing && ((0, jsx_runtime_1.jsx)("p", { className: cls.info, "aria-live": "polite", children: "Er wordt een concept geschreven\u2026" })), draft?.status === "failed" && ((0, jsx_runtime_1.jsx)("p", { className: cls.warning, children: can("regenerate")
411
+ ? "Het concept kon niet worden gemaakt. Laat een nieuw concept schrijven, schrijf zelf een antwoord of antwoord vanuit Outlook."
412
+ : "Het concept kon niet worden gemaakt. Schrijf zelf een antwoord of antwoord vanuit Outlook." })), thread.reviewReason === "delivery_failed" && thread.state === "review" && ((0, jsx_runtime_1.jsx)("p", { className: cls.warning, children: "Dit antwoord is niet afgeleverd: het kwam terug als onbestelbaar. De reden staat bij Berichten. Stuur het opnieuw vanuit Outlook, bijvoorbeeld naar een ander adres, en kies daarna Al beantwoord in Outlook. Of kies Geen antwoord nodig." })), !draft && can("edit") && (0, jsx_runtime_1.jsx)("p", { className: cls.info, children: "Er is geen concept. Schrijf hieronder zelf het antwoord." }), draft?.stale && (0, jsx_runtime_1.jsx)("p", { className: cls.warning, children: "Na dit concept heeft de klant opnieuw gemaild. Lees de nieuwste mail voor je verstuurt." }), sentView ? ((0, jsx_runtime_1.jsxs)("div", { className: cls.field, children: [(0, jsx_runtime_1.jsx)("h4", { className: cls.label, children: "Verstuurd antwoord" }), (0, jsx_runtime_1.jsx)("div", { className: cls.messageBody, children: draft?.text || text || "(leeg)" }), (0, jsx_runtime_1.jsxs)("p", { className: cls.muted, children: ["Verstuurd op ", formatDate(send.at), " vanaf ", thread.mailbox, "."] }), (0, jsx_runtime_1.jsx)(ReplyAttachments, { cls: cls, attachments: (detail.replyAttachments ?? []).filter((a) => a.sent), busy: busy, editable: false, run: run, uploadFile: uploadFile })] })) : ((draft?.text !== null && draft?.text !== undefined) || can("edit")) && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("div", { className: cls.field, children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: `${id}-text`, className: cls.label, children: draft?.text ? "Concept-antwoord" : "Jouw antwoord" }), (0, jsx_runtime_1.jsx)("textarea", { id: `${id}-text`, className: cls.textarea, value: text, readOnly: !can("edit"), "aria-describedby": `${id}-mode`, onChange: (e) => onText(e.target.value) })] }), (0, jsx_runtime_1.jsx)("p", { id: `${id}-mode`, className: cls.muted, children: modeExplanation(pilot.writeMode, thread.mailbox) }), can("edit") && ((0, jsx_runtime_1.jsx)("div", { className: cls.actions, children: (0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.secondaryButton, disabled: busy || !dirty || text.trim() === "", onClick: () => run("draft", { method: "PUT", body: { text } }), children: "Concept opslaan" }) }))] })), !sentView && ((0, jsx_runtime_1.jsx)(ReplyAttachments, { cls: cls, attachments: (detail.replyAttachments ?? []).filter((a) => !a.sent), busy: busy, editable: can("attach"), run: run, uploadFile: uploadFile })), send?.status === "unknown" && ((0, jsx_runtime_1.jsxs)("div", { role: "group", "aria-label": "Verzending controleren", className: cls.warning, children: [(0, jsx_runtime_1.jsxs)("p", { children: ["Het is niet zeker of het vorige antwoord is verstuurd. Kijk in Outlook bij Verzonden items van ", thread.mailbox, ". Heeft Microsoft een storing? Dan controleert de helpdesk vanzelf of het concept nog klaarstaat zodra Microsoft weer werkt."] }), (0, jsx_runtime_1.jsxs)("div", { className: cls.actions, children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.secondaryButton, disabled: busy || !can("resolve_send"), onClick: () => run("resolve-send", { body: { outcome: "sent" } }), children: "Het staat in Verzonden items" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.secondaryButton, disabled: busy || !can("resolve_send"), onClick: () => run("resolve-send", { body: { outcome: "not_sent" } }), children: "Het is niet verstuurd" })] })] })), showSend && confirming === null && ((0, jsx_runtime_1.jsxs)("div", { className: cls.actions, children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.primaryButton, disabled: busy || !canSend, "aria-describedby": sendBlockedReason ? `${id}-blocked` : undefined, onClick: () => setConfirming("send"), children: "Goedkeuren en versturen" }), sendBlockedReason && !can("send") && ((0, jsx_runtime_1.jsx)("p", { id: `${id}-blocked`, className: cls.muted, children: sendBlockedReason }))] })), showSend && confirming !== null && ((0, jsx_runtime_1.jsxs)("div", { role: "group", "aria-label": "Versturen bevestigen", className: cls.field, children: [(0, jsx_runtime_1.jsxs)("p", { className: cls.text, children: [confirming === "stale"
413
+ ? "Er is een nieuwere mail van de klant. Wil je dit antwoord toch versturen?"
414
+ : `Dit antwoord gaat nu naar ${thread.customerAddress}, vanaf ${thread.mailbox}${attachmentNote(detail)}.`, dirty ? " Je wijzigingen worden eerst opgeslagen." : ""] }), (0, jsx_runtime_1.jsxs)("div", { className: cls.actions, children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.primaryButton, disabled: busy, onClick: () => void sendNow(confirming === "stale"), children: confirming === "stale" ? "Toch versturen" : "Ja, versturen" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.secondaryButton, disabled: busy, onClick: () => setConfirming(null), children: "Annuleren" })] })] }))] }));
415
+ }
416
+ function OtherActions({ cls, busy, can, run, draftInOutlook, }) {
417
+ const [confirmExternal, setConfirmExternal] = (0, react_1.useState)(false);
418
+ const [confirmDismiss, setConfirmDismiss] = (0, react_1.useState)(false);
419
+ const any = ["regenerate", "retry_placement", "replied_externally", "mark_waiting", "close", "unignore", "dismiss"].some((a) => can(a));
420
+ if (!any)
421
+ return null;
422
+ return ((0, jsx_runtime_1.jsxs)("section", { "aria-label": "Overige acties", className: cls.actions, children: [can("regenerate") && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.secondaryButton, disabled: busy, onClick: () => run("regenerate"), children: "Nieuw concept laten schrijven" })), can("retry_placement") && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.secondaryButton, disabled: busy, onClick: () => run("retry-placement"), children: "Opnieuw in Outlook zetten" })), can("unignore") && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.secondaryButton, disabled: busy, onClick: () => run("unignore"), children: "Toch behandelen" })), can("mark_waiting") && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.secondaryButton, disabled: busy, onClick: () => run("waiting"), children: "Wacht op klant" })), can("close") && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.secondaryButton, disabled: busy, onClick: () => run("close"), children: "Ticket sluiten" })), can("replied_externally") &&
423
+ (confirmExternal ? ((0, jsx_runtime_1.jsxs)("span", { role: "group", "aria-label": "Beantwoord in Outlook bevestigen", className: cls.actions, children: [(0, jsx_runtime_1.jsx)("span", { className: cls.text, children: "Al beantwoord in Outlook? Het concept wordt dan niet verstuurd." }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.dangerButton, disabled: busy, onClick: () => {
424
+ setConfirmExternal(false);
425
+ run("replied-externally");
426
+ }, children: "Ja, al beantwoord" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.secondaryButton, onClick: () => setConfirmExternal(false), children: "Annuleren" })] })) : ((0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.secondaryButton, disabled: busy, onClick: () => setConfirmExternal(true), children: "Al beantwoord in Outlook" }))), can("dismiss") &&
427
+ (confirmDismiss ? ((0, jsx_runtime_1.jsxs)("span", { role: "group", "aria-label": "Geen antwoord nodig bevestigen", className: cls.actions, children: [(0, jsx_runtime_1.jsxs)("span", { className: cls.text, children: ["Geen antwoord nodig? Er wordt niets verstuurd en het ticket gaat naar Genegeerd.", draftInOutlook ? " Het concept in Outlook wordt verwijderd." : ""] }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.dangerButton, disabled: busy, onClick: () => {
428
+ setConfirmDismiss(false);
429
+ run("dismiss");
430
+ }, children: "Ja, geen antwoord nodig" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.secondaryButton, onClick: () => setConfirmDismiss(false), children: "Annuleren" })] })) : ((0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.secondaryButton, disabled: busy, onClick: () => setConfirmDismiss(true), children: "Geen antwoord nodig" })))] }));
431
+ }
@@ -0,0 +1,28 @@
1
+ import type { MailboxAccess } from "./controls";
2
+ import type { HelpdeskDeps, ReplyAttachment } from "./types";
3
+ export declare const MAX_REPLY_ATTACHMENT_BYTES: number;
4
+ export declare const MAX_REPLY_ATTACHMENTS = 10;
5
+ export declare function pendingReplyAttachments(deps: HelpdeskDeps, threadId: string): Promise<ReplyAttachment[]>;
6
+ export declare function startReplyUpload(deps: HelpdeskDeps, input: {
7
+ threadId: string;
8
+ staffId: string;
9
+ name: string;
10
+ contentType: string;
11
+ size: number;
12
+ }): Promise<{
13
+ uploadUrl: string;
14
+ uploadToken: string;
15
+ contentType: string;
16
+ }>;
17
+ export declare function addReplyAttachment(deps: HelpdeskDeps, input: {
18
+ threadId: string;
19
+ staffId: string;
20
+ uploadToken: string;
21
+ }): Promise<ReplyAttachment>;
22
+ export declare function removeReplyAttachment(deps: HelpdeskDeps, input: {
23
+ threadId: string;
24
+ attachmentId: string;
25
+ }): Promise<void>;
26
+ export declare function placeReplyAttachments(deps: HelpdeskDeps, access: MailboxAccess, threadId: string, draftId: string): Promise<void>;
27
+ export declare function markReplyAttachmentsSent(deps: HelpdeskDeps, threadId: string, draftId: string | null, intentId: string): Promise<void>;
28
+ export declare function discardPendingReplyAttachments(deps: HelpdeskDeps, threadId: string): Promise<void>;