@lightworkai.official/debug-capture-vue 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 +52 -0
  2. package/dist/components/BugIcon.d.ts +3 -0
  3. package/dist/components/DebugCaptureInit.vue.d.ts +13 -0
  4. package/dist/components/ImageAnnotatorDialog.vue.d.ts +19 -0
  5. package/dist/components/MyTicketsPanel.vue.d.ts +17 -0
  6. package/dist/components/ReopenPanel.vue.d.ts +12 -0
  7. package/dist/components/ReportProblemButton.vue.d.ts +17 -0
  8. package/dist/components/RichReplyEditor.vue.d.ts +59 -0
  9. package/dist/components/StatusHero.vue.d.ts +10 -0
  10. package/dist/components/StatusPill.vue.d.ts +16 -0
  11. package/dist/components/TicketBody.vue.d.ts +6 -0
  12. package/dist/components/TicketConversation.vue.d.ts +22 -0
  13. package/dist/components/TicketDetail.vue.d.ts +28 -0
  14. package/dist/components/TicketScreenshots.vue.d.ts +8 -0
  15. package/dist/components/TicketTable.vue.d.ts +25 -0
  16. package/dist/components/TicketTimeline.vue.d.ts +11 -0
  17. package/dist/components/ToolbarButton.vue.d.ts +21 -0
  18. package/dist/components/icons.d.ts +38 -0
  19. package/dist/index.d.ts +24 -0
  20. package/dist/index.mjs +2996 -0
  21. package/dist/index.mjs.map +1 -0
  22. package/package.json +64 -0
  23. package/src/components/BugIcon.ts +23 -0
  24. package/src/components/DebugCaptureInit.vue +41 -0
  25. package/src/components/ImageAnnotatorDialog.vue +160 -0
  26. package/src/components/MyTicketsPanel.vue +312 -0
  27. package/src/components/README.md +12 -0
  28. package/src/components/ReopenPanel.vue +47 -0
  29. package/src/components/ReportProblemButton.vue +28 -0
  30. package/src/components/RichReplyEditor.vue +321 -0
  31. package/src/components/StatusHero.vue +85 -0
  32. package/src/components/StatusPill.vue +14 -0
  33. package/src/components/TicketBody.vue +50 -0
  34. package/src/components/TicketConversation.vue +227 -0
  35. package/src/components/TicketDetail.vue +130 -0
  36. package/src/components/TicketScreenshots.vue +47 -0
  37. package/src/components/TicketTable.vue +142 -0
  38. package/src/components/TicketTimeline.vue +86 -0
  39. package/src/components/ToolbarButton.vue +33 -0
  40. package/src/components/icons.ts +90 -0
  41. package/src/index.ts +34 -0
