@meistrari/minuta-nuxt 0.1.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 (45) hide show
  1. package/README.md +106 -0
  2. package/dist/module.cjs +5 -0
  3. package/dist/module.d.mts +18 -0
  4. package/dist/module.d.ts +18 -0
  5. package/dist/module.json +9 -0
  6. package/dist/module.mjs +45 -0
  7. package/dist/runtime/components/MinutaAnnotationsGutter.vue +425 -0
  8. package/dist/runtime/components/MinutaChatEditCard.vue +124 -0
  9. package/dist/runtime/components/MinutaChromeCanvas.vue +549 -0
  10. package/dist/runtime/components/MinutaChromeEditor.vue +100 -0
  11. package/dist/runtime/components/MinutaDocumentViewer.vue +90 -0
  12. package/dist/runtime/components/MinutaDocxEditor.vue +1158 -0
  13. package/dist/runtime/components/MinutaEmbed.vue +783 -0
  14. package/dist/runtime/components/MinutaIcon.vue +53 -0
  15. package/dist/runtime/composables/annotationAnchors.d.ts +73 -0
  16. package/dist/runtime/composables/annotationAnchors.js +243 -0
  17. package/dist/runtime/composables/chatEditOps.d.ts +37 -0
  18. package/dist/runtime/composables/chatEditOps.js +61 -0
  19. package/dist/runtime/composables/docxExport.d.ts +24 -0
  20. package/dist/runtime/composables/docxExport.js +1292 -0
  21. package/dist/runtime/composables/editorExtensions.d.ts +56 -0
  22. package/dist/runtime/composables/editorExtensions.js +466 -0
  23. package/dist/runtime/composables/paginationPlugin.d.ts +126 -0
  24. package/dist/runtime/composables/paginationPlugin.js +421 -0
  25. package/dist/runtime/composables/useMinutaApi.d.ts +112 -0
  26. package/dist/runtime/composables/useMinutaApi.js +68 -0
  27. package/dist/runtime/composables/vaultImages.d.ts +12 -0
  28. package/dist/runtime/composables/vaultImages.js +85 -0
  29. package/dist/runtime/composables/versionDiff.d.ts +34 -0
  30. package/dist/runtime/composables/versionDiff.js +62 -0
  31. package/dist/runtime/server/api/minuta/[...path].d.ts +2 -0
  32. package/dist/runtime/server/api/minuta/[...path].js +6 -0
  33. package/dist/runtime/server/api/minuta/index.d.ts +2 -0
  34. package/dist/runtime/server/api/minuta/index.js +3 -0
  35. package/dist/runtime/server/utils/minuta-proxy.d.ts +10 -0
  36. package/dist/runtime/server/utils/minuta-proxy.js +63 -0
  37. package/dist/runtime/utils/footnoteHtml.d.ts +26 -0
  38. package/dist/runtime/utils/footnoteHtml.js +117 -0
  39. package/dist/runtime/utils/pocKeepWithNext.d.ts +44 -0
  40. package/dist/runtime/utils/pocKeepWithNext.js +9 -0
  41. package/dist/runtime/utils/sanitizeRichHtml.d.ts +10 -0
  42. package/dist/runtime/utils/sanitizeRichHtml.js +73 -0
  43. package/dist/types.d.mts +7 -0
  44. package/dist/types.d.ts +7 -0
  45. package/package.json +100 -0
