@consilioweb/payload-support 0.9.10 → 0.9.12

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.
@@ -134,7 +134,9 @@ const TicketDetailClient = () => {
134
134
  const [sendAsClient, setSendAsClient] = useState(false);
135
135
  const [editingMsgId, setEditingMsgId] = useState(null);
136
136
  const [editingBody, setEditingBody] = useState("");
137
+ const [editingHtml, setEditingHtml] = useState("");
137
138
  const [editSaving, setEditSaving] = useState(false);
139
+ const editEditorRef = useRef(null);
138
140
  const [sending, setSending] = useState(false);
139
141
  const [showMenu, setShowMenu] = useState(false);
140
142
  const [clientTyping, setClientTyping] = useState(false);
@@ -476,10 +478,12 @@ ${uploadedLinks.join("\n")}` : replyBody.trim() || "[Contenu enrichi]";
476
478
  const startEditMessage = (msg) => {
477
479
  setEditingMsgId(msg.id);
478
480
  setEditingBody(msg.body || "");
481
+ setEditingHtml(msg.bodyHtml || (msg.body || "").replace(/\n/g, "<br/>"));
479
482
  };
480
483
  const cancelEditMessage = () => {
481
484
  setEditingMsgId(null);
482
485
  setEditingBody("");
486
+ setEditingHtml("");
483
487
  };
484
488
  const saveEditMessage = async () => {
485
489
  if (editingMsgId === null) return;
@@ -489,11 +493,12 @@ ${uploadedLinks.join("\n")}` : replyBody.trim() || "[Contenu enrichi]";
489
493
  method: "PATCH",
490
494
  headers: { "Content-Type": "application/json" },
491
495
  credentials: "include",
492
- body: JSON.stringify({ body: editingBody, bodyHtml: null, skipNotification: true })
496
+ body: JSON.stringify({ body: editingBody, bodyHtml: editingHtml || null, skipNotification: true })
493
497
  });
494
498
  if (res.ok) {
495
499
  setEditingMsgId(null);
496
500
  setEditingBody("");
501
+ setEditingHtml("");
497
502
  fetchAll();
498
503
  }
