modified: src/components/featureStateStyles.js

modified:   src/index.css
modified:   src/pages/ReportsPage.jsx
This commit is contained in:
2026-05-09 19:10:20 -03:00
parent 94dab58d85
commit bcee06b908
3 changed files with 653 additions and 17 deletions

View File

@@ -6,21 +6,21 @@ export const featureStateStyles = {
label: '',
},
partial: {
badge: 'border-sky-500/40 bg-sky-500/15 text-sky-300',
panel: 'border-sky-500/35 bg-sky-500/8',
title: 'text-sky-300',
badge: 'feature-badge-partial border-sky-500/40 bg-sky-500/15 text-sky-300',
panel: 'feature-panel-partial border-sky-500/35 bg-sky-500/8',
title: 'feature-title-partial text-sky-300',
label: 'Parcial',
},
mock: {
badge: 'border-amber-500/40 bg-amber-500/15 text-amber-300',
panel: 'border-amber-500/35 bg-amber-500/8',
title: 'text-amber-300',
badge: 'feature-badge-mock border-amber-500/40 bg-amber-500/15 text-amber-300',
panel: 'feature-panel-mock border-amber-500/35 bg-amber-500/8',
title: 'feature-title-mock text-amber-300',
label: 'Mockado',
},
wip: {
badge: 'border-rose-500/40 bg-rose-500/15 text-rose-300',
panel: 'border-rose-500/35 bg-rose-500/8',
title: 'text-rose-300',
badge: 'feature-badge-wip border-rose-500/40 bg-rose-500/15 text-rose-300',
panel: 'feature-panel-wip border-rose-500/35 bg-rose-500/8',
title: 'feature-title-wip text-rose-300',
label: 'WIP',
},
}

View File

