@lightworkai.official/debug-capture-react 0.6.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 (41) hide show
  1. package/README.md +85 -0
  2. package/dist/DebugCaptureInit.d.ts +25 -0
  3. package/dist/DebugErrorBoundary.d.ts +26 -0
  4. package/dist/ReportProblemButton.d.ts +21 -0
  5. package/dist/components/ImageAnnotatorDialog.d.ts +11 -0
  6. package/dist/components/MyTicketsPanel.d.ts +11 -0
  7. package/dist/components/ReopenPanel.d.ts +7 -0
  8. package/dist/components/RichReplyEditor.d.ts +29 -0
  9. package/dist/components/StatusHero.d.ts +8 -0
  10. package/dist/components/StatusPill.d.ts +14 -0
  11. package/dist/components/TicketBody.d.ts +5 -0
  12. package/dist/components/TicketConversation.d.ts +13 -0
  13. package/dist/components/TicketDetail.d.ts +23 -0
  14. package/dist/components/TicketScreenshots.d.ts +6 -0
  15. package/dist/components/TicketTable.d.ts +18 -0
  16. package/dist/components/TicketTimeline.d.ts +14 -0
  17. package/dist/components/ToolbarButton.d.ts +18 -0
  18. package/dist/components/icons.d.ts +35 -0
  19. package/dist/crash.d.ts +5 -0
  20. package/dist/index.d.ts +23 -0
  21. package/dist/index.mjs +1705 -0
  22. package/package.json +52 -0
  23. package/src/DebugCaptureInit.tsx +56 -0
  24. package/src/DebugErrorBoundary.tsx +50 -0
  25. package/src/ReportProblemButton.tsx +79 -0
  26. package/src/components/ImageAnnotatorDialog.tsx +160 -0
  27. package/src/components/MyTicketsPanel.tsx +321 -0
  28. package/src/components/ReopenPanel.tsx +52 -0
  29. package/src/components/RichReplyEditor.tsx +264 -0
  30. package/src/components/StatusHero.tsx +92 -0
  31. package/src/components/StatusPill.tsx +14 -0
  32. package/src/components/TicketBody.tsx +50 -0
  33. package/src/components/TicketConversation.tsx +177 -0
  34. package/src/components/TicketDetail.tsx +110 -0
  35. package/src/components/TicketScreenshots.tsx +41 -0
  36. package/src/components/TicketTable.tsx +161 -0
  37. package/src/components/TicketTimeline.tsx +81 -0
  38. package/src/components/ToolbarButton.tsx +37 -0
  39. package/src/components/icons.tsx +79 -0
  40. package/src/crash.ts +15 -0
  41. package/src/index.ts +27 -0
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Where the report stands, in one banner.
3
+ *
4
+ * The status LINE is config-first: an awaiting lane's "please reply" wins, then
5
+ * the lane's own description (so a realm's custom lane carries its own words),
6
+ * then our default copy, then the bare label.
7
+ */
8
+ import type { ComponentType, SVGProps } from "react";
9
+ import type { MyTicketDetail, StatusMaps, TicketStrings } from "@lightworkai.official/debug-capture";
10
+ import { TicketBody } from "./TicketBody";
11
+ import { BanIcon, CheckCircleIcon, ClockIcon, InboxIcon, MessageIcon, WrenchIcon } from "./icons";
12
+
13
+ interface Tone {
14
+ Icon: ComponentType<SVGProps<SVGSVGElement>>;
15
+ border: string;
16
+ bg: string;
17
+ fg: string;
18
+ accent: string;
19
+ }
20
+
21
+ /** Only the SYSTEM statuses get a tone; an admin-added lane falls back to OPEN. */
22
+ const TONES: Record<string, Tone> = {
23
+ OPEN: { Icon: InboxIcon, border: "#bfdbfe", bg: "#eff6ff", fg: "#1e3a8a", accent: "#3b82f6" },
24
+ IN_PROGRESS: { Icon: ClockIcon, border: "#fde68a", bg: "#fffbeb", fg: "#78350f", accent: "#f59e0b" },
25
+ RESOLVED: { Icon: CheckCircleIcon, border: "#a7f3d0", bg: "#ecfdf5", fg: "#064e3b", accent: "#10b981" },
26
+ CLOSED: { Icon: CheckCircleIcon, border: "#e5e7eb", bg: "#f9fafb", fg: "#1f2937", accent: "#9ca3af" },
27
+ WONT_FIX: { Icon: BanIcon, border: "#e5e7eb", bg: "#f9fafb", fg: "#374151", accent: "#9ca3af" },
28
+ };
29
+ const AWAITING: Tone = { Icon: MessageIcon, border: "#fcd34d", bg: "#fffbeb", fg: "#78350f", accent: "#f59e0b" };
30
+
31
+ const MESSAGES: Record<string, { th: string; en: string }> = {
32
+ OPEN: { th: "รับเรื่องแล้ว — รอทีมงานตรวจสอบ", en: "Received — waiting for the team to look" },
33
+ IN_PROGRESS: { th: "ทีมงานกำลังดำเนินการแก้ไข", en: "The team is working on it" },
34
+ RESOLVED: { th: "แก้ไขเรียบร้อยแล้ว", en: "Fixed" },
35
+ CLOSED: { th: "ปิดเรื่องแล้ว", en: "Closed" },
36
+ WONT_FIX: { th: "พิจารณาแล้ว — ยังไม่ได้ดำเนินการในขณะนี้", en: "Reviewed — not being worked on for now" },
37
+ };
38
+
39
+ export function StatusHero({
40
+ ticket, maps, locale, host,
41
+ }: { ticket: MyTicketDetail; maps: StatusMaps; t: TicketStrings; locale: "th" | "en"; host: string }) {
42
+ const awaiting = maps.isAwaitingReply(ticket.status);
43
+ const tone = awaiting ? AWAITING : (TONES[ticket.status] ?? TONES.OPEN!);
44
+ const { Icon } = tone;
45
+
46
+ const headline = awaiting
47
+ ? locale === "en"
48
+ ? "The team needs more information from you"
49
+ : "ทีมงานขอข้อมูลเพิ่มเติม — รอการตอบกลับจากคุณ"
50
+ : maps.columnOf(ticket.status)?.description?.trim() ||
51
+ MESSAGES[ticket.status]?.[locale] ||
52
+ maps.labelOf(ticket.status);
53
+
54
+ /*
55
+ * The solution note, only when the thread does not already carry it. A
56
+ * resolved ticket usually has the team's answer as their last message, and
57
+ * printing the note here too says the same words twice on one screen. Kept as
58
+ * the fallback for a ticket whose note never reached the conversation.
59
+ */
60
+ const showNote =
61
+ !awaiting &&
62
+ !ticket.messages.some((m) => m.authorRole === "AGENT") &&
63
+ maps.isClosed(ticket.status) &&
64
+ Boolean(ticket.resolutionNote?.trim());
65
+
66
+ return (
67
+ <section className="lw-hero" style={{ borderColor: tone.border, background: tone.bg }}>
68
+ <Icon className="lw-hero__icon" style={{ color: tone.accent }} />
69
+ <div className="lw-hero__body">
70
+ <p className="lw-hero__kicker">
71
+ {awaiting
72
+ ? locale === "en" ? "AWAITING YOUR REPLY" : "รอการตอบกลับจากคุณ"
73
+ : locale === "en" ? "LATEST STATUS" : "สถานะล่าสุด"}
74
+ </p>
75
+ <p className="lw-hero__headline" style={{ color: tone.fg }}>{headline}</p>
76
+ {awaiting && (
77
+ <p className="lw-hero__nudge">
78
+ {locale === "en"
79
+ ? "Please reply to the team in the conversation below"
80
+ : "กรุณาตอบกลับให้ทีมงานในช่องแชทด้านล่าง"}
81
+ </p>
82
+ )}
83
+ {showNote && (
84
+ <div className="lw-hero__note">
85
+ <WrenchIcon className="lw-hero__wrench" />
86
+ <TicketBody html={ticket.resolutionNote ?? ""} host={host} />
87
+ </div>
88
+ )}
89
+ </div>
90
+ </section>
91
+ );
92
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A status as the realm coloured it.
3
+ *
4
+ * The tone arrives as DATA — a realm adds lanes whenever it likes — so it is an
5
+ * inline style rather than a class. No stylesheet could know the names ahead of
6
+ * time.
7
+ */
8
+ export function StatusPill({ label, tone }: { label: string; tone: { fg: string; bg: string } }) {
9
+ return (
10
+ <span className="lw-status" style={{ background: tone.bg, color: tone.fg }}>
11
+ {label}
12
+ </span>
13
+ );
14
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A message or description body, rendered safely.
3
+ *
4
+ * Deliberately NOT `dangerouslySetInnerHTML`. Bodies are HTML written by other
5
+ * people and shown inside the HOST application's page — markup that ran here
6
+ * would run with the host's origin, against the host's session. `renderBody`
7
+ * parses into an inert document, walks it against an allowlist and fills the
8
+ * node; setting innerHTML would hand the string straight to the parser.
9
+ */
10
+ import { useEffect, useRef, useState } from "react";
11
+ import { renderBody } from "@lightworkai.official/debug-capture";
12
+
13
+ export function TicketBody({ html, host, className }: { html: string; host: string; className?: string }) {
14
+ const target = useRef<HTMLDivElement>(null);
15
+ /**
16
+ * The image being viewed full size, if any.
17
+ *
18
+ * Bodies are capped to a thumbnail so one screenshot cannot push a whole
19
+ * conversation off the screen — which is only reasonable if the full size is
20
+ * a click away.
21
+ */
22
+ const [zoomed, setZoomed] = useState<string | null>(null);
23
+
24
+ useEffect(() => {
25
+ if (target.current) renderBody(target.current, html, host);
26
+ }, [html, host]);
27
+
28
+ return (
29
+ <>
30
+ <div
31
+ ref={target}
32
+ className={className ? `lw-body ${className}` : "lw-body"}
33
+ // Delegated rather than bound per image: this is sanitised HTML we
34
+ // inject, not elements we built, so there is nothing to attach to.
35
+ onClick={(event) => {
36
+ const node = event.target as HTMLElement;
37
+ if (node.tagName !== "IMG") return;
38
+ const src = node.getAttribute("src");
39
+ if (src) setZoomed(src);
40
+ }}
41
+ />
42
+ {zoomed && (
43
+ <div className="lw-zoom" role="dialog" aria-modal="true" onClick={() => setZoomed(null)}>
44
+ <button type="button" className="lw-zoom__close" aria-label="close">✕</button>
45
+ <img src={zoomed} alt="" />
46
+ </div>
47
+ )}
48
+ </>
49
+ );
50
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The thread, and the box to add to it.
3
+ *
4
+ * The composer is the same TipTap editor the team uses — the original's own
5
+ * reasoning, in its own words: "ผู้แจ้งอธิบายปัญหาด้วยภาพหน้าจอเป็นเรื่องปกติที่สุด".
6
+ */
7
+ import { useEffect, useRef, useState } from "react";
8
+ import { editMyMessage, formatDateTime, type MyTicketDetail, type TicketStrings } from "@lightworkai.official/debug-capture";
9
+ import { RichReplyEditor, type RichReplyEditorHandle } from "./RichReplyEditor";
10
+ import { TicketBody } from "./TicketBody";
11
+ import { CheckSmallIcon, PencilIcon, SendIcon, XSmallIcon } from "./icons";
12
+
13
+ export function TicketConversation({
14
+ ticket, t, host, timezone, locale, sending, uploadImage, onSend, onEdited, onError,
15
+ }: {
16
+ ticket: MyTicketDetail;
17
+ t: TicketStrings;
18
+ host: string;
19
+ timezone: string;
20
+ locale: "th" | "en" | undefined;
21
+ sending?: boolean;
22
+ uploadImage: (file: File) => Promise<string>;
23
+ onSend: (body: string) => void;
24
+ onEdited: () => void;
25
+ onError: (message: string) => void;
26
+ }) {
27
+ const editor = useRef<RichReplyEditorHandle>(null);
28
+ /**
29
+ * The message being rewritten, if any.
30
+ *
31
+ * Only ONE at a time: an open editor per bubble would let someone start three
32
+ * rewrites and lose track of which are unsaved.
33
+ */
34
+ const [editingId, setEditingId] = useState<string | null>(null);
35
+ const [savingEdit, setSavingEdit] = useState(false);
36
+ const draft = useRef<RichReplyEditorHandle>(null);
37
+
38
+ const LABELS: Record<string, string> = {
39
+ bold: t.bold, italic: t.italic, underline: t.underline,
40
+ bulletList: t.bulletList, orderedList: t.orderedList,
41
+ link: t.link, linkPrompt: t.linkPrompt, attachImage: t.attachImage, uploading: t.uploading,
42
+ };
43
+
44
+ async function saveEdit(messageId: string): Promise<void> {
45
+ const body = draft.current?.value() ?? "";
46
+ if (!body || savingEdit) return;
47
+ setSavingEdit(true);
48
+ try {
49
+ await editMyMessage(ticket.id, messageId, body);
50
+ setEditingId(null);
51
+ onEdited();
52
+ } catch (e) {
53
+ onError(e instanceof Error ? e.message : t.sendFailed);
54
+ } finally {
55
+ setSavingEdit(false);
56
+ }
57
+ }
58
+ const thread = useRef<HTMLDivElement>(null);
59
+ const seen = useRef(-1);
60
+
61
+ /**
62
+ * Open at the newest message, and stay there when one arrives.
63
+ *
64
+ * The thread is a fixed-height window (~320px), so without the first part it
65
+ * opens showing the OLDEST messages and the reader has to scroll to find out
66
+ * what has happened since. Every chat opens at the bottom.
67
+ *
68
+ * The second part matters because the thread sits above the composer: a reply
69
+ * pushes the composer down and out of sight, leaving the reader in the middle
70
+ * of the conversation with no sign their message landed.
71
+ */
72
+ useEffect(() => {
73
+ const box = thread.current;
74
+ if (!box) return;
75
+ const first = seen.current < 0;
76
+ if (first || ticket.messages.length > seen.current) {
77
+ box.scrollTop = box.scrollHeight;
78
+ // Jumped on open — that is where the conversation starts, not a
79
+ // transition from somewhere else, and animating it reads as a glitch.
80
+ if (!first) box.lastElementChild?.scrollIntoView({ block: "nearest", behavior: "smooth" });
81
+ }
82
+ seen.current = ticket.messages.length;
83
+ }, [ticket.id, ticket.messages.length]);
84
+
85
+ function send(): void {
86
+ const body = editor.current?.value() ?? "";
87
+ if (!body || sending) return;
88
+ onSend(body);
89
+ editor.current?.clear();
90
+ }
91
+
92
+ return (
93
+ <section className="lw-card">
94
+ <h3 className="lw-card__title">{t.conversation}</h3>
95
+
96
+ <div ref={thread} className="lw-thread">
97
+ {ticket.messages.length === 0 && <p className="lw-thread__empty">{t.noMessages}</p>}
98
+ {ticket.messages.map((message) => (
99
+ <article
100
+ key={message.id}
101
+ className={`lw-msg${message.authorRole === "REPORTER" ? " lw-msg--mine" : ""}`}
102
+ >
103
+ {/* The original's shape exactly: the meta line lives INSIDE a
104
+ bordered bubble, and a TEAM message shows only "ทีมงาน" — never
105
+ the officer's name. That is deliberate there and worth keeping:
106
+ the team answers as one unit, and a reporter should not see
107
+ which person replied. */}
108
+ <div className="lw-msg__bubble">
109
+ <p className="lw-msg__meta">
110
+ {message.authorRole === "AGENT" ? (
111
+ <span className="lw-msg__who">{t.team}</span>
112
+ ) : (
113
+ <>
114
+ <span className="lw-msg__who">{message.authorName || t.you}</span>
115
+ <span className="lw-msg__role">{t.reporterRole}</span>
116
+ </>
117
+ )}
118
+ <span>· {formatDateTime(message.createdAt, timezone, locale)}</span>
119
+ {message.editedAt && <span className="lw-msg__edited">· {t.edited}</span>}
120
+ {/* Your own only. The server refuses any message the caller did
121
+ not write, so this button is a convenience, not the rule. */}
122
+ {message.authorRole === "REPORTER" && editingId !== message.id && (
123
+ <button type="button" className="lw-msg__edit" onClick={() => setEditingId(message.id)}>
124
+ <PencilIcon /> {t.edit}
125
+ </button>
126
+ )}
127
+ </p>
128
+ {editingId === message.id ? (
129
+ <div className="lw-msg__editing">
130
+ <RichReplyEditor
131
+ ref={draft}
132
+ placeholder={t.replyPlaceholder}
133
+ host={host}
134
+ locale={locale}
135
+ disabled={savingEdit}
136
+ uploadImage={uploadImage}
137
+ onError={onError}
138
+ labels={LABELS}
139
+ initial={message.body}
140
+ />
141
+ <div className="lw-msg__editActions">
142
+ <button type="button" className="lw-btn" disabled={savingEdit} onClick={() => setEditingId(null)}>
143
+ <XSmallIcon /> {t.cancel}
144
+ </button>
145
+ <button type="button" className="lw-send" disabled={savingEdit} onClick={() => void saveEdit(message.id)}>
146
+ <CheckSmallIcon /> {t.saveEdit}
147
+ </button>
148
+ </div>
149
+ </div>
150
+ ) : (
151
+ <TicketBody html={message.body} host={host} />
152
+ )}
153
+ </div>
154
+ </article>
155
+ ))}
156
+ </div>
157
+
158
+ <div className="lw-composer">
159
+ <RichReplyEditor
160
+ ref={editor}
161
+ placeholder={t.replyPlaceholder}
162
+ host={host}
163
+ locale={locale}
164
+ disabled={sending}
165
+ uploadImage={uploadImage}
166
+ onError={onError}
167
+ labels={LABELS}
168
+ />
169
+ <div className="lw-composer__actions">
170
+ <button type="button" className="lw-send" disabled={sending} onClick={send}>
171
+ <SendIcon /> {sending ? t.sending : t.send}
172
+ </button>
173
+ </div>
174
+ </div>
175
+ </section>
176
+ );
177
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * One report: what was filed, where it stands, what happened, and the thread.
3
+ * The order is the original's.
4
+ */
5
+ import {
6
+ formatDateTime, ticketKey,
7
+ type MyTicketDetail, type RealmConfig, type StatusEvent, type StatusMaps, type TicketStrings,
8
+ } from "@lightworkai.official/debug-capture";
9
+ import { TicketBody } from "./TicketBody";
10
+ import { StatusHero } from "./StatusHero";
11
+ import { ReopenPanel } from "./ReopenPanel";
12
+ import { TicketTimeline } from "./TicketTimeline";
13
+ import { TicketConversation } from "./TicketConversation";
14
+ import { TicketScreenshots } from "./TicketScreenshots";
15
+ import { ArrowLeftIcon } from "./icons";
16
+
17
+ export function TicketDetail(props: {
18
+ ticket: MyTicketDetail;
19
+ history: StatusEvent[];
20
+ config: RealmConfig;
21
+ maps: StatusMaps;
22
+ t: TicketStrings;
23
+ host: string;
24
+ locale: "th" | "en" | undefined;
25
+ sending?: boolean;
26
+ reopening?: boolean;
27
+ uploadImage: (file: File) => Promise<string>;
28
+ attachmentUrl: (id: string) => Promise<string>;
29
+ onBack: () => void;
30
+ onSend: (body: string) => void;
31
+ onEdited: () => void;
32
+ onReopen: (note: string) => void;
33
+ onError: (message: string) => void;
34
+ }) {
35
+ const { ticket, config, maps, t, host, locale } = props;
36
+ const lang: "th" | "en" = locale === "en" ? "en" : "th";
37
+ const canReopen = maps.isClosed(ticket.status) && config.featureFlags.selfServiceReopen === true;
38
+ const updated = ticket.updatedAt && ticket.updatedAt !== ticket.createdAt;
39
+ const when = (iso: string | null) => formatDateTime(iso, config.timezone, locale);
40
+
41
+ /*
42
+ * Images only. The original's reporter view lists no attachments at all, and
43
+ * what a widget report actually carries makes the reason plain: the other
44
+ * file is `debug-bundle.json`, the capture payload the TEAM reads. The
45
+ * screenshot they drew on is theirs; the bundle is not.
46
+ */
47
+ const screenshots = ticket.attachments.filter((a) => (a.contentType ?? "").startsWith("image/"));
48
+
49
+ return (
50
+ <div className="lw-detail">
51
+ <button type="button" className="lw-back" onClick={props.onBack}>
52
+ <ArrowLeftIcon /> {t.back}
53
+ </button>
54
+
55
+ <section className="lw-card">
56
+ <h3 className="lw-card__heading">{ticket.title}</h3>
57
+ <div className="lw-facts">
58
+ <span className="lw-key">{ticketKey(config.realm.slug, ticket.number)}</span>
59
+ {ticket.category && <span className="lw-chip">{ticket.category}</span>}
60
+ <span>{t.colCreated} {when(ticket.createdAt)}</span>
61
+ {updated && <span>{t.colUpdated} {when(ticket.updatedAt)}</span>}
62
+ </div>
63
+ {ticket.description && <TicketBody className="lw-intro" html={ticket.description} host={host} />}
64
+ {ticket.routeUrl && (
65
+ <p className="lw-route">
66
+ {t.page}:{" "}
67
+ <a href={ticket.routeUrl} target="_blank" rel="noopener noreferrer">{ticket.routeUrl}</a>
68
+ </p>
69
+ )}
70
+ </section>
71
+
72
+ <StatusHero ticket={ticket} maps={maps} t={t} locale={lang} host={host} />
73
+
74
+ {canReopen && <ReopenPanel t={t} locale={lang} busy={props.reopening} onReopen={props.onReopen} />}
75
+
76
+ <section className="lw-card">
77
+ <h3 className="lw-card__title">{lang === "en" ? "Progress" : "ความคืบหน้า"}</h3>
78
+ <TicketTimeline
79
+ history={props.history}
80
+ status={ticket.status}
81
+ createdAt={ticket.createdAt}
82
+ maps={maps}
83
+ timezone={config.timezone}
84
+ locale={lang}
85
+ />
86
+ </section>
87
+
88
+ <TicketConversation
89
+ ticket={ticket}
90
+ t={t}
91
+ host={host}
92
+ timezone={config.timezone}
93
+ locale={locale}
94
+ sending={props.sending}
95
+ uploadImage={props.uploadImage}
96
+ onSend={props.onSend}
97
+ onEdited={props.onEdited}
98
+ onError={props.onError}
99
+ />
100
+
101
+ {screenshots.length > 0 && (
102
+ <TicketScreenshots
103
+ images={screenshots}
104
+ title={lang === "en" ? "Screenshots you sent" : "ภาพหน้าจอที่คุณแจ้ง"}
105
+ attachmentUrl={props.attachmentUrl}
106
+ />
107
+ )}
108
+ </div>
109
+ );
110
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The pictures the reporter attached — shown, not offered as downloads.
3
+ *
4
+ * The URL is FETCHED, not constructed. These are ordinary attachments: the
5
+ * inline-image route answers only to the per-object tokens embedded images
6
+ * carry, and the admin download route wants a session this widget has not got.
7
+ */
8
+ import { useEffect, useState } from "react";
9
+ import type { TicketAttachment } from "@lightworkai.official/debug-capture";
10
+
11
+ export function TicketScreenshots({
12
+ images, title, attachmentUrl,
13
+ }: { images: TicketAttachment[]; title: string; attachmentUrl: (id: string) => Promise<string> }) {
14
+ const [urls, setUrls] = useState<Record<string, string>>({});
15
+
16
+ useEffect(() => {
17
+ let live = true;
18
+ for (const image of images) {
19
+ attachmentUrl(image.id).then(
20
+ (url) => live && setUrls((prev) => ({ ...prev, [image.id]: url })),
21
+ // Drop the frame rather than leave a broken-image icon where a
22
+ // screenshot should be.
23
+ () => undefined,
24
+ );
25
+ }
26
+ return () => {
27
+ live = false;
28
+ };
29
+ }, [images, attachmentUrl]);
30
+
31
+ return (
32
+ <section className="lw-card">
33
+ <h3 className="lw-card__title">{title}</h3>
34
+ {images.map((image) =>
35
+ urls[image.id] ? (
36
+ <img key={image.id} className="lw-shot" src={urls[image.id]} alt={image.filename ?? ""} />
37
+ ) : null,
38
+ )}
39
+ </section>
40
+ );
41
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The reporter's list — the original's seven columns.
3
+ *
4
+ * Every decision here is imported: which rows survive the filter, how a column
5
+ * sorts, what "the team replied" means, how a date reads. That is the point of
6
+ * the split. This file arranges them; it decides nothing.
7
+ */
8
+ import { useMemo } from "react";
9
+ import {
10
+ compareBy, formatDateTime, hasSolution, hasTeamReply, matchesKeyword,
11
+ pageCount, pageOf, statusTone, ticketKey,
12
+ type MyTicketListItem, type Sort, type SortKey, type StatusMaps, type TicketStrings,
13
+ } from "@lightworkai.official/debug-capture";
14
+ import { StatusPill } from "./StatusPill";
15
+ import { CheckIcon, MessageIcon } from "./icons";
16
+
17
+ const COLUMNS: { key: SortKey; label: keyof TicketStrings; width?: string }[] = [
18
+ { key: "number", label: "colNumber", width: "120px" },
19
+ { key: "title", label: "colTitle" },
20
+ { key: "module", label: "colModule", width: "160px" },
21
+ { key: "status", label: "colStatus", width: "130px" },
22
+ { key: "response", label: "colResponse", width: "150px" },
23
+ { key: "createdAt", label: "colCreated", width: "170px" },
24
+ { key: "updatedAt", label: "colUpdated", width: "170px" },
25
+ ];
26
+
27
+ export interface TicketTableProps {
28
+ items: MyTicketListItem[];
29
+ maps: StatusMaps;
30
+ t: TicketStrings;
31
+ slug: string | undefined;
32
+ timezone: string;
33
+ locale: "th" | "en" | undefined;
34
+ keyword: string;
35
+ statusFilter: string;
36
+ sort: Sort;
37
+ page: number;
38
+ onOpen: (id: string) => void;
39
+ onSort: (key: SortKey) => void;
40
+ onPage: (page: number) => void;
41
+ onCount?: (count: number) => void;
42
+ }
43
+
44
+ export function TicketTable(props: TicketTableProps) {
45
+ const { items, maps, t, slug, timezone, locale, keyword, statusFilter, sort, page } = props;
46
+
47
+ const visible = useMemo(
48
+ () =>
49
+ items
50
+ .filter((item) => (statusFilter ? item.status === statusFilter : true))
51
+ .filter((item) => matchesKeyword(item, keyword, slug, maps.labelOf))
52
+ .sort(compareBy(sort, maps, slug)),
53
+ [items, statusFilter, keyword, slug, maps, sort],
54
+ );
55
+
56
+ const pages = pageCount(visible.length);
57
+ // A filter can strand the reader past the end of the shorter list.
58
+ const current = Math.min(page, pages);
59
+ const rows = pageOf(visible, current);
60
+ props.onCount?.(visible.length);
61
+
62
+ const when = (iso: string | null) => formatDateTime(iso, timezone, locale);
63
+
64
+ return (
65
+ <>
66
+ <div className="lw-tablewrap">
67
+ <table className="lw-table">
68
+ <thead>
69
+ <tr>
70
+ {COLUMNS.map((column) => (
71
+ <th
72
+ key={column.key}
73
+ scope="col"
74
+ className="lw-th"
75
+ style={column.width ? { width: column.width } : undefined}
76
+ aria-sort={
77
+ sort.key === column.key ? (sort.direction === "asc" ? "ascending" : "descending") : undefined
78
+ }
79
+ onClick={() => props.onSort(column.key)}
80
+ >
81
+ {t[column.label] as string}{" "}
82
+ <span className="lw-arrow">
83
+ {sort.key === column.key ? (sort.direction === "asc" ? "▲" : "▼") : "⇅"}
84
+ </span>
85
+ </th>
86
+ ))}
87
+ </tr>
88
+ </thead>
89
+ <tbody>
90
+ {rows.length === 0 ? (
91
+ <tr>
92
+ <td colSpan={COLUMNS.length} className="lw-empty">
93
+ {items.length === 0 ? t.empty : t.emptyFiltered}
94
+ </td>
95
+ </tr>
96
+ ) : (
97
+ rows.map((row) => (
98
+ <tr
99
+ key={row.id}
100
+ className="lw-row"
101
+ tabIndex={0}
102
+ onClick={() => props.onOpen(row.id)}
103
+ onKeyDown={(event) => {
104
+ if (event.key === "Enter" || event.key === " ") {
105
+ event.preventDefault();
106
+ props.onOpen(row.id);
107
+ }
108
+ }}
109
+ >
110
+ <td className="lw-num">{ticketKey(slug, row.number)}</td>
111
+ <td className="lw-title">{row.title}</td>
112
+ <td>
113
+ {row.category ? <span className="lw-chip">{row.category}</span> : <span className="lw-dash">—</span>}
114
+ </td>
115
+ <td>
116
+ {/* "Waiting on you" outranks the neutral status: it is the
117
+ honest state and the only one the reporter can act on. */}
118
+ {maps.isAwaitingReply(row.status) ? (
119
+ <span className="lw-pill lw-pill--awaiting">
120
+ <MessageIcon /> {t.awaitingReply}
121
+ </span>
122
+ ) : (
123
+ <StatusPill label={maps.labelOf(row.status)} tone={statusTone(maps.columnOf(row.status)?.color)} />
124
+ )}
125
+ </td>
126
+ <td>
127
+ {hasSolution(row) ? (
128
+ <span className="lw-pill lw-pill--solution">
129
+ <CheckIcon /> {t.hasSolution}
130
+ </span>
131
+ ) : hasTeamReply(row) ? (
132
+ <span className="lw-pill lw-pill--replied">
133
+ <MessageIcon /> {t.hasReply}
134
+ </span>
135
+ ) : (
136
+ <span className="lw-muted">{t.noReply}</span>
137
+ )}
138
+ </td>
139
+ <td className="lw-dim">{when(row.createdAt)}</td>
140
+ <td className="lw-dim">{when(row.updatedAt)}</td>
141
+ </tr>
142
+ ))
143
+ )}
144
+ </tbody>
145
+ </table>
146
+ </div>
147
+
148
+ {(visible.length > rows.length || current > 1) && (
149
+ <div className="lw-pager">
150
+ <span>{t.of(current, pages)}</span>
151
+ <button type="button" className="lw-btn" disabled={current <= 1} onClick={() => props.onPage(current - 1)}>
152
+ {t.prev}
153
+ </button>
154
+ <button type="button" className="lw-btn" disabled={current >= pages} onClick={() => props.onPage(current + 1)}>
155
+ {t.next}
156
+ </button>
157
+ </div>
158
+ )}
159
+ </>
160
+ );
161
+ }