modified: src/components/featureStateStyles.js
modified: src/index.css modified: src/pages/ReportsPage.jsx
This commit is contained in:
@@ -6,21 +6,21 @@ export const featureStateStyles = {
|
|||||||
label: '',
|
label: '',
|
||||||
},
|
},
|
||||||
partial: {
|
partial: {
|
||||||
badge: 'border-sky-500/40 bg-sky-500/15 text-sky-300',
|
badge: 'feature-badge-partial border-sky-500/40 bg-sky-500/15 text-sky-300',
|
||||||
panel: 'border-sky-500/35 bg-sky-500/8',
|
panel: 'feature-panel-partial border-sky-500/35 bg-sky-500/8',
|
||||||
title: 'text-sky-300',
|
title: 'feature-title-partial text-sky-300',
|
||||||
label: 'Parcial',
|
label: 'Parcial',
|
||||||
},
|
},
|
||||||
mock: {
|
mock: {
|
||||||
badge: 'border-amber-500/40 bg-amber-500/15 text-amber-300',
|
badge: 'feature-badge-mock border-amber-500/40 bg-amber-500/15 text-amber-300',
|
||||||
panel: 'border-amber-500/35 bg-amber-500/8',
|
panel: 'feature-panel-mock border-amber-500/35 bg-amber-500/8',
|
||||||
title: 'text-amber-300',
|
title: 'feature-title-mock text-amber-300',
|
||||||
label: 'Mockado',
|
label: 'Mockado',
|
||||||
},
|
},
|
||||||
wip: {
|
wip: {
|
||||||
badge: 'border-rose-500/40 bg-rose-500/15 text-rose-300',
|
badge: 'feature-badge-wip border-rose-500/40 bg-rose-500/15 text-rose-300',
|
||||||
panel: 'border-rose-500/35 bg-rose-500/8',
|
panel: 'feature-panel-wip border-rose-500/35 bg-rose-500/8',
|
||||||
title: 'text-rose-300',
|
title: 'feature-title-wip text-rose-300',
|
||||||
label: 'WIP',
|
label: 'WIP',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,12 +46,12 @@ button:disabled {
|
|||||||
|
|
||||||
:root[data-theme='light'] {
|
:root[data-theme='light'] {
|
||||||
color: #333333;
|
color: #333333;
|
||||||
background: #cfd7e0;
|
background: #d9e4f0;
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='light'] body {
|
[data-theme='light'] body {
|
||||||
background: #cfd7e0;
|
background: #d9e4f0;
|
||||||
color: #333333;
|
color: #333333;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ button:disabled {
|
|||||||
|
|
||||||
[data-theme='light'] .bg-\[\#0a0a0a\],
|
[data-theme='light'] .bg-\[\#0a0a0a\],
|
||||||
[data-theme='light'] .bg-\[\#171717\] {
|
[data-theme='light'] .bg-\[\#171717\] {
|
||||||
background-color: #cfd7e0;
|
background-color: #d9e4f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='light'] .bg-\[\#1a1a1a\] {
|
[data-theme='light'] .bg-\[\#1a1a1a\] {
|
||||||
@@ -106,7 +106,7 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='light'] .disabled\:bg-\[\#303030\]:disabled {
|
[data-theme='light'] .disabled\:bg-\[\#303030\]:disabled {
|
||||||
background-color: #cfd7e0;
|
background-color: #d9e4f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='light'] .border-\[\#404040\],
|
[data-theme='light'] .border-\[\#404040\],
|
||||||
@@ -254,6 +254,54 @@ button:disabled {
|
|||||||
background-color: #404040;
|
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 {
|
.agenda-calendar-shell {
|
||||||
border-color: #3b3b3b;
|
border-color: #3b3b3b;
|
||||||
background: #202020;
|
background: #202020;
|
||||||
|
|||||||
@@ -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 { normalizeRole } from '../config/permissions.js'
|
||||||
import { patientRepository } from '../repositories/patientRepository.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 labelClass = 'mb-1.5 block text-xs font-medium text-[#e5e5e5]'
|
||||||
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
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 = {
|
const emptyEditor = {
|
||||||
id: null,
|
id: null,
|
||||||
orderNumber: '',
|
orderNumber: '',
|
||||||
@@ -472,7 +557,7 @@ export function ReportsPage({ role }) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{editorOpen ? (
|
{editorOpen ? (
|
||||||
<ReportEditorModal
|
<ReportEditorModalV2
|
||||||
editor={editor}
|
editor={editor}
|
||||||
onChange={setEditor}
|
onChange={setEditor}
|
||||||
onClose={() => setEditorOpen(false)}
|
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 }) {
|
function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions, professionalOptions, saving }) {
|
||||||
const [requesterSearch, setRequesterSearch] = useState(editor.requestedBy || '')
|
const [requesterSearch, setRequesterSearch] = useState(editor.requestedBy || '')
|
||||||
const isValid = isReportEditorValid(editor)
|
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 }) {
|
function ReportViewModal({ onClose, report }) {
|
||||||
const currentStatus = statusConfig[report.status] || statusConfig.draft
|
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">
|
<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>
|
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-[#a3a3a3]">Complemento</p>
|
||||||
{report.contentHtml ? (
|
{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>
|
<p className="text-sm text-[#a3a3a3]">Nenhum complemento informado.</p>
|
||||||
)}
|
)}
|
||||||
@@ -922,7 +1411,7 @@ function printReportAsPdf(report, status) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="section box">
|
<div class="section box">
|
||||||
<p class="label">Complemento</p>
|
<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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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') {
|
if (name === 'file') {
|
||||||
return (
|
return (
|
||||||
<svg {...common}>
|
<svg {...common}>
|
||||||
|
|||||||
Reference in New Issue
Block a user