Merge pull request 'modo-claro' (#4) from modo-claro into main

Reviewed-on: RiseUP/riseup_squad_03#4
This commit is contained in:
2026-05-07 16:03:45 +00:00
17 changed files with 669 additions and 121 deletions

View File

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

View File

@@ -43,3 +43,133 @@ button:disabled {
#root { #root {
min-height: 100vh; 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 { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.jsx' import App from './App.jsx'
import { applyTheme, getStoredTheme } from './utils/theme.js'
applyTheme(getStoredTheme())
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>

View File

@@ -27,6 +27,7 @@ export const reportMapper = {
toApi(uiData) { toApi(uiData) {
return cleanPayload({ return cleanPayload({
patient_id: uiData.patientId, patient_id: uiData.patientId,
order_number: emptyToUndefined(uiData.orderNumber),
status: normalizeApiStatus(uiData.status), status: normalizeApiStatus(uiData.status),
exam: emptyToUndefined(uiData.exam), exam: emptyToUndefined(uiData.exam),
requested_by: emptyToUndefined(uiData.requestedBy), requested_by: emptyToUndefined(uiData.requestedBy),
@@ -38,6 +39,8 @@ export const reportMapper = {
hide_date: Boolean(uiData.hideDate), hide_date: Boolean(uiData.hideDate),
hide_signature: Boolean(uiData.hideSignature), hide_signature: Boolean(uiData.hideSignature),
due_at: emptyToUndefined(uiData.dueAt), 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' }, { label: 'Mês', value: 'Mes' },
] ]
const appointmentTypeOptions = ['Retorno', 'Primeira consulta', 'Exame', 'Avaliação pre-op']
export function AgendaPage({ navigate }) { export function AgendaPage({ navigate }) {
const [modalPatientSearch, setModalPatientSearch] = useState('') const [modalPatientSearch, setModalPatientSearch] = useState('')
const [modalDoctorSearch, setModalDoctorSearch] = useState('') const [modalDoctorSearch, setModalDoctorSearch] = useState('')
@@ -84,29 +86,35 @@ export function AgendaPage({ navigate }) {
), ),
].sort((a, b) => a.localeCompare(b, 'pt-BR')) ].sort((a, b) => a.localeCompare(b, 'pt-BR'))
const filteredPatients = (() => { const filteredPatients = (() => {
const query = modalPatientSearch.trim().toLowerCase() const query = normalizeSearch(modalPatientSearch)
if (!query) return patients if (!query) return patients
return patients.filter((patient) => return patients.filter((patient) =>
[patient.name, patient.full_name, patient.nome, patient.cpf, patient.email] [patient.name, patient.full_name, patient.nome, patient.cpf, patient.email]
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase() .toLowerCase()
.includes(query), .includes(query),
) )
})() })()
const filteredProfessionals = (() => { const filteredProfessionals = (() => {
const query = modalDoctorSearch.trim().toLowerCase() const query = normalizeSearch(modalDoctorSearch)
if (!query) return professionals if (!query) return professionals
return professionals.filter((professional) => return professionals.filter((professional) =>
[professional.name, professional.email, professional.unit] [professional.name, professional.email, professional.unit]
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase() .toLowerCase()
.includes(query), .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 ( return (
<div className="mx-auto flex max-w-[1180px] flex-col gap-8 text-[#e5e5e5]"> <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"> <DarkField label="Paciente">
<input <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]" 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)} onChange={(event) => {
setModalPatientSearch(event.target.value)
updateForm('patientId', '')
}}
placeholder="Pesquisar paciente" placeholder="Pesquisar paciente"
type="search" 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> </DarkField>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
@@ -389,33 +399,42 @@ export function AgendaPage({ navigate }) {
) : ( ) : (
<> <>
<input <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]" 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)} onChange={(event) => {
setModalDoctorSearch(event.target.value)
updateForm('professionalId', '')
}}
placeholder="Pesquisar médico" placeholder="Pesquisar médico"
type="search" 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>
<DarkField label="Tipo de consulta"> <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]" 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)} onChange={(event) => updateForm('type', event.target.value)}
value={form.type} value={form.type}
/> >
{appointmentTypeOptions.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</DarkField> </DarkField>
<div className="flex flex-wrap justify-end gap-3 pt-2"> <div className="flex flex-wrap justify-end gap-3 pt-2">
@@ -473,3 +492,44 @@ function DarkModal({ children, onClose, open, title }) {
</div> </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 { authRepository } from '../repositories/authRepository.js'
import { BrandLogo } from '../components/Brand.jsx' 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' import loginClinicImage from '../assets/figma/login-clinic.png'
const mockCredentials = [ const mockCredentials = [
@@ -164,7 +164,7 @@ export function LoginPage({ navigate }) {
{credentialsOpen ? ( {credentialsOpen ? (
<div className="mb-2 w-[292px] rounded-md border border-white/10 bg-[#0f1b2d] p-2 shadow-2xl"> <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"> <p className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-white/40">
Credenciais mockadas Credenciais de acesso
</p> </p>
<div className="grid gap-1"> <div className="grid gap-1">
{mockCredentials.map((credential) => ( {mockCredentials.map((credential) => (
@@ -190,11 +190,10 @@ export function LoginPage({ navigate }) {
<button <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" 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)} onClick={() => setCredentialsOpen((current) => !current)}
title="Preencher credenciais mockadas" title="Preencher credenciais de acesso"
type="button" type="button"
> >
dev · credenciais dev · credenciais
<FeatureBadge className="border-white/20 bg-white/10 text-white/70" status="mock" text="mock" />
<span aria-hidden="true" className="text-[9px]"> <span aria-hidden="true" className="text-[9px]">
{credentialsOpen ? 'v' : '^'} {credentialsOpen ? 'v' : '^'}
</span> </span>
@@ -345,7 +344,7 @@ function AuthLayout({ children, description, title }) {
<span className="text-[#3b82f6]">saúde.</span> <span className="text-[#3b82f6]">saúde.</span>
</h1> </h1>
<p className="mt-5 max-w-[360px] text-sm leading-[23px] text-white/60 xl:text-base xl:leading-[26px]"> <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> </p>
</div> </div>
</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 { FeatureCallout } from '../components/FeatureState.jsx'
import { medicalRecordRepository } from '../repositories/medicalRecordRepository.js' import { medicalRecordRepository } from '../repositories/medicalRecordRepository.js'
import { patientRepository } from '../repositories/patientRepository.js'
const inputClass = const inputClass =
@@ -12,9 +13,27 @@ const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
export function MedicalRecordsPage() { export function MedicalRecordsPage() {
const recordTypes = medicalRecordRepository.getRecordTypes() const recordTypes = medicalRecordRepository.getRecordTypes()
const [records, setRecords] = useState(() => medicalRecordRepository.getInitialRecords()) const [records, setRecords] = useState(() => medicalRecordRepository.getInitialRecords())
const [patients, setPatients] = useState([])
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [editorOpen, setEditorOpen] = useState(false) 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(() => { const filteredRecords = useMemo(() => {
return records.filter((record) => { return records.filter((record) => {
const matchesSearch = [record.patient, record.cid, record.doctor] const matchesSearch = [record.patient, record.cid, record.doctor]
@@ -81,6 +100,7 @@ export function MedicalRecordsPage() {
<RecordEditorModal <RecordEditorModal
onClose={() => setEditorOpen(false)} onClose={() => setEditorOpen(false)}
onSave={handleCreateRecord} onSave={handleCreateRecord}
patients={patients}
recordTypes={recordTypes} recordTypes={recordTypes}
/> />
) : null} ) : 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({ const [formData, setFormData] = useState({
patientId: '',
patient: '', patient: '',
date: '', date: '',
type: 'Primeira Consulta', type: 'Primeira Consulta',
@@ -168,6 +190,31 @@ function RecordEditorModal({ onClose, onSave, recordTypes }) {
setFormData((currentData) => ({ ...currentData, [name]: value })) 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) { function handleSubmit(event) {
event.preventDefault() event.preventDefault()
const submitter = event.nativeEvent.submitter const submitter = event.nativeEvent.submitter
@@ -199,11 +246,36 @@ function RecordEditorModal({ onClose, onSave, recordTypes }) {
<DarkField label="Paciente"> <DarkField label="Paciente">
<input <input
className={inputClass} className={inputClass}
name="patient" onChange={(event) => {
onChange={updateField} setPatientSearch(event.target.value)
setFormData((currentData) => ({ ...currentData, patientId: '', patient: '' }))
}}
placeholder="Buscar paciente..." 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>
<DarkField label="Data da Consulta"> <DarkField label="Data da Consulta">
<input <input
@@ -330,6 +402,18 @@ function formatDate(value) {
return `${day}/${month}/${year}` 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 }) { function RecordIcon({ className = 'size-4', name }) {
const common = { const common = {
className, className,

View File

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

View File

@@ -37,6 +37,7 @@ const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
const emptyEditor = { const emptyEditor = {
id: null, id: null,
orderNumber: '',
patientId: '', patientId: '',
status: 'draft', status: 'draft',
exam: '', exam: '',
@@ -242,6 +243,7 @@ export function ReportsPage({ role }) {
function openEdit(report) { function openEdit(report) {
setEditor({ setEditor({
id: report.id, id: report.id,
orderNumber: report.orderNumber,
patientId: String(report.patientId || ''), patientId: String(report.patientId || ''),
status: report.status, status: report.status,
exam: report.exam, exam: report.exam,
@@ -264,6 +266,7 @@ export function ReportsPage({ role }) {
setSaving(true) setSaving(true)
const payload = { const payload = {
orderNumber: editor.id ? editor.orderNumber : `REL-${Date.now()}`,
patientId: editor.patientId, patientId: editor.patientId,
status: editor.status, status: editor.status,
exam: editor.exam, exam: editor.exam,
@@ -276,6 +279,8 @@ export function ReportsPage({ role }) {
hideDate: editor.hideDate, hideDate: editor.hideDate,
hideSignature: editor.hideSignature, hideSignature: editor.hideSignature,
dueAt: editor.dueAt ? new Date(editor.dueAt).toISOString() : '', 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 { try {
@@ -519,7 +524,11 @@ function ReportRow({ onEdit, onView, report }) {
} }
function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions, professionalOptions, saving }) { function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions, professionalOptions, saving }) {
const [requesterSearch, setRequesterSearch] = useState(editor.requestedBy || '')
const isValid = Boolean(editor.patientId) const isValid = Boolean(editor.patientId)
const filteredRequesterOptions = professionalOptions
.filter((professional) => normalizeSearch(professional.name).includes(normalizeSearch(requesterSearch)))
.slice(0, 6)
function updateField(field, value) { function updateField(field, value) {
onChange((current) => ({ ...current, [field]: value })) onChange((current) => ({ ...current, [field]: value }))
@@ -573,19 +582,39 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
</DarkField> </DarkField>
<DarkField label="Solicitante"> <DarkField label="Solicitante">
<div> <div className="space-y-2">
<input <input
className={inputClass} className={inputClass}
list="report-requested-by-suggestions" onChange={(event) => {
onChange={(event) => updateField('requestedBy', event.target.value)} setRequesterSearch(event.target.value)
placeholder="Nome do solicitante" updateField('requestedBy', event.target.value)
value={editor.requestedBy} }}
placeholder="Pesquisar médico"
type="search"
value={requesterSearch}
/> />
<datalist id="report-requested-by-suggestions"> <div className="max-h-36 overflow-y-auto rounded-md border border-[#404040] bg-[#1a1a1a] p-1">
{professionalOptions.map((professional) => ( {filteredRequesterOptions.length ? (
<option key={professional.id} value={professional.name} /> filteredRequesterOptions.map((professional) => (
))} <button
</datalist> 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> </div>
</DarkField> </DarkField>
</div> </div>
@@ -632,7 +661,6 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
<textarea <textarea
className={`${textareaClass} min-h-72`} className={`${textareaClass} min-h-72`}
onChange={(event) => updateField('contentHtml', event.target.value)} onChange={(event) => updateField('contentHtml', event.target.value)}
placeholder="Complemento em texto simples"
value={editor.contentHtml} value={editor.contentHtml}
/> />
</DarkField> </DarkField>
@@ -698,9 +726,19 @@ function ReportViewModal({ onClose, report }) {
<h2 className="text-lg font-bold text-[#e5e5e5]">Relatório</h2> <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> <p className="mt-1 text-xs text-[#a3a3a3]">{report.orderNumber || 'Sem número'} </p>
</div> </div>
<button className="rounded-lg p-1.5 transition hover:bg-[#2a2a2a]" onClick={onClose} type="button"> <div className="flex items-center gap-2">
<ReportIcon className="size-4 text-[#a3a3a3]" name="x" /> <button
</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>
<div className="flex-1 overflow-y-auto p-6"> <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))] 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 }) { function ReportIcon({ className = 'size-4', name }) {
const common = { const common = {
className, 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 ( return (
<svg {...common}> <svg {...common}>
<path d="m6 9 6 6 6-6" /> <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 { FeatureCallout } from '../components/FeatureState.jsx'
import { settingsRepository } from '../repositories/settingsRepository.js' import { settingsRepository } from '../repositories/settingsRepository.js'
import { getStoredTheme, setStoredTheme } from '../utils/theme.js'
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm' const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
@@ -65,11 +66,15 @@ export function SettingsPage() {
} }
function AppearanceSection() { function AppearanceSection() {
const [theme, setTheme] = useState('dark') const [theme, setTheme] = useState(() => getStoredTheme())
const [compact, setCompact] = useState(false) const [compact, setCompact] = useState(false)
const [contrast, setContrast] = useState(false) const [contrast, setContrast] = useState(false)
const [animations, setAnimations] = useState(true) const [animations, setAnimations] = useState(true)
function handleThemeChange(nextTheme) {
setTheme(setStoredTheme(nextTheme))
}
return ( return (
<SectionFrame description="Personalize a interface do MediConnect." title="Aparência"> <SectionFrame description="Personalize a interface do MediConnect." title="Aparência">
<div className="mb-8"> <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' theme === item.id ? 'border-[#3b82f6] bg-[#3b82f6]/5 shadow-md shadow-[#3b82f6]/20' : 'border-[#404040] bg-[#262626] hover:border-[#3b82f6]/40'
}`} }`}
key={item.id} key={item.id}
onClick={() => setTheme(item.id)} onClick={() => handleThemeChange(item.id)}
type="button" 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}`}> <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() { getDashboardData() {
return { return {
absenteeismData: [ absenteeismData: [
{ month: 'Out', taxa: 18, meta: 15 }, { month: 'Out/2025', taxa: 18, meta: 15 },
{ month: 'Nov', taxa: 16, meta: 15 }, { month: 'Nov/2025', taxa: 16, meta: 15 },
{ month: 'Dez', taxa: 22, meta: 15 }, { month: 'Dez/2025', taxa: 22, meta: 15 },
{ month: 'Jan', taxa: 14, meta: 15 }, { month: 'Jan/2026', taxa: 14, meta: 15 },
{ month: 'Fev', taxa: 12, meta: 15 }, { month: 'Fev/2026', taxa: 12, meta: 15 },
{ month: 'Mar', taxa: 14.2, meta: 15 }, { month: 'Mar/2026', taxa: 14.2, meta: 15 },
], ],
consultationsData: [ consultationsData: [
{ month: 'Out', total: 380, realizadas: 312 }, { month: 'Out', total: 380, realizadas: 312 },

View File

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

View File

@@ -39,18 +39,28 @@ export const reportRepository = {
}, },
async create(uiData) { async create(uiData) {
const response = await fetch(`${apiConfig.restUrl}/reports`, { let lastResponse = null
method: 'POST',
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
body: JSON.stringify(reportMapper.toApi(uiData)),
})
if (!response.ok) { for (const payload of buildCreatePayloads(reportMapper.toApi(uiData))) {
throw new Error(await getResponseError(response, 'Falha ao criar relatório médico.')) const response = await fetch(`${apiConfig.restUrl}/reports`, {
method: 'POST',
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
body: JSON.stringify(payload),
})
if (response.ok) {
const data = await response.json()
return reportMapper.toUi(normalizeItem(data))
}
lastResponse = response
if (response.status !== 400) {
break
}
} }
const data = await response.json() throw new Error(await getResponseError(lastResponse, 'Falha ao criar relatório médico.'))
return reportMapper.toUi(normalizeItem(data))
}, },
async update(id, uiData) { async update(id, uiData) {
@@ -68,3 +78,38 @@ export const reportRepository = {
return reportMapper.toUi(normalizeItem(data)) 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) { if (lastError && !lastResponse) {
throw new Error(lastError.message || fallbackMessage) throw new Error(translateErrorMessage(lastError.message || fallbackMessage))
} }
throw new Error(await getResponseError(lastResponse, 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 text = await response.text().catch(() => '')
const error = parseErrorBody(text) const error = parseErrorBody(text)
const message = const message = translateErrorMessage(
error.error_description || error.error_description ||
error.msg || error.msg ||
error.message || error.message ||
@@ -62,11 +62,50 @@ export async function getResponseError(response, fallbackMessage) {
error.details || error.details ||
error.hint || error.hint ||
text || text ||
fallbackMessage fallbackMessage,
)
return response.status ? `${fallbackMessage} (${response.status}): ${message}` : message 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) { function shouldFallback(response) {
return [404, 405].includes(response.status) 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"
}
]
}