@lightworkai.official/debug-capture-angular 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.
- package/README.md +67 -0
- package/dist/components/icon.component.d.ts +15 -0
- package/dist/components/icons.d.ts +29 -0
- package/dist/components/image-annotator-dialog.component.d.ts +70 -0
- package/dist/components/my-tickets-panel.component.d.ts +100 -0
- package/dist/components/rich-reply-editor.component.d.ts +89 -0
- package/dist/components/ticket-body.component.d.ts +25 -0
- package/dist/components/ticket-detail.component.d.ts +125 -0
- package/dist/components/ticket-table.component.d.ts +75 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.mjs +1592 -0
- package/package.json +56 -0
- package/src/components/icon.component.ts +24 -0
- package/src/components/icons.ts +64 -0
- package/src/components/image-annotator-dialog.component.ts +159 -0
- package/src/components/my-tickets-panel.component.ts +326 -0
- package/src/components/rich-reply-editor.component.ts +287 -0
- package/src/components/ticket-body.component.ts +53 -0
- package/src/components/ticket-detail.component.ts +441 -0
- package/src/components/ticket-table.component.ts +183 -0
- package/src/index.ts +119 -0
|
@@ -0,0 +1,441 @@
|
|
|
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
|
+
Component, ElementRef, EventEmitter, Input, Output, ViewChild, type OnChanges,
|
|
7
|
+
} from "@angular/core";
|
|
8
|
+
import { NgClass, NgFor, NgIf } from "@angular/common";
|
|
9
|
+
import {
|
|
10
|
+
editMyMessage, formatDateTime, ticketKey,
|
|
11
|
+
type MyTicketDetail, type RealmConfig, type StatusEvent, type StatusMaps, type TicketStrings,
|
|
12
|
+
} from "@lightworkai.official/debug-capture";
|
|
13
|
+
import { IconComponent } from "./icon.component";
|
|
14
|
+
import { TicketBodyComponent } from "./ticket-body.component";
|
|
15
|
+
import { RichReplyEditorComponent } from "./rich-reply-editor.component";
|
|
16
|
+
import { ICONS } from "./icons";
|
|
17
|
+
|
|
18
|
+
/** Only the SYSTEM statuses get a tone; an admin-added lane falls back to OPEN. */
|
|
19
|
+
const TONES: Record<string, { icon: string; border: string; bg: string; fg: string; accent: string }> = {
|
|
20
|
+
OPEN: { icon: ICONS.inbox, border: "#bfdbfe", bg: "#eff6ff", fg: "#1e3a8a", accent: "#3b82f6" },
|
|
21
|
+
IN_PROGRESS: { icon: ICONS.clock, border: "#fde68a", bg: "#fffbeb", fg: "#78350f", accent: "#f59e0b" },
|
|
22
|
+
RESOLVED: { icon: ICONS.checkCircle, border: "#a7f3d0", bg: "#ecfdf5", fg: "#064e3b", accent: "#10b981" },
|
|
23
|
+
CLOSED: { icon: ICONS.checkCircle, border: "#e5e7eb", bg: "#f9fafb", fg: "#1f2937", accent: "#9ca3af" },
|
|
24
|
+
WONT_FIX: { icon: ICONS.ban, border: "#e5e7eb", bg: "#f9fafb", fg: "#374151", accent: "#9ca3af" },
|
|
25
|
+
};
|
|
26
|
+
const AWAITING = { icon: ICONS.message, border: "#fcd34d", bg: "#fffbeb", fg: "#78350f", accent: "#f59e0b" };
|
|
27
|
+
|
|
28
|
+
const MESSAGES: Record<string, { th: string; en: string }> = {
|
|
29
|
+
OPEN: { th: "รับเรื่องแล้ว — รอทีมงานตรวจสอบ", en: "Received — waiting for the team to look" },
|
|
30
|
+
IN_PROGRESS: { th: "ทีมงานกำลังดำเนินการแก้ไข", en: "The team is working on it" },
|
|
31
|
+
RESOLVED: { th: "แก้ไขเรียบร้อยแล้ว", en: "Fixed" },
|
|
32
|
+
CLOSED: { th: "ปิดเรื่องแล้ว", en: "Closed" },
|
|
33
|
+
WONT_FIX: { th: "พิจารณาแล้ว — ยังไม่ได้ดำเนินการในขณะนี้", en: "Reviewed — not being worked on for now" },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const HAPPY = ["OPEN", "IN_PROGRESS", "RESOLVED", "CLOSED"];
|
|
37
|
+
|
|
38
|
+
interface TimelineNode {
|
|
39
|
+
label: string;
|
|
40
|
+
time: string;
|
|
41
|
+
state: "done" | "current" | "pending";
|
|
42
|
+
reopened: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@Component({
|
|
46
|
+
selector: "lw-ticket-detail",
|
|
47
|
+
standalone: true,
|
|
48
|
+
imports: [NgIf, NgFor, NgClass, IconComponent, TicketBodyComponent, RichReplyEditorComponent],
|
|
49
|
+
template: `
|
|
50
|
+
<div class="lw-detail">
|
|
51
|
+
<button type="button" class="lw-back" (click)="back.emit()">
|
|
52
|
+
<lw-icon [svg]="icons.arrowLeft" /> {{ t.back }}
|
|
53
|
+
</button>
|
|
54
|
+
|
|
55
|
+
<section class="lw-card">
|
|
56
|
+
<h3 class="lw-card__heading">{{ ticket.title }}</h3>
|
|
57
|
+
<div class="lw-facts">
|
|
58
|
+
<span class="lw-key">{{ key }}</span>
|
|
59
|
+
<span *ngIf="ticket.category" class="lw-chip">{{ ticket.category }}</span>
|
|
60
|
+
<span>{{ t.colCreated }} {{ when(ticket.createdAt) }}</span>
|
|
61
|
+
<span *ngIf="updated">{{ t.colUpdated }} {{ when(ticket.updatedAt) }}</span>
|
|
62
|
+
</div>
|
|
63
|
+
<lw-ticket-body *ngIf="ticket.description" class="lw-intro" [html]="ticket.description" [host]="host" />
|
|
64
|
+
<p *ngIf="ticket.routeUrl" class="lw-route">
|
|
65
|
+
{{ t.page }}:
|
|
66
|
+
<a [href]="ticket.routeUrl" target="_blank" rel="noopener noreferrer">{{ ticket.routeUrl }}</a>
|
|
67
|
+
</p>
|
|
68
|
+
</section>
|
|
69
|
+
|
|
70
|
+
<section class="lw-hero" [style.border-color]="tone.border" [style.background]="tone.bg">
|
|
71
|
+
<lw-icon class="lw-hero__icon" [svg]="tone.icon" [style.color]="tone.accent" />
|
|
72
|
+
<div class="lw-hero__body">
|
|
73
|
+
<p class="lw-hero__kicker">{{ kicker }}</p>
|
|
74
|
+
<p class="lw-hero__headline" [style.color]="tone.fg">{{ headline }}</p>
|
|
75
|
+
<p *ngIf="awaiting" class="lw-hero__nudge">
|
|
76
|
+
{{ lang === "en" ? "Please reply to the team in the conversation below" : "กรุณาตอบกลับให้ทีมงานในช่องแชทด้านล่าง" }}
|
|
77
|
+
</p>
|
|
78
|
+
<div *ngIf="showNote" class="lw-hero__note">
|
|
79
|
+
<lw-icon class="lw-hero__wrench" [svg]="icons.wrench" />
|
|
80
|
+
<lw-ticket-body [html]="ticket.resolutionNote || ''" [host]="host" />
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</section>
|
|
84
|
+
|
|
85
|
+
<section *ngIf="canReopen" class="lw-reopen">
|
|
86
|
+
<div *ngIf="!reopenOpen" class="lw-reopen__row">
|
|
87
|
+
<span class="lw-reopen__hint">{{ lang === "en" ? "Problem not fixed?" : "ปัญหายังไม่หาย?" }}</span>
|
|
88
|
+
<button type="button" class="lw-reopen__open" (click)="reopenOpen = true">
|
|
89
|
+
<lw-icon [svg]="icons.rotate" /> {{ t.reopen }}
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
<div *ngIf="reopenOpen">
|
|
93
|
+
<p class="lw-reopen__title">{{ t.reopen }}</p>
|
|
94
|
+
<textarea #note class="lw-reopen__note" rows="2" [placeholder]="reopenPlaceholder"></textarea>
|
|
95
|
+
<div class="lw-reopen__actions">
|
|
96
|
+
<button type="button" class="lw-reopen__cancel" (click)="reopenOpen = false; note.value = ''">
|
|
97
|
+
{{ lang === "en" ? "Cancel" : "ยกเลิก" }}
|
|
98
|
+
</button>
|
|
99
|
+
<button type="button" class="lw-reopen__confirm" [disabled]="reopening" (click)="reopened.emit(note.value.trim())">
|
|
100
|
+
<lw-icon [svg]="icons.rotate" /> {{ reopening ? t.reopening : t.reopen }}
|
|
101
|
+
</button>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</section>
|
|
105
|
+
|
|
106
|
+
<section class="lw-card">
|
|
107
|
+
<h3 class="lw-card__title">{{ lang === "en" ? "Progress" : "ความคืบหน้า" }}</h3>
|
|
108
|
+
<ol class="lw-timeline">
|
|
109
|
+
<li *ngFor="let node of timeline; let i = index; let last = last" class="lw-node">
|
|
110
|
+
<div class="lw-rail">
|
|
111
|
+
<span *ngIf="!last" class="lw-line" [class.lw-line--pending]="node.state === 'pending'"></span>
|
|
112
|
+
<span class="lw-dot" [ngClass]="'lw-dot--' + node.state" [class.lw-dot--reopened]="node.reopened">
|
|
113
|
+
<span *ngIf="node.state === 'current' && !node.reopened" class="lw-pip"></span>
|
|
114
|
+
</span>
|
|
115
|
+
</div>
|
|
116
|
+
<div class="lw-node__text" [class.lw-node__text--last]="last">
|
|
117
|
+
<div class="lw-node__label" [ngClass]="'lw-node__label--' + node.state" [class.lw-node__label--reopened]="node.reopened">
|
|
118
|
+
<lw-icon *ngIf="node.reopened" class="lw-node__icon" [svg]="icons.rotate" />
|
|
119
|
+
{{ node.reopened ? (lang === "en" ? "Reopened" : "เปิดใหม่") + " · " + node.label : node.label }}
|
|
120
|
+
</div>
|
|
121
|
+
<div class="lw-node__time">{{ node.time }}</div>
|
|
122
|
+
</div>
|
|
123
|
+
</li>
|
|
124
|
+
</ol>
|
|
125
|
+
</section>
|
|
126
|
+
|
|
127
|
+
<section class="lw-card">
|
|
128
|
+
<h3 class="lw-card__title">{{ t.conversation }}</h3>
|
|
129
|
+
<div #thread class="lw-thread">
|
|
130
|
+
<p *ngIf="!ticket.messages.length" class="lw-thread__empty">{{ t.noMessages }}</p>
|
|
131
|
+
<article
|
|
132
|
+
*ngFor="let message of ticket.messages"
|
|
133
|
+
class="lw-msg"
|
|
134
|
+
[class.lw-msg--mine]="message.authorRole === 'REPORTER'"
|
|
135
|
+
>
|
|
136
|
+
<!-- The original's shape exactly: the meta line lives INSIDE a
|
|
137
|
+
bordered bubble, and a TEAM message shows only "ทีมงาน" — never
|
|
138
|
+
the officer's name. That is deliberate there and worth keeping:
|
|
139
|
+
the team answers as one unit, and a reporter should not see
|
|
140
|
+
which person replied. -->
|
|
141
|
+
<div class="lw-msg__bubble">
|
|
142
|
+
<p class="lw-msg__meta">
|
|
143
|
+
<span *ngIf="message.authorRole === 'AGENT'" class="lw-msg__who">{{ t.team }}</span>
|
|
144
|
+
<ng-container *ngIf="message.authorRole !== 'AGENT'">
|
|
145
|
+
<span class="lw-msg__who">{{ message.authorName || t.you }}</span>
|
|
146
|
+
<span class="lw-msg__role">{{ t.reporterRole }}</span>
|
|
147
|
+
</ng-container>
|
|
148
|
+
<span>· {{ when(message.createdAt) }}</span>
|
|
149
|
+
<span *ngIf="message.editedAt" class="lw-msg__edited">· {{ t.edited }}</span>
|
|
150
|
+
<!-- Your own only. The server refuses any message the caller did
|
|
151
|
+
not write, so this button is a convenience, not the rule. -->
|
|
152
|
+
<button
|
|
153
|
+
*ngIf="message.authorRole === 'REPORTER' && editingId !== message.id"
|
|
154
|
+
type="button"
|
|
155
|
+
class="lw-msg__edit"
|
|
156
|
+
(click)="editingId = message.id"
|
|
157
|
+
>
|
|
158
|
+
<lw-icon [svg]="icons.pencil" /> {{ t.edit }}
|
|
159
|
+
</button>
|
|
160
|
+
</p>
|
|
161
|
+
<div *ngIf="editingId === message.id; else plainBody" class="lw-msg__editing">
|
|
162
|
+
<lw-rich-reply-editor
|
|
163
|
+
#draft
|
|
164
|
+
[placeholder]="t.replyPlaceholder"
|
|
165
|
+
[host]="host"
|
|
166
|
+
[locale]="lang"
|
|
167
|
+
[labels]="editorLabels"
|
|
168
|
+
[disabledState]="savingEdit"
|
|
169
|
+
[uploadImage]="uploadImage"
|
|
170
|
+
[initial]="message.body"
|
|
171
|
+
(errored)="errored.emit($event)"
|
|
172
|
+
/>
|
|
173
|
+
<div class="lw-msg__editActions">
|
|
174
|
+
<button type="button" class="lw-btn" [disabled]="savingEdit" (click)="editingId = null">
|
|
175
|
+
<lw-icon [svg]="icons.xSmall" /> {{ t.cancel }}
|
|
176
|
+
</button>
|
|
177
|
+
<button type="button" class="lw-send" [disabled]="savingEdit" (click)="saveEdit(message.id)">
|
|
178
|
+
<lw-icon [svg]="icons.checkSmall" /> {{ t.saveEdit }}
|
|
179
|
+
</button>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
<ng-template #plainBody>
|
|
183
|
+
<lw-ticket-body [html]="message.body" [host]="host" />
|
|
184
|
+
</ng-template>
|
|
185
|
+
</div>
|
|
186
|
+
</article>
|
|
187
|
+
</div>
|
|
188
|
+
<div class="lw-composer">
|
|
189
|
+
<lw-rich-reply-editor
|
|
190
|
+
#editor
|
|
191
|
+
[placeholder]="t.replyPlaceholder"
|
|
192
|
+
[host]="host"
|
|
193
|
+
[locale]="lang"
|
|
194
|
+
[labels]="editorLabels"
|
|
195
|
+
[disabledState]="!!sending"
|
|
196
|
+
[uploadImage]="uploadImage"
|
|
197
|
+
(errored)="errored.emit($event)"
|
|
198
|
+
/>
|
|
199
|
+
<div class="lw-composer__actions">
|
|
200
|
+
<button type="button" class="lw-send" [disabled]="sending" (click)="send()">
|
|
201
|
+
<lw-icon [svg]="icons.send" /> {{ sending ? t.sending : t.send }}
|
|
202
|
+
</button>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</section>
|
|
206
|
+
|
|
207
|
+
<section *ngIf="screenshots.length" class="lw-card">
|
|
208
|
+
<h3 class="lw-card__title">{{ lang === "en" ? "Screenshots you sent" : "ภาพหน้าจอที่คุณแจ้ง" }}</h3>
|
|
209
|
+
<ng-container *ngFor="let shot of screenshots">
|
|
210
|
+
<img *ngIf="shotUrls[shot.id]" class="lw-shot" [src]="shotUrls[shot.id]" [alt]="shot.filename || ''" />
|
|
211
|
+
</ng-container>
|
|
212
|
+
</section>
|
|
213
|
+
</div>
|
|
214
|
+
`,
|
|
215
|
+
})
|
|
216
|
+
export class TicketDetailComponent implements OnChanges {
|
|
217
|
+
@Input({ required: true }) ticket!: MyTicketDetail;
|
|
218
|
+
@Input({ required: true }) history: StatusEvent[] = [];
|
|
219
|
+
@Input({ required: true }) config!: RealmConfig;
|
|
220
|
+
@Input({ required: true }) maps!: StatusMaps;
|
|
221
|
+
@Input({ required: true }) t!: TicketStrings;
|
|
222
|
+
@Input() host = "";
|
|
223
|
+
@Input() locale?: "th" | "en";
|
|
224
|
+
@Input() sending = false;
|
|
225
|
+
@Input() reopening = false;
|
|
226
|
+
@Input({ required: true }) uploadImage!: (file: File) => Promise<string>;
|
|
227
|
+
@Input({ required: true }) set attachmentUrl(read: (id: string) => Promise<string>) {
|
|
228
|
+
this.readUrl = read;
|
|
229
|
+
this.loadShots();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
@Output() back = new EventEmitter<void>();
|
|
233
|
+
@Output() sent = new EventEmitter<string>();
|
|
234
|
+
@Output() reopened = new EventEmitter<string>();
|
|
235
|
+
@Output() edited = new EventEmitter<void>();
|
|
236
|
+
@Output() errored = new EventEmitter<string>();
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The message being rewritten, if any.
|
|
240
|
+
*
|
|
241
|
+
* Only ONE at a time: an open editor per bubble would let someone start three
|
|
242
|
+
* rewrites and lose track of which are unsaved.
|
|
243
|
+
*/
|
|
244
|
+
protected editingId: string | null = null;
|
|
245
|
+
protected savingEdit = false;
|
|
246
|
+
|
|
247
|
+
@ViewChild("draft") private draft?: RichReplyEditorComponent;
|
|
248
|
+
|
|
249
|
+
protected async saveEdit(messageId: string): Promise<void> {
|
|
250
|
+
const body = this.draft?.value() ?? "";
|
|
251
|
+
if (!body || this.savingEdit) return;
|
|
252
|
+
this.savingEdit = true;
|
|
253
|
+
try {
|
|
254
|
+
await editMyMessage(this.ticket.id, messageId, body);
|
|
255
|
+
this.editingId = null;
|
|
256
|
+
this.edited.emit();
|
|
257
|
+
} catch (e) {
|
|
258
|
+
this.errored.emit(e instanceof Error ? e.message : this.t.sendFailed);
|
|
259
|
+
} finally {
|
|
260
|
+
this.savingEdit = false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
@ViewChild("editor") private editor?: RichReplyEditorComponent;
|
|
265
|
+
@ViewChild("thread") private thread?: ElementRef<HTMLElement>;
|
|
266
|
+
private seenMessages = -1;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Open at the newest message, and stay there when one arrives.
|
|
270
|
+
*
|
|
271
|
+
* The thread is a fixed-height window (~320px), so without the first part it
|
|
272
|
+
* opens showing the OLDEST messages and the reader has to scroll to find out
|
|
273
|
+
* what has happened since. Every chat opens at the bottom.
|
|
274
|
+
*
|
|
275
|
+
* The second part matters because the thread sits above the composer: a reply
|
|
276
|
+
* pushes the composer down and out of sight, leaving the reader in the middle
|
|
277
|
+
* of the conversation with no sign their message landed.
|
|
278
|
+
*/
|
|
279
|
+
ngOnChanges(): void {
|
|
280
|
+
const count = this.ticket?.messages.length ?? 0;
|
|
281
|
+
const first = this.seenMessages < 0;
|
|
282
|
+
if (first || count > this.seenMessages) {
|
|
283
|
+
// After the view has been updated with the new message, not before.
|
|
284
|
+
queueMicrotask(() => {
|
|
285
|
+
const box = this.thread?.nativeElement;
|
|
286
|
+
if (!box) return;
|
|
287
|
+
box.scrollTop = box.scrollHeight;
|
|
288
|
+
// Jumped on open — that is where the conversation starts, not a
|
|
289
|
+
// transition from somewhere else, and animating it reads as a glitch.
|
|
290
|
+
if (!first) box.lastElementChild?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
this.seenMessages = count;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
protected readonly icons = ICONS;
|
|
297
|
+
protected reopenOpen = false;
|
|
298
|
+
protected shotUrls: Record<string, string> = {};
|
|
299
|
+
private readUrl?: (id: string) => Promise<string>;
|
|
300
|
+
|
|
301
|
+
protected get lang(): "th" | "en" {
|
|
302
|
+
return this.locale === "en" ? "en" : "th";
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
protected get key(): string {
|
|
306
|
+
return ticketKey(this.config.realm.slug, this.ticket.number);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
protected get updated(): boolean {
|
|
310
|
+
return Boolean(this.ticket.updatedAt && this.ticket.updatedAt !== this.ticket.createdAt);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
protected get awaiting(): boolean {
|
|
314
|
+
return this.maps.isAwaitingReply(this.ticket.status);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
protected get tone() {
|
|
318
|
+
return this.awaiting ? AWAITING : (TONES[this.ticket.status] ?? TONES["OPEN"]!);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
protected get kicker(): string {
|
|
322
|
+
if (this.awaiting) return this.lang === "en" ? "AWAITING YOUR REPLY" : "รอการตอบกลับจากคุณ";
|
|
323
|
+
return this.lang === "en" ? "LATEST STATUS" : "สถานะล่าสุด";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Config-first: an awaiting lane's "please reply" wins, then the lane's own
|
|
328
|
+
* description (so a realm's custom lane carries its own words), then our
|
|
329
|
+
* default copy, then the bare label.
|
|
330
|
+
*/
|
|
331
|
+
protected get headline(): string {
|
|
332
|
+
if (this.awaiting) {
|
|
333
|
+
return this.lang === "en"
|
|
334
|
+
? "The team needs more information from you"
|
|
335
|
+
: "ทีมงานขอข้อมูลเพิ่มเติม — รอการตอบกลับจากคุณ";
|
|
336
|
+
}
|
|
337
|
+
const described = this.maps.columnOf(this.ticket.status)?.description?.trim();
|
|
338
|
+
return described || MESSAGES[this.ticket.status]?.[this.lang] || this.maps.labelOf(this.ticket.status);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/*
|
|
342
|
+
* The solution note, only when the thread does not already carry it. A
|
|
343
|
+
* resolved ticket usually has the team's answer as their last message, and
|
|
344
|
+
* printing the note here too says the same words twice on one screen.
|
|
345
|
+
*/
|
|
346
|
+
protected get showNote(): boolean {
|
|
347
|
+
return (
|
|
348
|
+
!this.awaiting &&
|
|
349
|
+
!this.ticket.messages.some((m) => m.authorRole === "AGENT") &&
|
|
350
|
+
this.maps.isClosed(this.ticket.status) &&
|
|
351
|
+
Boolean(this.ticket.resolutionNote?.trim())
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
protected get canReopen(): boolean {
|
|
356
|
+
return this.maps.isClosed(this.ticket.status) && this.config.featureFlags.selfServiceReopen === true;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
protected get reopenPlaceholder(): string {
|
|
360
|
+
return this.lang === "en"
|
|
361
|
+
? "Tell us what is still happening (optional)…"
|
|
362
|
+
: "บอกเราหน่อยว่ายังพบปัญหาอะไร (ไม่ระบุก็ได้)…";
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
protected get editorLabels() {
|
|
366
|
+
return {
|
|
367
|
+
bold: this.t.bold,
|
|
368
|
+
italic: this.t.italic,
|
|
369
|
+
underline: this.t.underline,
|
|
370
|
+
bulletList: this.t.bulletList,
|
|
371
|
+
orderedList: this.t.orderedList,
|
|
372
|
+
link: this.t.link,
|
|
373
|
+
linkPrompt: this.t.linkPrompt,
|
|
374
|
+
attachImage: this.t.attachImage,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/*
|
|
379
|
+
* Images only. The original's reporter view lists no attachments at all, and
|
|
380
|
+
* what a widget report actually carries makes the reason plain: the other
|
|
381
|
+
* file is `debug-bundle.json`, the capture payload the TEAM reads.
|
|
382
|
+
*/
|
|
383
|
+
protected get screenshots() {
|
|
384
|
+
return this.ticket.attachments.filter((a) => (a.contentType ?? "").startsWith("image/"));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** The lifecycle, chronologically — reopens included. */
|
|
388
|
+
protected get timeline(): TimelineNode[] {
|
|
389
|
+
const events = this.history.length
|
|
390
|
+
? this.history.map((h) => ({ status: h.toStatus, at: h.createdAt }))
|
|
391
|
+
: // A row from before history was recorded still gets one node.
|
|
392
|
+
[{ status: "OPEN", at: this.ticket.createdAt }];
|
|
393
|
+
|
|
394
|
+
const nodes: TimelineNode[] = events.map((event, i) => {
|
|
395
|
+
const previous = events[i - 1];
|
|
396
|
+
return {
|
|
397
|
+
label: i === 0 ? (this.lang === "en" ? "Reported" : "รายงานปัญหา") : this.maps.labelOf(event.status),
|
|
398
|
+
time: this.when(event.at),
|
|
399
|
+
state: i === events.length - 1 ? "current" : "done",
|
|
400
|
+
reopened: Boolean(previous && this.maps.isClosed(previous.status) && !this.maps.isClosed(event.status)),
|
|
401
|
+
};
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const index = HAPPY.indexOf(this.ticket.status);
|
|
405
|
+
const upcoming = this.maps.isClosed(this.ticket.status) || index < 0 ? [] : HAPPY.slice(index + 1);
|
|
406
|
+
for (const status of upcoming) {
|
|
407
|
+
nodes.push({
|
|
408
|
+
label: this.maps.labelOf(status),
|
|
409
|
+
time: this.lang === "en" ? "Pending" : "รอดำเนินการ",
|
|
410
|
+
state: "pending",
|
|
411
|
+
reopened: false,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
return nodes;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
protected when(iso: string | null): string {
|
|
418
|
+
return formatDateTime(iso, this.config.timezone, this.locale);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
protected send(): void {
|
|
422
|
+
const body = this.editor?.value() ?? "";
|
|
423
|
+
if (!body || this.sending) return;
|
|
424
|
+
this.sent.emit(body);
|
|
425
|
+
// Cleared deliberately. It used to happen by accident, because the whole
|
|
426
|
+
// detail was rebuilt after a reply and a fresh editor came back empty.
|
|
427
|
+
this.editor?.clear();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private loadShots(): void {
|
|
431
|
+
if (!this.readUrl || !this.ticket) return;
|
|
432
|
+
for (const shot of this.screenshots) {
|
|
433
|
+
this.readUrl(shot.id).then(
|
|
434
|
+
(url) => (this.shotUrls = { ...this.shotUrls, [shot.id]: url }),
|
|
435
|
+
// Drop the frame rather than leave a broken-image icon where a
|
|
436
|
+
// screenshot should be.
|
|
437
|
+
() => undefined,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
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 { Component, EventEmitter, Input, Output } from "@angular/core";
|
|
9
|
+
import { NgFor, NgIf } from "@angular/common";
|
|
10
|
+
import {
|
|
11
|
+
compareBy, formatDateTime, hasSolution, hasTeamReply, matchesKeyword, pageCount, pageOf,
|
|
12
|
+
statusTone, ticketKey,
|
|
13
|
+
type MyTicketListItem, type Sort, type SortKey, type StatusMaps, type TicketStrings,
|
|
14
|
+
} from "@lightworkai.official/debug-capture";
|
|
15
|
+
import { IconComponent } from "./icon.component";
|
|
16
|
+
import { ICONS } from "./icons";
|
|
17
|
+
|
|
18
|
+
@Component({
|
|
19
|
+
selector: "lw-ticket-table",
|
|
20
|
+
standalone: true,
|
|
21
|
+
imports: [NgFor, NgIf, IconComponent],
|
|
22
|
+
template: `
|
|
23
|
+
<div class="lw-tablewrap">
|
|
24
|
+
<table class="lw-table">
|
|
25
|
+
<thead>
|
|
26
|
+
<tr>
|
|
27
|
+
<th
|
|
28
|
+
*ngFor="let column of columns"
|
|
29
|
+
scope="col"
|
|
30
|
+
class="lw-th"
|
|
31
|
+
[style.width]="column.width"
|
|
32
|
+
[attr.aria-sort]="ariaSort(column.key)"
|
|
33
|
+
(click)="sorted.emit(column.key)"
|
|
34
|
+
>
|
|
35
|
+
{{ label(column.key) }}
|
|
36
|
+
<span class="lw-arrow">{{ arrow(column.key) }}</span>
|
|
37
|
+
</th>
|
|
38
|
+
</tr>
|
|
39
|
+
</thead>
|
|
40
|
+
<tbody>
|
|
41
|
+
<tr *ngIf="!rows.length">
|
|
42
|
+
<td [attr.colspan]="columns.length" class="lw-empty">
|
|
43
|
+
{{ items.length === 0 ? t.empty : t.emptyFiltered }}
|
|
44
|
+
</td>
|
|
45
|
+
</tr>
|
|
46
|
+
<tr
|
|
47
|
+
*ngFor="let row of rows"
|
|
48
|
+
class="lw-row"
|
|
49
|
+
tabindex="0"
|
|
50
|
+
(click)="opened.emit(row.id)"
|
|
51
|
+
(keydown.enter)="opened.emit(row.id)"
|
|
52
|
+
(keydown.space)="opened.emit(row.id)"
|
|
53
|
+
>
|
|
54
|
+
<td class="lw-num">{{ key(row) }}</td>
|
|
55
|
+
<td class="lw-title">{{ row.title }}</td>
|
|
56
|
+
<td>
|
|
57
|
+
<span *ngIf="row.category; else noModule" class="lw-chip">{{ row.category }}</span>
|
|
58
|
+
<ng-template #noModule><span class="lw-dash">—</span></ng-template>
|
|
59
|
+
</td>
|
|
60
|
+
<td>
|
|
61
|
+
<!-- "Waiting on you" outranks the neutral status: it is the honest
|
|
62
|
+
state and the only one the reporter can act on. -->
|
|
63
|
+
<span *ngIf="maps.isAwaitingReply(row.status); else plainStatus" class="lw-pill lw-pill--awaiting">
|
|
64
|
+
<lw-icon [svg]="icons.message" /> {{ t.awaitingReply }}
|
|
65
|
+
</span>
|
|
66
|
+
<ng-template #plainStatus>
|
|
67
|
+
<span class="lw-status" [style.background]="tone(row.status).bg" [style.color]="tone(row.status).fg">
|
|
68
|
+
{{ maps.labelOf(row.status) }}
|
|
69
|
+
</span>
|
|
70
|
+
</ng-template>
|
|
71
|
+
</td>
|
|
72
|
+
<td>
|
|
73
|
+
<span *ngIf="solved(row)" class="lw-pill lw-pill--solution">
|
|
74
|
+
<lw-icon [svg]="icons.check" /> {{ t.hasSolution }}
|
|
75
|
+
</span>
|
|
76
|
+
<span *ngIf="!solved(row) && replied(row)" class="lw-pill lw-pill--replied">
|
|
77
|
+
<lw-icon [svg]="icons.message" /> {{ t.hasReply }}
|
|
78
|
+
</span>
|
|
79
|
+
<span *ngIf="!solved(row) && !replied(row)" class="lw-muted">{{ t.noReply }}</span>
|
|
80
|
+
</td>
|
|
81
|
+
<td class="lw-dim">{{ when(row.createdAt) }}</td>
|
|
82
|
+
<td class="lw-dim">{{ when(row.updatedAt) }}</td>
|
|
83
|
+
</tr>
|
|
84
|
+
</tbody>
|
|
85
|
+
</table>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<div *ngIf="pages > 1" class="lw-pager">
|
|
89
|
+
<span>{{ t.of(current, pages) }}</span>
|
|
90
|
+
<button type="button" class="lw-btn" [disabled]="current <= 1" (click)="paged.emit(current - 1)">
|
|
91
|
+
{{ t.prev }}
|
|
92
|
+
</button>
|
|
93
|
+
<button type="button" class="lw-btn" [disabled]="current >= pages" (click)="paged.emit(current + 1)">
|
|
94
|
+
{{ t.next }}
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
`,
|
|
98
|
+
})
|
|
99
|
+
export class TicketTableComponent {
|
|
100
|
+
@Input({ required: true }) items: MyTicketListItem[] = [];
|
|
101
|
+
@Input({ required: true }) maps!: StatusMaps;
|
|
102
|
+
@Input({ required: true }) t!: TicketStrings;
|
|
103
|
+
@Input() slug?: string;
|
|
104
|
+
@Input() timezone = "Asia/Bangkok";
|
|
105
|
+
@Input() locale?: "th" | "en";
|
|
106
|
+
@Input() keyword = "";
|
|
107
|
+
@Input() statusFilter = "";
|
|
108
|
+
@Input({ required: true }) sort!: Sort;
|
|
109
|
+
@Input() page = 1;
|
|
110
|
+
|
|
111
|
+
@Output() opened = new EventEmitter<string>();
|
|
112
|
+
@Output() sorted = new EventEmitter<SortKey>();
|
|
113
|
+
@Output() paged = new EventEmitter<number>();
|
|
114
|
+
@Output() counted = new EventEmitter<number>();
|
|
115
|
+
|
|
116
|
+
protected readonly icons = ICONS;
|
|
117
|
+
protected readonly columns: { key: SortKey; label: keyof TicketStrings; width?: string }[] = [
|
|
118
|
+
{ key: "number", label: "colNumber", width: "120px" },
|
|
119
|
+
{ key: "title", label: "colTitle" },
|
|
120
|
+
{ key: "module", label: "colModule", width: "160px" },
|
|
121
|
+
{ key: "status", label: "colStatus", width: "130px" },
|
|
122
|
+
{ key: "response", label: "colResponse", width: "150px" },
|
|
123
|
+
{ key: "createdAt", label: "colCreated", width: "170px" },
|
|
124
|
+
{ key: "updatedAt", label: "colUpdated", width: "170px" },
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
protected get visible(): MyTicketListItem[] {
|
|
128
|
+
const rows = this.items
|
|
129
|
+
.filter((item) => (this.statusFilter ? item.status === this.statusFilter : true))
|
|
130
|
+
.filter((item) => matchesKeyword(item, this.keyword, this.slug, this.maps.labelOf))
|
|
131
|
+
.sort(compareBy(this.sort, this.maps, this.slug));
|
|
132
|
+
this.counted.emit(rows.length);
|
|
133
|
+
return rows;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
protected get pages(): number {
|
|
137
|
+
return pageCount(this.visible.length);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A filter can strand the reader past the end of the shorter list. */
|
|
141
|
+
protected get current(): number {
|
|
142
|
+
return Math.min(this.page, this.pages);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
protected get rows(): MyTicketListItem[] {
|
|
146
|
+
return pageOf(this.visible, this.current);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
protected label(key: SortKey): string {
|
|
150
|
+
const column = this.columns.find((c) => c.key === key)!;
|
|
151
|
+
return this.t[column.label] as string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
protected arrow(key: SortKey): string {
|
|
155
|
+
if (this.sort.key !== key) return "⇅";
|
|
156
|
+
return this.sort.direction === "asc" ? "▲" : "▼";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
protected ariaSort(key: SortKey): string | null {
|
|
160
|
+
if (this.sort.key !== key) return null;
|
|
161
|
+
return this.sort.direction === "asc" ? "ascending" : "descending";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
protected key(row: MyTicketListItem): string {
|
|
165
|
+
return ticketKey(this.slug, row.number);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
protected tone(status: string): { fg: string; bg: string } {
|
|
169
|
+
return statusTone(this.maps.columnOf(status)?.color);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
protected solved(row: MyTicketListItem): boolean {
|
|
173
|
+
return hasSolution(row);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
protected replied(row: MyTicketListItem): boolean {
|
|
177
|
+
return hasTeamReply(row);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
protected when(iso: string | null): string {
|
|
181
|
+
return formatDateTime(iso, this.timezone, this.locale);
|
|
182
|
+
}
|
|
183
|
+
}
|