499
504
  } catch {
@@ -712,12 +717,29 @@ ${uploadedLinks.join("\n")}` : replyBody.trim() || "[Contenu enrichi]";
712
717
  ] }),
713
718
  editingMsgId === msg.id ? /* @__PURE__ */ jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [
714
719
  /* @__PURE__ */ jsx(
715
- "textarea",
720
+ RichTextEditor,
716
721
  {
717
- value: editingBody,
718
- onChange: (e) => setEditingBody(e.target.value),
719
- style: { width: "100%", minHeight: 120, padding: 10, fontSize: 13, lineHeight: 1.5, fontFamily: "inherit", border: "1px solid var(--theme-elevation-200)", borderRadius: 6, background: "var(--theme-elevation-0)", color: "var(--theme-text)" },
720
- autoFocus: true
722
+ ref: editEditorRef,
723
+ initialValue: editingHtml,
724
+ onChange: (html, text) => {
725
+ setEditingHtml(html);
726
+ setEditingBody(text);
727
+ },
728
+ placeholder: "\xC9diter le message...",
729
+ minHeight: 120,
730
+ onFileUpload: async (file) => {
731
+ try {
732
+ const formData = new FormData();
733
+ formData.append("file", file);
734
+ formData.append("_payload", JSON.stringify({ alt: file.name }));
735
+ const ur = await fetch("/api/media", { method: "POST", credentials: "include", body: formData });
736
+ if (!ur.ok) return null;
737
+ const ud = await ur.json();
738
+ return ud.doc?.url || null;
739
+ } catch {
740
+ return null;
741
+ }
742
+ }
721
743
  }
722
744
  ),
723
745
  /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 6, justifyContent: "flex-end" }, children: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consilioweb/payload-support",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
4
4
  "description": "Payload CMS plugin — professional support & ticketing system with AI, SLA, time tracking, live chat, and more",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -13,6 +13,7 @@ import { dispatchWebhook } from '../utils/webhookDispatcher'
13
13
  import { readSupportSettings } from '../utils/readSettings'
14
14
  import { createTicketStatusEmail } from '../hooks/ticketStatusEmail'
15
15
  import { createAssignSlaDeadlines, createCheckSlaOnResolve } from '../hooks/checkSLA'
16
+ import { generateTicketSynthesis } from '../utils/generateTicketSynthesis'
16
17
 
17
18
  // ─── Hooks ───────────────────────────────────────────────
18
19
 
@@ -154,6 +155,40 @@ function createTrackSLA(slugs: CollectionSlugs): CollectionAfterChangeHook {
154
155
  }
155
156
  }
156
157
 
158
+ function createTrackAiSummaryOnResolve(slugs: CollectionSlugs): CollectionAfterChangeHook {
159
+ return async ({ doc, previousDoc, operation, req }) => {
160
+ if (operation !== 'update' || !previousDoc) return doc
161
+ const wasResolved = previousDoc.status === 'resolved'
162
+ const isResolved = doc.status === 'resolved'
163
+
164
+ // Reopening: clear the cached summary so the next resolve regenerates it
165
+ if (wasResolved && !isResolved && doc.aiSummary) {
166
+ try {
167
+ await req.payload.update({
168
+ collection: slugs.tickets,
169
+ id: doc.id,
170
+ data: { aiSummary: null, aiSummaryGeneratedAt: null, aiSummaryStatus: null },
171
+ overrideAccess: true,
172
+ })
173
+ } catch (err) {
174
+ console.error('[support] Failed to clear ai summary on reopen:', err)
175
+ }
176
+ return doc
177
+ }
178
+
179
+ // Just resolved: trigger generation if not already cached.
180
+ // Fire-and-forget so the admin UI doesn't block on the LLM call.
181
+ if (!wasResolved && isResolved && !doc.aiSummary) {
182
+ // setImmediate keeps the response snappy; failures are logged inside the util.
183
+ setImmediate(() => {
184
+ generateTicketSynthesis({ payload: req.payload, slugs, ticketId: doc.id })
185
+ .catch((err) => console.error('[support] Background ai synthesis failed:', err))
186
+ })
187
+ }
188
+ return doc
189
+ }
190
+ }
191
+
157
192
  function createLogTicketActivity(slugs: CollectionSlugs): CollectionAfterChangeHook {
158
193
  return async ({ doc, previousDoc, operation, req }) => {
159
194
  if (operation !== 'update' || !previousDoc) return doc
@@ -596,6 +631,48 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
596
631
  admin: { initCollapsed: true },
597
632
  fields: billingFields,
598
633
  },
634
+ // AI Synthesis collapsible — auto-filled when ticket is resolved
635
+ {
636
+ type: 'collapsible', label: 'Synthese IA',
637
+ admin: {
638
+ initCollapsed: true,
639
+ description: 'Recap factuel genere automatiquement au passage en resolu. Sert au copier-coller dans devis/factures.',
640
+ },
641
+ fields: [
642
+ {
643
+ name: 'aiSummary',
644
+ type: 'textarea',
645
+ label: 'Synthese',
646
+ admin: {
647
+ readOnly: true,
648
+ rows: 8,
649
+ description: 'Vide tant que le ticket n\'est pas resolu. Effacee si le ticket est reouvert.',
650
+ },
651
+ },
652
+ {
653
+ type: 'row',
654
+ fields: [
655
+ {
656
+ name: 'aiSummaryGeneratedAt',
657
+ type: 'date',
658
+ label: 'Genere le',
659
+ admin: { readOnly: true, width: '50%', date: { displayFormat: 'dd/MM/yyyy HH:mm' } },
660
+ },
661
+ {
662
+ name: 'aiSummaryStatus',
663
+ type: 'select',
664
+ label: 'Statut',
665
+ options: [
666
+ { label: 'En cours', value: 'pending' },
667
+ { label: 'Genere', value: 'done' },
668
+ { label: 'Erreur', value: 'error' },
669
+ ],
670
+ admin: { readOnly: true, width: '50%' },
671
+ },
672
+ ],
673
+ },
674
+ ],
675
+ },
599
676
  // SLA & Delais
600
677
  {
601
678
  type: 'collapsible', label: 'SLA & Delais',
@@ -710,6 +787,7 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
710
787
  ],
711
788
  afterChange: [
712
789
  createTrackSLA(slugs),
790
+ createTrackAiSummaryOnResolve(slugs),
713
791
  createAutoCalculateSLA(slugs),
714
792
  createAssignSlaDeadlines(slugs, notificationSlug),
715
793
  createCheckSlaOnResolve(slugs, notificationSlug),
@@ -89,22 +89,46 @@ export function useAI(
89
89
 
90
90
  const handleAiRewrite = async (style: string = 'auto') => {
91
91
  if (!replyBody.trim()) return
92
+ // Detect selection inside the editor — if present, rewrite only the selected text
93
+ const sel = typeof window !== 'undefined' ? window.getSelection() : null
94
+ const selectedText = sel?.toString().trim() || ''
95
+ const hasSelection = selectedText.length > 3
96
+ const textToRewrite = hasSelection ? selectedText : replyBody
97
+
92
98
  setAiRewriting(true)
93
99
  try {
94
100
  const res = await fetch('/api/support/ai', {
95
101
  method: 'POST',
96
102
  headers: { 'Content-Type': 'application/json' },
97
103
  credentials: 'include',
98
- body: JSON.stringify({ action: 'rewrite', text: replyBody, style }),
104
+ body: JSON.stringify({ action: 'rewrite', text: textToRewrite, style }),
99
105
  })
100
106
  if (res.ok) {
101
107
  const data = await res.json()
102
108
  const rewritten = data.rewritten || ''
103
109
  if (rewritten) {
104
- setReplyBody(rewritten)
105
- setReplyHtml(rewritten.replace(/\n/g, '<br/>'))
106
- if (replyEditorRef.current?.setContent) {
107
- replyEditorRef.current.setContent(rewritten.replace(/\n/g, '<br/>'))
110
+ if (hasSelection && sel && sel.rangeCount > 0) {
111
+ // Replace only the selected text in the contentEditable
112
+ const range = sel.getRangeAt(0)
113
+ range.deleteContents()
114
+ range.insertNode(document.createTextNode(rewritten))
115
+ // Trigger input event so parent state updates via onInput
116
+ const editorEl = replyEditorRef.current as unknown as { focus?: () => void } | null
117
+ editorEl?.focus?.()
118
+ // Sync state from DOM
119
+ const rootEl = (range.commonAncestorContainer as HTMLElement).closest?.('[contenteditable]') as HTMLElement | null
120
+ if (rootEl) {
121
+ const newHtml = rootEl.innerHTML
122
+ const newText = rootEl.innerText?.trim() || ''
123
+ setReplyBody(newText)
124
+ setReplyHtml(newHtml)
125
+ }
126
+ } else {
127
+ setReplyBody(rewritten)
128
+ setReplyHtml(rewritten.replace(/\n/g, '<br/>'))
129
+ if (replyEditorRef.current?.setContent) {
130
+ replyEditorRef.current.setContent(rewritten.replace(/\n/g, '<br/>'))
131
+ }
108
132
  }
109
133
  }
110
134
  }
@@ -172,17 +172,22 @@ Rédige une réponse appropriée au dernier message du client. Sois concis (3-5
172
172
  const { text, style } = body as { text: string; style?: string }
173
173
  if (!text?.trim()) return Response.json({ error: 'text required' }, { status: 400 })
174
174
 
175
- const styleInstructions: Record<string, string> = {
176
- auto: 'Garde le même ton (tutoiement/vouvoiement).',
177
- tutoyer: 'Utilise le tutoiement. Si le texte vouvoie, convertis en tutoiement.',
178
- vouvoyer: 'Utilise le vouvoiement. Si le texte tutoie, convertis en vouvoiement.',
179
- formel: 'Adopte un ton formel et professionnel avec vouvoiement.',
180
- court: 'Raccourcis le texte au maximum tout en gardant le sens. Sois concis et direct.',
181
- amical: 'Adopte un ton chaleureux et amical avec tutoiement.',
175
+ const styleInstructions: Record<string, { tone: string; person: string }> = {
176
+ auto: { tone: 'Garde le ton actuel (neutre professionnel).', person: 'IMPORTANT: Préserve EXACTEMENT le tutoiement ou vouvoiement du texte original.' },
177
+ tutoyer: { tone: 'Ton décontracté et direct, mais correct.', person: 'CRITIQUE: Utilise IMPÉRATIVEMENT le tutoiement partout (tu, ton, te, toi). Si le texte vouvoie, convertis TOUT en tutoiement. Exemple: "vous pouvez" → "tu peux", "votre" → "ton".' },
178
+ vouvoyer: { tone: 'Ton neutre et poli.', person: 'CRITIQUE: Utilise IMPÉRATIVEMENT le vouvoiement partout (vous, votre, etc). Si le texte tutoie, convertis TOUT en vouvoiement.' },
179
+ formel: { tone: 'Ton formel et institutionnel.', person: 'Utilise le vouvoiement partout.' },
180
+ court: { tone: 'Style concis et direct, phrases courtes, aller à l\'essentiel.', person: 'Préserve le tutoiement/vouvoiement du texte original.' },
181
+ amical: { tone: 'Ton chaleureux, amical, sympathique, avec un peu de cordialité.', person: 'CRITIQUE: Utilise IMPÉRATIVEMENT le tutoiement partout. Convertis le vouvoiement en tutoiement.' },
182
182
  }
183
183
  const styleGuide = styleInstructions[style || 'auto'] || styleInstructions.auto
184
184
 
185
- const prompt = `Tu es un agent de support technique professionnel. Reformule le texte ci-dessous de manière plus professionnelle et corrige les fautes d'orthographe/grammaire. ${styleGuide} Ne change pas le fond du message, améliore uniquement la forme. Réponds UNIQUEMENT avec le texte reformulé, sans commentaire ni explication.
185
+ const prompt = `Reformule le texte ci-dessous en corrigeant les fautes d'orthographe/grammaire. Ne change pas le fond du message, améliore uniquement la forme.
186
+
187
+ TON REQUIS: ${styleGuide.tone}
188
+ FORME REQUISE: ${styleGuide.person}
189
+
190
+ Réponds UNIQUEMENT avec le texte reformulé, sans commentaire ni explication.
186
191
 
187
192
  Texte original :
188
193
  ${text}`
@@ -8,6 +8,10 @@ const MAX_PAGES = 50
8
8
  /**
9
9
  * GET /api/support/billing?from=...&to=...&projectId=...
10
10
  * Admin-only endpoint returning billing data.
11
+ *
12
+ * Includes ALL billable tickets active during the period (i.e. updated/created/resolved
13
+ * between from and to) — even those without any time entries, so the admin can spot
14
+ * tickets where time was forgotten.
11
15
  */
12
16
  export function createBillingEndpoint(slugs: CollectionSlugs): Endpoint {
13
17
  return {
@@ -29,12 +33,43 @@ export function createBillingEndpoint(slugs: CollectionSlugs): Endpoint {
29
33
 
30
34
  const projectId = url.searchParams.get('projectId')
31
35
 
36
+ // Build an exclusive upper-bound for ISO datetime fields:
37
+ // when filtering on updatedAt / resolvedAt with `to=2026-04-30`, we want to include
38
+ // events that happened on April 30 after 00:00.
39
+ const toExclusive = new Date(to)
40
+ toExclusive.setDate(toExclusive.getDate() + 1)
41
+ const toExclusiveIso = toExclusive.toISOString()
42
+
32
43
  const ticketWhere: Where = {
33
- billable: { equals: true },
34
- ...(projectId ? { project: { equals: Number(projectId) } } : {}),
44
+ and: [
45
+ { billable: { equals: true } },
46
+ ...(projectId ? [{ project: { equals: Number(projectId) } } as Where] : []),
47
+ {
48
+ or: [
49
+ {
50
+ and: [
51
+ { updatedAt: { greater_than_equal: from } },
52
+ { updatedAt: { less_than: toExclusiveIso } },
53
+ ],
54
+ },
55
+ {
56
+ and: [
57
+ { createdAt: { greater_than_equal: from } },
58
+ { createdAt: { less_than: toExclusiveIso } },
59
+ ],
60
+ },
61
+ {
62
+ and: [
63
+ { resolvedAt: { greater_than_equal: from } },
64
+ { resolvedAt: { less_than: toExclusiveIso } },
65
+ ],
66
+ },
67
+ ],
68
+ },
69
+ ],
35
70
  }
36
71
 
37
- // Paginate tickets instead of limit:0
72
+ // Paginate tickets
38
73
  const allTickets: Array<Record<string, unknown>> = []
39
74
  let ticketPage = 1
40
75
  let ticketHasMore = true
@@ -53,7 +88,7 @@ export function createBillingEndpoint(slugs: CollectionSlugs): Endpoint {
53
88
  ticketPage++
54
89
  }
55
90
 
56
- // Paginate time entries instead of limit:0
91
+ // Paginate time entries within the period
57
92
  const allEntries: Array<Record<string, unknown>> = []
58
93
  let entryPage = 1
59
94
  let entryHasMore = true
@@ -94,15 +129,27 @@ export function createBillingEndpoint(slugs: CollectionSlugs): Endpoint {
94
129
  const projectGroups = new Map<string, {
95
130
  project: { id: number; name: string } | null
96
131
  client: { company: string } | null
97
- tickets: Array<{ id: number; ticketNumber: string; subject: string; entries: any[]; totalMinutes: number; billedAmount: number | null }>
132
+ tickets: Array<{
133
+ id: number
134
+ ticketNumber: string
135
+ subject: string
136
+ status: string
137
+ entries: Array<{ duration: number; description: string; date: string }>
138
+ totalMinutes: number
139
+ billedAmount: number | null
140
+ hasNoTimeEntries: boolean
141
+ aiSummary: string | null
142
+ aiSummaryGeneratedAt: string | null
143
+ aiSummaryStatus: string | null
144
+ }>
98
145
  totalMinutes: number
99
146
  totalBilledAmount: number
100
147
  }>()
101
148
 
102
149
  for (const ticket of allTickets) {
103
150
  const t = ticket as any
104
- const ticketEntries = entriesByTicket.get(t.id)
105
- if (!ticketEntries || ticketEntries.length === 0) continue
151
+ const ticketEntries = entriesByTicket.get(t.id) || []
152
+ const hasNoTimeEntries = ticketEntries.length === 0
106
153
 
107
154
  const project = typeof t.project === 'object' && t.project
108
155
  ? { id: t.project.id, name: t.project.name || 'Sans nom' }
@@ -138,9 +185,14 @@ export function createBillingEndpoint(slugs: CollectionSlugs): Endpoint {
138
185
  id: t.id,
139
186
  ticketNumber: t.ticketNumber || '',
140
187
  subject: t.subject || '',
188
+ status: t.status || '',
141
189
  entries: ticketEntries,
142
190
  totalMinutes: ticketTotalMinutes,
143
191
  billedAmount,
192
+ hasNoTimeEntries,
193
+ aiSummary: t.aiSummary || null,
194
+ aiSummaryGeneratedAt: t.aiSummaryGeneratedAt || null,
195
+ aiSummaryStatus: t.aiSummaryStatus || null,
144
196
  })
145
197
  projectGroups.get(projectKey)!.totalMinutes += ticketTotalMinutes
146
198
  if (billedAmount) projectGroups.get(projectKey)!.totalBilledAmount += billedAmount
@@ -149,8 +201,17 @@ export function createBillingEndpoint(slugs: CollectionSlugs): Endpoint {
149
201
  const groups = Array.from(projectGroups.values())
150
202
  const grandTotalMinutes = groups.reduce((sum, g) => sum + g.totalMinutes, 0)
151
203
  const grandTotalBilledAmount = groups.reduce((sum, g) => sum + g.totalBilledAmount, 0)
152
-
153
- return new Response(JSON.stringify({ groups, grandTotalMinutes, grandTotalBilledAmount }), {
204
+ const ticketsWithoutTime = groups.reduce(
205
+ (sum, g) => sum + g.tickets.filter((t) => t.hasNoTimeEntries).length,
206
+ 0,
207
+ )
208
+
209
+ return new Response(JSON.stringify({
210
+ groups,
211
+ grandTotalMinutes,
212
+ grandTotalBilledAmount,
213
+ ticketsWithoutTime,
214
+ }), {
154
215
  headers: {
155
216
  'Content-Type': 'application/json',
156
217
  'Cache-Control': 'private, max-age=300, stale-while-revalidate=600',
@@ -25,6 +25,7 @@ import { createAdminChatGetEndpoint, createAdminChatPostEndpoint } from './admin
25
25
  import { createAdminChatStreamEndpoint } from './admin-chat-stream'
26
26
  import { createAdminStatsEndpoint } from './admin-stats'
27
27
  import { createBillingEndpoint } from './billing'
28
+ import { createTicketSynthesisEndpoint } from './ticket-synthesis'
28
29
  import { createEmailStatsEndpoint } from './email-stats'
29
30
  import { createSatisfactionEndpoint } from './satisfaction'
30
31
  import { createTrackOpenEndpoint } from './track-open'
@@ -65,6 +66,7 @@ export { createAdminChatGetEndpoint, createAdminChatPostEndpoint } from './admin
65
66
  export { createAdminChatStreamEndpoint } from './admin-chat-stream'
66
67
  export { createAdminStatsEndpoint } from './admin-stats'
67
68
  export { createBillingEndpoint } from './billing'
69
+ export { createTicketSynthesisEndpoint } from './ticket-synthesis'
68
70
  export { createEmailStatsEndpoint } from './email-stats'
69
71
  export { createSatisfactionEndpoint } from './satisfaction'
70
72
  export { createTrackOpenEndpoint } from './track-open'
@@ -121,6 +123,7 @@ export function createSupportEndpoints(slugs: CollectionSlugs, options?: Support
121
123
  if (!f || f.ai !== false) {
122
124
  endpoints.push(createAiEndpoint(slugs))
123
125
  endpoints.push(...createClientIntelligenceEndpoint(slugs))
126
+ endpoints.push(createTicketSynthesisEndpoint(slugs))
124
127
  }
125
128
  if (!f || f.bulkActions !== false) endpoints.push(createBulkActionEndpoint(slugs))
126
129
  if (!f || f.merge !== false) endpoints.push(createMergeTicketsEndpoint(slugs))
@@ -0,0 +1,67 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { requireAdmin, handleAuthError } from '../utils/auth'
4
+ import { generateTicketSynthesis } from '../utils/generateTicketSynthesis'
5
+
6
+ /**
7
+ * POST /api/support/ticket-synthesis?ticketId=X[&force=true]
8
+ *
9
+ * Admin-only. Generates (or returns the cached) AI bullet-point synthesis for a single ticket.
10
+ * The synthesis is persisted on the ticket itself (aiSummary, aiSummaryGeneratedAt, aiSummaryStatus).
11
+ *
12
+ * Cache behaviour:
13
+ * - If aiSummary already exists and force is not set, returns the cached value (status: cached).
14
+ * - If force=true, regenerates regardless of existing summary.
15
+ * - The hook on Tickets clears aiSummary when a ticket is reopened, so the next pass through
16
+ * "resolved" status will trigger a regeneration automatically.
17
+ */
18
+ export function createTicketSynthesisEndpoint(slugs: CollectionSlugs): Endpoint {
19
+ return {
20
+ path: '/support/ticket-synthesis',
21
+ method: 'post',
22
+ handler: async (req) => {
23
+ try {
24
+ requireAdmin(req, slugs)
25
+ const payload = req.payload
26
+
27
+ const url = new URL(req.url || '', 'http://localhost')
28
+ const ticketIdRaw = url.searchParams.get('ticketId')
29
+ const force = url.searchParams.get('force') === 'true'
30
+
31
+ if (!ticketIdRaw) {
32
+ return Response.json({ error: 'ticketId required' }, { status: 400 })
33
+ }
34
+
35
+ const ticketId = Number(ticketIdRaw)
36
+ if (Number.isNaN(ticketId)) {
37
+ return Response.json({ error: 'ticketId must be a number' }, { status: 400 })
38
+ }
39
+
40
+ if (!force) {
41
+ const existing = await payload.findByID({
42
+ collection: slugs.tickets as any,
43
+ id: ticketId,
44
+ depth: 0,
45
+ overrideAccess: true,
46
+ }) as { aiSummary?: string; aiSummaryGeneratedAt?: string; aiSummaryStatus?: string } | null
47
+
48
+ if (existing?.aiSummary && existing.aiSummaryStatus === 'done') {
49
+ return Response.json({
50
+ summary: existing.aiSummary,
51
+ generatedAt: existing.aiSummaryGeneratedAt,
52
+ status: 'cached',
53
+ })
54
+ }
55
+ }
56
+
57
+ const result = await generateTicketSynthesis({ payload, slugs, ticketId })
58
+ return Response.json(result)
59
+ } catch (err) {
60
+ const authResponse = handleAuthError(err)
61
+ if (authResponse) return authResponse
62
+ console.error('[support/ticket-synthesis] Error:', err)
63
+ return Response.json({ error: 'Internal server error' }, { status: 500 })
64
+ }
65
+ },
66
+ }
67
+ }
@@ -276,6 +276,138 @@
276
276
  color: #16a34a;
277
277
  }
278
278
 
279
+ // Warning banner — tickets without time
280
+ .warningBanner {
281
+ padding: 10px 16px;
282
+ border: 1px solid #f59e0b;
283
+ border-radius: 8px;
284
+ background: rgba(245, 158, 11, 0.08);
285
+ margin-bottom: 16px;
286
+ display: flex;
287
+ justify-content: space-between;
288
+ align-items: center;
289
+ flex-wrap: wrap;
290
+ gap: 12px;
291
+ font-size: 13px;
292
+ color: var(--theme-text);
293
+ }
294
+
295
+ .toggleLabel {
296
+ display: flex;
297
+ align-items: center;
298
+ gap: 6px;
299
+ cursor: pointer;
300
+ font-weight: 600;
301
+ user-select: none;
302
+ }
303
+
304
+ .noTimeBadge {
305
+ display: inline-block;
306
+ margin-left: 8px;
307
+ padding: 2px 8px;
308
+ border-radius: 999px;
309
+ background: rgba(245, 158, 11, 0.15);
310
+ color: #b45309;
311
+ font-size: 11px;
312
+ font-weight: 700;
313
+ white-space: nowrap;
314
+ }
315
+
316
+ .tableRowNoTime {
317
+ border-bottom: 1px solid var(--theme-elevation-200);
318
+ background: rgba(245, 158, 11, 0.04);
319
+ }
320
+
321
+ // AI summary toggle button
322
+ .summaryBtn {
323
+ display: inline-block;
324
+ margin-left: 6px;
325
+ padding: 1px 6px;
326
+ border-radius: 4px;
327
+ border: 1px solid var(--theme-elevation-300);
328
+ background: var(--theme-elevation-100);
329
+ color: var(--theme-elevation-500);
330
+ font-size: 10px;
331
+ font-weight: 600;
332
+ cursor: pointer;
333
+ vertical-align: middle;
334
+ transition: background-color 100ms, color 100ms;
335
+
336
+ &:hover {
337
+ background: #2563eb;
338
+ color: #fff;
339
+ border-color: #2563eb;
340
+ }
341
+ }
342
+
343
+ // AI summary expanded row
344
+ .summaryRow {
345
+ background: var(--theme-elevation-50);
346
+ border-bottom: 1px solid var(--theme-elevation-300);
347
+ }
348
+
349
+ .summaryCell {
350
+ padding: 12px 16px 16px;
351
+ }
352
+
353
+ .summaryHeader {
354
+ display: flex;
355
+ justify-content: space-between;
356
+ align-items: center;
357
+ margin-bottom: 8px;
358
+ flex-wrap: wrap;
359
+ gap: 8px;
360
+ }
361
+
362
+ .summaryActions {
363
+ display: flex;
364
+ align-items: center;
365
+ gap: 8px;
366
+ }
367
+
368
+ .summaryMeta {
369
+ font-size: 11px;
370
+ color: var(--theme-elevation-500);
371
+ }
372
+
373
+ .summaryAction {
374
+ padding: 4px 10px;
375
+ border-radius: 6px;
376
+ border: 1px solid var(--theme-elevation-300);
377
+ background: var(--theme-elevation-100);
378
+ color: var(--theme-text);
379
+ font-size: 12px;
380
+ font-weight: 600;
381
+ cursor: pointer;
382
+ transition: background-color 100ms;
383
+
384
+ &:hover { background: var(--theme-elevation-200); }
385
+ &:disabled { cursor: not-allowed; opacity: 0.5; }
386
+ }
387
+
388
+ .summaryText {
389
+ margin: 0;
390
+ padding: 10px 12px;
391
+ background: var(--theme-elevation-100);
392
+ border: 1px solid var(--theme-elevation-300);
393
+ border-radius: 6px;
394
+ font-family: -apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif;
395
+ font-size: 13px;
396
+ color: var(--theme-text);
397
+ white-space: pre-wrap;
398
+ word-break: break-word;
399
+ }
400
+
401
+ .summaryEmpty {
402
+ padding: 10px 12px;
403
+ background: var(--theme-elevation-100);
404
+ border: 1px dashed var(--theme-elevation-300);
405
+ border-radius: 6px;
406
+ color: var(--theme-elevation-500);
407
+ font-size: 12px;
408
+ font-style: italic;
409
+ }
410
+
279
411
  // Grand total
280
412
  .grandTotal {
281
413
  padding: 16px;