@@ -46,12 +46,12 @@ button:disabled {
:root[data-theme='light'] {
color: #333333;
background: #cfd7e0;
background: #d9e4f0;
color-scheme: light;
}
[data-theme='light'] body {
background: #cfd7e0;
background: #d9e4f0;
color: #333333;
}
@@ -67,7 +67,7 @@ button:disabled {
[data-theme='light'] .bg-\[\#0a0a0a\],
[data-theme='light'] .bg-\[\#171717\] {
background-color: #cfd7e0;
background-color: #d9e4f0;
}
[data-theme='light'] .bg-\[\#1a1a1a\] {
@@ -106,7 +106,7 @@ button:disabled {
}
[data-theme='light'] .disabled\:bg-\[\#303030\]:disabled {
background-color: #cfd7e0;
background-color: #d9e4f0;
}
[data-theme='light'] .border-\[\#404040\],
@@ -254,6 +254,54 @@ button:disabled {
background-color: #404040;
}
[data-theme='light'] .feature-badge-partial {
border-color: #0284c7;
background: #dff3ff;
color: #075985;
box-shadow: inset 0 0 0 1px rgba(2, 132, 199, 0.12);
}
[data-theme='light'] .feature-panel-partial {
border-color: #38bdf8;
background: #eef9ff;
}
[data-theme='light'] .feature-title-partial {
color: #075985;
}
[data-theme='light'] .feature-badge-mock {
border-color: #d97706;
background: #fff2c2;
color: #92400e;
box-shadow: inset 0 0 0 1px rgba(217, 119, 6, 0.14);
}
[data-theme='light'] .feature-panel-mock {
border-color: #f59e0b;
background: #fff8db;
}
[data-theme='light'] .feature-title-mock {
color: #92400e;
}
[data-theme='light'] .feature-badge-wip {
border-color: #e11d48;
background: #ffe4e8;
color: #9f1239;
box-shadow: inset 0 0 0 1px rgba(225, 29, 72, 0.12);
}
[data-theme='light'] .feature-panel-wip {
border-color: #fb7185;
background: #fff1f3;
}
[data-theme='light'] .feature-title-wip {
color: #9f1239;
}
.agenda-calendar-shell {
border-color: #3b3b3b;
background: #202020;

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { normalizeRole } from '../config/permissions.js'
import { patientRepository } from '../repositories/patientRepository.js'
@@ -35,6 +35,91 @@ const textareaClass =
const labelClass = 'mb-1.5 block text-xs font-medium text-[#e5e5e5]'
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
const reportTemplates = [
{
id: 'consulta-medica',
category: 'Relatórios',
title: 'Relatório de Consulta Médica',
description: 'Resumo clínico com queixa, exame físico, hipótese diagnóstica e conduta.',
popular: true,
tags: ['consulta', 'clínico', 'conduta'],
exam: 'Consulta médica',
cidCode: 'Z00.0',
diagnosis: 'Paciente avaliado(a) em consulta médica, com hipótese diagnóstica em investigação conforme quadro clínico.',
conclusion: 'Paciente orientado(a) quanto à conduta proposta, sinais de alerta e necessidade de seguimento.',
contentHtml:
'<h2>Relatório de Consulta Médica</h2><p><strong>Queixa principal:</strong> </p><p><strong>História clínica:</strong> </p><p><strong>Exame físico:</strong> </p><p><strong>Hipóteses diagnósticas:</strong> </p><p><strong>Conduta:</strong> </p>',
},
{
id: 'evolucao-clinica',
category: 'Relatórios',
title: 'Evolução Clínica',
description: 'Registro de evolução diária para acompanhamento de internação.',
tags: ['internação', 'evolução', 'diário'],
exam: 'Evolução clínica',
cidCode: 'Z51.9',
diagnosis: 'Paciente em acompanhamento clínico durante internação, com evolução registrada em prontuário.',
conclusion: 'Manter acompanhamento multiprofissional e reavaliar conduta conforme evolução.',
contentHtml:
'<h2>Evolução Clínica</h2><p><strong>Data e hora:</strong> </p><p><strong>Estado geral:</strong> </p><p><strong>Sinais vitais:</strong> </p><p><strong>Evolução:</strong> </p><p><strong>Conduta do dia:</strong> </p><p><strong>Profissional:</strong> </p>',
},
{
id: 'hemograma',
category: 'Laudos',
title: 'Laudo de Hemograma',
description: 'Interpretação clínica de hemograma com correlação diagnóstica.',
tags: ['laboratorial', 'sangue', 'hemograma'],
exam: 'Hemograma completo',
cidCode: 'Z01.7',
diagnosis: 'Exame laboratorial avaliado em conjunto com quadro clínico e exames complementares.',
conclusion: 'Resultado analisado e correlacionado com a hipótese diagnóstica descrita.',
contentHtml:
'<h2>Laudo de Hemograma</h2><p><strong>Material:</strong> Sangue periférico.</p><p><strong>Achados principais:</strong> </p><p><strong>Interpretação:</strong> </p><p><strong>Conclusão:</strong> </p>',
},
{
id: 'imagem',
category: 'Laudos',
title: 'Laudo de Imagem',
description: 'Modelo para exames de imagem com descrição técnica e impressão diagnóstica.',
popular: true,
tags: ['imagem', 'radiologia', 'exame'],
exam: 'Exame de imagem',
cidCode: 'Z01.6',
diagnosis: 'Achados de imagem descritos conforme exame realizado e indicação clínica.',
conclusion: 'Impressão diagnóstica registrada conforme achados do exame.',
contentHtml:
'<h2>Laudo de Imagem</h2><p><strong>Técnica:</strong> </p><p><strong>Achados:</strong> </p><p><strong>Impressão diagnóstica:</strong> </p><p><strong>Recomendação:</strong> </p>',
},
{
id: 'pre-operatorio',
category: 'Relatórios',
title: 'Avaliação Pré-operatória',
description: 'Avaliação clínica para estratificação de risco e liberação cirúrgica.',
tags: ['pré-op', 'cirurgia', 'risco'],
exam: 'Avaliação pré-operatória',
cidCode: 'Z01.8',
diagnosis: 'Paciente em avaliação pré-operatória, com risco definido conforme dados clínicos disponíveis.',
conclusion: 'Conduta pré-operatória orientada conforme avaliação clínica e exames apresentados.',
contentHtml:
'<h2>Avaliação Pré-operatória</h2><p><strong>Procedimento proposto:</strong> </p><p><strong>Comorbidades:</strong> </p><p><strong>Medicamentos em uso:</strong> </p><p><strong>Estratificação de risco:</strong> </p><p><strong>Orientações:</strong> </p>',
},
{
id: 'encaminhamento',
category: 'Encaminhamentos',
title: 'Encaminhamento Especializado',
description: 'Encaminhamento com justificativa clínica e resumo do caso.',
tags: ['encaminhamento', 'especialista', 'conduta'],
exam: 'Encaminhamento médico',
cidCode: 'Z75.8',
diagnosis: 'Paciente encaminhado(a) para avaliação especializada por necessidade clínica descrita.',
conclusion: 'Solicitada avaliação especializada e continuidade do cuidado compartilhado.',
contentHtml:
'<h2>Encaminhamento Especializado</h2><p><strong>Especialidade solicitada:</strong> </p><p><strong>Resumo clínico:</strong> </p><p><strong>Motivo do encaminhamento:</strong> </p><p><strong>Exames anexos:</strong> </p>',
},
]
const templateCategories = ['Todos', ...Array.from(new Set(reportTemplates.map((template) => template.category)))]
const emptyEditor = {
id: null,
orderNumber: '',
@@ -472,7 +557,7 @@ export function ReportsPage({ role }) {
</section>
{editorOpen ? (
<ReportEditorModal
<ReportEditorModalV2
editor={editor}
onChange={setEditor}
onClose={() => setEditorOpen(false)}
@@ -520,6 +605,310 @@ function ReportRow({ onEdit, onView, report }) {
)
}
function ReportEditorModalV2({ editor, onChange, onClose, onSave, patientOptions, professionalOptions, saving }) {
const editorRef = useRef(null)
const [requesterSearch, setRequesterSearch] = useState(editor.requestedBy || '')
const [patientSearch, setPatientSearch] = useState('')
const [templateSearch, setTemplateSearch] = useState('')
const [templateCategory, setTemplateCategory] = useState('Todos')
const [selectedTemplateId, setSelectedTemplateId] = useState('')
const [previewOpen, setPreviewOpen] = useState(false)
const isValid = isReportEditorValid(editor)
const selectedPatient = patientOptions.find((patient) => patient.id === String(editor.patientId))
const filteredPatients = patientOptions
.filter((patient) => normalizeSearch(patient.name).includes(normalizeSearch(patientSearch)))
.slice(0, 5)
const filteredRequesterOptions = professionalOptions
.filter((professional) => normalizeSearch(professional.name).includes(normalizeSearch(requesterSearch)))
.slice(0, 5)
const filteredTemplates = reportTemplates.filter((template) => {
const matchesCategory = templateCategory === 'Todos' || template.category === templateCategory
const query = normalizeSearch(templateSearch)
const matchesSearch = !query || normalizeSearch([template.title, template.description, template.tags.join(' ')].join(' ')).includes(query)
return matchesCategory && matchesSearch
})
const selectedTemplate = reportTemplates.find((template) => template.id === selectedTemplateId)
function updateField(field, value) {
onChange((current) => ({ ...current, [field]: value }))
}
function applyTemplate(template) {
setSelectedTemplateId(template.id)
setPreviewOpen(true)
onChange((current) => ({
...current,
exam: template.exam,
cidCode: template.cidCode,
diagnosis: template.diagnosis,
conclusion: template.conclusion,
contentHtml: template.contentHtml,
contentJson: {
templateId: template.id,
templateTitle: template.title,
appliedAt: new Date().toISOString(),
},
}))
}
function runCommand(command, value = null) {
editorRef.current?.focus()
document.execCommand(command, false, value)
updateField('contentHtml', editorRef.current?.innerHTML || '')
}
function insertToken(token) {
const values = {
patient: selectedPatient?.name || '[Paciente]',
date: new Date().toLocaleDateString('pt-BR'),
doctor: editor.requestedBy || '[Médico]',
}
runCommand('insertText', values[token] || '')
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-3" onClick={onClose}>
<div
className="flex max-h-[94vh] w-full max-w-6xl flex-col overflow-hidden rounded-xl border border-[#404040] bg-[#242424] shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[#404040] px-6 py-4">
<div className="flex items-center gap-3">
<span className="grid size-9 place-items-center rounded-sm bg-[#0f2f66] text-[#3b82f6]">
<ReportIcon className="size-5" name="bolt" />
</span>
<div>
<h2 className="text-lg font-bold text-[#f5f5f5]">{editor.id ? 'Editar relatório' : 'Novo relatório'}</h2>
<p className="text-xs text-[#a3a3a3]">Selecione um template e finalize o conteúdo no editor rico.</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
className="inline-flex h-9 items-center gap-2 rounded-sm border border-[#404040] bg-[#1a1a1a] px-3 text-sm font-semibold text-[#d4d4d4] transition hover:bg-[#303030]"
onClick={() => setPreviewOpen((current) => !current)}
type="button"
>
<ReportIcon className="size-4" name="eye" />
Pré-visualizar
</button>
<button className="grid size-9 place-items-center rounded-sm text-[#a3a3a3] transition hover:bg-[#303030] hover:text-[#e5e5e5]" onClick={onClose} type="button">
<ReportIcon className="size-4" name="x" />
</button>
</div>
</div>
<div className="grid min-h-0 flex-1 lg:grid-cols-[230px_minmax(0,1fr)_300px]">
<aside className="min-h-0 border-b border-[#404040] bg-[#202020] p-4 lg:border-b-0 lg:border-r">
<p className="mb-3 text-xs font-bold uppercase tracking-[0.12em] text-[#a3a3a3]">Categorias</p>
<div className="space-y-1">
{templateCategories.map((category) => {
const count = category === 'Todos' ? reportTemplates.length : reportTemplates.filter((template) => template.category === category).length
return (
<button
className={`flex w-full items-center justify-between rounded-sm px-3 py-2 text-left text-sm font-semibold transition ${
templateCategory === category
? 'bg-[#3b82f6]/15 text-[#3b82f6]'
: 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
}`}
key={category}
onClick={() => setTemplateCategory(category)}
type="button"
>
<span>{category}</span>
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[10px]">{count}</span>
</button>
)
})}
</div>
</aside>
<main className="min-h-0 overflow-y-auto p-5">
<div className="mb-4">
<div className="relative">
<ReportIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#a3a3a3]" name="search" />
<input
className="h-10 w-full rounded-sm border border-[#404040] bg-[#171717] pl-10 pr-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6]"
onChange={(event) => setTemplateSearch(event.target.value)}
placeholder="Buscar templates..."
value={templateSearch}
/>
</div>
</div>
<div className="mb-5 grid gap-3 md:grid-cols-2">
{filteredTemplates.map((template) => (
<button
className={`min-h-[132px] rounded-md border p-4 text-left transition hover:border-[#3b82f6] ${
selectedTemplateId === template.id ? 'border-[#3b82f6] bg-[#2a2f3a]' : 'border-[#404040] bg-[#262626]'
}`}
key={template.id}
onClick={() => applyTemplate(template)}
type="button"
>
<span className="flex items-start justify-between gap-3">
<span className="text-sm font-bold leading-5 text-[#f5f5f5]">{template.title}</span>
{template.popular ? (
<span className="rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-bold text-amber-300">Popular</span>
) : null}
</span>
<span className="mt-2 block text-xs leading-5 text-[#b8b8b8]">{template.description}</span>
<span className="mt-3 flex flex-wrap gap-1.5">
{template.tags.map((tag) => (
<span className="rounded bg-[#1f1f1f] px-2 py-1 text-[10px] font-semibold text-[#a3a3a3]" key={tag}>
{tag}
</span>
))}
</span>
</button>
))}
</div>
<div className="space-y-4 border-t border-[#404040] pt-5">
<div className="grid gap-4 md:grid-cols-2">
<DarkField label="Tipo de relatório *">
<input
className={inputClass}
onChange={(event) => updateField('exam', event.target.value)}
placeholder="Ex: Relatório de consulta médica"
value={editor.exam}
/>
</DarkField>
<DarkField label="Paciente *">
<div className="space-y-2">
<input
className={inputClass}
onChange={(event) => setPatientSearch(event.target.value)}
placeholder="Digite o nome do paciente..."
value={patientSearch || selectedPatient?.name || ''}
/>
<SearchPickList
emptyText="Nenhum paciente encontrado."
items={filteredPatients}
labelKey="name"
onSelect={(patient) => {
updateField('patientId', patient.id)
setPatientSearch(patient.name)
}}
selectedValue={editor.patientId}
valueKey="id"
/>
</div>
</DarkField>
</div>
<div className="grid gap-4 md:grid-cols-[1fr_160px]">
<DarkField label="Médico responsável *">
<div className="space-y-2">
<input
className={inputClass}
onChange={(event) => {
setRequesterSearch(event.target.value)
updateField('requestedBy', event.target.value)
}}
placeholder="Pesquisar médico"
value={requesterSearch}
/>
<SearchPickList
emptyText="Nenhum médico encontrado."
items={filteredRequesterOptions}
labelKey="name"
onSelect={(professional) => {
setRequesterSearch(professional.name)
updateField('requestedBy', professional.name)
}}
selectedValue={editor.requestedBy}
valueKey="name"
/>
</div>
</DarkField>
<DarkField label="Status *">
<select className={inputClass} onChange={(event) => updateField('status', event.target.value)} value={editor.status}>
<option value="draft">Rascunho</option>
<option value="finalized">Finalizado</option>
</select>
</DarkField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<DarkField label="CID-10 *">
<input className={inputClass} onChange={(event) => updateField('cidCode', event.target.value)} placeholder="Ex: Z01.7" value={editor.cidCode} />
</DarkField>
<DarkField label="Prazo *">
<input className={`${inputClass} [color-scheme:dark]`} onChange={(event) => updateField('dueAt', event.target.value)} type="datetime-local" value={editor.dueAt} />
</DarkField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<DarkField label="Diagnóstico *">
<textarea className={textareaClass} onChange={(event) => updateField('diagnosis', event.target.value)} value={editor.diagnosis} />
</DarkField>
<DarkField label="Conclusão *">
<textarea className={textareaClass} onChange={(event) => updateField('conclusion', event.target.value)} value={editor.conclusion} />
</DarkField>
</div>
<DarkField label="Conteúdo">
<RichTextEditor
editorRef={editorRef}
onChange={(value) => updateField('contentHtml', value)}
onCommand={runCommand}
onInsertToken={insertToken}
value={editor.contentHtml}
/>
</DarkField>
</div>
</main>
<aside className="hidden min-h-0 border-l border-[#404040] bg-[#202020] p-5 lg:block">
{previewOpen || selectedTemplate ? (
<div className="h-full overflow-y-auto">
<p className="text-xs font-bold uppercase tracking-[0.12em] text-[#a3a3a3]">Pré-visualização</p>
<h3 className="mt-4 text-lg font-bold text-[#f5f5f5]">{editor.exam || selectedTemplate?.title || 'Relatório médico'}</h3>
<p className="mt-2 text-sm leading-6 text-[#a3a3a3]">
{selectedTemplate?.description || 'Use o editor para preencher o conteúdo do relatório.'}
</p>
<div className="mt-5 rounded-xl border border-[#404040] bg-[#171717] p-4 text-sm leading-6 text-[#d4d4d4]">
<div dangerouslySetInnerHTML={{ __html: sanitizePreviewHtml(editor.contentHtml || selectedTemplate?.contentHtml || '') }} />
</div>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center text-center">
<span className="grid size-16 place-items-center rounded-full bg-[#2a2a2a] text-[#a3a3a3]">
<ReportIcon className="size-8" name="file" />
</span>
<h3 className="mt-4 text-base font-bold text-[#f5f5f5]">Selecione um template</h3>
<p className="mt-2 text-sm leading-6 text-[#a3a3a3]">Clique em qualquer modelo para preencher o editor automaticamente.</p>
</div>
)}
</aside>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-[#404040] px-6 py-4">
<p className="text-xs font-semibold text-amber-300">
{!isValid ? '* Preencha paciente, tipo, médico, CID, prazo, diagnóstico e conclusão para salvar.' : 'Relatório pronto para salvar.'}
</p>
<div className="flex gap-3">
<button className="rounded-sm border border-[#404040] bg-[#262626] px-4 py-2 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]" onClick={onClose} type="button">
Cancelar
</button>
<button
className="inline-flex items-center gap-2 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#2563eb] disabled:cursor-not-allowed disabled:border-[#404040] disabled:bg-[#303030] disabled:text-[#737373]"
disabled={!isValid || saving}
onClick={onSave}
type="button"
>
<ReportIcon className="size-3.5" name="save" />
{saving ? 'Salvando...' : editor.status === 'finalized' ? 'Liberar relatório' : 'Salvar rascunho'}
</button>
</div>
</div>
</div>
</div>
)
}
function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions, professionalOptions, saving }) {
const [requesterSearch, setRequesterSearch] = useState(editor.requestedBy || '')
const isValid = isReportEditorValid(editor)
@@ -687,6 +1076,103 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
)
}
function SearchPickList({ emptyText, items, labelKey, onSelect, selectedValue, valueKey }) {
return (
<div className="max-h-32 overflow-y-auto rounded-md border border-[#404040] bg-[#1a1a1a] p-1">
{items.length ? (
items.map((item) => {
const value = String(item[valueKey] || '')
const selected = String(selectedValue || '') === value
return (
<button
className={`flex w-full items-center justify-between gap-3 rounded-sm px-3 py-2 text-left text-sm font-medium transition hover:bg-[#303030] ${
selected ? 'text-[#51a2ff]' : 'text-[#e5e5e5]'
}`}
key={value || item[labelKey]}
onClick={() => onSelect(item)}
type="button"
>
<span className="truncate">{item[labelKey]}</span>
{selected ? <ReportIcon className="size-3.5" name="check" /> : null}
</button>
)
})
) : (
<p className="px-3 py-2 text-sm text-[#a3a3a3]">{emptyText}</p>
)}
</div>
)
}
function RichTextEditor({ editorRef, onChange, onCommand, onInsertToken, value }) {
return (
<div className="overflow-hidden rounded-sm border border-[#404040] bg-[#171717]">
<div className="flex flex-wrap items-center gap-1 border-b border-[#404040] bg-[#202020] px-3 py-2">
<ToolbarButton label="Desfazer" name="undo" onClick={() => onCommand('undo')} />
<ToolbarButton label="Refazer" name="redo" onClick={() => onCommand('redo')} />
<span className="mx-1 h-5 w-px bg-[#404040]" />
<select className="h-8 rounded-sm border border-[#404040] bg-[#171717] px-2 text-xs font-semibold text-[#d4d4d4]" onChange={(event) => onCommand('formatBlock', event.target.value)} defaultValue="p">
<option value="p">Padrão</option>
<option value="h2">Título</option>
<option value="h3">Subtítulo</option>
</select>
<ToolbarButton active label="Negrito" name="bold" onClick={() => onCommand('bold')} />
<ToolbarButton label="Itálico" name="italic" onClick={() => onCommand('italic')} />
<ToolbarButton label="Sublinhado" name="underline" onClick={() => onCommand('underline')} />
<ToolbarButton label="Tachado" name="strike" onClick={() => onCommand('strikeThrough')} />
<span className="mx-1 h-5 w-px bg-[#404040]" />
<ToolbarButton label="Alinhar à esquerda" name="align-left" onClick={() => onCommand('justifyLeft')} />
<ToolbarButton label="Centralizar" name="align-center" onClick={() => onCommand('justifyCenter')} />
<ToolbarButton label="Alinhar à direita" name="align-right" onClick={() => onCommand('justifyRight')} />
<ToolbarButton label="Lista" name="list" onClick={() => onCommand('insertUnorderedList')} />
<div className="ml-auto flex items-center gap-1">
<span className="mr-1 text-[11px] text-[#a3a3a3]">Inserir:</span>
<button className="h-8 rounded-sm border border-[#3b82f6]/40 px-2 text-xs font-semibold text-[#3b82f6] hover:bg-[#3b82f6]/10" onClick={() => onInsertToken('patient')} type="button">
+ Paciente
</button>
<button className="h-8 rounded-sm border border-[#3b82f6]/40 px-2 text-xs font-semibold text-[#3b82f6] hover:bg-[#3b82f6]/10" onClick={() => onInsertToken('date')} type="button">
+ Data
</button>
<button className="h-8 rounded-sm border border-[#3b82f6]/40 px-2 text-xs font-semibold text-[#3b82f6] hover:bg-[#3b82f6]/10" onClick={() => onInsertToken('doctor')} type="button">
+ Médico
</button>
</div>
</div>
<div
className="min-h-[320px] px-4 py-3 text-sm leading-6 text-[#e5e5e5] outline-none empty:before:text-[#737373]"
contentEditable
dangerouslySetInnerHTML={{ __html: value || '' }}
onInput={(event) => onChange(event.currentTarget.innerHTML)}
ref={editorRef}
role="textbox"
suppressContentEditableWarning
/>
</div>
)
}
function ToolbarButton({ active = false, label, name, onClick }) {
return (
<button
aria-label={label}
className={`grid size-8 place-items-center rounded-sm transition ${
active ? 'bg-[#3b82f6]/20 text-[#3b82f6]' : 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
}`}
onClick={onClick}
title={label}
type="button"
>
<ReportIcon className="size-4" name={name} />
</button>
)
}
function sanitizePreviewHtml(value) {
return String(value || '')
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '')
.replace(/\son\w+="[^"]*"/gi, '')
}
function ReportViewModal({ onClose, report }) {
const currentStatus = statusConfig[report.status] || statusConfig.draft
@@ -738,7 +1224,10 @@ function ReportViewModal({ onClose, report }) {
<div className="mt-6 rounded-xl border border-[#404040] bg-[#1a1a1a] p-5">
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-[#a3a3a3]">Complemento</p>
{report.contentHtml ? (
<p className="whitespace-pre-wrap text-sm leading-6 text-[#e5e5e5]">{report.contentHtml}</p>
<div
className="whitespace-pre-wrap text-sm leading-6 text-[#e5e5e5]"
dangerouslySetInnerHTML={{ __html: sanitizePreviewHtml(report.contentHtml) }}
/>
) : (
<p className="text-sm text-[#a3a3a3]">Nenhum complemento informado.</p>
)}
@@ -922,7 +1411,7 @@ function printReportAsPdf(report, status) {
</div>
<div class="section box">
<p class="label">Complemento</p>
<p class="value">${escapeHtml(report.contentHtml || 'Nenhum complemento informado.')}</p>
<div class="value">${report.contentHtml ? sanitizePreviewHtml(report.contentHtml) : 'Nenhum complemento informado.'}</div>
</div>
</body>
</html>
@@ -969,6 +1458,105 @@ function ReportIcon({ className = 'size-4', name }) {
)
}
if (name === 'bolt') {
return (
<svg {...common}>
<path d="m13 2-8 12h6l-1 8 8-12h-6l1-8Z" />
</svg>
)
}
if (name === 'search') {
return (
<svg {...common}>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
)
}
if (name === 'undo') {
return (
<svg {...common}>
<path d="M9 7 5 11l4 4" />
<path d="M5 11h9a5 5 0 0 1 5 5v1" />
</svg>
)
}
if (name === 'redo') {
return (
<svg {...common}>
<path d="m15 7 4 4-4 4" />
<path d="M19 11h-9a5 5 0 0 0-5 5v1" />
</svg>
)
}
if (name === 'bold') {
return (
<svg {...common}>
<path d="M7 5h6a3 3 0 0 1 0 6H7zM7 11h7a3 3 0 0 1 0 6H7z" />
</svg>
)
}
if (name === 'italic') {
return (
<svg {...common}>
<path d="M10 5h7M7 19h7M14 5l-4 14" />
</svg>
)
}
if (name === 'underline') {
return (
<svg {...common}>
<path d="M7 5v6a5 5 0 0 0 10 0V5M5 21h14" />
</svg>
)
}
if (name === 'strike') {
return (
<svg {...common}>
<path d="M5 12h14M8 17a5 5 0 0 0 4 2c2.8 0 5-1.4 5-3.5 0-4-9-2.5-9-7C8 6.6 9.8 5 12.5 5c1.6 0 3 .5 4 1.5" />
</svg>
)
}
if (name === 'align-left') {
return (
<svg {...common}>
<path d="M4 6h16M4 10h10M4 14h16M4 18h10" />
</svg>
)
}
if (name === 'align-center') {
return (
<svg {...common}>
<path d="M4 6h16M7 10h10M4 14h16M7 18h10" />
</svg>
)
}
if (name === 'align-right') {
return (
<svg {...common}>
<path d="M4 6h16M10 10h10M4 14h16M10 18h10" />
</svg>
)
}
if (name === 'list') {
return (
<svg {...common}>
<path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01" />
</svg>
)
}
if (name === 'file') {
return (
<svg {...common}>