@@ -0,0 +1,227 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The thread, and the box to add to it.
4
+ *
5
+ * The composer is the same TipTap editor the team uses — the original's own
6
+ * reasoning, in its own words: "ผู้แจ้งอธิบายปัญหาด้วยภาพหน้าจอเป็นเรื่องปกติที่สุด".
7
+ */
8
+ import { computed, nextTick, ref, shallowRef, watch } from "vue";
9
+ import { editMyMessage, formatDateTime, type MyTicketDetail, type TicketStrings } from "@lightworkai.official/debug-capture";
10
+ import RichReplyEditor from "./RichReplyEditor.vue";
11
+ import TicketBody from "./TicketBody.vue";
12
+ import { CheckIcon2, PencilIcon, SendIcon, XIcon } from "./icons";
13
+
14
+ const props = defineProps<{
15
+ ticket: MyTicketDetail;
16
+ t: TicketStrings;
17
+ host: string;
18
+ timezone: string;
19
+ locale: "th" | "en" | undefined;
20
+ sending?: boolean;
21
+ uploadImage: (file: File) => Promise<string>;
22
+ }>();
23
+
24
+ const emit = defineEmits<{
25
+ (e: "send", body: string): void;
26
+ (e: "edited"): void;
27
+ (e: "error", message: string): void;
28
+ }>();
29
+
30
+ const editor = ref<InstanceType<typeof RichReplyEditor> | null>(null);
31
+ const thread = ref<HTMLElement | null>(null);
32
+
33
+ /**
34
+ * The message being rewritten, if any.
35
+ *
36
+ * Only ONE at a time: an open editor per bubble would let someone start three
37
+ * rewrites and lose track of which are unsaved. The original makes the same
38
+ * choice for the same reason.
39
+ *
40
+ * Only your own, and the server agrees — it refuses any message the caller did
41
+ * not write, so this button is a convenience, not the rule.
42
+ */
43
+ const editingId = ref<string | null>(null);
44
+ const savingEdit = ref(false);
45
+
46
+ /**
47
+ * The open edit box.
48
+ *
49
+ * A FUNCTION ref, not a string one. A `ref="x"` inside `v-for` collects an
50
+ * ARRAY of instances — even when only one of them renders — so calling
51
+ * `.value()` on it threw "value is not a function" the moment anyone pressed
52
+ * บันทึกการแก้ไข. A function ref is handed the instance itself, and null when it
53
+ * unmounts.
54
+ */
55
+ const editingDraft = shallowRef<InstanceType<typeof RichReplyEditor> | null>(null);
56
+
57
+ function bindDraft(el: unknown): void {
58
+ editingDraft.value = (el as InstanceType<typeof RichReplyEditor> | null) ?? null;
59
+ }
60
+
61
+ /** Just the toolbar's words: TicketStrings also carries formatters. */
62
+ const labels = computed<Record<string, string>>(() => ({
63
+ bold: props.t.bold,
64
+ italic: props.t.italic,
65
+ underline: props.t.underline,
66
+ bulletList: props.t.bulletList,
67
+ orderedList: props.t.orderedList,
68
+ link: props.t.link,
69
+ linkPrompt: props.t.linkPrompt,
70
+ attachImage: props.t.attachImage,
71
+ uploading: props.t.uploading,
72
+ }));
73
+
74
+ function canEdit(message: MyTicketDetail["messages"][number]): boolean {
75
+ return message.authorRole === "REPORTER";
76
+ }
77
+
78
+ async function saveEdit(message: MyTicketDetail["messages"][number]): Promise<void> {
79
+ const body = editingDraft.value?.value() ?? "";
80
+ if (!body || savingEdit.value) return;
81
+ savingEdit.value = true;
82
+ try {
83
+ await editMyMessage(props.ticket.id, message.id, body);
84
+ editingId.value = null;
85
+ emit("edited");
86
+ } catch (e) {
87
+ emit("error", e instanceof Error ? e.message : props.t.sendFailed);
88
+ } finally {
89
+ savingEdit.value = false;
90
+ }
91
+ }
92
+
93
+ function send(): void {
94
+ const body = editor.value?.value() ?? "";
95
+ if (!body || props.sending) return;
96
+ emit("send", body);
97
+ // Cleared here, not by the parent. It used to be cleared by accident: the
98
+ // whole detail unmounted after a reply, so a fresh editor came back empty.
99
+ // Now that the view survives the send, the box has to be emptied on purpose.
100
+ editor.value?.clear();
101
+ }
102
+
103
+ /**
104
+ * Open at the newest message.
105
+ *
106
+ * The thread is a fixed-height window now, so without this it opens showing the
107
+ * OLDEST messages and the reader has to scroll to find out what has happened
108
+ * since. Every chat opens at the bottom; this one should too.
109
+ *
110
+ * Jumped, not animated: this is where the conversation starts, not a transition
111
+ * from somewhere else, and watching it scroll on open reads as a glitch.
112
+ */
113
+ async function toBottom(behavior: ScrollBehavior): Promise<void> {
114
+ await nextTick();
115
+ const box = thread.value;
116
+ if (box) box.scrollTop = box.scrollHeight;
117
+ if (behavior === "smooth") {
118
+ thread.value?.lastElementChild?.scrollIntoView({ block: "nearest", behavior });
119
+ }
120
+ }
121
+
122
+ // A different ticket is a different conversation: start it at the bottom too.
123
+ watch(() => props.ticket.id, () => void toBottom("auto"), { immediate: true });
124
+
125
+ /**
126
+ * Keep the newest message in view.
127
+ *
128
+ * The thread sits above the composer, so a reply pushes the composer down and
129
+ * out of sight — the reader is left looking at the middle of the conversation
130
+ * with no sign their message landed.
131
+ */
132
+ watch(
133
+ () => props.ticket.messages.length,
134
+ (now, before) => {
135
+ if (now <= (before ?? 0)) return;
136
+ void toBottom("smooth");
137
+ },
138
+ );
139
+
140
+ defineExpose({ clear: () => editor.value?.clear() });
141
+ </script>
142
+
143
+ <template>
144
+ <section class="lw-card">
145
+ <h3 class="lw-card__title">{{ t.conversation }}</h3>
146
+
147
+ <div ref="thread" class="lw-thread">
148
+ <p v-if="!ticket.messages.length" class="lw-thread__empty">{{ t.noMessages }}</p>
149
+ <!-- The original's shape exactly: the meta line lives INSIDE a bordered
150
+ bubble, and a TEAM message shows only "ทีมงาน" — never the officer's
151
+ name. That is deliberate there and worth keeping: the team answers as
152
+ one unit, and a reporter should not see which person replied. -->
153
+ <article
154
+ v-for="message in ticket.messages"
155
+ :key="message.id"
156
+ class="lw-msg"
157
+ :class="{ 'lw-msg--mine': message.authorRole === 'REPORTER' }"
158
+ >
159
+ <div class="lw-msg__bubble">
160
+ <p class="lw-msg__meta">
161
+ <template v-if="message.authorRole === 'AGENT'">
162
+ <span class="lw-msg__who">{{ t.team }}</span>
163
+ </template>
164
+ <template v-else>
165
+ <span class="lw-msg__who">{{ message.authorName || t.you }}</span>
166
+ <span class="lw-msg__role">{{ t.reporterRole }}</span>
167
+ </template>
168
+ <span>· {{ formatDateTime(message.createdAt, timezone, locale) }}</span>
169
+ <span v-if="message.editedAt" class="lw-msg__edited">· {{ t.edited }}</span>
170
+ <button
171
+ v-if="canEdit(message) && editingId !== message.id"
172
+ type="button"
173
+ class="lw-msg__edit"
174
+ @click="editingId = message.id"
175
+ >
176
+ <PencilIcon /> {{ t.edit }}
177
+ </button>
178
+ </p>
179
+
180
+ <!-- Rewriting uses the same editor as the composer: a correction is
181
+ usually fixing a link or replacing the screenshot, and neither
182
+ survives a plain text box. -->
183
+ <div v-if="editingId === message.id" class="lw-msg__editing">
184
+ <RichReplyEditor
185
+ :ref="bindDraft"
186
+ :placeholder="t.replyPlaceholder"
187
+ :host="host"
188
+ :locale="locale"
189
+ :disabled="savingEdit"
190
+ :upload-image="uploadImage"
191
+ :labels="labels"
192
+ :initial="message.body"
193
+ @error="emit('error', $event)"
194
+ />
195
+ <div class="lw-msg__editActions">
196
+ <button type="button" class="lw-btn" :disabled="savingEdit" @click="editingId = null">
197
+ <XIcon /> {{ t.cancel }}
198
+ </button>
199
+ <button type="button" class="lw-send" :disabled="savingEdit" @click="saveEdit(message)">
200
+ <CheckIcon2 /> {{ t.saveEdit }}
201
+ </button>
202
+ </div>
203
+ </div>
204
+ <TicketBody v-else :html="message.body" :host="host" />
205
+ </div>
206
+ </article>
207
+ </div>
208
+
209
+ <div class="lw-composer">
210
+ <RichReplyEditor
211
+ ref="editor"
212
+ :placeholder="t.replyPlaceholder"
213
+ :host="host"
214
+ :locale="locale"
215
+ :disabled="sending"
216
+ :upload-image="uploadImage"
217
+ :labels="labels"
218
+ @error="emit('error', $event)"
219
+ />
220
+ <div class="lw-composer__actions">
221
+ <button type="button" class="lw-send" :disabled="sending" @click="send">
222
+ <SendIcon /> {{ sending ? t.sending : t.send }}
223
+ </button>
224
+ </div>
225
+ </div>
226
+ </section>
227
+ </template>
@@ -0,0 +1,130 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * One report: what was filed, where it stands, what happened, and the thread.
4
+ * The order is the original's.
5
+ */
6
+ import { computed } from "vue";
7
+ import {
8
+ formatDateTime,
9
+ ticketKey,
10
+ type MyTicketDetail,
11
+ type RealmConfig,
12
+ type StatusEvent,
13
+ type StatusMaps,
14
+ type TicketStrings,
15
+ } from "@lightworkai.official/debug-capture";
16
+ import TicketBody from "./TicketBody.vue";
17
+ import StatusHero from "./StatusHero.vue";
18
+ import ReopenPanel from "./ReopenPanel.vue";
19
+ import TicketTimeline from "./TicketTimeline.vue";
20
+ import TicketConversation from "./TicketConversation.vue";
21
+ import TicketScreenshots from "./TicketScreenshots.vue";
22
+ import { ArrowLeftIcon } from "./icons";
23
+
24
+ const props = defineProps<{
25
+ ticket: MyTicketDetail;
26
+ history: StatusEvent[];
27
+ config: RealmConfig;
28
+ maps: StatusMaps;
29
+ t: TicketStrings;
30
+ host: string;
31
+ locale: "th" | "en" | undefined;
32
+ sending?: boolean;
33
+ reopening?: boolean;
34
+ uploadImage: (file: File) => Promise<string>;
35
+ attachmentUrl: (id: string) => Promise<string>;
36
+ }>();
37
+
38
+ const emit = defineEmits<{
39
+ (e: "back"): void;
40
+ (e: "edited"): void;
41
+ (e: "send", body: string): void;
42
+ (e: "reopen", note: string): void;
43
+ (e: "error", message: string): void;
44
+ }>();
45
+
46
+ const lang = computed<"th" | "en">(() => (props.locale === "en" ? "en" : "th"));
47
+ const canReopen = computed(
48
+ () => props.maps.isClosed(props.ticket.status) && props.config.featureFlags.selfServiceReopen === true,
49
+ );
50
+ const updated = computed(() => props.ticket.updatedAt && props.ticket.updatedAt !== props.ticket.createdAt);
51
+
52
+ /*
53
+ * Images only. The original's reporter view lists no attachments at all, and
54
+ * what a widget report actually carries makes the reason plain: the other file
55
+ * is `debug-bundle.json`, the capture payload the TEAM reads. The screenshot
56
+ * they drew on is theirs; the bundle is not.
57
+ */
58
+ const screenshots = computed(() =>
59
+ props.ticket.attachments.filter((a) => (a.contentType ?? "").startsWith("image/")),
60
+ );
61
+
62
+ function when(iso: string | null): string {
63
+ return formatDateTime(iso, props.config.timezone, props.locale);
64
+ }
65
+ </script>
66
+
67
+ <template>
68
+ <div class="lw-detail">
69
+ <button type="button" class="lw-back" @click="emit('back')">
70
+ <ArrowLeftIcon /> {{ t.back }}
71
+ </button>
72
+
73
+ <section class="lw-card">
74
+ <h3 class="lw-card__heading">{{ ticket.title }}</h3>
75
+ <div class="lw-facts">
76
+ <span class="lw-key">{{ ticketKey(config.realm.slug, ticket.number) }}</span>
77
+ <span v-if="ticket.category" class="lw-chip">{{ ticket.category }}</span>
78
+ <span>{{ t.colCreated }} {{ when(ticket.createdAt) }}</span>
79
+ <span v-if="updated">{{ t.colUpdated }} {{ when(ticket.updatedAt) }}</span>
80
+ </div>
81
+ <TicketBody v-if="ticket.description" class="lw-intro" :html="ticket.description" :host="host" />
82
+ <p v-if="ticket.routeUrl" class="lw-route">
83
+ {{ t.page }}:
84
+ <a :href="ticket.routeUrl" target="_blank" rel="noopener noreferrer">{{ ticket.routeUrl }}</a>
85
+ </p>
86
+ </section>
87
+
88
+ <StatusHero :ticket="ticket" :maps="maps" :t="t" :locale="lang" :host="host" />
89
+
90
+ <ReopenPanel
91
+ v-if="canReopen"
92
+ :t="t"
93
+ :locale="lang"
94
+ :busy="reopening"
95
+ @reopen="emit('reopen', $event)"
96
+ />
97
+
98
+ <section class="lw-card">
99
+ <h3 class="lw-card__title">{{ lang === "en" ? "Progress" : "ความคืบหน้า" }}</h3>
100
+ <TicketTimeline
101
+ :history="history"
102
+ :status="ticket.status"
103
+ :created-at="ticket.createdAt"
104
+ :maps="maps"
105
+ :timezone="config.timezone"
106
+ :locale="lang"
107
+ />
108
+ </section>
109
+
110
+ <TicketConversation
111
+ :ticket="ticket"
112
+ :t="t"
113
+ :host="host"
114
+ :timezone="config.timezone"
115
+ :locale="locale"
116
+ :sending="sending"
117
+ :upload-image="uploadImage"
118
+ @send="emit('send', $event)"
119
+ @edited="emit('edited')"
120
+ @error="emit('error', $event)"
121
+ />
122
+
123
+ <TicketScreenshots
124
+ v-if="screenshots.length"
125
+ :images="screenshots"
126
+ :title="lang === 'en' ? 'Screenshots you sent' : 'ภาพหน้าจอที่คุณแจ้ง'"
127
+ :attachment-url="attachmentUrl"
128
+ />
129
+ </div>
130
+ </template>
@@ -0,0 +1,47 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The pictures the reporter attached — shown, not offered as downloads.
4
+ *
5
+ * The URL is FETCHED, not constructed. These are ordinary attachments: the
6
+ * inline-image route answers only to the per-object tokens embedded images
7
+ * carry, and the admin download route wants a session this widget has not got.
8
+ */
9
+ import { onMounted, ref } from "vue";
10
+ import type { TicketAttachment } from "@lightworkai.official/debug-capture";
11
+
12
+ const props = defineProps<{
13
+ images: TicketAttachment[];
14
+ title: string;
15
+ attachmentUrl: (id: string) => Promise<string>;
16
+ }>();
17
+
18
+ /** id → signed URL. A missing entry means it is still loading or failed. */
19
+ const urls = ref<Record<string, string>>({});
20
+ const failed = ref<Record<string, true>>({});
21
+
22
+ onMounted(() => {
23
+ for (const image of props.images) {
24
+ props.attachmentUrl(image.id).then(
25
+ (url) => (urls.value = { ...urls.value, [image.id]: url }),
26
+ // Drop the frame rather than leave a broken-image icon where a screenshot
27
+ // should be.
28
+ () => (failed.value = { ...failed.value, [image.id]: true }),
29
+ );
30
+ }
31
+ });
32
+ </script>
33
+
34
+ <template>
35
+ <section class="lw-card">
36
+ <h3 class="lw-card__title">{{ title }}</h3>
37
+ <template v-for="image in images" :key="image.id">
38
+ <img
39
+ v-if="urls[image.id] && !failed[image.id]"
40
+ class="lw-shot"
41
+ :src="urls[image.id]"
42
+ :alt="image.filename ?? ''"
43
+ @error="failed = { ...failed, [image.id]: true }"
44
+ />
45
+ </template>
46
+ </section>
47
+ </template>
@@ -0,0 +1,142 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The reporter's list — the original's seven columns, as a template.
4
+ *
5
+ * Every decision here is imported: which rows survive the filter, how a column
6
+ * sorts, what "the team replied" means, how a date reads. That is the point of
7
+ * the split. This file arranges them; it decides nothing.
8
+ */
9
+ import { computed } from "vue";
10
+ import {
11
+ compareBy,
12
+ formatDateTime,
13
+ hasSolution,
14
+ hasTeamReply,
15
+ matchesKeyword,
16
+ pageCount,
17
+ pageOf,
18
+ statusTone,
19
+ ticketKey,
20
+ type MyTicketListItem,
21
+ type Sort,
22
+ type SortKey,
23
+ type StatusMaps,
24
+ type TicketStrings,
25
+ } from "@lightworkai.official/debug-capture";
26
+ import StatusPill from "./StatusPill.vue";
27
+ import { CheckIcon, MessageIcon } from "./icons";
28
+
29
+ const props = defineProps<{
30
+ items: MyTicketListItem[];
31
+ maps: StatusMaps;
32
+ t: TicketStrings;
33
+ slug: string | undefined;
34
+ timezone: string;
35
+ locale: "th" | "en" | undefined;
36
+ keyword: string;
37
+ statusFilter: string;
38
+ sort: Sort;
39
+ page: number;
40
+ }>();
41
+
42
+ const emit = defineEmits<{
43
+ (e: "open", id: string): void;
44
+ (e: "sort", key: SortKey): void;
45
+ (e: "page", page: number): void;
46
+ }>();
47
+
48
+ const columns: { key: SortKey; label: keyof TicketStrings; width?: string }[] = [
49
+ { key: "number", label: "colNumber", width: "120px" },
50
+ { key: "title", label: "colTitle" },
51
+ { key: "module", label: "colModule", width: "160px" },
52
+ { key: "status", label: "colStatus", width: "130px" },
53
+ { key: "response", label: "colResponse", width: "150px" },
54
+ { key: "createdAt", label: "colCreated", width: "170px" },
55
+ { key: "updatedAt", label: "colUpdated", width: "170px" },
56
+ ];
57
+
58
+ const visible = computed(() =>
59
+ props.items
60
+ .filter((item) => (props.statusFilter ? item.status === props.statusFilter : true))
61
+ .filter((item) => matchesKeyword(item, props.keyword, props.slug, props.maps.labelOf))
62
+ .sort(compareBy(props.sort, props.maps, props.slug)),
63
+ );
64
+
65
+ const pages = computed(() => pageCount(visible.value.length));
66
+ // A filter can strand the reader past the end of the shorter list.
67
+ const current = computed(() => Math.min(props.page, pages.value));
68
+ const rows = computed(() => pageOf(visible.value, current.value));
69
+
70
+ defineExpose({ count: computed(() => visible.value.length) });
71
+
72
+ function when(iso: string | null): string {
73
+ return formatDateTime(iso, props.timezone, props.locale);
74
+ }
75
+ </script>
76
+
77
+ <template>
78
+ <div class="lw-tablewrap">
79
+ <table class="lw-table">
80
+ <thead>
81
+ <tr>
82
+ <th
83
+ v-for="column in columns"
84
+ :key="column.key"
85
+ scope="col"
86
+ class="lw-th"
87
+ :style="column.width ? { width: column.width } : undefined"
88
+ :aria-sort="
89
+ sort.key === column.key ? (sort.direction === 'asc' ? 'ascending' : 'descending') : undefined
90
+ "
91
+ @click="emit('sort', column.key)"
92
+ >
93
+ {{ t[column.label] }}
94
+ <span class="lw-arrow">{{
95
+ sort.key === column.key ? (sort.direction === "asc" ? "▲" : "▼") : "⇅"
96
+ }}</span>
97
+ </th>
98
+ </tr>
99
+ </thead>
100
+ <tbody>
101
+ <tr v-if="!rows.length">
102
+ <td :colspan="columns.length" class="lw-empty">
103
+ {{ items.length === 0 ? t.empty : t.emptyFiltered }}
104
+ </td>
105
+ </tr>
106
+ <tr v-for="row in rows" :key="row.id" class="lw-row" tabindex="0" @click="emit('open', row.id)" @keydown.enter.space.prevent="emit('open', row.id)">
107
+ <td class="lw-num">{{ ticketKey(slug, row.number) }}</td>
108
+ <td class="lw-title">{{ row.title }}</td>
109
+ <td>
110
+ <span v-if="row.category" class="lw-chip">{{ row.category }}</span>
111
+ <span v-else class="lw-dash">—</span>
112
+ </td>
113
+ <td>
114
+ <!-- "Waiting on you" outranks the neutral status: it is the honest
115
+ state and the only one the reporter can act on. -->
116
+ <span v-if="maps.isAwaitingReply(row.status)" class="lw-pill lw-pill--awaiting">
117
+ <MessageIcon /> {{ t.awaitingReply }}
118
+ </span>
119
+ <StatusPill v-else :label="maps.labelOf(row.status)" :tone="statusTone(maps.columnOf(row.status)?.color)" />
120
+ </td>
121
+ <td>
122
+ <span v-if="hasSolution(row)" class="lw-pill lw-pill--solution"><CheckIcon /> {{ t.hasSolution }}</span>
123
+ <span v-else-if="hasTeamReply(row)" class="lw-pill lw-pill--replied"><MessageIcon /> {{ t.hasReply }}</span>
124
+ <span v-else class="lw-muted">{{ t.noReply }}</span>
125
+ </td>
126
+ <td class="lw-dim">{{ when(row.createdAt) }}</td>
127
+ <td class="lw-dim">{{ when(row.updatedAt) }}</td>
128
+ </tr>
129
+ </tbody>
130
+ </table>
131
+ </div>
132
+
133
+ <div v-if="visible.length > rows.length || current > 1" class="lw-pager">
134
+ <span>{{ t.of(current, pages) }}</span>
135
+ <button type="button" class="lw-btn" :disabled="current <= 1" @click="emit('page', current - 1)">
136
+ {{ t.prev }}
137
+ </button>
138
+ <button type="button" class="lw-btn" :disabled="current >= pages" @click="emit('page', current + 1)">
139
+ {{ t.next }}
140
+ </button>
141
+ </div>
142
+ </template>
@@ -0,0 +1,86 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The lifecycle, chronologically — including a reopen, which is the event a
4
+ * fixed happy-path progress bar hides and the one a reporter most wants to see
5
+ * recorded.
6
+ */
7
+ import { computed } from "vue";
8
+ import { formatDateTime, type StatusEvent, type StatusMaps } from "@lightworkai.official/debug-capture";
9
+ import { RotateIcon } from "./icons";
10
+
11
+ const props = defineProps<{
12
+ history: StatusEvent[];
13
+ status: string;
14
+ createdAt: string;
15
+ maps: StatusMaps;
16
+ timezone: string;
17
+ locale: "th" | "en";
18
+ }>();
19
+
20
+ /** The forward happy path, for the stages still to come. */
21
+ const HAPPY = ["OPEN", "IN_PROGRESS", "RESOLVED", "CLOSED"];
22
+
23
+ interface Node {
24
+ label: string;
25
+ time: string;
26
+ state: "done" | "current" | "pending";
27
+ reopened: boolean;
28
+ }
29
+
30
+ const nodes = computed<Node[]>(() => {
31
+ const events = props.history.length
32
+ ? props.history.map((h) => ({ status: h.toStatus, at: h.createdAt }))
33
+ : // A row from before history was recorded still gets one node rather than
34
+ // an empty section.
35
+ [{ status: "OPEN", at: props.createdAt }];
36
+
37
+ const out: Node[] = events.map((event, i) => {
38
+ const previous = events[i - 1];
39
+ return {
40
+ label: i === 0 ? (props.locale === "en" ? "Reported" : "รายงานปัญหา") : props.maps.labelOf(event.status),
41
+ time: formatDateTime(event.at, props.timezone, props.locale),
42
+ state: i === events.length - 1 ? "current" : "done",
43
+ reopened: Boolean(previous && props.maps.isClosed(previous.status) && !props.maps.isClosed(event.status)),
44
+ };
45
+ });
46
+
47
+ const index = HAPPY.indexOf(props.status);
48
+ const upcoming = props.maps.isClosed(props.status) || index < 0 ? [] : HAPPY.slice(index + 1);
49
+ for (const status of upcoming) {
50
+ out.push({
51
+ label: props.maps.labelOf(status),
52
+ time: props.locale === "en" ? "Pending" : "รอดำเนินการ",
53
+ state: "pending",
54
+ reopened: false,
55
+ });
56
+ }
57
+ return out;
58
+ });
59
+ </script>
60
+
61
+ <template>
62
+ <ol class="lw-timeline">
63
+ <li v-for="(node, i) in nodes" :key="`${node.label}-${i}`" class="lw-node">
64
+ <div class="lw-rail">
65
+ <span
66
+ v-if="i < nodes.length - 1"
67
+ class="lw-line"
68
+ :class="{ 'lw-line--pending': node.state === 'pending' }"
69
+ />
70
+ <span
71
+ class="lw-dot"
72
+ :class="[`lw-dot--${node.state}`, { 'lw-dot--reopened': node.reopened }]"
73
+ >
74
+ <span v-if="node.state === 'current' && !node.reopened" class="lw-pip" />
75
+ </span>
76
+ </div>
77
+ <div class="lw-node__text" :class="{ 'lw-node__text--last': i === nodes.length - 1 }">
78
+ <div class="lw-node__label" :class="[`lw-node__label--${node.state}`, { 'lw-node__label--reopened': node.reopened }]">
79
+ <RotateIcon v-if="node.reopened" class="lw-node__icon" />
80
+ {{ node.reopened ? `${locale === "en" ? "Reopened" : "เปิดใหม่"} · ${node.label}` : node.label }}
81
+ </div>
82
+ <div class="lw-node__time">{{ node.time }}</div>
83
+ </div>
84
+ </li>
85
+ </ol>
86
+ </template>
@@ -0,0 +1,33 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * One editor toolbar button.
4
+ *
5
+ * It emits on `mousedown` with the default prevented, not on `click`, and that
6
+ * is the whole reason it is a component rather than a `<button>` repeated seven
7
+ * times: a toolbar button taking focus collapses the editor's selection, so by
8
+ * the time `click` fires the command has nothing to apply to. Getting that
9
+ * wrong is invisible until someone selects a word and presses B.
10
+ */
11
+ defineProps<{ label: string; active?: boolean; disabled?: boolean }>();
12
+ const emit = defineEmits<{ (e: "activate"): void }>();
13
+
14
+ function activate(event: MouseEvent): void {
15
+ event.preventDefault();
16
+ emit("activate");
17
+ }
18
+ </script>
19
+
20
+ <template>
21
+ <button
22
+ type="button"
23
+ class="lw-tool"
24
+ :class="{ 'lw-tool--on': active }"
25
+ :title="label"
26
+ :aria-label="label"
27
+ :aria-pressed="active ? 'true' : 'false'"
28
+ :disabled="disabled"
29
+ @mousedown="activate"
30
+ >
31
+ <slot />
32
+ </button>
33
+ </template>