modo-claro #4

Merged
Squad03_Leticia_Lacerda merged 1 commits from modo-claro into main 2026-05-07 16:03:46 +00:00
17 changed files with 669 additions and 121 deletions

View File

@@ -214,6 +214,11 @@ export function useAgenda() {
async function handleCreate(event) {
event.preventDefault()
if (!form.patientId) {
alert('Selecione um paciente para criar o agendamento.')
return
}
const targetProfessionalId = agendaScope === 'doctor'
? currentProfessional?.id
: form.professionalId

View File

@@ -43,3 +43,133 @@ button:disabled {
#root {
min-height: 100vh;
}
:root[data-theme='light'] {
color: #333333;
background: #eef2f7;
color-scheme: light;
}
[data-theme='light'] body {
background: #eef2f7;
color: #333333;
}
[data-theme='light'] input,
[data-theme='light'] select,
[data-theme='light'] textarea {
color-scheme: light;
}
[data-theme='light'] aside.bg-\[\#262626\] {
background-color: #f3f4f6;
}
[data-theme='light'] .bg-\[\#0a0a0a\],
[data-theme='light'] .bg-\[\#171717\] {
background-color: #eef2f7;
}
[data-theme='light'] .bg-\[\#1a1a1a\] {
background-color: #f9fafb;
}
[data-theme='light'] .bg-\[\#262626\] {
background-color: #ffffff;
}
[data-theme='light'] .bg-\[\#1f1f1f\],
[data-theme='light'] .bg-\[\#202020\] {
background-color: #f3f4f6;
}
[data-theme='light'] .bg-\[\#2a2a2a\],
[data-theme='light'] .bg-\[\#303030\],
[data-theme='light'] .bg-\[\#333333\] {
background-color: #e8edf4;
}
[data-theme='light'] .bg-\[\#3b82f6\] {
background-color: #3b82f6;
}
[data-theme='light'] .bg-\[\#2563eb\],
[data-theme='light'] .hover\:bg-\[\#2563eb\]:hover,
[data-theme='light'] .hover\:bg-\[\#3478ed\]:hover {
background-color: #2563eb;
}
[data-theme='light'] .hover\:bg-\[\#2a2a2a\]:hover,
[data-theme='light'] .hover\:bg-\[\#303030\]:hover,
[data-theme='light'] .hover\:bg-\[\#333333\]:hover {
background-color: #e8edf4;
}
[data-theme='light'] .disabled\:bg-\[\#303030\]:disabled {
background-color: #eef2f7;
}
[data-theme='light'] .border-\[\#404040\],
[data-theme='light'] .divide-\[\#404040\] > :not([hidden]) ~ :not([hidden]) {
border-color: #d6dee8;
}
[data-theme='light'] .border-\[\#525252\],
[data-theme='light'] .hover\:border-\[\#525252\]:hover {
border-color: #d1d5db;
}
[data-theme='light'] .hover\:border-\[\#404040\]:hover,
[data-theme='light'] .disabled\:border-\[\#404040\]:disabled {
border-color: #d6dee8;
}
[data-theme='light'] .text-\[\#f5f5f5\],
[data-theme='light'] .text-\[\#e5e5e5\],
[data-theme='light'] .hover\:text-\[\#e5e5e5\]:hover {
color: #333333;
}
[data-theme='light'] .text-\[\#d4d4d4\],
[data-theme='light'] .text-\[\#b8b8b8\] {
color: #4b5563;
}
[data-theme='light'] .text-\[\#a3a3a3\],
[data-theme='light'] .text-\[\#737373\],
[data-theme='light'] .disabled\:text-\[\#737373\]:disabled {
color: #6b7280;
}
[data-theme='light'] .text-\[\#51a2ff\] {
color: #1d4ed8;
}
[data-theme='light'] .placeholder\:text-\[\#737373\]::placeholder,
[data-theme='light'] .placeholder\:text-\[\#a3a3a3\]::placeholder {
color: #6b7280;
}
[data-theme='light'] [class*='[color-scheme:dark]'] {
color-scheme: light;
}
[data-theme='light'] svg [stroke='#303030'] {
stroke: #e5e7eb;
}
[data-theme='light'] svg [stroke='#1d4ed8'] {
stroke: #3b82f6;
}
[data-theme='light'] svg [fill='#262626'] {
fill: #ffffff;
}
[data-theme='light'] svg [fill='#a3a3a3'] {
fill: #6b7280;
}
[data-theme='light'] svg [fill='#171717'] {
fill: #f9fafb;
}

View File

@@ -2,6 +2,9 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import { applyTheme, getStoredTheme } from './utils/theme.js'
applyTheme(getStoredTheme())
createRoot(document.getElementById('root')).render(
<StrictMode>

View File

@@ -27,6 +27,7 @@ export const reportMapper = {
toApi(uiData) {
return cleanPayload({
patient_id: uiData.patientId,
order_number: emptyToUndefined(uiData.orderNumber),
status: normalizeApiStatus(uiData.status),
exam: emptyToUndefined(uiData.exam),
requested_by: emptyToUndefined(uiData.requestedBy),
@@ -38,6 +39,8 @@ export const reportMapper = {
hide_date: Boolean(uiData.hideDate),
hide_signature: Boolean(uiData.hideSignature),
due_at: emptyToUndefined(uiData.dueAt),
created_by: emptyToUndefined(uiData.createdBy),
updated_by: emptyToUndefined(uiData.updatedBy),
})
},
}

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') {
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,10 +726,20 @@ 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>
<div className="flex items-center gap-2">
<button
className="inline-flex h-9 items-center gap-2 rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 text-xs font-semibold text-[#e5e5e5] transition hover:bg-[#2a2a2a]"
onClick={() => printReportAsPdf(report, currentStatus)}
type="button"
>
<ReportIcon className="size-4" name="print" />
Imprimir PDF
</button>
<button className="rounded-lg p-1.5 transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
<ReportIcon className="size-4 text-[#a3a3a3]" name="x" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid gap-4 md:grid-cols-2">
@@ -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}`}>

View File

@@ -12,12 +12,12 @@ export const analyticsRepository = {
getDashboardData() {
return {
absenteeismData: [
{ month: 'Out', taxa: 18, meta: 15 },
{ month: 'Nov', taxa: 16, meta: 15 },
{ month: 'Dez', taxa: 22, meta: 15 },
{ month: 'Jan', taxa: 14, meta: 15 },
{ month: 'Fev', taxa: 12, meta: 15 },
{ month: 'Mar', taxa: 14.2, meta: 15 },
{ month: 'Out/2025', taxa: 18, meta: 15 },
{ month: 'Nov/2025', taxa: 16, meta: 15 },
{ month: 'Dez/2025', taxa: 22, meta: 15 },
{ month: 'Jan/2026', taxa: 14, meta: 15 },
{ month: 'Fev/2026', taxa: 12, meta: 15 },
{ month: 'Mar/2026', taxa: 14.2, meta: 15 },
],
consultationsData: [
{ month: 'Out', total: 380, realizadas: 312 },

View File

@@ -8,6 +8,7 @@ import {
hasAuthenticatedSession,
saveAuthSession,
} from '../config/api.js'
import { translateErrorMessage } from './repositoryUtils.js'
export const authRepository = {
async login({ email, password }) {
@@ -120,5 +121,5 @@ function shouldFallback(response) {
async function getResponseError(response, fallbackMessage) {
const error = await response.json().catch(() => ({}))
return error.error_description || error.msg || error.message || error.error || fallbackMessage
return translateErrorMessage(error.error_description || error.msg || error.message || error.error || fallbackMessage)
}

View File

@@ -1,10 +1,11 @@
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
import { getResponseError } from './repositoryUtils.js'
export const patientRepository = {
// 1. Listar pacientes
async getAll() {
const response = await fetch(`${apiConfig.restUrl}/patients?select=*`, { headers: getAuthenticatedHeaders() })
if (!response.ok) throw new Error('Erro ao buscar pacientes')
if (!response.ok) throw new Error(await getResponseError(response, 'Erro ao buscar pacientes.'))
return response.json()
},
@@ -19,7 +20,7 @@ export const patientRepository = {
async getDirectoryRows({ doctorId } = {}) {
const [patients, appointments] = await Promise.all([
this.getAll().catch(() => []),
this.getAll(),
getAppointments({ doctorId }).catch(() => []),
])
@@ -48,9 +49,11 @@ export const patientRepository = {
})
if (!response.ok) {
const error = await response.json().catch(() => ({}))
console.error('Erro da API ao criar paciente:', error)
throw new Error(error.message || error.hint || JSON.stringify(error))
if ([401, 403].includes(response.status)) {
return this.createWithValidation(data)
}
throw new Error(await getResponseError(response, 'Erro ao criar paciente.'))
}
return response.json()
@@ -74,8 +77,7 @@ export const patientRepository = {
})
if (!response.ok) {
const error = await response.json().catch(() => ({}))
throw new Error(error.message || 'Erro ao criar paciente com validação')
throw new Error(await getResponseError(response, 'Erro ao criar paciente com validação.'))
}
return response.json()
@@ -97,7 +99,7 @@ export const patientRepository = {
body: JSON.stringify(body),
})
if (!response.ok) throw new Error('Erro ao atualizar paciente')
if (!response.ok) throw new Error(await getResponseError(response, 'Erro ao atualizar paciente.'))
return response.json()
},
@@ -108,7 +110,7 @@ export const patientRepository = {
headers: getAuthenticatedHeaders(),
})
if (!response.ok) throw new Error('Erro ao deletar paciente')
if (!response.ok) throw new Error(await getResponseError(response, 'Erro ao deletar paciente.'))
return true
},
}

View File

@@ -39,18 +39,28 @@ export const reportRepository = {
},
async create(uiData) {
let lastResponse = null
for (const payload of buildCreatePayloads(reportMapper.toApi(uiData))) {
const response = await fetch(`${apiConfig.restUrl}/reports`, {
method: 'POST',
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
body: JSON.stringify(reportMapper.toApi(uiData)),
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error(await getResponseError(response, 'Falha ao criar relatório médico.'))
}
if (response.ok) {
const data = await response.json()
return reportMapper.toUi(normalizeItem(data))
}
lastResponse = response
if (response.status !== 400) {
break
}
}
throw new Error(await getResponseError(lastResponse, 'Falha ao criar relatório médico.'))
},
async update(id, uiData) {
@@ -68,3 +78,38 @@ export const reportRepository = {
return reportMapper.toUi(normalizeItem(data))
},
}
function buildCreatePayloads(payload) {
return uniquePayloads([
omitFields(payload, ['order_number', 'created_by', 'updated_by']),
omitFields(payload, ['order_number', 'created_by', 'updated_by', 'content_json']),
omitFields(payload, ['order_number', 'created_by', 'updated_by', 'content_json', 'hide_date', 'hide_signature', 'due_at']),
pickFields(payload, ['patient_id', 'status', 'exam', 'requested_by', 'cid_code', 'diagnosis', 'conclusion', 'content_html']),
payload,
])
}
function omitFields(payload, fields) {
return Object.fromEntries(
Object.entries(payload).filter(([field]) => !fields.includes(field)),
)
}
function pickFields(payload, fields) {
return Object.fromEntries(
fields
.filter((field) => payload[field] !== undefined)
.map((field) => [field, payload[field]]),
)
}
function uniquePayloads(payloads) {
const seen = new Set()
return payloads.filter((payload) => {
const signature = JSON.stringify(payload)
if (seen.has(signature)) return false
seen.add(signature)
return true
})
}

View File

@@ -23,7 +23,7 @@ export async function fetchJsonWithFallback(requests, fallbackMessage) {
}
if (lastError && !lastResponse) {
throw new Error(lastError.message || fallbackMessage)
throw new Error(translateErrorMessage(lastError.message || fallbackMessage))
}
throw new Error(await getResponseError(lastResponse, fallbackMessage))
@@ -54,7 +54,7 @@ export async function getResponseError(response, fallbackMessage) {
const text = await response.text().catch(() => '')
const error = parseErrorBody(text)
const message =
const message = translateErrorMessage(
error.error_description ||
error.msg ||
error.message ||
@@ -62,11 +62,50 @@ export async function getResponseError(response, fallbackMessage) {
error.details ||
error.hint ||
text ||
fallbackMessage
fallbackMessage,
)
return response.status ? `${fallbackMessage} (${response.status}): ${message}` : message
}
export function translateErrorMessage(message) {
const rawMessage = String(message || '').trim()
const normalized = rawMessage.toLowerCase()
if (!rawMessage) return 'Erro inesperado.'
if (isPortugueseMessage(rawMessage)) return rawMessage
const translations = [
[/failed to fetch|networkerror|load failed|network request failed/, 'Não foi possível conectar ao servidor. Verifique sua conexão e tente novamente.'],
[/invalid login credentials|invalid credentials/, 'E-mail ou senha inválidos.'],
[/email not confirmed/, 'E-mail ainda não confirmado. Verifique sua caixa de entrada.'],
[/user already registered|already registered/, 'Este e-mail já está cadastrado.'],
[/user not found/, 'Usuário não encontrado.'],
[/jwt expired|invalid jwt|jwt malformed|invalid token|token is expired/, 'Sessão expirada. Faça login novamente.'],
[/missing required parameters?/, 'Parâmetros obrigatórios não foram enviados.'],
[/duplicate key value violates unique constraint/, 'Já existe um registro com essas informações.'],
[/new row violates row-level security policy|row-level security policy|permission denied/, 'Você não tem permissão para realizar esta ação.'],
[/violates foreign key constraint/, 'Não foi possível salvar porque há um vínculo obrigatório ausente ou inválido.'],
[/null value in column "([^"]+)".*violates not-null constraint/, 'Campo obrigatório não preenchido.'],
[/invalid input value for enum ([^:]+): "([^"]+)"/, 'Valor inválido para uma opção do sistema.'],
[/invalid input syntax for type uuid/, 'Identificador inválido enviado para a API.'],
[/relation .* does not exist/, 'Recurso da API não encontrado.'],
[/function .* does not exist/, 'Endpoint da API não encontrado.'],
[/cors|preflight/, 'A API bloqueou a requisição por configuração de CORS.'],
]
for (const [pattern, translation] of translations) {
if (pattern.test(normalized)) return translation
}
return rawMessage
}
function isPortugueseMessage(message) {
return /[ãõáéíóúâêôç]/i.test(message) ||
/\b(erro|falha|não|nao|usuário|usuario|senha|campo|obrigatório|obrigatorio|sessão|sessao)\b/i.test(message)
}
function shouldFallback(response) {
return [404, 405].includes(response.status)
}

27
src/utils/theme.js Normal file
View File

@@ -0,0 +1,27 @@
export const THEME_STORAGE_KEY = 'mediconnect.theme'
export function getStoredTheme() {
if (typeof window === 'undefined') return 'dark'
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY)
return storedTheme === 'light' ? 'light' : 'dark'
}
export function applyTheme(theme) {
if (typeof document === 'undefined') return
const normalizedTheme = theme === 'light' ? 'light' : 'dark'
document.documentElement.dataset.theme = normalizedTheme
document.documentElement.style.colorScheme = normalizedTheme
}
export function setStoredTheme(theme) {
const normalizedTheme = theme === 'light' ? 'light' : 'dark'
if (typeof window !== 'undefined') {
window.localStorage.setItem(THEME_STORAGE_KEY, normalizedTheme)
}
applyTheme(normalizedTheme)
return normalizedTheme
}

8
vercel.json Normal file
View File

@@ -0,0 +1,8 @@
{
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}