@dxv-systems/helpdesk 0.0.0-stage → 0.2.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.
- package/README.md +272 -2
- package/dist/bounce.d.ts +13 -0
- package/dist/bounce.js +79 -0
- package/dist/controls.d.ts +13 -0
- package/dist/controls.js +82 -0
- package/dist/draft-body.d.ts +4 -0
- package/dist/draft-body.js +38 -0
- package/dist/drafting.d.ts +18 -0
- package/dist/drafting.js +243 -0
- package/dist/errors.d.ts +60 -0
- package/dist/errors.js +133 -0
- package/dist/inbox/handlers.d.ts +36 -0
- package/dist/inbox/handlers.js +523 -0
- package/dist/inbox/messages.d.ts +34 -0
- package/dist/inbox/messages.js +66 -0
- package/dist/inbox/plain-text.d.ts +1 -0
- package/dist/inbox/plain-text.js +58 -0
- package/dist/inbox/types.d.ts +116 -0
- package/dist/inbox/types.js +2 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +66 -0
- package/dist/ingest.d.ts +25 -0
- package/dist/ingest.js +335 -0
- package/dist/internal.d.ts +8 -0
- package/dist/internal.js +44 -0
- package/dist/noise.d.ts +17 -0
- package/dist/noise.js +77 -0
- package/dist/react.d.ts +97 -0
- package/dist/react.js +576 -0
- package/dist/reply-attachments.d.ts +28 -0
- package/dist/reply-attachments.js +119 -0
- package/dist/sending.d.ts +34 -0
- package/dist/sending.js +268 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +10 -0
- package/dist/states.d.ts +13 -0
- package/dist/states.js +62 -0
- package/dist/subject.d.ts +2 -0
- package/dist/subject.js +30 -0
- package/dist/testing/index.d.ts +97 -0
- package/dist/testing/index.js +472 -0
- package/dist/threading.d.ts +18 -0
- package/dist/threading.js +66 -0
- package/dist/types.d.ts +339 -0
- package/dist/types.js +2 -0
- package/package.json +71 -4
package/dist/react.js
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
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
|
+
const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background";
|
|
10
|
+
const AVATAR = "flex h-9 w-9 flex-none items-center justify-center rounded-full text-sm font-semibold";
|
|
11
|
+
const BADGE = "whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-semibold";
|
|
12
|
+
const BUBBLE = "flex min-w-0 flex-1 flex-col gap-2.5 rounded-xl border px-4 py-3.5";
|
|
13
|
+
exports.DEFAULT_HELPDESK_INBOX_CLASSNAMES = {
|
|
14
|
+
root: "flex w-full flex-col gap-4 text-sm leading-normal text-foreground",
|
|
15
|
+
layout: "flex w-full flex-col gap-6 md:flex-row md:items-start",
|
|
16
|
+
listPane: "flex min-w-0 flex-col gap-4 md:w-80 md:flex-none lg:w-[340px]",
|
|
17
|
+
detailPane: "flex min-w-0 flex-1 flex-col gap-4",
|
|
18
|
+
hiddenOnMobile: "max-md:hidden",
|
|
19
|
+
title: "text-2xl font-bold leading-tight tracking-tight",
|
|
20
|
+
filters: "grid grid-cols-2 gap-2",
|
|
21
|
+
field: "flex min-w-0 flex-col gap-1",
|
|
22
|
+
fieldWide: "col-span-2 flex min-w-0 flex-col gap-1",
|
|
23
|
+
label: "text-xs font-medium text-muted-foreground",
|
|
24
|
+
selectWrap: "relative",
|
|
25
|
+
select: `h-10 w-full min-w-0 appearance-none rounded-md border border-input bg-background pl-3 pr-9 text-sm text-foreground ${FOCUS}`,
|
|
26
|
+
selectIcon: "pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground",
|
|
27
|
+
list: "flex flex-col gap-1.5",
|
|
28
|
+
listItem: `flex w-full flex-col gap-1 rounded-lg border border-border bg-card px-3.5 py-3 text-left text-card-foreground hover:bg-accent ${FOCUS}`,
|
|
29
|
+
listItemCurrent: `flex w-full flex-col gap-1 rounded-lg border border-ring bg-accent px-3.5 py-3 text-left text-card-foreground ${FOCUS}`,
|
|
30
|
+
listItemTop: "flex items-baseline justify-between gap-2",
|
|
31
|
+
listItemTitle: "min-w-0 font-semibold break-words",
|
|
32
|
+
listItemTime: "whitespace-nowrap text-xs text-muted-foreground",
|
|
33
|
+
listItemMeta: "flex flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground",
|
|
34
|
+
card: "flex flex-col gap-3 rounded-xl border border-border bg-card p-4 text-card-foreground",
|
|
35
|
+
header: "flex flex-col gap-3 border-b border-border pb-4",
|
|
36
|
+
headerTop: "flex flex-wrap items-start justify-between gap-3",
|
|
37
|
+
headerActions: "relative flex flex-wrap items-center gap-2",
|
|
38
|
+
heading: "text-2xl font-bold leading-tight tracking-tight break-words",
|
|
39
|
+
subheading: "text-base font-semibold",
|
|
40
|
+
text: "",
|
|
41
|
+
muted: "text-sm text-muted-foreground",
|
|
42
|
+
meta: "flex flex-wrap items-center gap-2 text-sm text-muted-foreground break-words",
|
|
43
|
+
badge: `${BADGE} bg-muted text-foreground`,
|
|
44
|
+
badgeAttention: `${BADGE} bg-amber-50 text-amber-900 dark:bg-amber-950 dark:text-amber-100`,
|
|
45
|
+
badgeDone: `${BADGE} bg-emerald-50 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200`,
|
|
46
|
+
badgeError: `${BADGE} bg-destructive/10 text-destructive`,
|
|
47
|
+
reasonNote: "text-xs text-muted-foreground",
|
|
48
|
+
toolButton: `inline-flex h-9 items-center gap-2 rounded-md border border-border bg-card px-3 text-sm font-medium hover:bg-accent disabled:opacity-50 ${FOCUS}`,
|
|
49
|
+
assignedChip: "inline-flex h-9 items-center gap-2 rounded-md border border-border px-3 text-sm font-medium text-muted-foreground",
|
|
50
|
+
iconButton: `inline-flex h-9 w-9 items-center justify-center rounded-md border border-border bg-card hover:bg-accent disabled:opacity-50 ${FOCUS}`,
|
|
51
|
+
menu: "absolute right-0 top-full z-20 mt-1 flex min-w-56 flex-col rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md",
|
|
52
|
+
menuItem: "flex min-h-10 w-full items-center rounded-sm px-3 text-left text-sm hover:bg-accent focus-visible:bg-accent focus-visible:outline-none",
|
|
53
|
+
backLink: "inline-flex min-h-10 items-center self-start font-semibold text-primary underline-offset-4 hover:underline md:hidden",
|
|
54
|
+
conversation: "flex flex-col gap-3",
|
|
55
|
+
entry: "flex items-start gap-3",
|
|
56
|
+
entryOurs: "flex flex-row-reverse items-start gap-3",
|
|
57
|
+
avatar: `${AVATAR} bg-secondary text-secondary-foreground`,
|
|
58
|
+
avatarOurs: `${AVATAR} bg-primary text-primary-foreground`,
|
|
59
|
+
avatarEvent: `${AVATAR} bg-destructive/10 text-destructive`,
|
|
60
|
+
message: `${BUBBLE} border-border bg-card text-card-foreground`,
|
|
61
|
+
messageOurs: `${BUBBLE} border-primary/20 bg-primary/5 text-foreground sm:max-w-[85%]`,
|
|
62
|
+
messageEvent: `${BUBBLE} border-destructive/40 bg-destructive/5 text-foreground`,
|
|
63
|
+
messageHeader: "flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1",
|
|
64
|
+
messageBody: "whitespace-pre-wrap break-words leading-relaxed",
|
|
65
|
+
emptyBody: "italic text-muted-foreground",
|
|
66
|
+
eventTitle: "font-semibold text-destructive",
|
|
67
|
+
details: "text-sm text-muted-foreground [&>summary]:cursor-pointer",
|
|
68
|
+
detailsPre: "mt-2 whitespace-pre-wrap break-words rounded-md bg-background px-3 py-2.5 font-mono text-xs text-foreground",
|
|
69
|
+
fileChips: "flex flex-wrap gap-2",
|
|
70
|
+
fileChip: "inline-flex max-w-full items-center gap-2.5 rounded-md border border-border bg-muted/50 px-3 py-2 text-foreground no-underline hover:bg-accent",
|
|
71
|
+
fileChipName: "font-medium break-all",
|
|
72
|
+
fileChipMeta: "text-xs text-muted-foreground",
|
|
73
|
+
sentNote: "inline-flex items-center gap-1.5 text-xs text-muted-foreground",
|
|
74
|
+
composer: "flex flex-col gap-3 rounded-xl border border-ring/40 bg-card p-4 text-card-foreground",
|
|
75
|
+
composerHeader: "flex flex-wrap items-center justify-between gap-2",
|
|
76
|
+
aiChip: "inline-flex items-center gap-1.5 rounded-full bg-accent px-2.5 py-0.5 text-xs text-accent-foreground",
|
|
77
|
+
composerFooter: "flex flex-wrap items-center justify-between gap-3 border-t border-border pt-3",
|
|
78
|
+
replyChip: "inline-flex max-w-full items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1.5",
|
|
79
|
+
chipRemove: `inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 ${FOCUS}`,
|
|
80
|
+
dashedButton: "inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-dashed border-border px-2.5 hover:bg-accent focus-within:ring-2 focus-within:ring-ring",
|
|
81
|
+
srOnly: "sr-only",
|
|
82
|
+
icon: "h-4 w-4 flex-none",
|
|
83
|
+
textarea: `min-h-48 w-full resize-y rounded-md border border-input bg-background px-3.5 py-3 text-base leading-relaxed ${FOCUS} read-only:bg-muted`,
|
|
84
|
+
actions: "flex flex-wrap items-center gap-2",
|
|
85
|
+
primaryButton: `inline-flex min-h-10 items-center justify-center gap-2 rounded-md bg-primary px-4 font-semibold text-primary-foreground hover:bg-primary/90 disabled:opacity-50 ${FOCUS}`,
|
|
86
|
+
secondaryButton: `inline-flex min-h-10 items-center justify-center gap-2 rounded-md border border-border bg-background px-3.5 font-medium hover:bg-accent disabled:opacity-50 ${FOCUS}`,
|
|
87
|
+
dangerButton: `inline-flex min-h-10 items-center justify-center rounded-md border border-destructive bg-background px-3.5 font-semibold text-destructive hover:bg-destructive/10 disabled:opacity-50 ${FOCUS}`,
|
|
88
|
+
linkButton: "inline-flex min-h-10 items-center font-semibold text-primary underline-offset-4 hover:underline",
|
|
89
|
+
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",
|
|
90
|
+
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",
|
|
91
|
+
warning: "rounded-md bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:bg-amber-950 dark:text-amber-100",
|
|
92
|
+
error: "rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive",
|
|
93
|
+
info: "rounded-md bg-muted px-4 py-3 text-sm",
|
|
94
|
+
context: "flex flex-col gap-2",
|
|
95
|
+
};
|
|
96
|
+
class ApiError extends Error {
|
|
97
|
+
constructor(code, message, status) {
|
|
98
|
+
super(message);
|
|
99
|
+
this.code = code;
|
|
100
|
+
this.status = status;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const GENERIC_ERROR = "Er ging iets mis. Probeer het opnieuw.";
|
|
104
|
+
const errorText = (error) => (error instanceof ApiError ? error.message : GENERIC_ERROR);
|
|
105
|
+
function useApi(basePath, fetchImpl) {
|
|
106
|
+
return (0, react_1.useCallback)(async (path, init = {}) => {
|
|
107
|
+
const headers = { Accept: "application/json" };
|
|
108
|
+
if (init.body !== undefined)
|
|
109
|
+
headers["Content-Type"] = "application/json";
|
|
110
|
+
const doFetch = fetchImpl ?? fetch;
|
|
111
|
+
const response = await doFetch(`${basePath.replace(/\/$/, "")}/${path}`, {
|
|
112
|
+
method: init.method ?? "GET",
|
|
113
|
+
headers,
|
|
114
|
+
credentials: "same-origin",
|
|
115
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
116
|
+
});
|
|
117
|
+
const body = (await response.json().catch(() => ({})));
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
const code = typeof body.error === "string" ? body.error : "error";
|
|
120
|
+
const message = typeof body.message === "string" ? body.message : GENERIC_ERROR;
|
|
121
|
+
throw new ApiError(code, message, response.status);
|
|
122
|
+
}
|
|
123
|
+
return { status: response.status, body: body };
|
|
124
|
+
}, [basePath, fetchImpl]);
|
|
125
|
+
}
|
|
126
|
+
const VIEWS = [
|
|
127
|
+
{ value: "review", label: "Te beoordelen", states: ["review"] },
|
|
128
|
+
{ value: "new", label: "Nieuw", states: ["new", "drafted"] },
|
|
129
|
+
{ value: "sent", label: "Verstuurd en wachtend", states: ["sent", "waiting"] },
|
|
130
|
+
{ value: "closed", label: "Gesloten", states: ["closed"] },
|
|
131
|
+
{ value: "ignored", label: "Genegeerd", states: ["ignored"] },
|
|
132
|
+
{ value: "all", label: "Alles", states: null },
|
|
133
|
+
];
|
|
134
|
+
const ASSIGNEE_OPTIONS = [
|
|
135
|
+
{ value: "", label: "Iedereen" },
|
|
136
|
+
{ value: "me", label: "Aan mij" },
|
|
137
|
+
{ value: "none", label: "Niet toegewezen" },
|
|
138
|
+
];
|
|
139
|
+
const timeOfDay = new Intl.DateTimeFormat("nl-NL", { hour: "2-digit", minute: "2-digit" });
|
|
140
|
+
const dayMonth = new Intl.DateTimeFormat("nl-NL", { day: "numeric", month: "short" });
|
|
141
|
+
const dayMonthTime = new Intl.DateTimeFormat("nl-NL", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
|
142
|
+
const formatDate = (iso) => dayMonthTime.format(new Date(iso));
|
|
143
|
+
function listTime(iso) {
|
|
144
|
+
const at = new Date(iso);
|
|
145
|
+
return at.toDateString() === new Date().toDateString() ? timeOfDay.format(at) : dayMonth.format(at);
|
|
146
|
+
}
|
|
147
|
+
function statusBadge(cls, state, reason) {
|
|
148
|
+
if (state === "review" && reason === "delivery_failed") {
|
|
149
|
+
return { label: messages_1.REVIEW_REASON_LABELS.delivery_failed, className: cls.badgeError, note: null };
|
|
150
|
+
}
|
|
151
|
+
if (state === "review") {
|
|
152
|
+
return { label: messages_1.STATE_LABELS.review, className: cls.badgeAttention, note: reason ? messages_1.REVIEW_REASON_LABELS[reason] : null };
|
|
153
|
+
}
|
|
154
|
+
const done = state === "sent" || state === "waiting" || state === "closed";
|
|
155
|
+
return { label: messages_1.STATE_LABELS[state], className: done ? cls.badgeDone : cls.badge, note: null };
|
|
156
|
+
}
|
|
157
|
+
function StatusBadge({ cls, state, reason, withNote }) {
|
|
158
|
+
const b = statusBadge(cls, state, reason);
|
|
159
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: b.className, children: b.label }), withNote && b.note && (0, jsx_runtime_1.jsx)("span", { className: cls.reasonNote, children: b.note })] }));
|
|
160
|
+
}
|
|
161
|
+
function initials(address) {
|
|
162
|
+
const local = address.split("@")[0] ?? "";
|
|
163
|
+
const parts = local.split(/[._+-]+/).filter((p) => /[a-z]/i.test(p));
|
|
164
|
+
const letters = parts.length > 1 ? `${parts[0][0]}${parts[1][0]}` : (parts[0] ?? local).replace(/[^a-z]/gi, "").slice(0, 2);
|
|
165
|
+
return (letters || "?").toUpperCase();
|
|
166
|
+
}
|
|
167
|
+
function formatSize(bytes) {
|
|
168
|
+
if (bytes < 1024)
|
|
169
|
+
return `${bytes} B`;
|
|
170
|
+
if (bytes < 1024 * 1024)
|
|
171
|
+
return `${Math.round(bytes / 1024)} kB`;
|
|
172
|
+
return `${(bytes / (1024 * 1024)).toFixed(1).replace(".", ",")} MB`;
|
|
173
|
+
}
|
|
174
|
+
function fileType(name) {
|
|
175
|
+
const ext = /\.([a-z0-9]{1,5})$/i.exec(name)?.[1];
|
|
176
|
+
return ext ? ext.toUpperCase() : "Bestand";
|
|
177
|
+
}
|
|
178
|
+
const ICON_PATHS = {
|
|
179
|
+
chevron: ["M6 9l6 6 6-6"],
|
|
180
|
+
user: ["M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8z", "M4 21c0-4 4-6 8-6s8 2 8 6"],
|
|
181
|
+
more: ["M5 12h.01", "M12 12h.01", "M19 12h.01"],
|
|
182
|
+
file: ["M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z", "M14 3v5h5"],
|
|
183
|
+
paperclip: ["M21 12.5l-8.5 8.5a5 5 0 0 1-7-7L14 5.5a3.5 3.5 0 0 1 5 5L10.5 19"],
|
|
184
|
+
close: ["M6 6l12 12", "M18 6L6 18"],
|
|
185
|
+
send: ["M22 2L11 13", "M22 2l-7 20-4-9-9-4z"],
|
|
186
|
+
check: ["M20 6L9 17l-5-5"],
|
|
187
|
+
warning: ["M12 9v4", "M12 17h.01", "M10.3 3.9L1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"],
|
|
188
|
+
sparkle: ["M12 3l1.8 4.7L18 9.5l-4.2 1.8L12 16l-1.8-4.7L6 9.5l4.2-1.8z"],
|
|
189
|
+
};
|
|
190
|
+
function Icon({ name, className }) {
|
|
191
|
+
return ((0, jsx_runtime_1.jsx)("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: name === "more" ? 3 : 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", className: className, children: ICON_PATHS[name].map((d) => ((0, jsx_runtime_1.jsx)("path", { d: d }, d))) }));
|
|
192
|
+
}
|
|
193
|
+
function Select({ cls, id, label, value, onChange, wide = false, children, }) {
|
|
194
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: wide ? cls.fieldWide : cls.field, children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: id, className: cls.label, children: label }), (0, jsx_runtime_1.jsxs)("div", { className: cls.selectWrap, children: [(0, jsx_runtime_1.jsx)("select", { id: id, className: cls.select, value: value, onChange: (e) => onChange(e.target.value), children: children }), (0, jsx_runtime_1.jsx)(Icon, { name: "chevron", className: cls.selectIcon })] })] }));
|
|
195
|
+
}
|
|
196
|
+
const syncSince = new Intl.DateTimeFormat("nl-NL", { weekday: "long", hour: "2-digit", minute: "2-digit" });
|
|
197
|
+
function SyncIssueBanner({ cls, issues }) {
|
|
198
|
+
const since = issues.map((i) => i.failingSince).sort()[0];
|
|
199
|
+
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."] }));
|
|
200
|
+
}
|
|
201
|
+
function PausedBanner({ cls }) {
|
|
202
|
+
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 voorstel geschreven en niets verstuurd. Je kunt tickets wel bekijken." }));
|
|
203
|
+
}
|
|
204
|
+
function HelpdeskInbox({ apiBasePath = messages_1.HELPDESK_BASE_PATH, classNames, pollIntervalMs = 30000, fetch: fetchImpl, renderCustomerContext, initialThreadId, onThreadChange, }) {
|
|
205
|
+
const cls = (0, react_1.useMemo)(() => ({ ...exports.DEFAULT_HELPDESK_INBOX_CLASSNAMES, ...classNames }), [classNames]);
|
|
206
|
+
const api = useApi(apiBasePath, fetchImpl);
|
|
207
|
+
const id = (0, react_1.useId)();
|
|
208
|
+
const [view, setView] = (0, react_1.useState)("review");
|
|
209
|
+
const [assignee, setAssignee] = (0, react_1.useState)("");
|
|
210
|
+
const [mailbox, setMailbox] = (0, react_1.useState)("");
|
|
211
|
+
const [list, setList] = (0, react_1.useState)(null);
|
|
212
|
+
const [pages, setPages] = (0, react_1.useState)(1);
|
|
213
|
+
const [listError, setListError] = (0, react_1.useState)(null);
|
|
214
|
+
const [loadingMore, setLoadingMore] = (0, react_1.useState)(false);
|
|
215
|
+
const [selected, setSelected] = (0, react_1.useState)(initialThreadId ?? null);
|
|
216
|
+
const [detailPaused, setDetailPaused] = (0, react_1.useState)(null);
|
|
217
|
+
const interval = Math.max(5000, pollIntervalMs);
|
|
218
|
+
const query = (0, react_1.useCallback)((cursor) => {
|
|
219
|
+
const params = new URLSearchParams();
|
|
220
|
+
const states = VIEWS.find((v) => v.value === view)?.states;
|
|
221
|
+
if (states)
|
|
222
|
+
params.set("state", states.join(","));
|
|
223
|
+
if (assignee)
|
|
224
|
+
params.set("assignee", assignee);
|
|
225
|
+
if (mailbox)
|
|
226
|
+
params.set("mailbox", mailbox);
|
|
227
|
+
if (cursor)
|
|
228
|
+
params.set("cursor", cursor);
|
|
229
|
+
const qs = params.toString();
|
|
230
|
+
return qs ? `threads?${qs}` : "threads";
|
|
231
|
+
}, [view, assignee, mailbox]);
|
|
232
|
+
const loadFirst = (0, react_1.useCallback)(async () => {
|
|
233
|
+
try {
|
|
234
|
+
const { body } = await api(query(null));
|
|
235
|
+
setList(body);
|
|
236
|
+
setPages(1);
|
|
237
|
+
setListError(null);
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
setListError(errorText(error));
|
|
241
|
+
}
|
|
242
|
+
}, [api, query]);
|
|
243
|
+
(0, react_1.useEffect)(() => {
|
|
244
|
+
void loadFirst();
|
|
245
|
+
}, [loadFirst]);
|
|
246
|
+
(0, react_1.useEffect)(() => {
|
|
247
|
+
if (pages > 1)
|
|
248
|
+
return;
|
|
249
|
+
const timer = setInterval(() => void loadFirst(), interval);
|
|
250
|
+
return () => clearInterval(timer);
|
|
251
|
+
}, [loadFirst, interval, pages]);
|
|
252
|
+
const loadMore = async () => {
|
|
253
|
+
if (!list?.nextCursor)
|
|
254
|
+
return;
|
|
255
|
+
setLoadingMore(true);
|
|
256
|
+
try {
|
|
257
|
+
const { body } = await api(query(list.nextCursor));
|
|
258
|
+
setList({ ...body, threads: [...list.threads, ...body.threads] });
|
|
259
|
+
setPages((p) => p + 1);
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
setListError(errorText(error));
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
setLoadingMore(false);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
const select = (threadId) => {
|
|
269
|
+
setSelected(threadId);
|
|
270
|
+
if (threadId === null)
|
|
271
|
+
setDetailPaused(null);
|
|
272
|
+
onThreadChange?.(threadId);
|
|
273
|
+
};
|
|
274
|
+
const viewStates = (0, react_1.useRef)(null);
|
|
275
|
+
viewStates.current = VIEWS.find((v) => v.value === view)?.states ?? null;
|
|
276
|
+
const onThreadUpdated = (0, react_1.useCallback)((detail) => {
|
|
277
|
+
setDetailPaused(detail.pilot.paused);
|
|
278
|
+
const { id: threadId, state, reviewReason, assignee: owner } = detail.thread;
|
|
279
|
+
const outOfView = viewStates.current !== null && !viewStates.current.includes(state);
|
|
280
|
+
setList((current) => current
|
|
281
|
+
? {
|
|
282
|
+
...current,
|
|
283
|
+
threads: current.threads.flatMap((t) => t.id !== threadId ? [t] : outOfView ? [] : [{ ...t, state, reviewReason, assignee: owner }]),
|
|
284
|
+
}
|
|
285
|
+
: current);
|
|
286
|
+
}, []);
|
|
287
|
+
const paused = detailPaused ?? list?.pilot.paused ?? false;
|
|
288
|
+
const mailboxes = list?.pilot.mailboxes ?? [];
|
|
289
|
+
const viewCount = list ? ` (${list.threads.length}${list.nextCursor ? "+" : ""})` : "";
|
|
290
|
+
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.jsx)(Select, { cls: cls, id: `${id}-view`, label: "Status", value: view, onChange: setView, children: VIEWS.map((v) => ((0, jsx_runtime_1.jsxs)("option", { value: v.value, children: [v.label, v.value === view ? viewCount : ""] }, v.value))) }), (0, jsx_runtime_1.jsx)(Select, { cls: cls, id: `${id}-assignee`, label: "Toegewezen", value: assignee, onChange: setAssignee, 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)(Select, { cls: cls, id: `${id}-mailbox`, label: "Mailbox", value: mailbox, onChange: setMailbox, wide: true, 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." })) })] })] }));
|
|
291
|
+
}
|
|
292
|
+
function ThreadListItem({ cls, thread, current, showMailbox, onSelect, }) {
|
|
293
|
+
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.jsxs)("span", { className: cls.listItemTop, children: [(0, jsx_runtime_1.jsx)("span", { className: cls.listItemTitle, children: thread.subject || "(geen onderwerp)" }), (0, jsx_runtime_1.jsx)("time", { dateTime: thread.lastActivityAt, className: cls.listItemTime, children: listTime(thread.lastActivityAt) })] }), (0, jsx_runtime_1.jsx)("span", { className: cls.listItemMeta, children: thread.customerAddress }), (0, jsx_runtime_1.jsxs)("span", { className: cls.listItemMeta, children: [(0, jsx_runtime_1.jsx)(StatusBadge, { cls: cls, state: thread.state, reason: thread.reviewReason, withNote: true }), thread.assignee && (0, jsx_runtime_1.jsxs)("span", { className: cls.reasonNote, children: ["Bij ", thread.assignee.name] }), showMailbox && (0, jsx_runtime_1.jsx)("span", { className: cls.reasonNote, children: thread.mailbox })] })] }) }));
|
|
294
|
+
}
|
|
295
|
+
function ThreadView({ cls, api, fetchImpl, threadId, pollIntervalMs, renderCustomerContext, onBack, onUpdated }) {
|
|
296
|
+
const id = (0, react_1.useId)();
|
|
297
|
+
const headingRef = (0, react_1.useRef)(null);
|
|
298
|
+
const [detail, setDetail] = (0, react_1.useState)(null);
|
|
299
|
+
const [loadError, setLoadError] = (0, react_1.useState)(null);
|
|
300
|
+
const [notice, setNotice] = (0, react_1.useState)(null);
|
|
301
|
+
const [busy, setBusy] = (0, react_1.useState)(false);
|
|
302
|
+
const [text, setText] = (0, react_1.useState)("");
|
|
303
|
+
const [dirty, setDirty] = (0, react_1.useState)(false);
|
|
304
|
+
const [confirming, setConfirming] = (0, react_1.useState)(null);
|
|
305
|
+
const bodies = (0, react_1.useRef)(new Map());
|
|
306
|
+
const [, setBodiesVersion] = (0, react_1.useState)(0);
|
|
307
|
+
const apply = (0, react_1.useCallback)((next, keepText) => {
|
|
308
|
+
setDetail(next);
|
|
309
|
+
onUpdated(next);
|
|
310
|
+
if (!keepText) {
|
|
311
|
+
setText(next.draft?.text ?? "");
|
|
312
|
+
setDirty(false);
|
|
313
|
+
}
|
|
314
|
+
}, [onUpdated]);
|
|
315
|
+
const dirtyRef = (0, react_1.useRef)(dirty);
|
|
316
|
+
dirtyRef.current = dirty;
|
|
317
|
+
const detailRef = (0, react_1.useRef)(detail);
|
|
318
|
+
detailRef.current = detail;
|
|
319
|
+
const load = (0, react_1.useCallback)(async () => {
|
|
320
|
+
try {
|
|
321
|
+
const { body } = await api(`threads/${encodeURIComponent(threadId)}`);
|
|
322
|
+
apply(body, dirtyRef.current);
|
|
323
|
+
setLoadError(null);
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
setLoadError(errorText(error));
|
|
327
|
+
}
|
|
328
|
+
}, [api, threadId, apply]);
|
|
329
|
+
(0, react_1.useEffect)(() => {
|
|
330
|
+
void load();
|
|
331
|
+
}, [load]);
|
|
332
|
+
(0, react_1.useEffect)(() => {
|
|
333
|
+
const timer = setInterval(() => void load(), pollIntervalMs);
|
|
334
|
+
return () => clearInterval(timer);
|
|
335
|
+
}, [load, pollIntervalMs]);
|
|
336
|
+
const loaded = detail !== null;
|
|
337
|
+
(0, react_1.useEffect)(() => {
|
|
338
|
+
if (loaded)
|
|
339
|
+
headingRef.current?.focus();
|
|
340
|
+
}, [loaded]);
|
|
341
|
+
const messageIds = detail?.messages.map((m) => m.id).join(",") ?? "";
|
|
342
|
+
(0, react_1.useEffect)(() => {
|
|
343
|
+
if (!messageIds)
|
|
344
|
+
return;
|
|
345
|
+
let cancelled = false;
|
|
346
|
+
void (async () => {
|
|
347
|
+
for (const messageId of messageIds.split(",")) {
|
|
348
|
+
const cached = bodies.current.get(messageId);
|
|
349
|
+
if (cached && "text" in cached)
|
|
350
|
+
continue;
|
|
351
|
+
try {
|
|
352
|
+
const { body } = await api(`threads/${encodeURIComponent(threadId)}/messages/${encodeURIComponent(messageId)}`);
|
|
353
|
+
bodies.current.set(messageId, { text: body.text, attachments: body.attachments ?? [], bounce: body.bounce ?? null });
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
bodies.current.set(messageId, { error: errorText(error) });
|
|
357
|
+
}
|
|
358
|
+
if (cancelled)
|
|
359
|
+
return;
|
|
360
|
+
setBodiesVersion((v) => v + 1);
|
|
361
|
+
}
|
|
362
|
+
})();
|
|
363
|
+
return () => {
|
|
364
|
+
cancelled = true;
|
|
365
|
+
};
|
|
366
|
+
}, [api, threadId, messageIds]);
|
|
367
|
+
const act = (0, react_1.useCallback)(async (path, init = {}) => {
|
|
368
|
+
setBusy(true);
|
|
369
|
+
setNotice(null);
|
|
370
|
+
try {
|
|
371
|
+
const { body } = await api(`threads/${encodeURIComponent(threadId)}/${path}`, {
|
|
372
|
+
method: init.method ?? "POST",
|
|
373
|
+
body: init.body,
|
|
374
|
+
});
|
|
375
|
+
const draftUnchanged = (body.detail.draft?.text ?? null) === (detailRef.current?.draft?.text ?? null);
|
|
376
|
+
apply(body.detail, dirtyRef.current && draftUnchanged);
|
|
377
|
+
const kind = body.outcome === "unknown_outcome" || body.outcome === "in_progress" ? "warning" : "success";
|
|
378
|
+
setNotice({ kind, text: body.message });
|
|
379
|
+
return body;
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
setNotice({ kind: "error", text: errorText(error) });
|
|
383
|
+
if (error instanceof ApiError && error.code !== "invalid_input")
|
|
384
|
+
void load();
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
setBusy(false);
|
|
389
|
+
}
|
|
390
|
+
}, [api, threadId, apply, load]);
|
|
391
|
+
const run = (path, init) => void act(path, init).catch(() => undefined);
|
|
392
|
+
const uploadFile = async (file) => {
|
|
393
|
+
const thread = encodeURIComponent(threadId);
|
|
394
|
+
let target;
|
|
395
|
+
try {
|
|
396
|
+
({ body: target } = await api(`threads/${thread}/attachments/upload`, {
|
|
397
|
+
method: "POST",
|
|
398
|
+
body: { name: file.name, contentType: file.type || "application/octet-stream", size: file.size },
|
|
399
|
+
}));
|
|
400
|
+
const put = await (fetchImpl ?? fetch)(target.uploadUrl, { method: "PUT", headers: { "Content-Type": target.contentType }, body: file });
|
|
401
|
+
if (!put.ok)
|
|
402
|
+
throw new ApiError("upload_failed", `${file.name} kon niet worden geüpload. Probeer het opnieuw.`, put.status);
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
setNotice({ kind: "error", text: errorText(error) });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
await act("attachments", { body: { uploadToken: target.uploadToken } }).catch(() => undefined);
|
|
409
|
+
};
|
|
410
|
+
if (!detail) {
|
|
411
|
+
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" }))] }));
|
|
412
|
+
}
|
|
413
|
+
const { thread, pilot, draft, send } = detail;
|
|
414
|
+
const can = (action) => detail.actions.includes(action);
|
|
415
|
+
const bounced = thread.state === "review" && thread.reviewReason === "delivery_failed";
|
|
416
|
+
const sentReply = send?.status === "sent" && draft?.text && !draft.stale ? { text: draft.text, at: send.at } : null;
|
|
417
|
+
const showComposer = !bounced && !sentReply && (draft !== null || thread.state === "review");
|
|
418
|
+
const menuItems = [];
|
|
419
|
+
if (!bounced && can("dismiss"))
|
|
420
|
+
menuItems.push({ key: "dismiss", label: "Geen antwoord nodig", onSelect: () => setConfirming("dismiss") });
|
|
421
|
+
if (!bounced && can("replied_externally")) {
|
|
422
|
+
menuItems.push({ key: "external", label: "Al beantwoord in Outlook", onSelect: () => setConfirming("external") });
|
|
423
|
+
}
|
|
424
|
+
if (thread.assignee)
|
|
425
|
+
menuItems.push({ key: "unassign", label: "Toewijzing weghalen", onSelect: () => run("assign", { method: "DELETE" }) });
|
|
426
|
+
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.jsxs)("header", { className: cls.header, children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.backLink, onClick: onBack, children: "Terug naar tickets" }), (0, jsx_runtime_1.jsxs)("div", { className: cls.headerTop, children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex min-w-0 flex-col gap-1.5", 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.meta, children: [(0, jsx_runtime_1.jsx)(StatusBadge, { cls: cls, state: thread.state, reason: thread.reviewReason, withNote: false }), pilot.writeMode === "read_only" && (0, jsx_runtime_1.jsx)("span", { className: cls.badge, children: "Alleen lezen" }), (0, jsx_runtime_1.jsx)("span", { children: thread.customerAddress }), (0, jsx_runtime_1.jsx)("span", { "aria-hidden": "true", children: "\u00B7" }), (0, jsx_runtime_1.jsxs)("span", { children: ["naar ", thread.mailbox] })] }), (thread.relatedThreadId || (thread.state === "ignored" && thread.dismissedBy)) && ((0, jsx_runtime_1.jsxs)("div", { className: cls.meta, children: [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.headerActions, children: [(0, jsx_runtime_1.jsx)(Assignment, { cls: cls, detail: detail, busy: busy, run: run }), menuItems.length > 0 && (0, jsx_runtime_1.jsx)(MoreActions, { cls: cls, busy: busy, items: menuItems })] })] }), confirming && ((0, jsx_runtime_1.jsx)(ConfirmAction, { cls: cls, kind: confirming, busy: busy, onCancel: () => setConfirming(null), onConfirm: () => {
|
|
427
|
+
setConfirming(null);
|
|
428
|
+
run(confirming === "dismiss" ? "dismiss" : "replied-externally");
|
|
429
|
+
} }))] }), (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.jsx)(Conversation, { cls: cls, detail: detail, bodies: bodies.current, sentReply: sentReply }), bounced && (can("replied_externally") || can("dismiss")) && (0, jsx_runtime_1.jsx)(BouncedNextSteps, { cls: cls, busy: busy, can: can, run: run }), showComposer && ((0, jsx_runtime_1.jsx)(Composer, { cls: cls, detail: detail, busy: busy, text: text, dirty: dirty, onText: (value) => {
|
|
430
|
+
setText(value);
|
|
431
|
+
setDirty(true);
|
|
432
|
+
}, act: act, run: run, can: can, uploadFile: uploadFile })), showComposer && thread.state === "review" && (can("dismiss") || can("replied_externally")) && ((0, jsx_runtime_1.jsxs)("p", { className: cls.muted, children: ["Geen antwoord nodig of al vanuit Outlook beantwoord? Kies het bij ", (0, jsx_runtime_1.jsx)("strong", { className: "font-medium text-foreground", children: "Meer acties" }), " ", "bovenaan."] })), (0, jsx_runtime_1.jsx)(OtherActions, { cls: cls, busy: busy, can: can, run: run })] }));
|
|
433
|
+
}
|
|
434
|
+
function Assignment({ cls, detail, busy, run }) {
|
|
435
|
+
const owner = detail.thread.assignee;
|
|
436
|
+
if (owner && owner.id === detail.viewer.id) {
|
|
437
|
+
return ((0, jsx_runtime_1.jsxs)("span", { className: cls.assignedChip, children: [(0, jsx_runtime_1.jsx)(Icon, { name: "user", className: cls.icon }), "Toegewezen aan jou"] }));
|
|
438
|
+
}
|
|
439
|
+
return ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: cls.toolButton, disabled: busy, onClick: () => run("assign"), children: [(0, jsx_runtime_1.jsx)(Icon, { name: "user", className: cls.icon }), owner ? `Toegewezen aan ${owner.name} · Aan mij` : "Niet toegewezen · Aan mij"] }));
|
|
440
|
+
}
|
|
441
|
+
function MoreActions({ cls, busy, items }) {
|
|
442
|
+
const id = (0, react_1.useId)();
|
|
443
|
+
const [open, setOpen] = (0, react_1.useState)(false);
|
|
444
|
+
const wrapRef = (0, react_1.useRef)(null);
|
|
445
|
+
const toggleRef = (0, react_1.useRef)(null);
|
|
446
|
+
const firstRef = (0, react_1.useRef)(null);
|
|
447
|
+
(0, react_1.useEffect)(() => {
|
|
448
|
+
if (!open)
|
|
449
|
+
return;
|
|
450
|
+
firstRef.current?.focus();
|
|
451
|
+
const onPointer = (e) => {
|
|
452
|
+
if (wrapRef.current && !wrapRef.current.contains(e.target))
|
|
453
|
+
setOpen(false);
|
|
454
|
+
};
|
|
455
|
+
document.addEventListener("mousedown", onPointer);
|
|
456
|
+
return () => document.removeEventListener("mousedown", onPointer);
|
|
457
|
+
}, [open]);
|
|
458
|
+
return ((0, jsx_runtime_1.jsxs)("div", { ref: wrapRef, className: "relative", onKeyDown: (e) => {
|
|
459
|
+
if (e.key === "Escape" && open) {
|
|
460
|
+
setOpen(false);
|
|
461
|
+
toggleRef.current?.focus();
|
|
462
|
+
}
|
|
463
|
+
}, children: [(0, jsx_runtime_1.jsx)("button", { ref: toggleRef, type: "button", className: cls.iconButton, "aria-label": "Meer acties", "aria-expanded": open, "aria-controls": `${id}-menu`, disabled: busy, onClick: () => setOpen((o) => !o), children: (0, jsx_runtime_1.jsx)(Icon, { name: "more", className: cls.icon }) }), open && ((0, jsx_runtime_1.jsx)("div", { id: `${id}-menu`, className: cls.menu, children: items.map((item, i) => ((0, jsx_runtime_1.jsx)("button", { ref: i === 0 ? firstRef : undefined, type: "button", className: cls.menuItem, onClick: () => {
|
|
464
|
+
setOpen(false);
|
|
465
|
+
item.onSelect();
|
|
466
|
+
}, children: item.label }, item.key))) }))] }));
|
|
467
|
+
}
|
|
468
|
+
function ConfirmAction({ cls, kind, busy, onConfirm, onCancel, }) {
|
|
469
|
+
const external = kind === "external";
|
|
470
|
+
return ((0, jsx_runtime_1.jsxs)("div", { role: "group", "aria-label": external ? "Beantwoord in Outlook bevestigen" : "Geen antwoord nodig bevestigen", className: cls.actions, children: [(0, jsx_runtime_1.jsx)("span", { className: cls.text, children: external
|
|
471
|
+
? "Al beantwoord in Outlook? Het antwoord in de helpdesk wordt dan niet verstuurd."
|
|
472
|
+
: "Geen antwoord nodig? Er wordt niets verstuurd en het ticket gaat naar Genegeerd." }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.dangerButton, disabled: busy, onClick: onConfirm, children: external ? "Ja, al beantwoord" : "Ja, geen antwoord nodig" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.secondaryButton, onClick: onCancel, autoFocus: true, children: "Annuleren" })] }));
|
|
473
|
+
}
|
|
474
|
+
const UNAVAILABLE_TEXT = {
|
|
475
|
+
item: "bijgevoegde mail, open deze in Outlook",
|
|
476
|
+
reference: "link naar een gedeeld bestand, open deze in Outlook",
|
|
477
|
+
too_large: "te groot om hier te openen, open de mail in Outlook",
|
|
478
|
+
};
|
|
479
|
+
function FileChips({ cls, attachments }) {
|
|
480
|
+
if (attachments.length === 0)
|
|
481
|
+
return null;
|
|
482
|
+
return ((0, jsx_runtime_1.jsx)("ul", { "aria-label": attachments.length === 1 ? "Bijlage" : `Bijlagen (${attachments.length})`, className: cls.fileChips, children: attachments.map((a) => {
|
|
483
|
+
const type = a.kind === "item" ? "Mail" : a.kind === "reference" ? "Link" : fileType(a.name);
|
|
484
|
+
const content = ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(Icon, { name: "file", className: cls.icon }), (0, jsx_runtime_1.jsxs)("span", { className: "flex min-w-0 flex-col leading-snug", children: [(0, jsx_runtime_1.jsx)("span", { className: cls.fileChipName, children: a.name }), (0, jsx_runtime_1.jsxs)("span", { className: cls.fileChipMeta, children: [type, " \u00B7 ", formatSize(a.size), a.unavailable ? `, ${UNAVAILABLE_TEXT[a.unavailable]}` : ""] })] })] }));
|
|
485
|
+
return ((0, jsx_runtime_1.jsx)("li", { className: "max-w-full", children: a.href ? ((0, jsx_runtime_1.jsx)("a", { href: a.href, download: a.name, className: cls.fileChip, children: content })) : ((0, jsx_runtime_1.jsx)("span", { className: cls.fileChip, children: content })) }, a.id));
|
|
486
|
+
}) }));
|
|
487
|
+
}
|
|
488
|
+
function Conversation({ cls, detail, bodies, sentReply, }) {
|
|
489
|
+
const entries = detail.messages.map((m) => ({ kind: m.bounce ? "bounce" : "mail", at: m.receivedAt, message: m }));
|
|
490
|
+
if (sentReply)
|
|
491
|
+
entries.push({ kind: "reply", at: sentReply.at, text: sentReply.text });
|
|
492
|
+
entries.sort((a, b) => a.at.localeCompare(b.at));
|
|
493
|
+
const sentFiles = (detail.replyAttachments ?? []).filter((a) => a.sent);
|
|
494
|
+
return ((0, jsx_runtime_1.jsx)("ol", { "aria-label": "Gesprek", className: cls.conversation, children: entries.map((e) => e.kind === "reply" ? ((0, jsx_runtime_1.jsxs)("li", { className: cls.entryOurs, children: [(0, jsx_runtime_1.jsx)("span", { className: cls.avatarOurs, children: (0, jsx_runtime_1.jsx)(Icon, { name: "send", className: cls.icon }) }), (0, jsx_runtime_1.jsxs)("article", { className: cls.messageOurs, children: [(0, jsx_runtime_1.jsxs)("div", { className: cls.messageHeader, children: [(0, jsx_runtime_1.jsxs)("span", { children: [(0, jsx_runtime_1.jsx)("strong", { className: "font-semibold", children: "Jouw antwoord" }), " ", (0, jsx_runtime_1.jsxs)("span", { className: "text-muted-foreground", children: ["\u00B7 vanaf ", detail.thread.mailbox] })] }), (0, jsx_runtime_1.jsx)("time", { dateTime: e.at, className: cls.muted, children: formatDate(e.at) })] }), (0, jsx_runtime_1.jsx)("div", { className: cls.messageBody, children: e.text }), sentFiles.length > 0 && ((0, jsx_runtime_1.jsx)("ul", { "aria-label": "Verstuurde bijlagen", className: cls.fileChips, children: sentFiles.map((a) => ((0, jsx_runtime_1.jsxs)("li", { className: cls.replyChip, children: [(0, jsx_runtime_1.jsx)("span", { className: "break-all", children: a.name }), " ", (0, jsx_runtime_1.jsx)("span", { className: cls.fileChipMeta, children: formatSize(a.size) })] }, a.id))) })), (0, jsx_runtime_1.jsxs)("span", { className: cls.sentNote, children: [(0, jsx_runtime_1.jsx)(Icon, { name: "check", className: cls.icon }), "Verstuurd, staat in Verzonden items"] })] })] }, "reply")) : e.kind === "bounce" ? ((0, jsx_runtime_1.jsx)(BounceItem, { cls: cls, message: e.message, body: bodies.get(e.message.id) }, e.message.id)) : ((0, jsx_runtime_1.jsx)(MessageItem, { cls: cls, message: e.message, body: bodies.get(e.message.id) }, e.message.id))) }));
|
|
495
|
+
}
|
|
496
|
+
function MessageItem({ cls, message, body }) {
|
|
497
|
+
return ((0, jsx_runtime_1.jsxs)("li", { className: cls.entry, children: [(0, jsx_runtime_1.jsx)("span", { className: cls.avatar, "aria-hidden": "true", children: initials(message.fromAddress) }), (0, jsx_runtime_1.jsxs)("article", { className: cls.message, children: [(0, jsx_runtime_1.jsxs)("div", { className: cls.messageHeader, children: [(0, jsx_runtime_1.jsxs)("span", { className: "min-w-0 break-words", children: [(0, jsx_runtime_1.jsx)("strong", { className: "font-semibold", children: message.fromAddress }), " ", (0, jsx_runtime_1.jsx)("span", { className: "text-muted-foreground", children: "\u00B7 klant" }), message.noise && (0, jsx_runtime_1.jsx)("span", { className: "text-muted-foreground", children: " \u00B7 automatisch bericht" }), message.removed && (0, jsx_runtime_1.jsx)("span", { className: "text-muted-foreground", children: " \u00B7 niet meer in de Inbox" })] }), (0, jsx_runtime_1.jsx)("time", { dateTime: message.receivedAt, className: cls.muted, children: formatDate(message.receivedAt) })] }), 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 &&
|
|
498
|
+
(body.text ? ((0, jsx_runtime_1.jsx)("div", { className: cls.messageBody, children: body.text })) : ((0, jsx_runtime_1.jsx)("p", { className: cls.emptyBody, children: body.attachments.length > 0 ? "Geen tekst, alleen een bijlage." : "Geen tekst." }))), body && "text" in body && (0, jsx_runtime_1.jsx)(FileChips, { cls: cls, attachments: body.attachments })] })] }));
|
|
499
|
+
}
|
|
500
|
+
function BounceItem({ cls, message, body }) {
|
|
501
|
+
const report = body && "text" in body ? body.bounce : null;
|
|
502
|
+
const reason = report?.reason ?? "De ontvangende server weigerde het bericht.";
|
|
503
|
+
const diagnostic = report ? report.diagnostic : body && "text" in body ? body.text : "";
|
|
504
|
+
return ((0, jsx_runtime_1.jsxs)("li", { className: cls.entry, "data-bounce": "", children: [(0, jsx_runtime_1.jsx)("span", { className: cls.avatarEvent, children: (0, jsx_runtime_1.jsx)(Icon, { name: "warning", className: cls.icon }) }), (0, jsx_runtime_1.jsxs)("div", { className: cls.messageEvent, children: [(0, jsx_runtime_1.jsxs)("div", { className: cls.messageHeader, children: [(0, jsx_runtime_1.jsx)("strong", { className: cls.eventTitle, children: "Jouw antwoord is niet afgeleverd" }), (0, jsx_runtime_1.jsx)("time", { dateTime: message.receivedAt, className: cls.muted, children: formatDate(message.receivedAt) })] }), 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)("p", { children: reason }), diagnostic && ((0, jsx_runtime_1.jsxs)("details", { className: cls.details, children: [(0, jsx_runtime_1.jsx)("summary", { children: "Technische details van Microsoft" }), (0, jsx_runtime_1.jsx)("pre", { className: cls.detailsPre, children: diagnostic })] }))] })] }));
|
|
505
|
+
}
|
|
506
|
+
function BouncedNextSteps({ cls, busy, can, run }) {
|
|
507
|
+
const id = (0, react_1.useId)();
|
|
508
|
+
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: "Wat nu?" }), (0, jsx_runtime_1.jsx)("p", { className: cls.text, children: "De klant heeft je antwoord niet. Stuur het opnieuw vanuit Outlook, bijvoorbeeld naar een ander adres van de klant, of sluit het ticket als het niet meer nodig is." }), (0, jsx_runtime_1.jsxs)("div", { className: cls.actions, children: [can("replied_externally") && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.primaryButton, disabled: busy, onClick: () => run("replied-externally"), children: "Opnieuw verstuurd vanuit Outlook" })), can("dismiss") && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.secondaryButton, disabled: busy, onClick: () => run("dismiss"), children: "Geen antwoord meer nodig" }))] })] }));
|
|
509
|
+
}
|
|
510
|
+
function ReplyAttachments({ cls, attachments, busy, editable, run, uploadFile, }) {
|
|
511
|
+
const id = (0, react_1.useId)();
|
|
512
|
+
const [uploading, setUploading] = (0, react_1.useState)([]);
|
|
513
|
+
if (!editable && attachments.length === 0)
|
|
514
|
+
return null;
|
|
515
|
+
const choose = async (files) => {
|
|
516
|
+
const list = files ? Array.from(files) : [];
|
|
517
|
+
setUploading(list.map((f) => f.name));
|
|
518
|
+
for (const file of list)
|
|
519
|
+
await uploadFile(file);
|
|
520
|
+
setUploading([]);
|
|
521
|
+
};
|
|
522
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: cls.actions, children: [attachments.length > 0 && ((0, jsx_runtime_1.jsx)("ul", { "aria-label": "Bijlagen bij je antwoord", className: cls.fileChips, children: attachments.map((a) => ((0, jsx_runtime_1.jsxs)("li", { className: cls.replyChip, children: [(0, jsx_runtime_1.jsx)("span", { className: "break-all", children: a.name }), " ", (0, jsx_runtime_1.jsx)("span", { className: cls.fileChipMeta, children: formatSize(a.size) }), editable && !a.sent && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.chipRemove, disabled: busy || uploading.length > 0, "aria-label": `${a.name} verwijderen`, onClick: () => run(`attachments/${encodeURIComponent(a.id)}`, { method: "DELETE" }), children: (0, jsx_runtime_1.jsx)(Icon, { name: "close", className: "h-3.5 w-3.5" }) }))] }, 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.jsxs)("label", { htmlFor: `${id}-file`, className: cls.dashedButton, children: [(0, jsx_runtime_1.jsx)(Icon, { name: "paperclip", className: "h-3.5 w-3.5 flex-none" }), "Bijlage toevoegen", (0, jsx_runtime_1.jsx)("input", { id: `${id}-file`, type: "file", multiple: true, className: cls.srOnly, disabled: busy || uploading.length > 0, onChange: (e) => {
|
|
523
|
+
const input = e.currentTarget;
|
|
524
|
+
void choose(input.files).finally(() => {
|
|
525
|
+
input.value = "";
|
|
526
|
+
});
|
|
527
|
+
} })] }), (0, jsx_runtime_1.jsx)("span", { className: cls.reasonNote, children: "Max. 10 bestanden, samen 20 MB" })] }))] }));
|
|
528
|
+
}
|
|
529
|
+
function attachmentNote(detail) {
|
|
530
|
+
const n = (detail.replyAttachments ?? []).filter((a) => !a.sent).length;
|
|
531
|
+
return n === 0 ? "" : n === 1 ? ", met 1 bijlage" : `, met ${n} bijlagen`;
|
|
532
|
+
}
|
|
533
|
+
function footerNote(mode, mailbox) {
|
|
534
|
+
return mode === "read_only"
|
|
535
|
+
? "Deze mailbox staat op alleen lezen. Je kunt het antwoord lezen en aanpassen, maar er wordt niets verstuurd."
|
|
536
|
+
: `Wordt verstuurd vanaf ${mailbox}.`;
|
|
537
|
+
}
|
|
538
|
+
function Composer({ cls, detail, busy, text, dirty, onText, act, run, can, uploadFile }) {
|
|
539
|
+
const id = (0, react_1.useId)();
|
|
540
|
+
const [confirming, setConfirming] = (0, react_1.useState)(null);
|
|
541
|
+
const { thread, pilot, draft, send } = detail;
|
|
542
|
+
const writing = draft?.status === "requested" || draft?.status === "started";
|
|
543
|
+
const sendBlockedReason = pilot.paused
|
|
544
|
+
? "De helpdesk staat op pauze, daarom kan er nu niets worden verstuurd."
|
|
545
|
+
: pilot.writeMode === "read_only"
|
|
546
|
+
? "Versturen staat uit: deze mailbox staat op alleen lezen."
|
|
547
|
+
: !draft?.text
|
|
548
|
+
? "Er is nog geen antwoord om te versturen."
|
|
549
|
+
: send?.status === "unknown"
|
|
550
|
+
? "Geef eerst aan of het vorige antwoord is verstuurd."
|
|
551
|
+
: null;
|
|
552
|
+
const showSend = thread.state === "review";
|
|
553
|
+
const canSend = can("send") && text.trim() !== "";
|
|
554
|
+
const showText = (draft?.text !== null && draft?.text !== undefined) || can("edit");
|
|
555
|
+
const sendNow = async (acceptStale) => {
|
|
556
|
+
try {
|
|
557
|
+
if (dirty)
|
|
558
|
+
await act("draft", { method: "PUT", body: { text } });
|
|
559
|
+
await act("send", { body: acceptStale ? { acceptStale: true } : {} });
|
|
560
|
+
setConfirming(null);
|
|
561
|
+
}
|
|
562
|
+
catch (error) {
|
|
563
|
+
setConfirming(error instanceof ApiError && error.code === "stale_draft" ? "stale" : null);
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
return ((0, jsx_runtime_1.jsxs)("section", { "aria-labelledby": `${id}-h`, className: cls.composer, children: [(0, jsx_runtime_1.jsxs)("div", { className: cls.composerHeader, children: [(0, jsx_runtime_1.jsx)("h3", { id: `${id}-h`, className: cls.subheading, children: "Jouw antwoord" }), draft?.fromDrafter && ((0, jsx_runtime_1.jsxs)("span", { className: cls.aiChip, children: [(0, jsx_runtime_1.jsx)(Icon, { name: "sparkle", className: "h-3.5 w-3.5 flex-none" }), "Voorstel van de AI \u00B7 pas aan waar nodig"] }))] }), writing && ((0, jsx_runtime_1.jsx)("p", { className: cls.info, "aria-live": "polite", children: "Er wordt een voorstel geschreven\u2026" })), draft?.status === "failed" && ((0, jsx_runtime_1.jsx)("p", { className: cls.warning, children: can("regenerate")
|
|
567
|
+
? "Het voorstel kon niet worden gemaakt. Laat een nieuw voorstel schrijven, schrijf zelf een antwoord of antwoord vanuit Outlook."
|
|
568
|
+
: "Het voorstel kon niet worden gemaakt. Schrijf zelf een antwoord of antwoord vanuit Outlook." })), !draft && can("edit") && (0, jsx_runtime_1.jsx)("p", { className: cls.info, children: "Er is geen voorstel. Schrijf hieronder zelf het antwoord." }), draft?.stale && (0, jsx_runtime_1.jsx)("p", { className: cls.warning, children: "Na dit voorstel heeft de klant opnieuw gemaild. Lees de nieuwste mail voor je verstuurt." }), showText && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("label", { htmlFor: `${id}-text`, className: cls.srOnly, children: ["Antwoord aan ", thread.customerAddress] }), (0, jsx_runtime_1.jsx)("textarea", { id: `${id}-text`, className: cls.textarea, rows: 7, value: text, readOnly: !can("edit"), "aria-describedby": `${id}-mode`, onChange: (e) => onText(e.target.value) })] })), (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 antwoord 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" })] })] })), (showText || showSend) && ((0, jsx_runtime_1.jsxs)("div", { className: cls.composerFooter, children: [(0, jsx_runtime_1.jsx)("p", { id: `${id}-mode`, className: cls.muted, children: footerNote(pilot.writeMode, thread.mailbox) }), confirming === null && ((0, jsx_runtime_1.jsxs)("div", { className: cls.actions, children: [can("regenerate") && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.secondaryButton, disabled: busy, onClick: () => run("regenerate"), children: "Nieuw voorstel" })), can("edit") && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.secondaryButton, disabled: busy || !dirty || text.trim() === "", onClick: () => run("draft", { method: "PUT", body: { text } }), children: "Opslaan" })), showSend && ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: cls.primaryButton, disabled: busy || !canSend, "aria-describedby": sendBlockedReason && !can("send") ? `${id}-blocked` : undefined, onClick: () => setConfirming("send"), children: [(0, jsx_runtime_1.jsx)(Icon, { name: "send", className: cls.icon }), "Goedkeuren en versturen"] }))] }))] })), showSend && confirming === null && 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: "flex flex-col gap-3", children: [(0, jsx_runtime_1.jsxs)("p", { className: cls.text, children: [confirming === "stale"
|
|
569
|
+
? "Er is een nieuwere mail van de klant. Wil je dit antwoord toch versturen?"
|
|
570
|
+
: `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" })] })] }))] }));
|
|
571
|
+
}
|
|
572
|
+
function OtherActions({ cls, busy, can, run }) {
|
|
573
|
+
if (!can("unignore") && !can("mark_waiting") && !can("close"))
|
|
574
|
+
return null;
|
|
575
|
+
return ((0, jsx_runtime_1.jsxs)("section", { "aria-label": "Overige acties", className: cls.actions, children: [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" }))] }));
|
|
576
|
+
}
|