forked from RiseUP/riseup_squad_03
modified: src/hooks/useAgenda.js modified: src/index.css modified: src/main.jsx modified: src/mappers/reportMapper.js modified: src/pages/AgendaPage.jsx modified: src/pages/AuthPages.jsx modified: src/pages/MedicalRecordsPage.jsx modified: src/pages/PatientsPage.jsx modified: src/pages/ReportsPage.jsx modified: src/pages/SettingsPage.jsx modified: src/repositories/analyticsRepository.js modified: src/repositories/authRepository.js modified: src/repositories/patientRepository.js modified: src/repositories/reportRepository.js modified: src/repositories/repositoryUtils.js new file: src/utils/theme.js new file: vercel.json
1077 lines
37 KiB
JavaScript
1077 lines
37 KiB
JavaScript
import { useCallback, useEffect, useMemo, 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 emptyEditor = {
|
|
id: null,
|
|
orderNumber: '',
|
|
patientId: '',
|
|
status: 'draft',
|
|
exam: '',
|
|
requestedBy: '',
|
|
cidCode: '',
|
|
diagnosis: '',
|
|
conclusion: '',
|
|
contentHtml: '',
|
|
contentJson: undefined,
|
|
hideDate: false,
|
|
hideSignature: false,
|
|
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,
|
|
hideDate: report.hideDate,
|
|
hideSignature: report.hideSignature,
|
|
dueAt: toDateTimeLocal(report.dueAt),
|
|
})
|
|
setEditorOpen(true)
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (!editor.patientId) 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,
|
|
hideDate: editor.hideDate,
|
|
hideSignature: editor.hideSignature,
|
|
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 ? (
|
|
<ReportEditorModal
|
|
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 ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions, professionalOptions, saving }) {
|
|
const [requesterSearch, setRequesterSearch] = useState(editor.requestedBy || '')
|
|
const isValid = Boolean(editor.patientId)
|
|
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 className="flex flex-wrap items-center gap-6">
|
|
<label className="flex cursor-pointer items-center gap-2 text-sm text-[#e5e5e5]">
|
|
<input
|
|
checked={editor.hideDate}
|
|
className="size-4 accent-[#3b82f6]"
|
|
onChange={(event) => updateField('hideDate', event.target.checked)}
|
|
type="checkbox"
|
|
/>
|
|
Ocultar data
|
|
</label>
|
|
|
|
<label className="flex cursor-pointer items-center gap-2 text-sm text-[#e5e5e5]">
|
|
<input
|
|
checked={editor.hideSignature}
|
|
className="size-4 accent-[#3b82f6]"
|
|
onChange={(event) => updateField('hideSignature', event.target.checked)}
|
|
type="checkbox"
|
|
/>
|
|
Ocultar assinatura
|
|
</label>
|
|
</div>
|
|
</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 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-4 flex flex-wrap gap-3 text-xs text-[#a3a3a3]">
|
|
<span className="rounded-full border border-[#404040] px-3 py-1">
|
|
{report.hideDate ? 'Data oculta' : 'Data visivel'}
|
|
</span>
|
|
<span className="rounded-full border border-[#404040] px-3 py-1">
|
|
{report.hideSignature ? 'Assinatura oculta' : 'Assinatura visivel'}
|
|
</span>
|
|
</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 ? (
|
|
<p className="whitespace-pre-wrap text-sm leading-6 text-[#e5e5e5]">{report.contentHtml}</p>
|
|
) : (
|
|
<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 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>
|
|
<p class="value">${escapeHtml(report.contentHtml || 'Nenhum complemento informado.')}</p>
|
|
</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 === '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>
|
|
)
|
|
}
|