modified: index.html

modified:   src/App.jsx
modified:   src/components/AppShell.jsx
modified:   src/components/featureStateStyles.js
modified:   src/config/permissions.js
modified:   src/hooks/useAgenda.js
modified:   src/mappers/reportMapper.js
modified:   src/pages/AgendaPage.jsx
modified:   src/pages/AnalyticsPage.jsx
modified:   src/pages/AuthPages.jsx
modified:   src/pages/HomePage.jsx
modified:   src/pages/MedicalRecordsPage.jsx
modified:   src/pages/MessagesPage.jsx
modified:   src/pages/PatientsPage.jsx
modified:   src/pages/ReportsPage.jsx
modified:   src/pages/SettingsPage.jsx
deleted:    src/pages/TeamPage.jsx
modified:   src/pages/UsersPage.jsx
modified:   src/repositories/availabilityRepository.js
modified:   src/repositories/patientRepository.js
modified:   src/repositories/professionalRepository.js
modified:   src/repositories/reportRepository.js
modified:   src/repositories/settingsRepository.js
This commit is contained in:
2026-05-07 01:11:10 -03:00
parent 9335e974eb
commit efb942d5aa
23 changed files with 1461 additions and 591 deletions

View File

@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState } from 'react'
import { hasCapability } from '../config/permissions.js'
import { hasCapability, normalizeRole } 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 =
@@ -15,6 +17,38 @@ const patientTabs = [
{ label: 'Documentos', value: 'documentos' },
]
const BRAZILIAN_STATES = [
{ value: 'AC', label: 'Acre' },
{ value: 'AL', label: 'Alagoas' },
{ value: 'AP', label: 'Amapá' },
{ value: 'AM', label: 'Amazonas' },
{ value: 'BA', label: 'Bahia' },
{ value: 'CE', label: 'Ceará' },
{ value: 'DF', label: 'Distrito Federal' },
{ value: 'ES', label: 'Espírito Santo' },
{ value: 'GO', label: 'Goiás' },
{ value: 'MA', label: 'Maranhão' },
{ value: 'MT', label: 'Mato Grosso' },
{ value: 'MS', label: 'Mato Grosso do Sul' },
{ value: 'MG', label: 'Minas Gerais' },
{ value: 'PA', label: 'Pará' },
{ value: 'PB', label: 'Paraíba' },
{ value: 'PR', label: 'Paraná' },
{ value: 'PE', label: 'Pernambuco' },
{ value: 'PI', label: 'Piauí' },
{ value: 'RJ', label: 'Rio de Janeiro' },
{ value: 'RN', label: 'Rio Grande do Norte' },
{ value: 'RS', label: 'Rio Grande do Sul' },
{ value: 'RO', label: 'Rondônia' },
{ value: 'RR', label: 'Roraima' },
{ value: 'SC', label: 'Santa Catarina' },
{ value: 'SP', label: 'São Paulo' },
{ value: 'SE', label: 'Sergipe' },
{ value: 'TO', label: 'Tocantins' },
]
const INSURANCE_OPTIONS = ['Unimed', 'Bradesco Saúde', 'Amil']
export function PatientsPage({ navigate, role }) {
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
@@ -36,22 +70,29 @@ export function PatientsPage({ navigate, role }) {
const [page, setPage] = useState(1)
useEffect(() => {
buildPatientRows()
buildPatientRows(role)
.then((data) => setRows(data))
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [])
}, [role])
const editingPatient = rows.find((patient) => patient.id === editingId)
const insuranceOptions = useMemo(() => [...new Set(rows.map((patient) => patient.insurance).filter(Boolean))], [rows])
const stateOptions = useMemo(() => [...new Set(rows.map((patient) => patient.state).filter(Boolean))], [rows])
const hasAdvancedFilters = city || state || ageMin || ageMax || lastVisitSince
const canEditPatients = hasCapability(role, 'canEditPatients')
const canHardDeletePatients = hasCapability(role, 'hardDeletePatients')
const filteredPatients = useMemo(() => {
return rows.filter((patient) => {
const haystack = [patient.name, patient.cpf, patient.document, patient.insurance, patient.phone]
const haystack = [
patient.name,
patient.cpf,
patient.document,
patient.insurance,
patient.phone,
patient.email,
patient.city,
patient.state,
patient.motherName,
]
.filter(Boolean)
.join(' ')
.toLowerCase()
@@ -60,7 +101,7 @@ export function PatientsPage({ navigate, role }) {
return false
}
if (insurance && patient.insurance !== insurance) {
if (insurance && normalizeFilterValue(patient.insurance) !== normalizeFilterValue(insurance)) {
return false
}
@@ -72,15 +113,16 @@ export function PatientsPage({ navigate, role }) {
return false
}
if (birthday === 'Hoje' && patient.birthday !== '07/04') {
const patientBirthday = getPatientBirthday(patient)
if (birthday === 'Hoje' && patientBirthday !== getTodayBirthday()) {
return false
}
if (birthday === 'Neste mes' && !patient.birthday?.endsWith('/04')) {
if (birthday === 'Neste mês' && !patientBirthday.endsWith(`/${getCurrentMonth()}`)) {
return false
}
if (city && !patient.city.toLowerCase().includes(city.toLowerCase())) {
if (city && !String(patient.city || '').toLowerCase().includes(city.toLowerCase())) {
return false
}
@@ -88,15 +130,16 @@ export function PatientsPage({ navigate, role }) {
return false
}
if (ageMin && patient.age < Number(ageMin)) {
const patientAge = Number(patient.age) || 0
if (ageMin && patientAge < Number(ageMin)) {
return false
}
if (ageMax && patient.age > Number(ageMax)) {
if (ageMax && patientAge > Number(ageMax)) {
return false
}
if (lastVisitSince && patient.lastVisitIso && patient.lastVisitIso < lastVisitSince) {
if (lastVisitSince && (!patient.lastVisitIso || patient.lastVisitIso < lastVisitSince)) {
return false
}
@@ -164,24 +207,6 @@ export function PatientsPage({ navigate, role }) {
setView('list')
}
async function deletePatient(patientId) {
if (!canHardDeletePatients) {
window.alert('Você não tem permissão para excluir pacientes.')
return
}
if (window.confirm('Tem certeza que deseja excluir este paciente?')) {
try {
await patientRepository.remove(patientId)
setRows((currentRows) => currentRows.filter((patient) => patient.id !== patientId))
} catch (err) {
window.alert(`Erro ao excluir paciente: ${err.message}`)
}
setOpenMenuId(null)
setPage(1)
}
}
function openDetail(patient) {
setOpenMenuId(null)
if (patient.detailId) {
@@ -258,7 +283,7 @@ async function deletePatient(patientId) {
setInsurance(value)
setPage(1)
}}
options={insuranceOptions}
options={INSURANCE_OPTIONS}
value={insurance}
/>
@@ -282,7 +307,7 @@ async function deletePatient(patientId) {
setBirthday(value)
setPage(1)
}}
options={['Hoje', 'Neste mes']}
options={['Hoje', 'Neste mês']}
value={birthday}
/>
<button
@@ -357,7 +382,10 @@ async function deletePatient(patientId) {
<button
aria-label={`Ações de ${patient.name}`}
className="rounded p-1 text-[#a3a3a3] transition hover:bg-[#333333] hover:text-[#e5e5e5]"
onClick={() => setOpenMenuId(openMenuId === patient.id ? null : patient.id)}
onClick={(event) => {
event.stopPropagation()
setOpenMenuId(openMenuId === patient.id ? null : patient.id)
}}
type="button"
>
<PatientIcon className="size-5" name="more" />
@@ -366,11 +394,11 @@ async function deletePatient(patientId) {
<>
<button
aria-label="Fechar menu"
className="fixed inset-0 z-10 cursor-default"
className="fixed inset-0 z-40 cursor-default"
onClick={() => setOpenMenuId(null)}
type="button"
/>
<div className="absolute right-8 top-10 z-20 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-lg border border-[#404040] bg-[#303030] py-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
@@ -381,9 +409,6 @@ async function deletePatient(patientId) {
navigate('/agenda')
}}
/>
{canHardDeletePatients ? (
<ActionItem danger icon="trash" label="Excluir" onClick={() => deletePatient(patient.id)} />
) : null}
</div>
</>
) : null}
@@ -449,7 +474,7 @@ async function deletePatient(patientId) {
setLastVisitSince={setLastVisitSince}
setState={setState}
state={state}
stateOptions={stateOptions}
stateOptions={BRAZILIAN_STATES}
/>
) : null}
</div>
@@ -462,8 +487,18 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
detailId: patient?.detailId || null,
name: patient?.name || '',
cpf: patient?.cpf || '',
birthDate: patient?.birthDate || patient?.birth_date || '',
motherName: patient?.motherName || patient?.mother_name || '',
fatherName: patient?.fatherName || patient?.father_name || '',
ethnicity: patient?.ethnicity || '',
maritalStatus: patient?.maritalStatus || patient?.marital_status || '',
phone: patient?.phone || '',
phoneSecondary: patient?.phoneSecondary || patient?.phone_secondary || '',
email: patient?.email || '',
zipCode: patient?.zipCode || patient?.zip_code || '',
addressStreet: patient?.addressStreet || patient?.address_street || patient?.address || '',
addressNumber: patient?.addressNumber || patient?.address_number || '',
addressComplement: patient?.addressComplement || patient?.address_complement || '',
city: patient?.city || '',
state: patient?.state || '',
insurance: patient?.insurance || '',
@@ -471,12 +506,14 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
age: patient?.age || '',
condition: patient?.condition || '',
birthday: patient?.birthday || '',
notesText: patient?.notesText || patient?.notes_text || '',
vip: Boolean(patient?.vip),
lastVisit: patient?.lastVisit || null,
nextVisit: patient?.nextVisit || null,
lastVisitIso: patient?.lastVisitIso || null,
}))
const [attachmentsOpen, setAttachmentsOpen] = useState(false)
const isNewPatient = !patient
function handleChange(event) {
const { checked, name, type, value } = event.target
@@ -490,6 +527,14 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
nextValue = maskPhone(value)
}
if (name === 'phoneSecondary') {
nextValue = maskPhone(value)
}
if (name === 'zipCode') {
nextValue = maskCEP(value)
}
setFormData((currentData) => ({ ...currentData, [name]: nextValue }))
}
@@ -501,19 +546,46 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
return
}
const requiredFields = [
['cpf', 'CPF'],
['age', 'idade'],
['birthDate', 'data de nascimento'],
['motherName', 'nome da mãe'],
['email', 'email'],
['phone', 'celular'],
['zipCode', 'CEP'],
['addressStreet', 'endereço'],
['addressNumber', 'número'],
['city', 'cidade'],
['state', 'estado'],
['plan', 'plano'],
]
if (isNewPatient) {
const missingFields = requiredFields
.filter(([field]) => !String(formData[field] || '').trim())
.map(([, label]) => label)
if (missingFields.length) {
window.alert(`Preencha os campos obrigatórios: ${missingFields.join(', ')}.`)
return
}
}
onSave({
...formData,
id: formData.id || uniqueSlug(formData.name, existingIds),
age: Number(formData.age) || 0,
birthday: formData.birthday || '07/04',
city: formData.city || 'Cidade não informada',
birthday: formData.birthday || formatBirthday(formData.birthDate),
city: formData.city,
document: formData.cpf ? `CPF ${formData.cpf}` : 'CPF não informado',
insurance: formData.insurance || 'Particular',
insurance: formData.insurance,
lastVisit: formData.lastVisit || 'Ainda não houve atendimento',
nextVisit: formData.nextVisit || null,
phone: formData.phone || 'Telefone não informado',
plan: formData.insurance || formData.plan || 'Particular',
state: formData.state || 'UF',
phone: formData.phone,
plan: formData.plan,
state: formData.state,
address: formatAddress(formData),
notes: formData.notesText ? [formData.notesText] : [],
})
}
@@ -552,43 +624,43 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
<div className="grid grid-cols-1 gap-x-6 gap-y-6 md:grid-cols-12">
<DarkField className="md:col-span-6" label="Nome *">
<input className={darkInput} name="name" onChange={handleChange} required value={formData.name} />
<input className={darkInput} name="name" onChange={handleChange} required={isNewPatient} value={formData.name} />
</DarkField>
<DarkField className="md:col-span-3" label="CPF">
<input className={darkInput} maxLength={14} name="cpf" onChange={handleChange} value={formData.cpf} />
<DarkField className="md:col-span-3" label={requiredLabel('CPF')}>
<input className={darkInput} maxLength={14} name="cpf" onChange={handleChange} required={isNewPatient} value={formData.cpf} />
</DarkField>
<DarkField className="md:col-span-3" label="Idade">
<input className={darkInput} min="0" name="age" onChange={handleChange} type="number" value={formData.age} />
<DarkField className="md:col-span-3" label={requiredLabel('Idade')}>
<input className={darkInput} min="0" name="age" onChange={handleChange} required={isNewPatient} type="number" value={formData.age} />
</DarkField>
<DarkField className="md:col-span-3" label="Data de Nascimento">
<input className={`${darkInput} [color-scheme:dark]`} type="date" />
<DarkField className="md:col-span-3" label={requiredLabel('Data de Nascimento')}>
<input className={`${darkInput} [color-scheme:dark]`} name="birthDate" onChange={handleChange} required={isNewPatient} type="date" value={formData.birthDate} />
</DarkField>
<DarkField className="md:col-span-3" label="Aniversario">
<DarkField className="md:col-span-3" label="Aniversário">
<input className={darkInput} maxLength={5} name="birthday" onChange={handleChange} placeholder="07/04" value={formData.birthday} />
</DarkField>
<DarkField className="md:col-span-3" label="Etnia">
<select className={darkInput} defaultValue="">
<select className={darkInput} name="ethnicity" onChange={handleChange} value={formData.ethnicity}>
<option value="">Selecione</option>
<option>Indígena</option>
<option>Não Indígena</option>
</select>
</DarkField>
<DarkField className="md:col-span-3" label="Estado civil">
<select className={darkInput} defaultValue="">
<select className={darkInput} name="maritalStatus" onChange={handleChange} value={formData.maritalStatus}>
<option value="">Selecione</option>
<option>Solteiro(a)</option>
<option>Casado(a)</option>
<option>Divorciado(a)</option>
</select>
</DarkField>
<DarkField className="md:col-span-6" label="Nome da mae">
<input className={darkInput} />
<DarkField className="md:col-span-6" label={requiredLabel('Nome da mãe')}>
<input className={darkInput} name="motherName" onChange={handleChange} required={isNewPatient} value={formData.motherName} />
</DarkField>
<DarkField className="md:col-span-6" label="Nome do pai">
<input className={darkInput} />
<input className={darkInput} name="fatherName" onChange={handleChange} value={formData.fatherName} />
</DarkField>
<DarkField className="md:col-span-12" label="Observacoes">
<textarea className={`${darkInput} min-h-24 py-2`} />
<textarea className={`${darkInput} min-h-24 py-2`} name="notesText" onChange={handleChange} value={formData.notesText} />
</DarkField>
<div className="md:col-span-12">
<button
@@ -610,14 +682,14 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
<section className={darkCard}>
<h2 className="mb-6 text-lg font-semibold text-[#e5e5e5]">Contato</h2>
<div className="grid grid-cols-1 gap-x-6 gap-y-6 md:grid-cols-12">
<DarkField className="md:col-span-4" label="E-mail">
<input className={darkInput} name="email" onChange={handleChange} type="email" value={formData.email} />
<DarkField className="md:col-span-4" label={requiredLabel('E-mail')}>
<input className={darkInput} name="email" onChange={handleChange} required={isNewPatient} type="email" value={formData.email} />
</DarkField>
<DarkField className="md:col-span-4" label="Celular">
<input className={darkInput} maxLength={15} name="phone" onChange={handleChange} value={formData.phone} />
<DarkField className="md:col-span-4" label={requiredLabel('Celular')}>
<input className={darkInput} maxLength={15} name="phone" onChange={handleChange} required={isNewPatient} value={formData.phone} />
</DarkField>
<DarkField className="md:col-span-4" label="Telefone 2">
<input className={darkInput} />
<input className={darkInput} maxLength={15} name="phoneSecondary" onChange={handleChange} value={formData.phoneSecondary} />
</DarkField>
</div>
</section>
@@ -625,22 +697,29 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
<section className={darkCard}>
<h2 className="mb-6 text-lg font-semibold text-[#e5e5e5]">Endereço</h2>
<div className="grid grid-cols-1 gap-x-6 gap-y-6 md:grid-cols-12">
<DarkField className="md:col-span-3" label="CEP">
<input className={darkInput} maxLength={9} onChange={maskCEPInput} placeholder="_____-___" />
<DarkField className="md:col-span-3" label={requiredLabel('CEP')}>
<input className={darkInput} maxLength={9} name="zipCode" onChange={handleChange} placeholder="_____-___" required={isNewPatient} value={formData.zipCode} />
</DarkField>
<DarkField className="md:col-span-5" label="Endereço">
<input className={darkInput} />
<DarkField className="md:col-span-5" label={requiredLabel('Endereço')}>
<input className={darkInput} name="addressStreet" onChange={handleChange} required={isNewPatient} value={formData.addressStreet} />
</DarkField>
<DarkField className="md:col-span-4" label="Cidade">
<input className={darkInput} name="city" onChange={handleChange} value={formData.city} />
<DarkField className="md:col-span-2" label={requiredLabel('Número')}>
<input className={darkInput} name="addressNumber" onChange={handleChange} required={isNewPatient} value={formData.addressNumber} />
</DarkField>
<DarkField className="md:col-span-4" label="Estado">
<select className={darkInput} name="state" onChange={handleChange} value={formData.state}>
<DarkField className="md:col-span-6" label="Complemento">
<input className={darkInput} name="addressComplement" onChange={handleChange} value={formData.addressComplement} />
</DarkField>
<DarkField className="md:col-span-4" label={requiredLabel('Cidade')}>
<input className={darkInput} name="city" onChange={handleChange} required={isNewPatient} value={formData.city} />
</DarkField>
<DarkField className="md:col-span-4" label={requiredLabel('Estado')}>
<select className={darkInput} name="state" onChange={handleChange} required={isNewPatient} value={formData.state}>
<option value="">Selecione</option>
<option value="PE">Pernambuco</option>
<option value="SE">Sergipe</option>
<option value="SP">São Paulo</option>
<option value="RJ">Rio de Janeiro</option>
{BRAZILIAN_STATES.map((stateOption) => (
<option key={stateOption.value} value={stateOption.value}>
{stateOption.label}
</option>
))}
</select>
</DarkField>
</div>
@@ -649,17 +728,18 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
<section className={darkCard}>
<h2 className="mb-6 text-lg font-semibold text-[#e5e5e5]">Informações de convenio</h2>
<div className="grid grid-cols-1 gap-x-6 gap-y-6 md:grid-cols-12">
<DarkField className="md:col-span-6" label="Convenio">
<DarkField className="md:col-span-6" label="Convênio">
<select className={darkInput} name="insurance" onChange={handleChange} value={formData.insurance}>
<option value="">Selecione</option>
<option value="Unimed">Unimed</option>
<option value="Bradesco Saude">Bradesco Saude</option>
<option value="Amil">Amil</option>
<option value="Particular">Particular</option>
{INSURANCE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</DarkField>
<DarkField className="md:col-span-6" label="Plano">
<input className={darkInput} name="plan" onChange={handleChange} value={formData.plan} />
<DarkField className="md:col-span-6" label={requiredLabel('Plano')}>
<input className={darkInput} name="plan" onChange={handleChange} required={isNewPatient} value={formData.plan} />
</DarkField>
<label className="flex w-fit cursor-pointer items-center gap-2 text-sm text-[#e5e5e5] md:col-span-12">
<input className="size-4 accent-[#3b82f6]" checked={formData.vip} name="vip" onChange={handleChange} type="checkbox" />
@@ -690,8 +770,57 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
)
}
export function PatientDetailPage({ navigate, patient }) {
export function PatientDetailPage({ navigate, patient, role }) {
const [activeTab, setActiveTab] = useState('resumo')
const [localPatient, setLocalPatient] = useState(patient)
const [editing, setEditing] = useState(false)
const [messageShortcutOpen, setMessageShortcutOpen] = useState(false)
const [appointmentShortcutOpen, setAppointmentShortcutOpen] = useState(false)
const [saving, setSaving] = useState(false)
const canEditPatients = hasCapability(role, 'canEditPatients')
const canHardDeletePatients = hasCapability(role, 'hardDeletePatients')
async function savePatient(updatedPatient) {
if (!canEditPatients) return
setSaving(true)
try {
await patientRepository.update(updatedPatient.id, updatedPatient)
setLocalPatient((current) => ({ ...current, ...updatedPatient }))
setEditing(false)
} catch (err) {
window.alert(`Erro ao salvar paciente: ${err.message}`)
} finally {
setSaving(false)
}
}
async function deletePatient() {
if (!canHardDeletePatients) return
if (!window.confirm('Tem certeza que deseja excluir este paciente definitivamente?')) {
return
}
try {
await patientRepository.remove(localPatient.id)
navigate('/pacientes')
} catch (err) {
window.alert(`Erro ao excluir paciente: ${err.message}`)
}
}
if (editing) {
return (
<PatientEditor
existingIds={[localPatient.id]}
onCancel={() => setEditing(false)}
onSave={savePatient}
patient={localPatient}
saving={saving}
/>
)
}
return (
<div className="mx-auto max-w-7xl space-y-6">
@@ -706,24 +835,33 @@ export function PatientDetailPage({ navigate, patient }) {
</button>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[#3b82f6]">Dados do Paciente</p>
<h1 className="mt-1 text-2xl font-bold tracking-tight text-[#f5f5f5]">{patient.name}</h1>
<h1 className="mt-1 text-2xl font-bold tracking-tight text-[#f5f5f5]">{localPatient.name}</h1>
<p className="mt-1 text-sm text-[#b8b8b8]">
{patient.condition} {patient.status} {patient.document}
{localPatient.condition} {localPatient.status} {localPatient.document}
</p>
</div>
</div>
<div className="flex flex-wrap gap-3">
{canEditPatients ? (
<button
className="h-10 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
onClick={() => setEditing(true)}
type="button"
>
Editar dados
</button>
) : null}
<button
className="h-10 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
onClick={() => navigate('/comunicacao')}
onClick={() => setMessageShortcutOpen(true)}
type="button"
>
Enviar mensagem
</button>
<button
className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
onClick={() => navigate('/agenda')}
onClick={() => setAppointmentShortcutOpen(true)}
type="button"
>
Novo retorno
@@ -732,10 +870,10 @@ export function PatientDetailPage({ navigate, patient }) {
</header>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<SummaryTile label="Idade" value={`${patient.age} anos`} />
<SummaryTile label="Risco" value={patient.risk} tone={riskColor(patient.risk)} />
<SummaryTile label="Última consulta" value={patient.lastVisit} />
<SummaryTile label="Próxima consulta" value={patient.nextVisit} />
<SummaryTile label="Idade" value={`${localPatient.age} anos`} />
<SummaryTile label="Risco" value={localPatient.risk} tone={riskColor(localPatient.risk)} />
<SummaryTile label="Última consulta" value={localPatient.lastVisit || 'Ainda não houve atendimento'} />
<SummaryTile label="Próxima consulta" value={localPatient.nextVisit || 'Nenhum atendimento agendado'} />
</section>
<section className={darkCard}>
@@ -757,48 +895,235 @@ export function PatientDetailPage({ navigate, patient }) {
</div>
<div className="mt-6">
{activeTab === 'resumo' ? <PatientSummary patient={patient} /> : null}
{activeTab === 'consultas' ? <PatientVisits navigate={navigate} patient={patient} /> : null}
{activeTab === 'documentos' ? <PatientDocuments patient={patient} /> : null}
{activeTab === 'resumo' ? <PatientSummary patient={localPatient} /> : null}
{activeTab === 'consultas' ? <PatientVisits navigate={navigate} patient={localPatient} /> : null}
{activeTab === 'documentos' ? <PatientDocuments patient={localPatient} /> : null}
</div>
</section>
{canHardDeletePatients ? (
<section className="rounded-2xl border border-red-500/30 bg-red-500/10 p-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-sm font-bold text-red-300">Zona de exclusão</h2>
<p className="mt-1 text-sm text-red-100/80">Remove definitivamente o paciente e seus dados locais carregados.</p>
</div>
<button
className="h-10 rounded-sm border border-red-500/40 bg-red-500/10 px-4 text-sm font-semibold text-red-300 transition hover:bg-red-500/20"
onClick={deletePatient}
type="button"
>
Excluir paciente
</button>
</div>
</section>
) : null}
{messageShortcutOpen ? (
<PatientMessageShortcutModal
onClose={() => setMessageShortcutOpen(false)}
patient={localPatient}
/>
) : null}
{appointmentShortcutOpen ? (
<PatientAppointmentShortcutModal
onClose={() => setAppointmentShortcutOpen(false)}
patient={localPatient}
/>
) : null}
</div>
)
}
function PatientSummary({ patient }) {
const notes = Array.isArray(patient.notes) ? patient.notes : []
return (
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
<div>
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
<div className="space-y-6">
<h2 className="text-xl font-bold text-[#f5f5f5]">Resumo clínico</h2>
<div className="mt-4 grid gap-3">
{patient.notes.map((note) => (
<div className="grid gap-3">
{notes.length ? notes.map((note) => (
<p className="rounded-xl border border-[#404040] bg-[#171717] p-4 text-sm leading-6 text-[#b8b8b8]" key={note}>
{note}
</p>
))}
)) : (
<p className="rounded-xl border border-[#404040] bg-[#171717] p-4 text-sm leading-6 text-[#b8b8b8]">
Nenhuma observação clínica registrada.
</p>
)}
</div>
<PatientInfoSection
items={[
['CPF', patient.cpf || patient.document],
['Data de nascimento', formatDisplayDate(patient.birthDate || patient.birth_date)],
['Nome da mãe', patient.motherName],
['Nome do pai', patient.fatherName],
['Etnia', patient.ethnicity],
['Estado civil', patient.maritalStatus],
]}
title="Dados pessoais"
/>
<PatientInfoSection
items={[
['CEP', patient.zipCode],
['Endereço', patient.addressStreet],
['Número', patient.addressNumber],
['Complemento', patient.addressComplement],
['Cidade', patient.city],
['Estado', patient.state],
]}
title="Endereço"
/>
<PatientInfoSection
items={[
['Convênio', patient.insurance],
['Plano', patient.plan],
['VIP', patient.vip ? 'Sim' : 'Não'],
]}
title="Informações de convênio"
/>
</div>
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4">
<h3 className="font-bold text-[#f5f5f5]">Contato e equipe</h3>
<dl className="mt-4 grid gap-3 text-sm">
<InfoRow label="Telefone" value={patient.phone} />
<InfoRow label="Telefone 2" value={patient.phoneSecondary} />
<InfoRow label="E-mail" value={patient.email} />
<InfoRow label="Endereço" value={patient.address} />
<InfoRow label="Equipe" value={patient.team.join(', ')} />
<InfoRow label="Equipe" value={(patient.team || []).join(', ')} />
</dl>
</div>
</div>
)
}
function PatientMessageShortcutModal({ onClose, patient }) {
const [message, setMessage] = useState('')
const [channel, setChannel] = useState('whatsapp')
function handleSubmit(event) {
event.preventDefault()
onClose()
}
return (
<ShortcutModal onClose={onClose} title="Nova mensagem">
<form className="space-y-4" onSubmit={handleSubmit}>
<DarkField label="Paciente">
<input className={darkInput} readOnly value={patient.name || ''} />
</DarkField>
<DarkField label="Canal">
<select className={darkInput} onChange={(event) => setChannel(event.target.value)} value={channel}>
<option value="whatsapp">WhatsApp</option>
<option value="sms">SMS</option>
<option value="email">E-mail</option>
</select>
</DarkField>
<DarkField label="Mensagem">
<textarea
className={`${darkInput} min-h-28 py-2`}
onChange={(event) => setMessage(event.target.value)}
value={message}
/>
</DarkField>
<ShortcutActions disabled={!message.trim()} onClose={onClose} submitLabel="Enviar" />
</form>
</ShortcutModal>
)
}
function PatientAppointmentShortcutModal({ onClose, patient }) {
const [date, setDate] = useState('')
const [time, setTime] = useState('')
const [type, setType] = useState('Retorno')
function handleSubmit(event) {
event.preventDefault()
onClose()
}
return (
<ShortcutModal onClose={onClose} title="Novo agendamento">
<form className="space-y-4" onSubmit={handleSubmit}>
<DarkField label="Paciente">
<input className={darkInput} readOnly value={patient.name || ''} />
</DarkField>
<div className="grid gap-4 md:grid-cols-2">
<DarkField label="Data">
<input className={`${darkInput} [color-scheme:dark]`} onChange={(event) => setDate(event.target.value)} type="date" value={date} />
</DarkField>
<DarkField label="Horário">
<input className={`${darkInput} [color-scheme:dark]`} onChange={(event) => setTime(event.target.value)} type="time" value={time} />
</DarkField>
</div>
<DarkField label="Tipo">
<input className={darkInput} onChange={(event) => setType(event.target.value)} value={type} />
</DarkField>
<ShortcutActions disabled={!date || !time} onClose={onClose} submitLabel="Salvar" />
</form>
</ShortcutModal>
)
}
function ShortcutModal({ children, onClose, title }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div className="w-full max-w-xl rounded-2xl border border-[#404040] bg-[#262626] shadow-2xl">
<div className="flex items-center justify-between border-b border-[#404040] px-5 py-4">
<h2 className="text-lg font-bold text-[#f5f5f5]">{title}</h2>
<button className="grid size-9 place-items-center rounded-sm text-[#a3a3a3] hover:bg-[#303030]" onClick={onClose} type="button">
<PatientIcon className="size-5" name="x" />
</button>
</div>
<div className="p-5">{children}</div>
</div>
</div>
)
}
function ShortcutActions({ disabled, onClose, submitLabel }) {
return (
<div className="flex justify-end gap-3 border-t border-[#404040] pt-4">
<button className="h-10 rounded-sm border border-[#404040] px-4 text-sm font-semibold text-[#e5e5e5]" onClick={onClose} type="button">
Cancelar
</button>
<button
className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
disabled={disabled}
type="submit"
>
{submitLabel}
</button>
</div>
)
}
function PatientInfoSection({ items, title }) {
return (
<section className="rounded-xl border border-[#404040] bg-[#171717] p-4">
<h3 className="font-bold text-[#f5f5f5]">{title}</h3>
<dl className="mt-4 grid gap-3 text-sm md:grid-cols-2">
{items.map(([label, value]) => (
<InfoRow key={label} label={label} value={value || 'Não informado'} />
))}
</dl>
</section>
)
}
function PatientVisits({ navigate, patient }) {
return (
<div className="grid gap-3">
{[
{ date: patient.nextVisit, status: 'Agendada', description: `Retorno para ${patient.condition}` },
{ date: patient.lastVisit, status: 'Finalizada', description: 'Consulta registrada no historico do paciente.' },
].map((visit) => (
patient.nextVisit
? { date: patient.nextVisit, status: 'Agendada', description: `Retorno para ${patient.condition}` }
: null,
patient.lastVisit
? { date: patient.lastVisit, status: 'Finalizada', description: 'Consulta registrada no histórico do paciente.' }
: null,
].filter(Boolean).map((visit) => (
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4" key={`${visit.date}-${visit.status}`}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
@@ -815,6 +1140,11 @@ function PatientVisits({ navigate, patient }) {
</div>
</div>
))}
{!patient.nextVisit && !patient.lastVisit ? (
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4 text-sm text-[#a3a3a3]">
Nenhum agendamento encontrado para este paciente.
</div>
) : null}
<button
className="h-10 justify-self-start rounded-sm border border-[#404040] bg-[#303030] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:border-[#3b82f6]"
onClick={() => navigate('/consultas')}
@@ -827,9 +1157,11 @@ function PatientVisits({ navigate, patient }) {
}
function PatientDocuments({ patient }) {
const exams = Array.isArray(patient.exams) ? patient.exams : []
return (
<div className="grid gap-3 md:grid-cols-3">
{patient.exams.map((exam) => (
{exams.length ? exams.map((exam) => (
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4" key={exam}>
<p className="font-semibold text-[#f5f5f5]">{exam}</p>
<p className="mt-2 text-sm text-[#a3a3a3]">Pendente de revisão.</p>
@@ -837,7 +1169,11 @@ function PatientDocuments({ patient }) {
A revisar
</span>
</div>
))}
)) : (
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4 text-sm text-[#a3a3a3]">
Nenhum documento encontrado.
</div>
)}
</div>
)
}
@@ -861,11 +1197,17 @@ function InfoRow({ label, value }) {
return (
<div>
<dt className="font-semibold text-[#737373]">{label}</dt>
<dd className="mt-1 text-[#e5e5e5]">{value}</dd>
<dd className="mt-1 text-[#e5e5e5]">{value || 'Não informado'}</dd>
</div>
)
}
function formatDisplayDate(value) {
if (!value) return ''
const [year, month, day] = String(value).split('-')
return year && month && day ? `${day}/${month}/${year}` : value
}
function riskColor(risk) {
if (risk === 'Alto') {
return 'bg-red-500/20 text-red-400'
@@ -878,6 +1220,32 @@ function riskColor(risk) {
return 'bg-emerald-500/20 text-emerald-400'
}
function normalizeFilterValue(value) {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
}
function getPatientBirthday(patient) {
if (patient.birthday) return patient.birthday
const birthDate = patient.birthDate || patient.birth_date
if (!birthDate) return ''
const [, month, day] = String(birthDate).split('-')
return month && day ? `${day}/${month}` : ''
}
function getTodayBirthday() {
const today = new Date()
return `${String(today.getDate()).padStart(2, '0')}/${getCurrentMonth()}`
}
function getCurrentMonth() {
return String(new Date().getMonth() + 1).padStart(2, '0')
}
function PatientSelect({ className = '', icon, label, onChange, options, value }) {
return (
<div className={`relative ${className}`}>
@@ -940,6 +1308,14 @@ function ActionItem({ danger = false, icon, label, onClick }) {
)
}
function requiredLabel(label) {
return (
<>
{label} <span className="text-red-400">*</span>
</>
)
}
function DarkField({ children, className = '', label }) {
return (
<label className={`block ${className}`}>
@@ -1002,8 +1378,8 @@ function AdvancedFilterModal({
<select className={darkInput} onChange={(event) => setState(event.target.value)} value={state}>
<option value="">Todos</option>
{stateOptions.map((option) => (
<option key={option} value={option}>
{option}
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
@@ -1015,7 +1391,6 @@ function AdvancedFilterModal({
className={darkInput}
min="0"
onChange={(event) => setAgeMin(event.target.value)}
placeholder="0"
type="number"
value={ageMin}
/>
@@ -1025,7 +1400,6 @@ function AdvancedFilterModal({
className={darkInput}
min="0"
onChange={(event) => setAgeMax(event.target.value)}
placeholder="120"
type="number"
value={ageMax}
/>
@@ -1246,8 +1620,22 @@ function PatientIcon({ className = 'size-4', name }) {
)
}
async function buildPatientRows() {
return patientRepository.getDirectoryRows()
async function buildPatientRows(role) {
if (normalizeRole(role) !== 'medico') {
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 uniqueSlug(value, existingIds) {
@@ -1289,9 +1677,28 @@ function maskPhone(value) {
.replace(/(-\d{4})\d+?$/, '$1')
}
function maskCEPInput(event) {
event.target.value = event.target.value
function maskCEP(value) {
return value
.replace(/\D/g, '')
.replace(/(\d{5})(\d)/, '$1-$2')
.replace(/(-\d{3})\d+?$/, '$1')
}
function formatBirthday(birthDate) {
if (!birthDate) return ''
const [, month, day] = birthDate.split('-')
return day && month ? `${day}/${month}` : ''
}
function formatAddress(patient) {
return [
patient.addressStreet,
patient.addressNumber,
patient.addressComplement,
patient.city,
patient.state,
patient.zipCode,
]
.filter(Boolean)
.join(', ')
}