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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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" />
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
Reference in New Issue
Block a user