1643 lines
62 KiB
JavaScript
1643 lines
62 KiB
JavaScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
import { normalizeRole } from '../config/permissions.js'
|
|
import { patientRepository } from '../repositories/patientRepository.js'
|
|
import { professionalRepository } from '../repositories/professionalRepository.js'
|
|
import { profileRepository } from '../repositories/profileRepository.js'
|
|
import { reportRepository } from '../repositories/reportRepository.js'
|
|
|
|
const ITEMS_PER_PAGE = 25
|
|
|
|
const statusConfig = {
|
|
draft: {
|
|
label: 'Rascunho',
|
|
pill: 'bg-amber-500/20 text-amber-400',
|
|
stat: 'text-amber-400',
|
|
},
|
|
finalized: {
|
|
label: 'Finalizado',
|
|
pill: 'bg-emerald-500/20 text-emerald-400',
|
|
stat: 'text-emerald-400',
|
|
},
|
|
}
|
|
|
|
const orderOptions = [
|
|
{ label: 'Criação mais recente', value: 'created_at.desc' },
|
|
{ label: 'Criação mais antiga', value: 'created_at.asc' },
|
|
{ label: 'Prazo mais proximo', value: 'due_at.asc' },
|
|
{ label: 'Prazo mais distante', value: 'due_at.desc' },
|
|
]
|
|
|
|
const inputClass =
|
|
'h-10 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]'
|
|
const textareaClass =
|
|
'min-h-24 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 py-2 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]'
|
|
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: '',
|
|
patientId: '',
|
|
status: 'draft',
|
|
exam: '',
|
|
requestedBy: '',
|
|
cidCode: '',
|
|
diagnosis: '',
|
|
conclusion: '',
|
|
contentHtml: '',
|
|
contentJson: undefined,
|
|
dueAt: '',
|
|
}
|
|
|
|
export function ReportsPage({ role }) {
|
|
const [reports, setReports] = useState([])
|
|
const [patients, setPatients] = useState([])
|
|
const [professionals, setProfessionals] = useState([])
|
|
const [viewerProfile, setViewerProfile] = useState(null)
|
|
const [currentProfessional, setCurrentProfessional] = useState(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [scopeLoading, setScopeLoading] = useState(true)
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const isDoctorRole = normalizeRole(role) === 'medico'
|
|
|
|
const [filterPatientId, setFilterPatientId] = useState('')
|
|
const [filterStatus, setFilterStatus] = useState('')
|
|
const [filterCreatedBy, setFilterCreatedBy] = useState('')
|
|
const [filterOrder, setFilterOrder] = useState('created_at.desc')
|
|
|
|
const [editorOpen, setEditorOpen] = useState(false)
|
|
const [viewerReport, setViewerReport] = useState(null)
|
|
const [editor, setEditor] = useState(emptyEditor)
|
|
const [page, setPage] = useState(1)
|
|
|
|
const patientOptions = useMemo(
|
|
() =>
|
|
patients.map((patient) => ({
|
|
id: String(patient.id || ''),
|
|
name: patient.name || patient.full_name || patient.nome || 'Paciente',
|
|
})),
|
|
[patients],
|
|
)
|
|
|
|
const professionalOptions = useMemo(() => {
|
|
const seen = new Set()
|
|
|
|
return professionals
|
|
.map((professional) => {
|
|
const createdByValue = String(professional.userId || professional.id || '')
|
|
return {
|
|
id: String(professional.id || ''),
|
|
createdByValue,
|
|
name: professional.name || 'Médico(a)',
|
|
}
|
|
})
|
|
.filter((professional) => {
|
|
if (!professional.createdByValue || seen.has(professional.createdByValue)) {
|
|
return false
|
|
}
|
|
|
|
seen.add(professional.createdByValue)
|
|
return true
|
|
})
|
|
}, [professionals])
|
|
|
|
const patientNameById = useMemo(
|
|
() => Object.fromEntries(patientOptions.map((patient) => [patient.id, patient.name])),
|
|
[patientOptions],
|
|
)
|
|
|
|
const professionalNameByCreatedBy = useMemo(
|
|
() => Object.fromEntries(professionalOptions.map((professional) => [professional.createdByValue, professional.name])),
|
|
[professionalOptions],
|
|
)
|
|
|
|
const enrichedReports = useMemo(
|
|
() =>
|
|
reports.map((report) => ({
|
|
...report,
|
|
patientName: patientNameById[String(report.patientId || '')] || 'Paciente não encontrado',
|
|
createdByName: professionalNameByCreatedBy[String(report.createdBy || '')] || report.createdBy || 'Sistema',
|
|
})),
|
|
[patientNameById, professionalNameByCreatedBy, reports],
|
|
)
|
|
|
|
const stats = useMemo(
|
|
() => [
|
|
{ label: 'Total', value: enrichedReports.length, className: 'text-[#e5e5e5]' },
|
|
{
|
|
label: 'Rascunhos',
|
|
value: enrichedReports.filter((report) => report.status === 'draft').length,
|
|
className: statusConfig.draft.stat,
|
|
},
|
|
{
|
|
label: 'Finalizados',
|
|
value: enrichedReports.filter((report) => report.status === 'finalized').length,
|
|
className: statusConfig.finalized.stat,
|
|
},
|
|
],
|
|
[enrichedReports],
|
|
)
|
|
|
|
const totalPages = Math.max(1, Math.ceil(enrichedReports.length / ITEMS_PER_PAGE))
|
|
const currentPage = Math.min(page, totalPages)
|
|
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE
|
|
const paginatedReports = enrichedReports.slice(startIndex, startIndex + ITEMS_PER_PAGE)
|
|
|
|
const loadReports = useCallback(async () => {
|
|
if (scopeLoading) return
|
|
|
|
setLoading(true)
|
|
setError('')
|
|
|
|
try {
|
|
const doctorPatientIds = isDoctorRole
|
|
? patientOptions.map((patient) => patient.id).filter(Boolean)
|
|
: []
|
|
const createdByValues = isDoctorRole
|
|
? uniqueValues([
|
|
viewerProfile?.id,
|
|
viewerProfile?.doctorId,
|
|
currentProfessional?.userId,
|
|
currentProfessional?.id,
|
|
])
|
|
: []
|
|
|
|
const data = await reportRepository.getInitialReports({
|
|
patientId: filterPatientId || undefined,
|
|
patientIds: !filterPatientId && doctorPatientIds.length ? doctorPatientIds : undefined,
|
|
status: filterStatus || undefined,
|
|
createdBy: !isDoctorRole ? filterCreatedBy || undefined : undefined,
|
|
createdByValues: isDoctorRole && !doctorPatientIds.length ? createdByValues : undefined,
|
|
order: filterOrder,
|
|
})
|
|
|
|
setReports(data)
|
|
setPage(1)
|
|
} catch (loadError) {
|
|
console.error(loadError)
|
|
setError(loadError.message || 'Erro ao carregar relatórios.')
|
|
setReports([])
|
|
setPage(1)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [currentProfessional, filterCreatedBy, filterOrder, filterPatientId, filterStatus, isDoctorRole, patientOptions, scopeLoading, viewerProfile])
|
|
|
|
useEffect(() => {
|
|
let active = true
|
|
|
|
async function loadAuxiliaryData() {
|
|
setScopeLoading(true)
|
|
|
|
try {
|
|
const [professionalData, currentProfile] = await Promise.all([
|
|
professionalRepository.getAll(),
|
|
profileRepository.getCurrentUserProfile(),
|
|
])
|
|
|
|
if (!active) return
|
|
|
|
const resolvedProfessional = professionalRepository.resolveCurrentProfessional(currentProfile, professionalData || [])
|
|
const patientData = isDoctorRole && resolvedProfessional?.id
|
|
? await patientRepository.getDirectoryRows({ doctorId: resolvedProfessional.id })
|
|
: await patientRepository.getAll()
|
|
|
|
if (!active) return
|
|
|
|
setViewerProfile(currentProfile)
|
|
setCurrentProfessional(resolvedProfessional)
|
|
setPatients(patientData || [])
|
|
setProfessionals(professionalData || [])
|
|
} catch (loadError) {
|
|
if (!active) return
|
|
console.error(loadError)
|
|
setError(loadError.message || 'Erro ao carregar dados auxiliares.')
|
|
} finally {
|
|
if (active) setScopeLoading(false)
|
|
}
|
|
}
|
|
|
|
loadAuxiliaryData()
|
|
|
|
return () => {
|
|
active = false
|
|
}
|
|
}, [isDoctorRole])
|
|
|
|
useEffect(() => {
|
|
loadReports()
|
|
}, [loadReports])
|
|
|
|
function openNew() {
|
|
setEditor({
|
|
...emptyEditor,
|
|
patientId: patientOptions[0]?.id || '',
|
|
})
|
|
setEditorOpen(true)
|
|
}
|
|
|
|
function openEdit(report) {
|
|
setEditor({
|
|
id: report.id,
|
|
orderNumber: report.orderNumber,
|
|
patientId: String(report.patientId || ''),
|
|
status: report.status,
|
|
exam: report.exam,
|
|
requestedBy: report.requestedBy,
|
|
cidCode: report.cidCode,
|
|
diagnosis: report.diagnosis,
|
|
conclusion: report.conclusion,
|
|
contentHtml: report.contentHtml,
|
|
contentJson: report.contentJson,
|
|
dueAt: toDateTimeLocal(report.dueAt),
|
|
})
|
|
setEditorOpen(true)
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (!isReportEditorValid(editor)) {
|
|
alert('Preencha todos os campos obrigatórios antes de salvar o relatório.')
|
|
return
|
|
}
|
|
|
|
setSaving(true)
|
|
|
|
const payload = {
|
|
orderNumber: editor.id ? editor.orderNumber : `REL-${Date.now()}`,
|
|
patientId: editor.patientId,
|
|
status: editor.status,
|
|
exam: editor.exam,
|
|
requestedBy: editor.requestedBy,
|
|
cidCode: editor.cidCode,
|
|
diagnosis: editor.diagnosis,
|
|
conclusion: editor.conclusion,
|
|
contentHtml: editor.contentHtml,
|
|
contentJson: editor.contentJson,
|
|
dueAt: editor.dueAt ? new Date(editor.dueAt).toISOString() : '',
|
|
createdBy: editor.id ? undefined : viewerProfile?.id || currentProfessional?.userId || currentProfessional?.id || undefined,
|
|
updatedBy: viewerProfile?.id || currentProfessional?.userId || currentProfessional?.id || undefined,
|
|
}
|
|
|
|
try {
|
|
if (editor.id) {
|
|
await reportRepository.update(editor.id, payload)
|
|
} else {
|
|
await reportRepository.create(payload)
|
|
}
|
|
|
|
setEditorOpen(false)
|
|
await loadReports()
|
|
} catch (saveError) {
|
|
alert(saveError.message || 'Erro ao salvar relatório.')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-7xl space-y-6 text-[#e5e5e5]">
|
|
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight text-[#e5e5e5]">Relatórios</h1>
|
|
<p className="mt-1 text-sm text-[#a3a3a3]">Consulta, criação e edição de relatórios.</p>
|
|
</div>
|
|
<button
|
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-[#3b82f6] px-4 text-sm font-medium text-white transition hover:bg-[#2563eb]"
|
|
onClick={openNew}
|
|
type="button"
|
|
>
|
|
<ReportIcon name="plus" />
|
|
Novo relatório
|
|
</button>
|
|
</div>
|
|
|
|
<section className="grid gap-4 md:grid-cols-3">
|
|
{stats.map((stat) => (
|
|
<div className={cardClass} key={stat.label}>
|
|
<div className="p-4">
|
|
<p className="text-xs font-semibold text-[#a3a3a3]">{stat.label}</p>
|
|
<p className={`mt-1 text-2xl font-bold ${stat.className}`}>{stat.value}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</section>
|
|
|
|
<section className={`${cardClass} p-6`}>
|
|
<div className="mb-6 grid gap-4 lg:grid-cols-4">
|
|
<FilterField label="Paciente">
|
|
<select
|
|
className={inputClass}
|
|
onChange={(event) => {
|
|
setFilterPatientId(event.target.value)
|
|
setPage(1)
|
|
}}
|
|
value={filterPatientId}
|
|
>
|
|
<option value="">Todos os pacientes</option>
|
|
{patientOptions.map((patient) => (
|
|
<option key={patient.id} value={patient.id}>
|
|
{patient.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</FilterField>
|
|
|
|
<FilterField label="Status">
|
|
<select
|
|
className={inputClass}
|
|
onChange={(event) => {
|
|
setFilterStatus(event.target.value)
|
|
setPage(1)
|
|
}}
|
|
value={filterStatus}
|
|
>
|
|
<option value="">Todos os status</option>
|
|
<option value="draft">Rascunho</option>
|
|
<option value="finalized">Finalizado</option>
|
|
</select>
|
|
</FilterField>
|
|
|
|
<FilterField label="Criado por">
|
|
<select
|
|
className={inputClass}
|
|
onChange={(event) => {
|
|
setFilterCreatedBy(event.target.value)
|
|
setPage(1)
|
|
}}
|
|
value={filterCreatedBy}
|
|
>
|
|
<option value="">Todos os autores</option>
|
|
{professionalOptions.map((professional) => (
|
|
<option key={professional.createdByValue} value={professional.createdByValue}>
|
|
{professional.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</FilterField>
|
|
|
|
<FilterField label="Ordenação">
|
|
<select
|
|
className={inputClass}
|
|
onChange={(event) => {
|
|
setFilterOrder(event.target.value)
|
|
setPage(1)
|
|
}}
|
|
value={filterOrder}
|
|
>
|
|
{orderOptions.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</FilterField>
|
|
</div>
|
|
|
|
{error ? (
|
|
<div className="mb-6 rounded-xl border border-[#7f1d1d] bg-[#2a1111] px-4 py-3 text-sm text-[#fecaca]">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="overflow-x-auto rounded-xl border border-[#404040]">
|
|
<table className="w-full min-w-full table-fixed text-left text-sm">
|
|
<thead className="bg-[#171717] text-xs font-semibold uppercase text-[#a3a3a3]">
|
|
<tr>
|
|
<th className="w-[12%] px-4 py-3">Numero</th>
|
|
<th className="w-[20%] px-4 py-3">Exame</th>
|
|
<th className="w-[18%] px-4 py-3">Paciente</th>
|
|
<th className="w-[18%] px-4 py-3">Solicitante</th>
|
|
<th className="w-[14%] px-4 py-3">Criado em</th>
|
|
<th className="w-[10%] px-4 py-3">Status</th>
|
|
<th className="sticky right-0 w-[8.5rem] bg-[#171717] px-4 py-3 text-right">Ações</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-[#404040] bg-[#262626]">
|
|
{loading ? (
|
|
<tr>
|
|
<td className="px-4 py-8 text-center text-sm text-[#a3a3a3]" colSpan={7}>
|
|
Carregando relatórios...
|
|
</td>
|
|
</tr>
|
|
) : paginatedReports.length ? (
|
|
paginatedReports.map((report) => (
|
|
<ReportRow
|
|
key={report.id}
|
|
onEdit={() => openEdit(report)}
|
|
onView={() => setViewerReport(report)}
|
|
report={report}
|
|
/>
|
|
))
|
|
) : (
|
|
<tr>
|
|
<td className="px-4 py-8 text-center text-sm text-[#a3a3a3]" colSpan={7}>
|
|
Nenhum relatório encontrado com os filtros atuais.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="mt-4 flex flex-col gap-4 border-t border-[#404040] pt-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<p className="text-xs text-[#a3a3a3]">
|
|
Mostrando {enrichedReports.length ? startIndex + 1 : 0}-{Math.min(startIndex + ITEMS_PER_PAGE, enrichedReports.length)} de{' '}
|
|
{enrichedReports.length} relatórios
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<PageButton disabled={currentPage === 1} onClick={() => setPage(currentPage - 1)}>
|
|
<ReportIcon className="size-4" name="chevron-left" />
|
|
</PageButton>
|
|
{Array.from({ length: totalPages }, (_, index) => index + 1).map((pageNumber) => (
|
|
<button
|
|
className={`grid size-8 place-items-center rounded-lg text-xs font-medium transition ${
|
|
pageNumber === currentPage
|
|
? 'bg-[#3b82f6] text-white'
|
|
: 'border border-[#404040] bg-[#1a1a1a] text-[#a3a3a3] hover:bg-[#333333]'
|
|
}`}
|
|
key={pageNumber}
|
|
onClick={() => setPage(pageNumber)}
|
|
type="button"
|
|
>
|
|
{pageNumber}
|
|
</button>
|
|
))}
|
|
<PageButton disabled={currentPage === totalPages} onClick={() => setPage(currentPage + 1)}>
|
|
<ReportIcon className="size-4" name="chevron-right" />
|
|
</PageButton>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{editorOpen ? (
|
|
<ReportEditorModalV2
|
|
editor={editor}
|
|
onChange={setEditor}
|
|
onClose={() => setEditorOpen(false)}
|
|
onSave={handleSave}
|
|
patientOptions={patientOptions}
|
|
professionalOptions={professionalOptions}
|
|
saving={saving}
|
|
/>
|
|
) : null}
|
|
|
|
{viewerReport ? (
|
|
<ReportViewModal onClose={() => setViewerReport(null)} report={viewerReport} />
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ReportRow({ onEdit, onView, report }) {
|
|
const currentStatus = statusConfig[report.status] || statusConfig.draft
|
|
|
|
return (
|
|
<tr className="transition hover:bg-[#303030]">
|
|
<td className="px-4 py-3 align-top text-[#a3a3a3]">{report.orderNumber || '-'}</td>
|
|
<td className="px-4 py-3 align-top">
|
|
<div className="flex items-center gap-2">
|
|
<ReportIcon className="mt-0.5 size-4 shrink-0 text-[#3b82f6]" name="file" />
|
|
<span className="whitespace-normal break-words font-medium text-[#e5e5e5]">{report.exam || 'Sem exame'}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 align-top whitespace-normal break-words text-[#e5e5e5]">{report.patientName}</td>
|
|
<td className="px-4 py-3 align-top whitespace-normal break-words text-[#a3a3a3]">{report.requestedBy || '-'}</td>
|
|
<td className="px-4 py-3 align-top text-[#a3a3a3]">{formatDate(report.createdAt)}</td>
|
|
<td className="px-4 py-3 align-top">
|
|
<span className={`rounded px-2 py-1 text-[10px] font-bold ${currentStatus.pill}`}>
|
|
{currentStatus.label}
|
|
</span>
|
|
</td>
|
|
<td className="sticky right-0 bg-[#262626] px-4 py-3 text-right shadow-[-10px_0_12px_-12px_rgba(0,0,0,0.75)]">
|
|
<div className="flex justify-end gap-2">
|
|
<IconButton label="Visualizar" name="eye" onClick={onView} />
|
|
<IconButton label="Editar" name="edit" onClick={onEdit} />
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
}
|
|
|
|
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)
|
|
const filteredRequesterOptions = professionalOptions
|
|
.filter((professional) => normalizeSearch(professional.name).includes(normalizeSearch(requesterSearch)))
|
|
.slice(0, 6)
|
|
|
|
function updateField(field, value) {
|
|
onChange((current) => ({ ...current, [field]: value }))
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
|
<div
|
|
className="flex max-h-[92vh] w-full max-w-4xl flex-col rounded-2xl border border-[#404040] bg-[#262626] shadow-xl"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="flex items-center justify-between border-b border-[#404040] px-6 py-4">
|
|
<h2 className="text-lg font-bold text-[#e5e5e5]">
|
|
{editor.id ? 'Editar relatório' : 'Novo relatório'}
|
|
</h2>
|
|
<button className="rounded-lg p-1.5 transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
|
|
<ReportIcon className="size-4 text-[#a3a3a3]" name="x" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
<div className="space-y-4">
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<DarkField label="Paciente *">
|
|
<select className={inputClass} onChange={(event) => updateField('patientId', event.target.value)} value={editor.patientId}>
|
|
<option value="">Selecione um paciente</option>
|
|
{patientOptions.map((patient) => (
|
|
<option key={patient.id} value={patient.id}>
|
|
{patient.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</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="Exame *">
|
|
<input
|
|
className={inputClass}
|
|
onChange={(event) => updateField('exam', event.target.value)}
|
|
placeholder="Nome do exame"
|
|
value={editor.exam}
|
|
/>
|
|
</DarkField>
|
|
|
|
<DarkField label="Solicitante *">
|
|
<div className="space-y-2">
|
|
<input
|
|
className={inputClass}
|
|
onChange={(event) => {
|
|
setRequesterSearch(event.target.value)
|
|
updateField('requestedBy', event.target.value)
|
|
}}
|
|
placeholder="Pesquisar médico"
|
|
type="search"
|
|
value={requesterSearch}
|
|
/>
|
|
<div className="max-h-36 overflow-y-auto rounded-md border border-[#404040] bg-[#1a1a1a] p-1">
|
|
{filteredRequesterOptions.length ? (
|
|
filteredRequesterOptions.map((professional) => (
|
|
<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] ${
|
|
editor.requestedBy === professional.name ? 'text-[#51a2ff]' : 'text-[#e5e5e5]'
|
|
}`}
|
|
key={professional.id || professional.createdByValue || professional.name}
|
|
onClick={() => {
|
|
setRequesterSearch(professional.name)
|
|
updateField('requestedBy', professional.name)
|
|
}}
|
|
type="button"
|
|
>
|
|
<span className="truncate">{professional.name}</span>
|
|
{editor.requestedBy === professional.name ? <ReportIcon className="size-3.5" name="check" /> : null}
|
|
</button>
|
|
))
|
|
) : (
|
|
<p className="px-3 py-2 text-sm text-[#a3a3a3]">Nenhum médico encontrado.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</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>
|
|
|
|
<DarkField label="Diagnóstico *">
|
|
<textarea
|
|
className={textareaClass}
|
|
onChange={(event) => updateField('diagnosis', event.target.value)}
|
|
placeholder="Diagnóstico do relatório"
|
|
value={editor.diagnosis}
|
|
/>
|
|
</DarkField>
|
|
|
|
<DarkField label="Conclusão *">
|
|
<textarea
|
|
className={textareaClass}
|
|
onChange={(event) => updateField('conclusion', event.target.value)}
|
|
placeholder="Conclusão do relatório"
|
|
value={editor.conclusion}
|
|
/>
|
|
</DarkField>
|
|
|
|
<DarkField label="Complemento">
|
|
<textarea
|
|
className={`${textareaClass} min-h-72`}
|
|
onChange={(event) => updateField('contentHtml', event.target.value)}
|
|
value={editor.contentHtml}
|
|
/>
|
|
</DarkField>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between border-t border-[#404040] px-6 py-4">
|
|
<button
|
|
className="rounded-lg border border-[#404040] bg-[#262626] px-4 py-2 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#2a2a2a]"
|
|
onClick={onClose}
|
|
type="button"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
className="inline-flex items-center gap-2 rounded-lg bg-[#3b82f6] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#2563eb] disabled:cursor-not-allowed disabled:opacity-40"
|
|
disabled={!isValid || saving}
|
|
onClick={onSave}
|
|
type="button"
|
|
>
|
|
<ReportIcon className="size-3.5" name="save" />
|
|
{saving ? 'Salvando...' : 'Salvar relatório'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
|
<div
|
|
className="flex max-h-[92vh] w-full max-w-4xl flex-col rounded-2xl border border-[#404040] bg-[#262626] shadow-xl"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="flex items-center justify-between border-b border-[#404040] px-6 py-4">
|
|
<div>
|
|
<h2 className="text-lg font-bold text-[#e5e5e5]">Relatório</h2>
|
|
<p className="mt-1 text-xs text-[#a3a3a3]">{report.orderNumber || 'Sem número'} </p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
className="inline-flex h-9 items-center gap-2 rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 text-xs font-semibold text-[#e5e5e5] transition hover:bg-[#2a2a2a]"
|
|
onClick={() => printReportAsPdf(report, currentStatus)}
|
|
type="button"
|
|
>
|
|
<ReportIcon className="size-4" name="print" />
|
|
Imprimir PDF
|
|
</button>
|
|
<button className="rounded-lg p-1.5 transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
|
|
<ReportIcon className="size-4 text-[#a3a3a3]" name="x" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<DetailCard label="Paciente" value={report.patientName} />
|
|
<DetailCard label="Solicitante" value={report.requestedBy || '-'} />
|
|
<DetailCard label="Criado em" value={formatDate(report.createdAt)} />
|
|
<DetailCard label="Criado por" value={report.createdByName} />
|
|
<DetailCard label="Status" value={currentStatus.label} />
|
|
<DetailCard label="Prazo" value={formatDateTime(report.dueAt)} />
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
|
<DetailBlock label="Exame" value={report.exam || '-'} />
|
|
<DetailBlock label="CID-10" value={report.cidCode || '-'} />
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
|
<DetailBlock label="Diagnóstico" value={report.diagnosis || '-'} />
|
|
<DetailBlock label="Conclusão" value={report.conclusion || '-'} />
|
|
</div>
|
|
<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 ? (
|
|
<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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FilterField({ children, label }) {
|
|
return (
|
|
<label className="block">
|
|
<span className={labelClass}>{label}</span>
|
|
{children}
|
|
</label>
|
|
)
|
|
}
|
|
|
|
function DarkField({ children, label }) {
|
|
return (
|
|
<label className="block">
|
|
<span className={labelClass}>{label}</span>
|
|
{children}
|
|
</label>
|
|
)
|
|
}
|
|
|
|
function DetailCard({ label, value }) {
|
|
return (
|
|
<div className="rounded-xl border border-[#404040] bg-[#1a1a1a] p-4">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-[#a3a3a3]">{label}</p>
|
|
<p className="mt-2 text-sm text-[#e5e5e5]">{value}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function DetailBlock({ label, value }) {
|
|
return (
|
|
<div className="rounded-xl border border-[#404040] bg-[#1a1a1a] p-4">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-[#a3a3a3]">{label}</p>
|
|
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-[#e5e5e5]">{value}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function IconButton({ label, name, onClick }) {
|
|
return (
|
|
<button
|
|
aria-label={label}
|
|
className="grid size-9 place-items-center rounded-lg border border-[#404040] bg-[#1a1a1a] text-[#a3a3a3] transition hover:bg-[#2a2a2a] hover:text-[#e5e5e5]"
|
|
onClick={onClick}
|
|
title={label}
|
|
type="button"
|
|
>
|
|
<ReportIcon className="size-4" name={name} />
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function PageButton({ children, disabled, onClick }) {
|
|
return (
|
|
<button
|
|
className="grid size-8 place-items-center rounded-lg border border-[#404040] bg-[#1a1a1a] text-[#e5e5e5] transition hover:bg-[#333333] disabled:cursor-not-allowed disabled:opacity-30"
|
|
disabled={disabled}
|
|
onClick={onClick}
|
|
type="button"
|
|
>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return '-'
|
|
|
|
const parsed = new Date(value)
|
|
if (Number.isNaN(parsed.getTime())) return '-'
|
|
|
|
return parsed.toLocaleDateString('pt-BR')
|
|
}
|
|
|
|
function formatDateTime(value) {
|
|
if (!value) return '-'
|
|
|
|
const parsed = new Date(value)
|
|
if (Number.isNaN(parsed.getTime())) return '-'
|
|
|
|
return parsed.toLocaleString('pt-BR')
|
|
}
|
|
|
|
function toDateTimeLocal(value) {
|
|
if (!value) return ''
|
|
|
|
const parsed = new Date(value)
|
|
if (Number.isNaN(parsed.getTime())) return ''
|
|
|
|
const year = parsed.getFullYear()
|
|
const month = String(parsed.getMonth() + 1).padStart(2, '0')
|
|
const day = String(parsed.getDate()).padStart(2, '0')
|
|
const hours = String(parsed.getHours()).padStart(2, '0')
|
|
const minutes = String(parsed.getMinutes()).padStart(2, '0')
|
|
|
|
return `${year}-${month}-${day}T${hours}:${minutes}`
|
|
}
|
|
|
|
function uniqueValues(values) {
|
|
return [...new Set(values.map((value) => String(value || '').trim()).filter(Boolean))]
|
|
}
|
|
|
|
function isReportEditorValid(editor) {
|
|
return [
|
|
editor.patientId,
|
|
editor.status,
|
|
editor.exam,
|
|
editor.requestedBy,
|
|
editor.cidCode,
|
|
editor.diagnosis,
|
|
editor.conclusion,
|
|
editor.dueAt,
|
|
].every((value) => String(value || '').trim())
|
|
}
|
|
|
|
function normalizeSearch(value) {
|
|
return String(value || '')
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.trim()
|
|
.toLowerCase()
|
|
}
|
|
|
|
function printReportAsPdf(report, status) {
|
|
const printWindow = window.open('', '_blank', 'noopener,noreferrer,width=900,height=1100')
|
|
|
|
if (!printWindow) {
|
|
window.print()
|
|
return
|
|
}
|
|
|
|
printWindow.document.write(`
|
|
<!doctype html>
|
|
<html lang="pt-BR">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>Relatório ${escapeHtml(report.orderNumber || '')}</title>
|
|
<style>
|
|
* { box-sizing: border-box; }
|
|
body { color: #171717; font-family: Arial, sans-serif; margin: 40px; }
|
|
h1 { font-size: 24px; margin: 0 0 4px; }
|
|
.muted { color: #525252; font-size: 12px; }
|
|
.grid { display: grid; gap: 12px; grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 24px; }
|
|
.box { border: 1px solid #d4d4d4; border-radius: 8px; padding: 12px; }
|
|
.label { color: #525252; font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
|
.value { font-size: 13px; margin-top: 6px; white-space: pre-wrap; }
|
|
.section { margin-top: 20px; }
|
|
@media print { body { margin: 24mm; } button { display: none; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Relatório</h1>
|
|
<p class="muted">${escapeHtml(report.orderNumber || 'Sem número')}</p>
|
|
<div class="grid">
|
|
${printDetail('Paciente', report.patientName)}
|
|
${printDetail('Solicitante', report.requestedBy || '-')}
|
|
${printDetail('Criado em', formatDate(report.createdAt))}
|
|
${printDetail('Criado por', report.createdByName)}
|
|
${printDetail('Status', status.label)}
|
|
${printDetail('Prazo', formatDateTime(report.dueAt))}
|
|
</div>
|
|
<div class="grid">
|
|
${printDetail('Exame', report.exam || '-')}
|
|
${printDetail('CID-10', report.cidCode || '-')}
|
|
</div>
|
|
<div class="section box">
|
|
<p class="label">Diagnóstico</p>
|
|
<p class="value">${escapeHtml(report.diagnosis || '-')}</p>
|
|
</div>
|
|
<div class="section box">
|
|
<p class="label">Conclusão</p>
|
|
<p class="value">${escapeHtml(report.conclusion || '-')}</p>
|
|
</div>
|
|
<div class="section box">
|
|
<p class="label">Complemento</p>
|
|
<div class="value">${report.contentHtml ? sanitizePreviewHtml(report.contentHtml) : 'Nenhum complemento informado.'}</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`)
|
|
printWindow.document.close()
|
|
printWindow.focus()
|
|
printWindow.print()
|
|
}
|
|
|
|
function printDetail(label, value) {
|
|
return `
|
|
<div class="box">
|
|
<p class="label">${escapeHtml(label)}</p>
|
|
<p class="value">${escapeHtml(value || '-')}</p>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
}
|
|
|
|
function ReportIcon({ className = 'size-4', name }) {
|
|
const common = {
|
|
className,
|
|
fill: 'none',
|
|
stroke: 'currentColor',
|
|
strokeLinecap: 'round',
|
|
strokeLinejoin: 'round',
|
|
strokeWidth: 1.8,
|
|
viewBox: '0 0 24 24',
|
|
}
|
|
|
|
if (name === 'plus') {
|
|
return (
|
|
<svg {...common}>
|
|
<path d="M12 5v14M5 12h14" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
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}>
|
|
<path d="M7 3h7l4 4v14H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z" />
|
|
<path d="M14 3v5h5M9 13h6M9 17h6" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
if (name === 'eye') {
|
|
return (
|
|
<svg {...common}>
|
|
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
if (name === 'edit') {
|
|
return (
|
|
<svg {...common}>
|
|
<path d="m16 3 5 5L8 21H3v-5L16 3Z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
if (name === 'x') {
|
|
return (
|
|
<svg {...common}>
|
|
<path d="M18 6 6 18M6 6l12 12" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
if (name === 'chevron-left') {
|
|
return (
|
|
<svg {...common}>
|
|
<path d="m15 18-6-6 6-6" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
if (name === 'chevron-right') {
|
|
return (
|
|
<svg {...common}>
|
|
<path d="m9 18 6-6-6-6" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
if (name === 'save') {
|
|
return (
|
|
<svg {...common}>
|
|
<path d="M5 21h14a1 1 0 0 0 1-1V7.4a1 1 0 0 0-.3-.7l-2.4-2.4a1 1 0 0 0-.7-.3H5a1 1 0 0 0-1 1v15a1 1 0 0 0 1 1Z" />
|
|
<path d="M8 21v-6h8v6M8 4v5h7" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
if (name === 'print') {
|
|
return (
|
|
<svg {...common}>
|
|
<path d="M7 8V4h10v4" />
|
|
<path d="M7 17H5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-2" />
|
|
<path d="M7 14h10v7H7zM17 12h.01" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
if (name === 'check') {
|
|
return (
|
|
<svg {...common}>
|
|
<path d="m5 12 4 4L19 6" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<svg {...common}>
|
|
<path d="m6 9 6 6 6-6" />
|
|
</svg>
|
|
)
|
|
}
|