@@ -0,0 +1,783 @@
1
+ <script setup lang="ts">
2
+ import type { Editor } from '@tiptap/core'
3
+ import { computed, markRaw, nextTick, onMounted, onUnmounted, reactive, ref, resolveComponent, shallowRef, watch } from 'vue'
4
+ import type { Minuta, MinutaAnalysis, MinutaChrome, MinutaVersion } from '../composables/useMinutaApi'
5
+ import { useMinutaApi } from '../composables/useMinutaApi'
6
+ import type { ApplyChatEditResult, MinutaChatEdit } from '../composables/chatEditOps'
7
+ import { applyChatEditOps, mergeChatAnnotations } from '../composables/chatEditOps'
8
+ import { pocPageKey } from '../composables/paginationPlugin'
9
+ import { pocEnterVersionDiff, pocExitVersionDiff } from '../composables/versionDiff'
10
+ import { refreshVaultImages } from '../composables/vaultImages'
11
+ import MinutaDocxEditor from './MinutaDocxEditor.vue'
12
+ import MinutaAnnotationsGutter from './MinutaAnnotationsGutter.vue'
13
+ import MinutaChatEditCard from './MinutaChatEditCard.vue'
14
+ import MinutaChromeEditor from './MinutaChromeEditor.vue'
15
+ import MinutaDocumentViewer from './MinutaDocumentViewer.vue'
16
+
17
+ const props = withDefaults(defineProps<{
18
+ minutaId: string
19
+ /** permite editar o documento (default: true) */
20
+ editable?: boolean
21
+ /** salva automaticamente 2s após a última edição (default: true) */
22
+ autoSave?: boolean
23
+ /** mostra a barra superior com nome, status de save e export (default: true) */
24
+ showToolbar?: boolean
25
+ /** margens da página em px @96dpi (default: 2,5cm topo/rodapé, 3cm laterais) */
26
+ margins?: { top: number, right: number, bottom: number, left: number }
27
+ /**
28
+ * id de plataforma de um agente Tela de edição de minuta: habilita o
29
+ * assistente de chat na coluna esquerda. Requer `@meistrari/chat-nuxt`
30
+ * registrado como módulo no app host (o pacote já traz a dependência).
31
+ */
32
+ chatAgentId?: string | null
33
+ /** deixa o nome da minuta editável na barra (default: segue `editable`) */
34
+ renamable?: boolean
35
+ }>(), {
36
+ editable: true,
37
+ autoSave: true,
38
+ showToolbar: true,
39
+ margins: () => ({ top: 95, right: 113, bottom: 95, left: 113 }),
40
+ chatAgentId: null,
41
+ renamable: undefined,
42
+ })
43
+
44
+ const emit = defineEmits<{
45
+ ready: [editor: Editor]
46
+ saved: [minuta: Minuta]
47
+ error: [message: string]
48
+ }>()
49
+
50
+ const HEADER = { logoUrl: null as string | null, position: 'left' as const }
51
+
52
+ // cabeçalho/rodapé editáveis no canvas (persistidos na minuta)
53
+ const chromeText = reactive({
54
+ header: '',
55
+ footer: '',
56
+ headerScope: 'all' as 'all' | 'first',
57
+ footerScope: 'all' as 'all' | 'first',
58
+ showPageNumber: true,
59
+ })
60
+
61
+ function hydrateChrome(chrome?: MinutaChrome | null) {
62
+ chromeText.header = chrome?.header ?? ''
63
+ chromeText.footer = chrome?.footer ?? ''
64
+ chromeText.headerScope = chrome?.headerScope ?? 'all'
65
+ chromeText.footerScope = chrome?.footerScope ?? 'all'
66
+ chromeText.showPageNumber = chrome?.showPageNumber !== false
67
+ }
68
+
69
+ const api = useMinutaApi()
70
+
71
+ const minuta = ref<Minuta | null>(null)
72
+ const isLoading = ref(true)
73
+ const loadError = ref<string | null>(null)
74
+ const editor = shallowRef<Editor | null>(null)
75
+ // preview de uma versão antiga: trava auto-save, export e edição
76
+ const versionPreview = ref<{ versionNumber: number, createdAt: string } | null>(null)
77
+ // debounce do PATCH de análise (anotações mudam a cada edição do texto)
78
+ let analysisPatchTimer: ReturnType<typeof setTimeout> | null = null
79
+ const saveState = ref<'idle' | 'pending' | 'saving' | 'saved' | 'error'>('idle')
80
+ const isExporting = ref(false)
81
+
82
+ // nome editável na barra: o host que renderiza sem toolbar própria (ou que só
83
+ // injeta um botão de voltar) continua com o rename dentro do embed
84
+ const canRename = computed(() => props.renamable ?? props.editable)
85
+ const editableName = ref('')
86
+ const nameInputRef = ref<HTMLInputElement | null>(null)
87
+ // não sobrescreve o que o usuário está digitando quando o poll traz a minuta
88
+ watch(minuta, (m) => {
89
+ if (m && (!import.meta.client || document.activeElement !== nameInputRef.value))
90
+ editableName.value = m.name
91
+ }, { immediate: true })
92
+
93
+ async function saveName() {
94
+ const name = editableName.value.trim()
95
+ if (!minuta.value || !name || name === minuta.value.name) {
96
+ editableName.value = minuta.value?.name ?? ''
97
+ return
98
+ }
99
+ try {
100
+ minuta.value = await api.updateMinuta(props.minutaId, { name })
101
+ }
102
+ catch (err) {
103
+ editableName.value = minuta.value.name
104
+ emit('error', err instanceof Error ? err.message : 'Falha ao renomear a minuta')
105
+ }
106
+ }
107
+
108
+ let pollTimer: ReturnType<typeof setTimeout> | null = null
109
+ let saveTimer: ReturnType<typeof setTimeout> | null = null
110
+
111
+ async function loadMinuta() {
112
+ try {
113
+ const fetched = await api.fetchMinutaById(props.minutaId)
114
+ minuta.value = fetched
115
+ hydrateChrome(fetched.chrome)
116
+ if (fetched.status === 'PROCESSING')
117
+ schedulePoll()
118
+ }
119
+ catch (err) {
120
+ loadError.value = err instanceof Error ? err.message : 'Falha ao carregar a minuta'
121
+ emit('error', loadError.value)
122
+ }
123
+ finally {
124
+ isLoading.value = false
125
+ }
126
+ }
127
+
128
+ function schedulePoll() {
129
+ pollTimer = setTimeout(async () => {
130
+ try {
131
+ const fetched = await api.fetchMinutaById(props.minutaId)
132
+ minuta.value = fetched
133
+ if (fetched.status === 'PROCESSING')
134
+ schedulePoll()
135
+ }
136
+ catch {
137
+ schedulePoll()
138
+ }
139
+ }, 3000)
140
+ }
141
+
142
+ // só no cliente: `/api/minuta` é rota do próprio app (URL relativa), que não
143
+ // resolve durante o SSR de um host que renderize no servidor
144
+ onMounted(loadMinuta)
145
+
146
+ onUnmounted(() => {
147
+ if (pollTimer)
148
+ clearTimeout(pollTimer)
149
+ if (saveTimer)
150
+ clearTimeout(saveTimer)
151
+ if (analysisPatchTimer)
152
+ clearTimeout(analysisPatchTimer)
153
+ })
154
+
155
+ // --- imagens do corpo no Vault: sobe no insert, re-resolve o src no load ---
156
+ async function uploadEditorImage(file: File) {
157
+ const uploaded = await api.uploadMinutaFile(file)
158
+ if (!uploaded?.url)
159
+ return null
160
+ const src = await api.getDownloadUrl(uploaded.url)
161
+ return { src, vaultSrc: uploaded.url }
162
+ }
163
+
164
+ function refreshEditorVaultImages() {
165
+ const ed = editor.value
166
+ if (!ed || ed.isDestroyed)
167
+ return
168
+ void refreshVaultImages(ed, url => api.getDownloadUrl(url))
169
+ }
170
+
171
+ function onEditorReady(ed: Editor) {
172
+ editor.value = ed
173
+ emit('ready', ed)
174
+ void nextTick(refreshEditorVaultImages)
175
+ }
176
+
177
+ function onEditorChanged() {
178
+ if (!props.autoSave || versionPreview.value)
179
+ return
180
+ saveState.value = 'pending'
181
+ if (saveTimer)
182
+ clearTimeout(saveTimer)
183
+ saveTimer = setTimeout(() => void saveNow(), 2000)
184
+ }
185
+
186
+ async function saveNow(): Promise<boolean> {
187
+ const ed = editor.value
188
+ if (!ed || ed.isDestroyed || !minuta.value)
189
+ return false
190
+ if (versionPreview.value)
191
+ return true
192
+ if (saveTimer) {
193
+ clearTimeout(saveTimer)
194
+ saveTimer = null
195
+ }
196
+ saveState.value = 'saving'
197
+ try {
198
+ const updated = await api.updateMinuta(props.minutaId, { contentHtml: ed.getHTML() })
199
+ minuta.value = updated
200
+ saveState.value = 'saved'
201
+ emit('saved', updated)
202
+ return true
203
+ }
204
+ catch (err) {
205
+ console.error('[minuta-nuxt] erro ao salvar:', err)
206
+ saveState.value = 'error'
207
+ emit('error', 'Falha ao salvar a minuta')
208
+ return false
209
+ }
210
+ }
211
+
212
+ async function exportDocx() {
213
+ const ed = editor.value
214
+ if (!ed || ed.isDestroyed || isExporting.value || versionPreview.value)
215
+ return
216
+ isExporting.value = true
217
+ try {
218
+ await saveNow()
219
+ // a lib `docx` é o maior pedaço do bundle e só serve no clique:
220
+ // carregar sob demanda tira ~1 MB do abrir da minuta
221
+ const { pocExportDocx } = await import('../composables/docxExport')
222
+ const breaks = pocPageKey.getState(ed.state)?.breaks ?? []
223
+ const blob = await pocExportDocx(ed, props.margins, {
224
+ headerText: chromeText.header,
225
+ footerText: chromeText.footer,
226
+ headerScope: chromeText.headerScope,
227
+ footerScope: chromeText.footerScope,
228
+ headerLogoUrl: HEADER.logoUrl,
229
+ headerPosition: HEADER.position,
230
+ showPageNumber: chromeText.showPageNumber,
231
+ }, { breaks })
232
+
233
+ const url = URL.createObjectURL(blob)
234
+ const link = document.createElement('a')
235
+ link.href = url
236
+ link.download = `${(minuta.value?.name || 'minuta').trim()}.docx`
237
+ link.click()
238
+ URL.revokeObjectURL(url)
239
+ }
240
+ catch (err) {
241
+ console.error('[minuta-nuxt] erro ao exportar:', err)
242
+ emit('error', 'Falha ao exportar o DOCX')
243
+ }
244
+ finally {
245
+ isExporting.value = false
246
+ }
247
+ }
248
+
249
+ // ==========================================
250
+ // Histórico (preview + diff + restore — mesmo fluxo do defesa.ai)
251
+ // ==========================================
252
+
253
+ const historyOpen = ref(false)
254
+ const versions = ref<MinutaVersion[]>([])
255
+ const loadingVersions = ref(false)
256
+ const savingCheckpoint = ref(false)
257
+ const restoring = ref(false)
258
+ const previewLoading = ref(false)
259
+ // preview/volta de versão trocam o doc inteiro — re-resolve os src das imagens
260
+ watch(versionPreview, () => void nextTick(refreshEditorVaultImages))
261
+ const compareMode = ref(false)
262
+ const compareChangeCount = ref(0)
263
+ const checkpointComment = ref('')
264
+ let baselineHtml: string | null = null
265
+
266
+ const VERSION_TYPE_LABEL: Record<string, string> = {
267
+ initial: 'Original',
268
+ auto: 'Automática',
269
+ manual: 'Manual',
270
+ }
271
+
272
+ function formatVersionDate(iso: string): string {
273
+ try {
274
+ return new Date(iso).toLocaleString('pt-BR')
275
+ }
276
+ catch {
277
+ return iso
278
+ }
279
+ }
280
+
281
+ async function loadVersions() {
282
+ loadingVersions.value = true
283
+ try {
284
+ versions.value = await api.fetchVersions(props.minutaId)
285
+ }
286
+ catch {
287
+ emit('error', 'Falha ao carregar o histórico')
288
+ }
289
+ finally {
290
+ loadingVersions.value = false
291
+ }
292
+ }
293
+
294
+ function toggleHistory() {
295
+ if (historyOpen.value) {
296
+ closeHistory()
297
+ return
298
+ }
299
+ historyOpen.value = true
300
+ void loadVersions()
301
+ }
302
+
303
+ function closeHistory() {
304
+ historyOpen.value = false
305
+ exitPreview()
306
+ }
307
+
308
+ async function saveCheckpoint() {
309
+ if (savingCheckpoint.value)
310
+ return
311
+ savingCheckpoint.value = true
312
+ try {
313
+ if (!await saveNow())
314
+ return
315
+ await api.createVersion(props.minutaId, checkpointComment.value)
316
+ checkpointComment.value = ''
317
+ await loadVersions()
318
+ }
319
+ catch {
320
+ emit('error', 'Falha ao salvar a versão')
321
+ }
322
+ finally {
323
+ savingCheckpoint.value = false
324
+ }
325
+ }
326
+
327
+ async function enterPreview(versionNumber: number) {
328
+ const ed = editor.value
329
+ if (!ed || ed.isDestroyed || previewLoading.value)
330
+ return
331
+ if (versionPreview.value?.versionNumber === versionNumber)
332
+ return
333
+ previewLoading.value = true
334
+ try {
335
+ if (!versionPreview.value && !await saveNow())
336
+ return
337
+ if (compareMode.value) {
338
+ pocExitVersionDiff(ed)
339
+ compareMode.value = false
340
+ }
341
+ const full = await api.fetchVersion(props.minutaId, versionNumber)
342
+ // guard entra ANTES do setContent — o @changed da troca não salva
343
+ versionPreview.value = { versionNumber: full.versionNumber, createdAt: formatVersionDate(full.createdAt) }
344
+ ed.commands.setContent(full.contentHtml)
345
+ }
346
+ catch {
347
+ emit('error', 'Falha ao abrir esta versão')
348
+ }
349
+ finally {
350
+ previewLoading.value = false
351
+ }
352
+ }
353
+
354
+ function exitPreview() {
355
+ const ed = editor.value
356
+ if (!versionPreview.value)
357
+ return
358
+ if (ed && !ed.isDestroyed) {
359
+ pocExitVersionDiff(ed)
360
+ if (minuta.value?.contentHtml != null)
361
+ ed.commands.setContent(minuta.value.contentHtml)
362
+ }
363
+ compareMode.value = false
364
+ versionPreview.value = null
365
+ }
366
+
367
+ /** diff da versão em preview contra a v1 (o documento como o agente entregou) */
368
+ async function toggleCompare() {
369
+ const ed = editor.value
370
+ if (!versionPreview.value || !ed || ed.isDestroyed)
371
+ return
372
+ if (compareMode.value) {
373
+ pocExitVersionDiff(ed)
374
+ compareMode.value = false
375
+ return
376
+ }
377
+ if (baselineHtml === null) {
378
+ try {
379
+ baselineHtml = (await api.fetchVersion(props.minutaId, 1)).contentHtml
380
+ }
381
+ catch {
382
+ emit('error', 'Falha ao carregar a versão original para comparar')
383
+ return
384
+ }
385
+ }
386
+ const { changeCount } = pocEnterVersionDiff(ed, baselineHtml)
387
+ compareChangeCount.value = changeCount
388
+ compareMode.value = true
389
+ }
390
+
391
+ async function restoreVersion(versionNumber: number) {
392
+ const ed = editor.value
393
+ if (restoring.value)
394
+ return
395
+ restoring.value = true
396
+ try {
397
+ const restored = await api.restoreVersion(props.minutaId, versionNumber)
398
+ minuta.value = restored
399
+ if (ed && !ed.isDestroyed) {
400
+ pocExitVersionDiff(ed)
401
+ ed.commands.setContent(restored.contentHtml || '<p></p>')
402
+ }
403
+ compareMode.value = false
404
+ versionPreview.value = null
405
+ saveState.value = 'saved'
406
+ await loadVersions()
407
+ void nextTick(refreshEditorVaultImages)
408
+ }
409
+ catch {
410
+ emit('error', 'Falha ao restaurar a versão')
411
+ }
412
+ finally {
413
+ restoring.value = false
414
+ }
415
+ }
416
+
417
+ // ==========================================
418
+ // Edição de cabeçalho/rodapé (canvas estilo Word, salva na minuta)
419
+ // ==========================================
420
+
421
+ const chromeEditor = ref<{ part: 'header' | 'footer' } | null>(null)
422
+
423
+ function onEditChrome(event: Event) {
424
+ const { part } = (event as CustomEvent).detail || {}
425
+ if (part !== 'header' && part !== 'footer')
426
+ return
427
+ if (versionPreview.value)
428
+ return
429
+ chromeEditor.value = { part }
430
+ }
431
+
432
+ async function applyChrome({ html, scope, showPageNumber }: { html: string, scope: 'all' | 'first', showPageNumber?: boolean }) {
433
+ if (!chromeEditor.value)
434
+ return
435
+ const part = chromeEditor.value.part
436
+ chromeText[part] = html
437
+ chromeText[part === 'header' ? 'headerScope' : 'footerScope'] = scope
438
+ if (showPageNumber !== undefined)
439
+ chromeText.showPageNumber = showPageNumber
440
+ chromeEditor.value = null
441
+ try {
442
+ minuta.value = await api.updateMinuta(props.minutaId, { chrome: { ...chromeText } })
443
+ }
444
+ catch {
445
+ emit('error', 'Falha ao salvar o cabeçalho/rodapé')
446
+ }
447
+ }
448
+
449
+ onMounted(() => window.addEventListener('poc:edit-chrome', onEditChrome))
450
+ onUnmounted(() => window.removeEventListener('poc:edit-chrome', onEditChrome))
451
+
452
+ // ==========================================
453
+ // Assistente de chat (agente Tela edita a peça via blocos minuta-edit)
454
+ // ==========================================
455
+
456
+ /** componente do chat-nuxt — presente só quando o host registra o módulo */
457
+ const resolvedChatEmbed = resolveComponent('MeistrariChatEmbed')
458
+ const chatEmbedComponent = typeof resolvedChatEmbed === 'string' ? null : markRaw(resolvedChatEmbed as object)
459
+ const chatComponents = { 'minuta-edit': markRaw(MinutaChatEditCard) }
460
+
461
+ function htmlToPlainText(html: string): string {
462
+ const el = document.createElement('div')
463
+ el.innerHTML = html
464
+ return el.textContent ?? ''
465
+ }
466
+
467
+ /** inputs reenviados a cada mensagem: documento atual + análise + nome */
468
+ const chatInputs = computed(() => {
469
+ const current = minuta.value
470
+ if (!current)
471
+ return []
472
+ // contentHtml muda a cada save → recomputa com o texto vivo do editor
473
+ void current.contentHtml
474
+ const documento = editor.value && !editor.value.isDestroyed
475
+ ? editor.value.getText({ blockSeparator: '\n\n' })
476
+ : htmlToPlainText(current.contentHtml || '')
477
+ return [
478
+ { type: 'text' as const, name: 'documento', content: documento },
479
+ { type: 'text' as const, name: 'analise', content: JSON.stringify(current.analysis ?? {}) },
480
+ { type: 'text' as const, name: 'minuta_nome', content: current.name },
481
+ ]
482
+ })
483
+
484
+ /** card "Aplicar" do chat → ops no editor + anotações novas na análise */
485
+ async function onChatEditEvent(event: Event) {
486
+ const detail = (event as CustomEvent).detail as { edit: MinutaChatEdit, onResult?: (result: ApplyChatEditResult) => void } | undefined
487
+ if (!detail?.edit)
488
+ return
489
+ const ed = editor.value
490
+ if (!ed || ed.isDestroyed || !minuta.value) {
491
+ detail.onResult?.({ applied: 0, failures: ['Editor indisponível'] })
492
+ return
493
+ }
494
+ if (versionPreview.value) {
495
+ detail.onResult?.({ applied: 0, failures: ['Volte para a versão atual antes de aplicar edições'] })
496
+ return
497
+ }
498
+
499
+ const result = applyChatEditOps(ed, detail.edit.ops ?? [])
500
+ if (result.applied > 0)
501
+ await saveNow()
502
+
503
+ if (detail.edit.anotacoes?.length) {
504
+ try {
505
+ const merged = mergeChatAnnotations(minuta.value.analysis, detail.edit.anotacoes)
506
+ minuta.value = await api.updateMinuta(props.minutaId, { analysis: merged })
507
+ }
508
+ catch {
509
+ result.failures.push('Falha ao salvar as anotações novas')
510
+ }
511
+ }
512
+ detail.onResult?.(result)
513
+ }
514
+
515
+ onMounted(() => window.addEventListener('minuta-nuxt:chat-edit', onChatEditEvent))
516
+ onUnmounted(() => window.removeEventListener('minuta-nuxt:chat-edit', onChatEditEvent))
517
+
518
+ // ==========================================
519
+ // Análise da peça (comentários de margem, sempre visíveis)
520
+ // ==========================================
521
+
522
+ const editorCompRef = ref<any>(null)
523
+ const docPreviewOpen = ref(false)
524
+ const docPreviewUrl = ref<string | null>(null)
525
+ const docPreviewPage = ref<number | null>(null)
526
+
527
+ function openDocPreview(url?: string | null) {
528
+ if (!minuta.value?.files.length)
529
+ return
530
+ docPreviewUrl.value = url ?? minuta.value.files[0].url
531
+ docPreviewOpen.value = true
532
+ }
533
+
534
+ // --- persistência do estado de âncora (anotações editadas + texto adicionado) ---
535
+ function onAnalysisUpdate(analysis: MinutaAnalysis) {
536
+ // durante preview de versão o documento não é o atual — não persiste nada
537
+ if (versionPreview.value || !minuta.value)
538
+ return
539
+ // otimista (evita re-emissão do gutter); PATCH debounced
540
+ minuta.value = { ...minuta.value, analysis }
541
+ if (analysisPatchTimer)
542
+ clearTimeout(analysisPatchTimer)
543
+ analysisPatchTimer = setTimeout(async () => {
544
+ try {
545
+ await api.updateMinuta(props.minutaId, { analysis })
546
+ }
547
+ catch (err) {
548
+ console.error('[minuta-nuxt] falha ao persistir estado das anotações:', err)
549
+ }
550
+ }, 800)
551
+ }
552
+
553
+ /** card de fato extraído → abre o documento-fonte no visualizador */
554
+ function onOpenDocument(payload: { documento: string, pagina?: number, highlight?: string }) {
555
+ const file = minuta.value?.files.find(f => f.name === payload.documento)
556
+ ?? minuta.value?.files.find(f => f.name.includes(payload.documento) || payload.documento.includes(f.name))
557
+ docPreviewPage.value = payload.pagina ?? null
558
+ openDocPreview(file?.url)
559
+ }
560
+
561
+ const hasAnalysis = computed(() => {
562
+ const analysis = minuta.value?.analysis
563
+ if (!analysis)
564
+ return false
565
+ return Boolean(analysis.resumo || analysis.teses?.length || analysis.jurisprudencias?.length
566
+ || analysis.citacoes?.length || analysis.documentos?.length || analysis.fatos?.length)
567
+ })
568
+
569
+ const SAVE_LABEL: Record<'idle' | 'pending' | 'saving' | 'saved' | 'error', string> = {
570
+ idle: '',
571
+ pending: 'Alterações pendentes…',
572
+ saving: 'Salvando…',
573
+ saved: 'Salvo',
574
+ error: 'Erro ao salvar',
575
+ }
576
+
577
+ defineExpose({ saveNow, exportDocx, editor })
578
+ </script>
579
+
580
+ <template>
581
+ <div class="minuta-embed">
582
+ <div v-if="showToolbar && minuta" class="minuta-embed__toolbar">
583
+ <slot name="toolbar-start" />
584
+ <input
585
+ v-if="canRename"
586
+ ref="nameInputRef"
587
+ v-model="editableName"
588
+ class="minuta-embed__name minuta-embed__name--input"
589
+ :disabled="minuta.status !== 'READY'"
590
+ @blur="saveName"
591
+ @keydown.enter="($event.target as HTMLInputElement).blur()"
592
+ >
593
+ <span v-else class="minuta-embed__name">{{ minuta.name }}</span>
594
+ <span class="minuta-embed__save-state">{{ SAVE_LABEL[saveState] }}</span>
595
+ <button
596
+ v-if="minuta.status === 'READY' && minuta.files.length"
597
+ class="minuta-embed__ghost-btn"
598
+ type="button"
599
+ @click="openDocPreview()"
600
+ >
601
+ Documentos
602
+ </button>
603
+ <button
604
+ v-if="minuta.status === 'READY'"
605
+ class="minuta-embed__ghost-btn"
606
+ :class="{ 'minuta-embed__ghost-btn--on': historyOpen }"
607
+ type="button"
608
+ @click="toggleHistory"
609
+ >
610
+ Histórico
611
+ </button>
612
+ <button
613
+ v-if="minuta.status === 'READY'"
614
+ class="minuta-embed__export"
615
+ :disabled="isExporting || !!versionPreview"
616
+ type="button"
617
+ @click="exportDocx"
618
+ >
619
+ {{ isExporting ? 'Exportando…' : 'Baixar DOCX' }}
620
+ </button>
621
+ </div>
622
+
623
+ <div v-if="isLoading" class="minuta-embed__status">
624
+ Carregando…
625
+ </div>
626
+
627
+ <div v-else-if="loadError" class="minuta-embed__status minuta-embed__status--error">
628
+ {{ loadError }}
629
+ </div>
630
+
631
+ <div v-else-if="minuta?.status === 'PROCESSING'" class="minuta-embed__status">
632
+ Gerando a minuta… O agente está lendo os documentos e montando o texto.
633
+ </div>
634
+
635
+ <div v-else-if="minuta?.status === 'ERROR'" class="minuta-embed__status minuta-embed__status--error">
636
+ A geração falhou: {{ minuta.error || 'erro desconhecido' }}
637
+ </div>
638
+
639
+ <div v-else-if="minuta" class="minuta-embed__body">
640
+ <div class="minuta-embed__document">
641
+ <MinutaDocxEditor
642
+ ref="editorCompRef"
643
+ :initial-html="minuta.contentHtml || '<p></p>'"
644
+ :margins="props.margins"
645
+ :header="HEADER"
646
+ :editable="props.editable && !versionPreview"
647
+ :show-block-origin="false"
648
+ :upload-image="uploadEditorImage"
649
+ :chrome-text="chromeText"
650
+ @ready="onEditorReady"
651
+ @changed="onEditorChanged"
652
+ >
653
+ <template #gutter>
654
+ <MinutaAnnotationsGutter
655
+ v-if="hasAnalysis && minuta.analysis"
656
+ :editor="editor"
657
+ :analysis="minuta.analysis"
658
+ :scroll-container="editorCompRef?.containerRef ?? null"
659
+ @open-document="onOpenDocument"
660
+ @update-analysis="onAnalysisUpdate"
661
+ />
662
+ </template>
663
+ </MinutaDocxEditor>
664
+
665
+ <!-- edição de cabeçalho/rodapé (canvas estilo Word) -->
666
+ <div v-if="chromeEditor" class="minuta-embed__chrome-overlay">
667
+ <MinutaChromeEditor
668
+ :key="chromeEditor.part"
669
+ :part="chromeEditor.part"
670
+ :html="chromeText[chromeEditor.part]"
671
+ :scope="chromeText[chromeEditor.part === 'header' ? 'headerScope' : 'footerScope']"
672
+ :band-height-px="chromeEditor.part === 'header' ? props.margins.top : props.margins.bottom"
673
+ :show-page-number="chromeText.showPageNumber"
674
+ :allow-page-number-toggle="true"
675
+ @apply="applyChrome"
676
+ @close="chromeEditor = null"
677
+ />
678
+ </div>
679
+
680
+ <!-- banner do preview de versão -->
681
+ <div v-if="versionPreview" class="minuta-embed__banner">
682
+ <span class="minuta-embed__banner-text">
683
+ <template v-if="compareMode">
684
+ Mudanças da versão {{ versionPreview.versionNumber }} em relação à original —
685
+ {{ compareChangeCount }} {{ compareChangeCount === 1 ? 'trecho alterado' : 'trechos alterados' }}
686
+ </template>
687
+ <template v-else>
688
+ Visualizando a versão {{ versionPreview.versionNumber }}
689
+ de {{ versionPreview.createdAt }} — somente leitura
690
+ </template>
691
+ </span>
692
+ <span class="minuta-embed__banner-actions">
693
+ <button v-if="versionPreview.versionNumber !== 1" type="button" @click="toggleCompare">
694
+ {{ compareMode ? 'Ocultar mudanças' : 'Ver mudanças' }}
695
+ </button>
696
+ <button type="button" :disabled="restoring" @click="restoreVersion(versionPreview.versionNumber)">
697
+ Restaurar
698
+ </button>
699
+ <button type="button" @click="exitPreview">
700
+ Voltar para a atual
701
+ </button>
702
+ </span>
703
+ </div>
704
+ </div>
705
+
706
+ <!-- painel: histórico -->
707
+ <aside v-if="historyOpen" class="minuta-embed__panel">
708
+ <div class="minuta-embed__panel-head">
709
+ <span>Histórico de versões</span>
710
+ <button type="button" @click="closeHistory">
711
+ ×
712
+ </button>
713
+ </div>
714
+ <div class="minuta-embed__checkpoint">
715
+ <input
716
+ v-model="checkpointComment"
717
+ placeholder="Comentário (opcional)"
718
+ :disabled="savingCheckpoint"
719
+ @keydown.enter="saveCheckpoint"
720
+ >
721
+ <button type="button" :disabled="savingCheckpoint" @click="saveCheckpoint">
722
+ {{ savingCheckpoint ? 'Salvando…' : 'Salvar versão' }}
723
+ </button>
724
+ </div>
725
+ <div class="minuta-embed__versions">
726
+ <p v-if="!versions.length && !loadingVersions" class="minuta-embed__versions-empty">
727
+ Nenhuma versão ainda — salve uma com o botão acima. Versões
728
+ automáticas são criadas conforme você edita.
729
+ </p>
730
+ <button
731
+ v-for="version in versions"
732
+ :key="version.id"
733
+ class="minuta-embed__version"
734
+ :class="{ 'minuta-embed__version--active': versionPreview?.versionNumber === version.versionNumber }"
735
+ :disabled="previewLoading"
736
+ type="button"
737
+ @click="enterPreview(version.versionNumber)"
738
+ >
739
+ <span class="minuta-embed__version-row">
740
+ <strong>v{{ version.versionNumber }}</strong>
741
+ <em class="minuta-embed__version-type" :data-type="version.versionType">
742
+ {{ VERSION_TYPE_LABEL[version.versionType] ?? version.versionType }}
743
+ </em>
744
+ <span class="minuta-embed__version-date">{{ formatVersionDate(version.createdAt) }}</span>
745
+ </span>
746
+ <span v-if="version.comment" class="minuta-embed__version-comment">{{ version.comment }}</span>
747
+ <span v-if="version.createdByEmail" class="minuta-embed__version-author">{{ version.createdByEmail }}</span>
748
+ </button>
749
+ </div>
750
+ </aside>
751
+
752
+ <!-- assistente: launcher flutuante nativo do chat-nuxt (bolinha → janela
753
+ ancorada). Renderizado por ÚLTIMO: o wrapper fixed do launcher tem
754
+ z-index auto e ficaria atrás do editor se viesse antes no DOM. -->
755
+ <component
756
+ :is="chatEmbedComponent"
757
+ v-if="chatAgentId && chatEmbedComponent && minuta.status === 'READY'"
758
+ variant="floating"
759
+ launcher-label="Assistente"
760
+ :tela-agent-id="chatAgentId"
761
+ :tela-agent-inputs="chatInputs"
762
+ :conversation-scope="`minuta:${props.minutaId}`"
763
+ :custom-html-tags="['minuta-edit']"
764
+ :custom-components="chatComponents"
765
+ :features="{ showCancelButton: true }"
766
+ />
767
+
768
+ <!-- visualizador de documentos (overlay) -->
769
+ <MinutaDocumentViewer
770
+ v-if="docPreviewOpen"
771
+ :files="minuta.files"
772
+ :initial-url="docPreviewUrl"
773
+ :initial-page="docPreviewPage"
774
+ @close="docPreviewOpen = false"
775
+ @error="msg => emit('error', msg)"
776
+ />
777
+ </div>
778
+ </div>
779
+ </template>
780
+
781
+ <style scoped>
782
+ .minuta-embed{background:#f7f7f8;display:flex;flex-direction:column;height:100%}.minuta-embed__toolbar{align-items:center;background:#fff;border-bottom:1px solid #e5e7eb;display:flex;gap:8px;padding:8px 16px}.minuta-embed__name--input{background:transparent;border:0;font-family:inherit;outline:none}.minuta-embed__name--input:focus{text-decoration:underline}.minuta-embed__name{color:#111827;flex:1;font-size:15px;font-weight:500;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.minuta-embed__save-state{color:#9ca3af;font-size:12px;margin-right:4px}.minuta-embed__ghost-btn{background:#fff;border:1px solid #e5e7eb;border-radius:6px;color:#374151;cursor:pointer;font-size:13px;padding:6px 12px}.minuta-embed__ghost-btn--on{background:#f3f4f6}.minuta-embed__export{background:#111827;border:none;border-radius:6px;color:#fff;cursor:pointer;font-size:13px;padding:6px 12px}.minuta-embed__export:disabled{cursor:default;opacity:.6}.minuta-embed__chrome-overlay{bottom:16px;position:absolute;right:16px;z-index:30}.minuta-embed__status{color:#6b7280;font-size:14px;padding:48px 24px;text-align:center}.minuta-embed__status--error{color:#b91c1c}.minuta-embed__body,.minuta-embed__document{display:flex;flex:1;min-height:0}.minuta-embed__document{min-width:0;position:relative}.minuta-embed__banner{align-items:center;background:#fffbeb;border:1px solid #fcd34d;border-radius:16px;bottom:16px;box-shadow:0 4px 6px -1px rgba(0,0,0,.1);display:flex;flex-wrap:wrap;gap:8px 12px;justify-content:center;left:50%;max-width:calc(100% - 32px);padding:8px 16px;position:absolute;transform:translateX(-50%);width:-moz-max-content;width:max-content;z-index:30}.minuta-embed__banner-text{color:#92400e;font-size:12px;font-weight:500}.minuta-embed__banner-actions{display:flex;gap:8px}.minuta-embed__banner-actions button{background:#fff;border:1px solid #fcd34d;border-radius:6px;color:#92400e;cursor:pointer;font-size:12px;padding:4px 10px;white-space:nowrap}.minuta-embed__banner-actions button:disabled{cursor:default;opacity:.6}.minuta-embed__panel{background:#fff;border-left:1px solid #e5e7eb;display:flex;flex-direction:column;width:320px}.minuta-embed__panel-head{align-items:center;border-bottom:1px solid #e5e7eb;color:#111827;display:flex;font-size:14px;font-weight:600;justify-content:space-between;padding:10px 16px}.minuta-embed__panel-head button{background:none;border:none;color:#6b7280;cursor:pointer;font-size:18px;line-height:1}.minuta-embed__checkpoint{border-bottom:1px solid #e5e7eb;display:flex;flex-direction:column;gap:8px;padding:12px}.minuta-embed__checkpoint input{border:1px solid #e5e7eb;border-radius:6px;font-size:13px;padding:6px 10px}.minuta-embed__checkpoint button{background:#fff;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;font-size:13px;padding:6px 12px}.minuta-embed__versions{flex:1;min-height:0;overflow-y:auto;padding:8px}.minuta-embed__versions-empty{color:#6b7280;font-size:12px;margin:0;padding:8px 12px}.minuta-embed__version{background:#fff;border:1px solid #e5e7eb;border-radius:10px;cursor:pointer;display:block;margin-bottom:6px;padding:10px;text-align:left;width:100%}.minuta-embed__version:hover{background:#faf5ff;border-color:#c4b5fd}.minuta-embed__version--active{background:#f5f3ff;border-color:#a78bfa}.minuta-embed__version:disabled{cursor:wait;opacity:.7}.minuta-embed__version-row{align-items:center;color:#111827;display:flex;font-size:12px;gap:6px}.minuta-embed__version-type{background:#f3f4f6;border-radius:999px;color:#4b5563;font-size:11px;font-style:normal;padding:1px 8px}.minuta-embed__version-type[data-type=auto]{background:#f5f3ff;color:#6d28d9}.minuta-embed__version-type[data-type=manual]{background:#eff6ff;color:#1d4ed8}.minuta-embed__version-date{color:#9ca3af;font-size:11px;margin-left:auto}.minuta-embed__version-comment{color:#111827;display:block;font-size:12px;margin-top:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.minuta-embed__version-author{color:#9ca3af;display:block;font-size:11px;margin-top:2px}
783
+ </style>