modo-claro

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
This commit is contained in:
2026-05-07 05:51:07 -03:00
parent 64d9527318
commit db7a2fe8f5
17 changed files with 669 additions and 121 deletions

View File

@@ -31,6 +31,8 @@ const viewFilters = [
{ label: 'Mês', value: 'Mes' },
]
const appointmentTypeOptions = ['Retorno', 'Primeira consulta', 'Exame', 'Avaliação pre-op']
export function AgendaPage({ navigate }) {
const [modalPatientSearch, setModalPatientSearch] = useState('')
const [modalDoctorSearch, setModalDoctorSearch] = useState('')
@@ -84,29 +86,35 @@ export function AgendaPage({ navigate }) {
),
].sort((a, b) => a.localeCompare(b, 'pt-BR'))
const filteredPatients = (() => {
const query = modalPatientSearch.trim().toLowerCase()
const query = normalizeSearch(modalPatientSearch)
if (!query) return patients
return patients.filter((patient) =>
[patient.name, patient.full_name, patient.nome, patient.cpf, patient.email]
.filter(Boolean)
.join(' ')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.includes(query),
)
})()
const filteredProfessionals = (() => {
const query = modalDoctorSearch.trim().toLowerCase()
const query = normalizeSearch(modalDoctorSearch)
if (!query) return professionals
return professionals.filter((professional) =>
[professional.name, professional.email, professional.unit]
.filter(Boolean)
.join(' ')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.includes(query),
)
})()
const selectedPatient = patients.find((patient) => String(patient.id) === String(form.patientId))
const selectedProfessional = professionals.find((professional) => String(professional.id) === String(form.professionalId))
return (
<div className="mx-auto flex max-w-[1180px] flex-col gap-8 text-[#e5e5e5]">
@@ -318,23 +326,25 @@ export function AgendaPage({ navigate }) {
<DarkField label="Paciente">
<input
className="mb-2 h-10 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
onChange={(event) => setModalPatientSearch(event.target.value)}
className="h-10 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
onChange={(event) => {
setModalPatientSearch(event.target.value)
updateForm('patientId', '')
}}
placeholder="Pesquisar paciente"
type="search"
value={modalPatientSearch}
value={modalPatientSearch || getPatientLabel(selectedPatient)}
/>
<SearchResults
emptyText="Nenhum paciente encontrado."
getLabel={getPatientLabel}
items={filteredPatients.slice(0, 6)}
onSelect={(patient) => {
updateForm('patientId', patient.id)
setModalPatientSearch(getPatientLabel(patient))
}}
selectedId={form.patientId}
/>
<select
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
onChange={(event) => updateForm('patientId', event.target.value)}
value={form.patientId}
>
{filteredPatients.map((patient) => (
<option key={patient.id} value={patient.id}>
{patient.name || patient.full_name || patient.nome}
</option>
))}
</select>
</DarkField>
<div className="grid gap-4 sm:grid-cols-2">
@@ -389,33 +399,42 @@ export function AgendaPage({ navigate }) {
) : (
<>
<input
className="mb-2 h-10 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
onChange={(event) => setModalDoctorSearch(event.target.value)}
className="h-10 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
onChange={(event) => {
setModalDoctorSearch(event.target.value)
updateForm('professionalId', '')
}}
placeholder="Pesquisar médico"
type="search"
value={modalDoctorSearch}
value={modalDoctorSearch || selectedProfessional?.name || ''}
/>
<SearchResults
emptyText="Nenhum médico encontrado."
getDescription={(professional) => professional.unit || professional.email}
getLabel={(professional) => professional.name}
items={filteredProfessionals.slice(0, 6)}
onSelect={(professional) => {
updateForm('professionalId', professional.id)
setModalDoctorSearch(professional.name)
}}
selectedId={form.professionalId}
/>
<select
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
onChange={(event) => updateForm('professionalId', event.target.value)}
value={form.professionalId}
>
{filteredProfessionals.map((professional) => (
<option key={professional.id} value={professional.id}>
{professional.name}
</option>
))}
</select>
</>
)}
</DarkField>
<DarkField label="Tipo de consulta">
<input
<select
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
onChange={(event) => updateForm('type', event.target.value)}
value={form.type}
/>
>
{appointmentTypeOptions.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</DarkField>
<div className="flex flex-wrap justify-end gap-3 pt-2">
@@ -473,3 +492,44 @@ function DarkModal({ children, onClose, open, title }) {
</div>
)
}
function SearchResults({ emptyText, getDescription, getLabel, items, onSelect, selectedId }) {
return (
<div className="max-h-44 overflow-y-auto rounded-md border border-[#404040] bg-[#1f1f1f]">
{items.length ? (
items.map((item) => {
const isSelected = String(item.id) === String(selectedId)
return (
<button
className={`block w-full px-3 py-2 text-left text-sm transition ${
isSelected ? 'bg-[#3b82f6]/20 text-[#e5e5e5]' : 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
}`}
key={item.id}
onClick={() => onSelect(item)}
type="button"
>
<span className="block font-semibold">{getLabel(item)}</span>
{getDescription?.(item) ? (
<span className="mt-0.5 block text-xs text-[#737373]">{getDescription(item)}</span>
) : null}
</button>
)
})
) : (
<p className="px-3 py-2 text-xs text-[#737373]">{emptyText}</p>
)}
</div>
)
}
function getPatientLabel(patient) {
return patient?.name || patient?.full_name || patient?.nome || ''
}
function normalizeSearch(value) {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
}

View File

@@ -3,7 +3,7 @@ import { useState } from 'react'
import { authRepository } from '../repositories/authRepository.js'
import { BrandLogo } from '../components/Brand.jsx'
import { FeatureBadge, FeatureCallout } from '../components/FeatureState.jsx'
import { FeatureCallout } from '../components/FeatureState.jsx'
import loginClinicImage from '../assets/figma/login-clinic.png'
const mockCredentials = [
@@ -164,7 +164,7 @@ export function LoginPage({ navigate }) {
{credentialsOpen ? (
<div className="mb-2 w-[292px] rounded-md border border-white/10 bg-[#0f1b2d] p-2 shadow-2xl">
<p className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-white/40">
Credenciais mockadas
Credenciais de acesso
</p>
<div className="grid gap-1">
{mockCredentials.map((credential) => (
@@ -190,11 +190,10 @@ export function LoginPage({ navigate }) {
<button
className="flex h-[29px] items-center gap-1.5 rounded-sm border border-white/10 bg-white/[0.05] px-3 font-mono text-[10px] font-medium leading-[15px] text-white/30 transition hover:text-white/50"
onClick={() => setCredentialsOpen((current) => !current)}
title="Preencher credenciais mockadas"
title="Preencher credenciais de acesso"
type="button"
>
dev · credenciais
<FeatureBadge className="border-white/20 bg-white/10 text-white/70" status="mock" text="mock" />
<span aria-hidden="true" className="text-[9px]">
{credentialsOpen ? 'v' : '^'}
</span>
@@ -345,7 +344,7 @@ function AuthLayout({ children, description, title }) {
<span className="text-[#3b82f6]">saúde.</span>
</h1>
<p className="mt-5 max-w-[360px] text-sm leading-[23px] text-white/60 xl:text-base xl:leading-[26px]">
Fluxos de acesso simulados para manter a navegação ponta a ponta sem backend real.
Segurança e continuidade para equipes de saúde.
</p>
</div>
</div>

View File

@@ -1,7 +1,8 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { FeatureCallout } from '../components/FeatureState.jsx'
import { medicalRecordRepository } from '../repositories/medicalRecordRepository.js'
import { patientRepository } from '../repositories/patientRepository.js'
const inputClass =
@@ -12,9 +13,27 @@ const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
export function MedicalRecordsPage() {
const recordTypes = medicalRecordRepository.getRecordTypes()
const [records, setRecords] = useState(() => medicalRecordRepository.getInitialRecords())
const [patients, setPatients] = useState([])
const [search, setSearch] = useState('')
const [editorOpen, setEditorOpen] = useState(false)
useEffect(() => {
let active = true
patientRepository
.getDirectoryRows()
.then((data) => {
if (active) setPatients(data || [])
})
.catch(() => {
if (active) setPatients([])
})
return () => {
active = false
}
}, [])
const filteredRecords = useMemo(() => {
return records.filter((record) => {
const matchesSearch = [record.patient, record.cid, record.doctor]
@@ -81,6 +100,7 @@ export function MedicalRecordsPage() {
<RecordEditorModal
onClose={() => setEditorOpen(false)}
onSave={handleCreateRecord}
patients={patients}
recordTypes={recordTypes}
/>
) : null}
@@ -149,8 +169,10 @@ function IconButton({ label, name }) {
)
}
function RecordEditorModal({ onClose, onSave, recordTypes }) {
function RecordEditorModal({ onClose, onSave, patients, recordTypes }) {
const [patientSearch, setPatientSearch] = useState('')
const [formData, setFormData] = useState({
patientId: '',
patient: '',
date: '',
type: 'Primeira Consulta',
@@ -168,6 +190,31 @@ function RecordEditorModal({ onClose, onSave, recordTypes }) {
setFormData((currentData) => ({ ...currentData, [name]: value }))
}
const filteredPatients = (() => {
const query = normalizeSearch(patientSearch)
if (!query) return patients
return patients.filter((patient) =>
[patient.name, patient.full_name, patient.nome, patient.cpf, patient.document, patient.email]
.filter(Boolean)
.join(' ')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.includes(query),
)
})()
function selectPatient(patient) {
const name = getPatientName(patient)
setFormData((currentData) => ({
...currentData,
patientId: patient.id,
patient: name,
}))
setPatientSearch(name)
}
function handleSubmit(event) {
event.preventDefault()
const submitter = event.nativeEvent.submitter
@@ -199,11 +246,36 @@ function RecordEditorModal({ onClose, onSave, recordTypes }) {
<DarkField label="Paciente">
<input
className={inputClass}
name="patient"
onChange={updateField}
onChange={(event) => {
setPatientSearch(event.target.value)
setFormData((currentData) => ({ ...currentData, patientId: '', patient: '' }))
}}
placeholder="Buscar paciente..."
value={formData.patient}
type="search"
value={patientSearch || formData.patient}
/>
<div className="mt-2 max-h-44 overflow-y-auto rounded-lg border border-[#404040] bg-[#1a1a1a]">
{filteredPatients.length ? (
filteredPatients.slice(0, 6).map((patient) => {
const selected = String(patient.id) === String(formData.patientId)
return (
<button
className={`block w-full px-3 py-2 text-left text-sm transition ${
selected ? 'bg-[#3b82f6]/20 text-[#e5e5e5]' : 'text-[#a3a3a3] hover:bg-[#2a2a2a] hover:text-[#e5e5e5]'
}`}
key={patient.id}
onClick={() => selectPatient(patient)}
type="button"
>
<span className="block font-semibold">{getPatientName(patient)}</span>
<span className="mt-0.5 block text-xs text-[#737373]">{patient.cpf || patient.document || patient.email || 'Sem documento'}</span>
</button>
)
})
) : (
<p className="px-3 py-2 text-xs text-[#737373]">Nenhum paciente encontrado.</p>
)}
</div>
</DarkField>
<DarkField label="Data da Consulta">
<input
@@ -330,6 +402,18 @@ function formatDate(value) {
return `${day}/${month}/${year}`
}
function getPatientName(patient) {
return patient?.name || patient?.full_name || patient?.nome || ''
}
function normalizeSearch(value) {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
}
function RecordIcon({ className = 'size-4', name }) {
const common = {
className,

View File

@@ -1,9 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { hasCapability, normalizeRole } from '../config/permissions.js'
import { hasCapability } from '../config/permissions.js'
import { patientRepository } from '../repositories/patientRepository.js'
import { professionalRepository } from '../repositories/professionalRepository.js'
import { profileRepository } from '../repositories/profileRepository.js'
const ITEMS_PER_PAGE = 25
const darkInput =
@@ -70,11 +68,11 @@ export function PatientsPage({ navigate, role }) {
const [page, setPage] = useState(1)
useEffect(() => {
buildPatientRows(role)
buildPatientRows()
.then((data) => setRows(data))
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [role])
}, [])
const editingPatient = rows.find((patient) => patient.id === editingId)
const hasAdvancedFilters = city || state || ageMin || ageMax || lastVisitSince
@@ -180,13 +178,13 @@ export function PatientsPage({ navigate, role }) {
try {
if (isNew) {
const [created] = await patientRepository.create(patient)
const created = normalizeCreatedPatient(await patientRepository.create(patient))
const newRow = {
...patient,
id: created.id,
detailId: created.id,
name: created.full_name || patient.name,
phone: created.phone_mobile || patient.phone,
id: created?.id || patient.id,
detailId: created?.id || patient.detailId || patient.id,
name: created?.full_name || created?.name || patient.name,
phone: created?.phone_mobile || created?.phone || patient.phone,
}
setRows((currentRows) => [newRow, ...currentRows])
} else {
@@ -368,14 +366,14 @@ export function PatientsPage({ navigate, role }) {
{patient.name}
</span>
<span className="mt-0.5 block whitespace-normal break-words text-xs text-[#a3a3a3]">
{patient.insurance || 'Sem convenio'} {patient.vip ? ' | VIP' : ''}
{patient.insurance || missingValue('Convênio')} {patient.vip ? ' | VIP' : ''}
</span>
</span>
</button>
</td>
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.phone}</td>
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.city}</td>
<td className="px-6 py-4 align-top text-[#a3a3a3]">{patient.state}</td>
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.phone || missingValue('Telefone')}</td>
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.city || missingValue('Cidade')}</td>
<td className="px-6 py-4 align-top text-[#a3a3a3]">{patient.state || missingValue('Estado')}</td>
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.lastVisit || 'Ainda não houve atendimento'}</td>
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.nextVisit || 'Nenhum atendimento agendado'}</td>
<td className="relative sticky right-0 bg-[#262626] px-6 py-4 text-right shadow-[-10px_0_12px_-12px_rgba(0,0,0,0.75)]">
@@ -398,7 +396,7 @@ export function PatientsPage({ navigate, role }) {
onClick={() => setOpenMenuId(null)}
type="button"
/>
<div className="absolute right-4 top-12 z-50 w-48 rounded-lg border border-[#404040] bg-[#303030] py-1 text-left shadow-lg">
<div className="absolute right-4 top-12 z-50 w-48 rounded-md border border-[#404040] bg-[#262626] p-1 text-left shadow-lg">
<ActionItem icon="file" label="Ver detalhes" onClick={() => openDetail(patient)} />
{canEditPatients ? <ActionItem icon="edit" label="Editar" onClick={() => openForm(patient.id)} /> : null}
<ActionItem
@@ -870,8 +868,8 @@ export function PatientDetailPage({ navigate, patient, role }) {
</header>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<SummaryTile label="Idade" value={`${localPatient.age} anos`} />
<SummaryTile label="Risco" value={localPatient.risk} tone={riskColor(localPatient.risk)} />
<SummaryTile label="Idade" value={localPatient.age ? `${localPatient.age} anos` : missingValue('Idade')} />
<SummaryTile label="Risco" value={localPatient.risk || missingValue('Risco')} tone={localPatient.risk ? riskColor(localPatient.risk) : null} />
<SummaryTile label="Última consulta" value={localPatient.lastVisit || 'Ainda não houve atendimento'} />
<SummaryTile label="Próxima consulta" value={localPatient.nextVisit || 'Nenhum atendimento agendado'} />
</section>
@@ -1197,11 +1195,15 @@ function InfoRow({ label, value }) {
return (
<div>
<dt className="font-semibold text-[#737373]">{label}</dt>
<dd className="mt-1 text-[#e5e5e5]">{value || 'Não informado'}</dd>
<dd className="mt-1 text-[#e5e5e5]">{value || missingValue(label)}</dd>
</div>
)
}
function missingValue(label) {
return `${label} não informado`
}
function formatDisplayDate(value) {
if (!value) return ''
const [year, month, day] = String(value).split('-')
@@ -1296,8 +1298,8 @@ function PageButton({ children, disabled, onClick }) {
function ActionItem({ danger = false, icon, label, onClick }) {
return (
<button
className={`flex w-full items-center gap-2 px-4 py-2 text-sm transition ${
danger ? 'text-[#ef4444] hover:bg-[#ef4444]/10' : 'text-[#e5e5e5] hover:bg-[#333333]'
className={`flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm font-medium transition ${
danger ? 'text-[#f87171] hover:bg-[#303030]' : 'text-[#e5e5e5] hover:bg-[#303030]'
}`}
onClick={onClick}
type="button"
@@ -1620,22 +1622,13 @@ function PatientIcon({ className = 'size-4', name }) {
)
}
async function buildPatientRows(role) {
if (normalizeRole(role) !== 'medico') {
return patientRepository.getDirectoryRows()
}
async function buildPatientRows() {
return patientRepository.getDirectoryRows()
}
const [profile, professionals] = await Promise.all([
profileRepository.getCurrentUserProfile(),
professionalRepository.getAll(),
])
const currentProfessional = professionalRepository.resolveCurrentProfessional(profile, professionals)
if (!currentProfessional?.id) {
throw new Error('Não foi possível vincular o médico logado a um profissional da base.')
}
return patientRepository.getDirectoryRows({ doctorId: currentProfessional.id })
function normalizeCreatedPatient(payload) {
if (Array.isArray(payload)) return payload[0] || null
return payload?.patient || payload?.data || payload?.created || payload || null
}
function uniqueSlug(value, existingIds) {

View File

@@ -37,6 +37,7 @@ const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
const emptyEditor = {
id: null,
orderNumber: '',
patientId: '',
status: 'draft',
exam: '',
@@ -242,6 +243,7 @@ export function ReportsPage({ role }) {
function openEdit(report) {
setEditor({
id: report.id,
orderNumber: report.orderNumber,
patientId: String(report.patientId || ''),
status: report.status,
exam: report.exam,
@@ -264,6 +266,7 @@ export function ReportsPage({ role }) {
setSaving(true)
const payload = {
orderNumber: editor.id ? editor.orderNumber : `REL-${Date.now()}`,
patientId: editor.patientId,
status: editor.status,
exam: editor.exam,
@@ -276,6 +279,8 @@ export function ReportsPage({ role }) {
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 {
@@ -519,7 +524,11 @@ function ReportRow({ onEdit, onView, report }) {
}
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 }))
@@ -573,19 +582,39 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
</DarkField>
<DarkField label="Solicitante">
<div>
<div className="space-y-2">
<input
className={inputClass}
list="report-requested-by-suggestions"
onChange={(event) => updateField('requestedBy', event.target.value)}
placeholder="Nome do solicitante"
value={editor.requestedBy}
onChange={(event) => {
setRequesterSearch(event.target.value)
updateField('requestedBy', event.target.value)
}}
placeholder="Pesquisar médico"
type="search"
value={requesterSearch}
/>
<datalist id="report-requested-by-suggestions">
{professionalOptions.map((professional) => (
<option key={professional.id} value={professional.name} />
))}
</datalist>
<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>
@@ -632,7 +661,6 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
<textarea
className={`${textareaClass} min-h-72`}
onChange={(event) => updateField('contentHtml', event.target.value)}
placeholder="Complemento em texto simples"
value={editor.contentHtml}
/>
</DarkField>
@@ -698,9 +726,19 @@ function ReportViewModal({ onClose, report }) {
<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>
<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 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">
@@ -846,6 +884,94 @@ 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
function ReportIcon({ className = 'size-4', name }) {
const common = {
className,
@@ -924,6 +1050,24 @@ function ReportIcon({ className = 'size-4', name }) {
)
}
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" />

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
import { FeatureCallout } from '../components/FeatureState.jsx'
import { settingsRepository } from '../repositories/settingsRepository.js'
import { getStoredTheme, setStoredTheme } from '../utils/theme.js'
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
@@ -65,11 +66,15 @@ export function SettingsPage() {
}
function AppearanceSection() {
const [theme, setTheme] = useState('dark')
const [theme, setTheme] = useState(() => getStoredTheme())
const [compact, setCompact] = useState(false)
const [contrast, setContrast] = useState(false)
const [animations, setAnimations] = useState(true)
function handleThemeChange(nextTheme) {
setTheme(setStoredTheme(nextTheme))
}
return (
<SectionFrame description="Personalize a interface do MediConnect." title="Aparência">
<div className="mb-8">
@@ -84,7 +89,7 @@ function AppearanceSection() {
theme === item.id ? 'border-[#3b82f6] bg-[#3b82f6]/5 shadow-md shadow-[#3b82f6]/20' : 'border-[#404040] bg-[#262626] hover:border-[#3b82f6]/40'
}`}
key={item.id}
onClick={() => setTheme(item.id)}
onClick={() => handleThemeChange(item.id)}
type="button"
>
<span className={`mb-3 flex h-20 flex-col gap-1.5 overflow-hidden rounded-xl border border-[#404040] p-2 ${item.preview}`}>