Merge pull request 'modo-claro' (#4) from modo-claro into main
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
@@ -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
|
||||
|
||||
130
src/index.css
130
src/index.css
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ const viewFilters = [
|
||||
{ label: 'Mês', value: 'Mes' },
|
||||
]
|
||||
|
||||
const appointmentTypeOptions = ['Retorno', 'Primeira consulta', 'Exame', 'Avaliação pre-op']
|
||||
|
||||
export function AgendaPage({ navigate }) {
|
||||
const [modalPatientSearch, setModalPatientSearch] = useState('')
|
||||
const [modalDoctorSearch, setModalDoctorSearch] = useState('')
|
||||
@@ -84,29 +86,35 @@ export function AgendaPage({ navigate }) {
|
||||
),
|
||||
].sort((a, b) => a.localeCompare(b, 'pt-BR'))
|
||||
const filteredPatients = (() => {
|
||||
const query = modalPatientSearch.trim().toLowerCase()
|
||||
const query = normalizeSearch(modalPatientSearch)
|
||||
if (!query) return patients
|
||||
|
||||
return patients.filter((patient) =>
|
||||
[patient.name, patient.full_name, patient.nome, patient.cpf, patient.email]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
)
|
||||
})()
|
||||
const filteredProfessionals = (() => {
|
||||
const query = modalDoctorSearch.trim().toLowerCase()
|
||||
const query = normalizeSearch(modalDoctorSearch)
|
||||
if (!query) return professionals
|
||||
|
||||
return professionals.filter((professional) =>
|
||||
[professional.name, professional.email, professional.unit]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
)
|
||||
})()
|
||||
const selectedPatient = patients.find((patient) => String(patient.id) === String(form.patientId))
|
||||
const selectedProfessional = professionals.find((professional) => String(professional.id) === String(form.professionalId))
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-[1180px] flex-col gap-8 text-[#e5e5e5]">
|
||||
@@ -318,23 +326,25 @@ export function AgendaPage({ navigate }) {
|
||||
|
||||
<DarkField label="Paciente">
|
||||
<input
|
||||
className="mb-2 h-10 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
|
||||
onChange={(event) => setModalPatientSearch(event.target.value)}
|
||||
className="h-10 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
|
||||
onChange={(event) => {
|
||||
setModalPatientSearch(event.target.value)
|
||||
updateForm('patientId', '')
|
||||
}}
|
||||
placeholder="Pesquisar paciente"
|
||||
type="search"
|
||||
value={modalPatientSearch}
|
||||
value={modalPatientSearch || getPatientLabel(selectedPatient)}
|
||||
/>
|
||||
<SearchResults
|
||||
emptyText="Nenhum paciente encontrado."
|
||||
getLabel={getPatientLabel}
|
||||
items={filteredPatients.slice(0, 6)}
|
||||
onSelect={(patient) => {
|
||||
updateForm('patientId', patient.id)
|
||||
setModalPatientSearch(getPatientLabel(patient))
|
||||
}}
|
||||
selectedId={form.patientId}
|
||||
/>
|
||||
<select
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||
onChange={(event) => updateForm('patientId', event.target.value)}
|
||||
value={form.patientId}
|
||||
>
|
||||
{filteredPatients.map((patient) => (
|
||||
<option key={patient.id} value={patient.id}>
|
||||
{patient.name || patient.full_name || patient.nome}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</DarkField>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
@@ -389,33 +399,42 @@ export function AgendaPage({ navigate }) {
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className="mb-2 h-10 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
|
||||
onChange={(event) => setModalDoctorSearch(event.target.value)}
|
||||
className="h-10 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
|
||||
onChange={(event) => {
|
||||
setModalDoctorSearch(event.target.value)
|
||||
updateForm('professionalId', '')
|
||||
}}
|
||||
placeholder="Pesquisar médico"
|
||||
type="search"
|
||||
value={modalDoctorSearch}
|
||||
value={modalDoctorSearch || selectedProfessional?.name || ''}
|
||||
/>
|
||||
<SearchResults
|
||||
emptyText="Nenhum médico encontrado."
|
||||
getDescription={(professional) => professional.unit || professional.email}
|
||||
getLabel={(professional) => professional.name}
|
||||
items={filteredProfessionals.slice(0, 6)}
|
||||
onSelect={(professional) => {
|
||||
updateForm('professionalId', professional.id)
|
||||
setModalDoctorSearch(professional.name)
|
||||
}}
|
||||
selectedId={form.professionalId}
|
||||
/>
|
||||
<select
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||
onChange={(event) => updateForm('professionalId', event.target.value)}
|
||||
value={form.professionalId}
|
||||
>
|
||||
{filteredProfessionals.map((professional) => (
|
||||
<option key={professional.id} value={professional.id}>
|
||||
{professional.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Tipo de consulta">
|
||||
<input
|
||||
<select
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||
onChange={(event) => updateForm('type', event.target.value)}
|
||||
value={form.type}
|
||||
/>
|
||||
>
|
||||
{appointmentTypeOptions.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</DarkField>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-3 pt-2">
|
||||
@@ -473,3 +492,44 @@ function DarkModal({ children, onClose, open, title }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchResults({ emptyText, getDescription, getLabel, items, onSelect, selectedId }) {
|
||||
return (
|
||||
<div className="max-h-44 overflow-y-auto rounded-md border border-[#404040] bg-[#1f1f1f]">
|
||||
{items.length ? (
|
||||
items.map((item) => {
|
||||
const isSelected = String(item.id) === String(selectedId)
|
||||
return (
|
||||
<button
|
||||
className={`block w-full px-3 py-2 text-left text-sm transition ${
|
||||
isSelected ? 'bg-[#3b82f6]/20 text-[#e5e5e5]' : 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
|
||||
}`}
|
||||
key={item.id}
|
||||
onClick={() => onSelect(item)}
|
||||
type="button"
|
||||
>
|
||||
<span className="block font-semibold">{getLabel(item)}</span>
|
||||
{getDescription?.(item) ? (
|
||||
<span className="mt-0.5 block text-xs text-[#737373]">{getDescription(item)}</span>
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<p className="px-3 py-2 text-xs text-[#737373]">{emptyText}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getPatientLabel(patient) {
|
||||
return patient?.name || patient?.full_name || patient?.nome || ''
|
||||
}
|
||||
|
||||
function normalizeSearch(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from 'react'
|
||||
import { authRepository } from '../repositories/authRepository.js'
|
||||
|
||||
import { BrandLogo } from '../components/Brand.jsx'
|
||||
import { FeatureBadge, FeatureCallout } from '../components/FeatureState.jsx'
|
||||
import { FeatureCallout } from '../components/FeatureState.jsx'
|
||||
import loginClinicImage from '../assets/figma/login-clinic.png'
|
||||
|
||||
const mockCredentials = [
|
||||
@@ -164,7 +164,7 @@ export function LoginPage({ navigate }) {
|
||||
{credentialsOpen ? (
|
||||
<div className="mb-2 w-[292px] rounded-md border border-white/10 bg-[#0f1b2d] p-2 shadow-2xl">
|
||||
<p className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-white/40">
|
||||
Credenciais mockadas
|
||||
Credenciais de acesso
|
||||
</p>
|
||||
<div className="grid gap-1">
|
||||
{mockCredentials.map((credential) => (
|
||||
@@ -190,11 +190,10 @@ export function LoginPage({ navigate }) {
|
||||
<button
|
||||
className="flex h-[29px] items-center gap-1.5 rounded-sm border border-white/10 bg-white/[0.05] px-3 font-mono text-[10px] font-medium leading-[15px] text-white/30 transition hover:text-white/50"
|
||||
onClick={() => setCredentialsOpen((current) => !current)}
|
||||
title="Preencher credenciais mockadas"
|
||||
title="Preencher credenciais de acesso"
|
||||
type="button"
|
||||
>
|
||||
dev · credenciais
|
||||
<FeatureBadge className="border-white/20 bg-white/10 text-white/70" status="mock" text="mock" />
|
||||
<span aria-hidden="true" className="text-[9px]">
|
||||
{credentialsOpen ? 'v' : '^'}
|
||||
</span>
|
||||
@@ -345,7 +344,7 @@ function AuthLayout({ children, description, title }) {
|
||||
<span className="text-[#3b82f6]">saúde.</span>
|
||||
</h1>
|
||||
<p className="mt-5 max-w-[360px] text-sm leading-[23px] text-white/60 xl:text-base xl:leading-[26px]">
|
||||
Fluxos de acesso simulados para manter a navegação ponta a ponta sem backend real.
|
||||
Segurança e continuidade para equipes de saúde.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { FeatureCallout } from '../components/FeatureState.jsx'
|
||||
import { medicalRecordRepository } from '../repositories/medicalRecordRepository.js'
|
||||
import { patientRepository } from '../repositories/patientRepository.js'
|
||||
|
||||
|
||||
const inputClass =
|
||||
@@ -12,9 +13,27 @@ const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||
export function MedicalRecordsPage() {
|
||||
const recordTypes = medicalRecordRepository.getRecordTypes()
|
||||
const [records, setRecords] = useState(() => medicalRecordRepository.getInitialRecords())
|
||||
const [patients, setPatients] = useState([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
patientRepository
|
||||
.getDirectoryRows()
|
||||
.then((data) => {
|
||||
if (active) setPatients(data || [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setPatients([])
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
return records.filter((record) => {
|
||||
const matchesSearch = [record.patient, record.cid, record.doctor]
|
||||
@@ -81,6 +100,7 @@ export function MedicalRecordsPage() {
|
||||
<RecordEditorModal
|
||||
onClose={() => setEditorOpen(false)}
|
||||
onSave={handleCreateRecord}
|
||||
patients={patients}
|
||||
recordTypes={recordTypes}
|
||||
/>
|
||||
) : null}
|
||||
@@ -149,8 +169,10 @@ function IconButton({ label, name }) {
|
||||
)
|
||||
}
|
||||
|
||||
function RecordEditorModal({ onClose, onSave, recordTypes }) {
|
||||
function RecordEditorModal({ onClose, onSave, patients, recordTypes }) {
|
||||
const [patientSearch, setPatientSearch] = useState('')
|
||||
const [formData, setFormData] = useState({
|
||||
patientId: '',
|
||||
patient: '',
|
||||
date: '',
|
||||
type: 'Primeira Consulta',
|
||||
@@ -168,6 +190,31 @@ function RecordEditorModal({ onClose, onSave, recordTypes }) {
|
||||
setFormData((currentData) => ({ ...currentData, [name]: value }))
|
||||
}
|
||||
|
||||
const filteredPatients = (() => {
|
||||
const query = normalizeSearch(patientSearch)
|
||||
if (!query) return patients
|
||||
|
||||
return patients.filter((patient) =>
|
||||
[patient.name, patient.full_name, patient.nome, patient.cpf, patient.document, patient.email]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
)
|
||||
})()
|
||||
|
||||
function selectPatient(patient) {
|
||||
const name = getPatientName(patient)
|
||||
setFormData((currentData) => ({
|
||||
...currentData,
|
||||
patientId: patient.id,
|
||||
patient: name,
|
||||
}))
|
||||
setPatientSearch(name)
|
||||
}
|
||||
|
||||
function handleSubmit(event) {
|
||||
event.preventDefault()
|
||||
const submitter = event.nativeEvent.submitter
|
||||
@@ -199,11 +246,36 @@ function RecordEditorModal({ onClose, onSave, recordTypes }) {
|
||||
<DarkField label="Paciente">
|
||||
<input
|
||||
className={inputClass}
|
||||
name="patient"
|
||||
onChange={updateField}
|
||||
onChange={(event) => {
|
||||
setPatientSearch(event.target.value)
|
||||
setFormData((currentData) => ({ ...currentData, patientId: '', patient: '' }))
|
||||
}}
|
||||
placeholder="Buscar paciente..."
|
||||
value={formData.patient}
|
||||
type="search"
|
||||
value={patientSearch || formData.patient}
|
||||
/>
|
||||
<div className="mt-2 max-h-44 overflow-y-auto rounded-lg border border-[#404040] bg-[#1a1a1a]">
|
||||
{filteredPatients.length ? (
|
||||
filteredPatients.slice(0, 6).map((patient) => {
|
||||
const selected = String(patient.id) === String(formData.patientId)
|
||||
return (
|
||||
<button
|
||||
className={`block w-full px-3 py-2 text-left text-sm transition ${
|
||||
selected ? 'bg-[#3b82f6]/20 text-[#e5e5e5]' : 'text-[#a3a3a3] hover:bg-[#2a2a2a] hover:text-[#e5e5e5]'
|
||||
}`}
|
||||
key={patient.id}
|
||||
onClick={() => selectPatient(patient)}
|
||||
type="button"
|
||||
>
|
||||
<span className="block font-semibold">{getPatientName(patient)}</span>
|
||||
<span className="mt-0.5 block text-xs text-[#737373]">{patient.cpf || patient.document || patient.email || 'Sem documento'}</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<p className="px-3 py-2 text-xs text-[#737373]">Nenhum paciente encontrado.</p>
|
||||
)}
|
||||
</div>
|
||||
</DarkField>
|
||||
<DarkField label="Data da Consulta">
|
||||
<input
|
||||
@@ -330,6 +402,18 @@ function formatDate(value) {
|
||||
return `${day}/${month}/${year}`
|
||||
}
|
||||
|
||||
function getPatientName(patient) {
|
||||
return patient?.name || patient?.full_name || patient?.nome || ''
|
||||
}
|
||||
|
||||
function normalizeSearch(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function RecordIcon({ className = 'size-4', name }) {
|
||||
const common = {
|
||||
className,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { hasCapability, normalizeRole } from '../config/permissions.js'
|
||||
import { hasCapability } from '../config/permissions.js'
|
||||
import { patientRepository } from '../repositories/patientRepository.js'
|
||||
import { professionalRepository } from '../repositories/professionalRepository.js'
|
||||
import { profileRepository } from '../repositories/profileRepository.js'
|
||||
const ITEMS_PER_PAGE = 25
|
||||
|
||||
const darkInput =
|
||||
@@ -70,11 +68,11 @@ export function PatientsPage({ navigate, role }) {
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
buildPatientRows(role)
|
||||
buildPatientRows()
|
||||
.then((data) => setRows(data))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [role])
|
||||
}, [])
|
||||
|
||||
const editingPatient = rows.find((patient) => patient.id === editingId)
|
||||
const hasAdvancedFilters = city || state || ageMin || ageMax || lastVisitSince
|
||||
@@ -180,13 +178,13 @@ export function PatientsPage({ navigate, role }) {
|
||||
|
||||
try {
|
||||
if (isNew) {
|
||||
const [created] = await patientRepository.create(patient)
|
||||
const created = normalizeCreatedPatient(await patientRepository.create(patient))
|
||||
const newRow = {
|
||||
...patient,
|
||||
id: created.id,
|
||||
detailId: created.id,
|
||||
name: created.full_name || patient.name,
|
||||
phone: created.phone_mobile || patient.phone,
|
||||
id: created?.id || patient.id,
|
||||
detailId: created?.id || patient.detailId || patient.id,
|
||||
name: created?.full_name || created?.name || patient.name,
|
||||
phone: created?.phone_mobile || created?.phone || patient.phone,
|
||||
}
|
||||
setRows((currentRows) => [newRow, ...currentRows])
|
||||
} else {
|
||||
@@ -368,14 +366,14 @@ export function PatientsPage({ navigate, role }) {
|
||||
{patient.name}
|
||||
</span>
|
||||
<span className="mt-0.5 block whitespace-normal break-words text-xs text-[#a3a3a3]">
|
||||
{patient.insurance || 'Sem convenio'} {patient.vip ? ' | VIP' : ''}
|
||||
{patient.insurance || missingValue('Convênio')} {patient.vip ? ' | VIP' : ''}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.phone}</td>
|
||||
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.city}</td>
|
||||
<td className="px-6 py-4 align-top text-[#a3a3a3]">{patient.state}</td>
|
||||
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.phone || missingValue('Telefone')}</td>
|
||||
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.city || missingValue('Cidade')}</td>
|
||||
<td className="px-6 py-4 align-top text-[#a3a3a3]">{patient.state || missingValue('Estado')}</td>
|
||||
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.lastVisit || 'Ainda não houve atendimento'}</td>
|
||||
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.nextVisit || 'Nenhum atendimento agendado'}</td>
|
||||
<td className="relative sticky right-0 bg-[#262626] px-6 py-4 text-right shadow-[-10px_0_12px_-12px_rgba(0,0,0,0.75)]">
|
||||
@@ -398,7 +396,7 @@ export function PatientsPage({ navigate, role }) {
|
||||
onClick={() => setOpenMenuId(null)}
|
||||
type="button"
|
||||
/>
|
||||
<div className="absolute right-4 top-12 z-50 w-48 rounded-lg border border-[#404040] bg-[#303030] py-1 text-left shadow-lg">
|
||||
<div className="absolute right-4 top-12 z-50 w-48 rounded-md border border-[#404040] bg-[#262626] p-1 text-left shadow-lg">
|
||||
<ActionItem icon="file" label="Ver detalhes" onClick={() => openDetail(patient)} />
|
||||
{canEditPatients ? <ActionItem icon="edit" label="Editar" onClick={() => openForm(patient.id)} /> : null}
|
||||
<ActionItem
|
||||
@@ -870,8 +868,8 @@ export function PatientDetailPage({ navigate, patient, role }) {
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<SummaryTile label="Idade" value={`${localPatient.age} anos`} />
|
||||
<SummaryTile label="Risco" value={localPatient.risk} tone={riskColor(localPatient.risk)} />
|
||||
<SummaryTile label="Idade" value={localPatient.age ? `${localPatient.age} anos` : missingValue('Idade')} />
|
||||
<SummaryTile label="Risco" value={localPatient.risk || missingValue('Risco')} tone={localPatient.risk ? riskColor(localPatient.risk) : null} />
|
||||
<SummaryTile label="Última consulta" value={localPatient.lastVisit || 'Ainda não houve atendimento'} />
|
||||
<SummaryTile label="Próxima consulta" value={localPatient.nextVisit || 'Nenhum atendimento agendado'} />
|
||||
</section>
|
||||
@@ -1197,11 +1195,15 @@ function InfoRow({ label, value }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="font-semibold text-[#737373]">{label}</dt>
|
||||
<dd className="mt-1 text-[#e5e5e5]">{value || 'Não informado'}</dd>
|
||||
<dd className="mt-1 text-[#e5e5e5]">{value || missingValue(label)}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function missingValue(label) {
|
||||
return `${label} não informado`
|
||||
}
|
||||
|
||||
function formatDisplayDate(value) {
|
||||
if (!value) return ''
|
||||
const [year, month, day] = String(value).split('-')
|
||||
@@ -1296,8 +1298,8 @@ function PageButton({ children, disabled, onClick }) {
|
||||
function ActionItem({ danger = false, icon, label, onClick }) {
|
||||
return (
|
||||
<button
|
||||
className={`flex w-full items-center gap-2 px-4 py-2 text-sm transition ${
|
||||
danger ? 'text-[#ef4444] hover:bg-[#ef4444]/10' : 'text-[#e5e5e5] hover:bg-[#333333]'
|
||||
className={`flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm font-medium transition ${
|
||||
danger ? 'text-[#f87171] hover:bg-[#303030]' : 'text-[#e5e5e5] hover:bg-[#303030]'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
@@ -1620,22 +1622,13 @@ function PatientIcon({ className = 'size-4', name }) {
|
||||
)
|
||||
}
|
||||
|
||||
async function buildPatientRows(role) {
|
||||
if (normalizeRole(role) !== 'medico') {
|
||||
return patientRepository.getDirectoryRows()
|
||||
}
|
||||
async function buildPatientRows() {
|
||||
return patientRepository.getDirectoryRows()
|
||||
}
|
||||
|
||||
const [profile, professionals] = await Promise.all([
|
||||
profileRepository.getCurrentUserProfile(),
|
||||
professionalRepository.getAll(),
|
||||
])
|
||||
const currentProfessional = professionalRepository.resolveCurrentProfessional(profile, professionals)
|
||||
|
||||
if (!currentProfessional?.id) {
|
||||
throw new Error('Não foi possível vincular o médico logado a um profissional da base.')
|
||||
}
|
||||
|
||||
return patientRepository.getDirectoryRows({ doctorId: currentProfessional.id })
|
||||
function normalizeCreatedPatient(payload) {
|
||||
if (Array.isArray(payload)) return payload[0] || null
|
||||
return payload?.patient || payload?.data || payload?.created || payload || null
|
||||
}
|
||||
|
||||
function uniqueSlug(value, existingIds) {
|
||||
|
||||
@@ -37,6 +37,7 @@ const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||
|
||||
const emptyEditor = {
|
||||
id: null,
|
||||
orderNumber: '',
|
||||
patientId: '',
|
||||
status: 'draft',
|
||||
exam: '',
|
||||
@@ -242,6 +243,7 @@ export function ReportsPage({ role }) {
|
||||
function openEdit(report) {
|
||||
setEditor({
|
||||
id: report.id,
|
||||
orderNumber: report.orderNumber,
|
||||
patientId: String(report.patientId || ''),
|
||||
status: report.status,
|
||||
exam: report.exam,
|
||||
@@ -264,6 +266,7 @@ export function ReportsPage({ role }) {
|
||||
setSaving(true)
|
||||
|
||||
const payload = {
|
||||
orderNumber: editor.id ? editor.orderNumber : `REL-${Date.now()}`,
|
||||
patientId: editor.patientId,
|
||||
status: editor.status,
|
||||
exam: editor.exam,
|
||||
@@ -276,6 +279,8 @@ export function ReportsPage({ role }) {
|
||||
hideDate: editor.hideDate,
|
||||
hideSignature: editor.hideSignature,
|
||||
dueAt: editor.dueAt ? new Date(editor.dueAt).toISOString() : '',
|
||||
createdBy: editor.id ? undefined : viewerProfile?.id || currentProfessional?.userId || currentProfessional?.id || undefined,
|
||||
updatedBy: viewerProfile?.id || currentProfessional?.userId || currentProfessional?.id || undefined,
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -519,7 +524,11 @@ function ReportRow({ onEdit, onView, report }) {
|
||||
}
|
||||
|
||||
function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions, professionalOptions, saving }) {
|
||||
const [requesterSearch, setRequesterSearch] = useState(editor.requestedBy || '')
|
||||
const isValid = Boolean(editor.patientId)
|
||||
const filteredRequesterOptions = professionalOptions
|
||||
.filter((professional) => normalizeSearch(professional.name).includes(normalizeSearch(requesterSearch)))
|
||||
.slice(0, 6)
|
||||
|
||||
function updateField(field, value) {
|
||||
onChange((current) => ({ ...current, [field]: value }))
|
||||
@@ -573,19 +582,39 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Solicitante">
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
className={inputClass}
|
||||
list="report-requested-by-suggestions"
|
||||
onChange={(event) => updateField('requestedBy', event.target.value)}
|
||||
placeholder="Nome do solicitante"
|
||||
value={editor.requestedBy}
|
||||
onChange={(event) => {
|
||||
setRequesterSearch(event.target.value)
|
||||
updateField('requestedBy', event.target.value)
|
||||
}}
|
||||
placeholder="Pesquisar médico"
|
||||
type="search"
|
||||
value={requesterSearch}
|
||||
/>
|
||||
<datalist id="report-requested-by-suggestions">
|
||||
{professionalOptions.map((professional) => (
|
||||
<option key={professional.id} value={professional.name} />
|
||||
))}
|
||||
</datalist>
|
||||
<div className="max-h-36 overflow-y-auto rounded-md border border-[#404040] bg-[#1a1a1a] p-1">
|
||||
{filteredRequesterOptions.length ? (
|
||||
filteredRequesterOptions.map((professional) => (
|
||||
<button
|
||||
className={`flex w-full items-center justify-between gap-3 rounded-sm px-3 py-2 text-left text-sm font-medium transition hover:bg-[#303030] ${
|
||||
editor.requestedBy === professional.name ? 'text-[#51a2ff]' : 'text-[#e5e5e5]'
|
||||
}`}
|
||||
key={professional.id || professional.createdByValue || professional.name}
|
||||
onClick={() => {
|
||||
setRequesterSearch(professional.name)
|
||||
updateField('requestedBy', professional.name)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{professional.name}</span>
|
||||
{editor.requestedBy === professional.name ? <ReportIcon className="size-3.5" name="check" /> : null}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p className="px-3 py-2 text-sm text-[#a3a3a3]">Nenhum médico encontrado.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DarkField>
|
||||
</div>
|
||||
@@ -632,7 +661,6 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
||||
<textarea
|
||||
className={`${textareaClass} min-h-72`}
|
||||
onChange={(event) => updateField('contentHtml', event.target.value)}
|
||||
placeholder="Complemento em texto simples"
|
||||
value={editor.contentHtml}
|
||||
/>
|
||||
</DarkField>
|
||||
@@ -698,9 +726,19 @@ function ReportViewModal({ onClose, report }) {
|
||||
<h2 className="text-lg font-bold text-[#e5e5e5]">Relatório</h2>
|
||||
<p className="mt-1 text-xs text-[#a3a3a3]">{report.orderNumber || 'Sem número'} </p>
|
||||
</div>
|
||||
<button className="rounded-lg p-1.5 transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
|
||||
<ReportIcon className="size-4 text-[#a3a3a3]" name="x" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 text-xs font-semibold text-[#e5e5e5] transition hover:bg-[#2a2a2a]"
|
||||
onClick={() => printReportAsPdf(report, currentStatus)}
|
||||
type="button"
|
||||
>
|
||||
<ReportIcon className="size-4" name="print" />
|
||||
Imprimir PDF
|
||||
</button>
|
||||
<button className="rounded-lg p-1.5 transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
|
||||
<ReportIcon className="size-4 text-[#a3a3a3]" name="x" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
@@ -846,6 +884,94 @@ function uniqueValues(values) {
|
||||
return [...new Set(values.map((value) => String(value || '').trim()).filter(Boolean))]
|
||||
}
|
||||
|
||||
function normalizeSearch(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function printReportAsPdf(report, status) {
|
||||
const printWindow = window.open('', '_blank', 'noopener,noreferrer,width=900,height=1100')
|
||||
|
||||
if (!printWindow) {
|
||||
window.print()
|
||||
return
|
||||
}
|
||||
|
||||
printWindow.document.write(`
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Relatório ${escapeHtml(report.orderNumber || '')}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { color: #171717; font-family: Arial, sans-serif; margin: 40px; }
|
||||
h1 { font-size: 24px; margin: 0 0 4px; }
|
||||
.muted { color: #525252; font-size: 12px; }
|
||||
.grid { display: grid; gap: 12px; grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 24px; }
|
||||
.box { border: 1px solid #d4d4d4; border-radius: 8px; padding: 12px; }
|
||||
.label { color: #525252; font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.value { font-size: 13px; margin-top: 6px; white-space: pre-wrap; }
|
||||
.section { margin-top: 20px; }
|
||||
@media print { body { margin: 24mm; } button { display: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Relatório</h1>
|
||||
<p class="muted">${escapeHtml(report.orderNumber || 'Sem número')}</p>
|
||||
<div class="grid">
|
||||
${printDetail('Paciente', report.patientName)}
|
||||
${printDetail('Solicitante', report.requestedBy || '-')}
|
||||
${printDetail('Criado em', formatDate(report.createdAt))}
|
||||
${printDetail('Criado por', report.createdByName)}
|
||||
${printDetail('Status', status.label)}
|
||||
${printDetail('Prazo', formatDateTime(report.dueAt))}
|
||||
</div>
|
||||
<div class="grid">
|
||||
${printDetail('Exame', report.exam || '-')}
|
||||
${printDetail('CID-10', report.cidCode || '-')}
|
||||
</div>
|
||||
<div class="section box">
|
||||
<p class="label">Diagnóstico</p>
|
||||
<p class="value">${escapeHtml(report.diagnosis || '-')}</p>
|
||||
</div>
|
||||
<div class="section box">
|
||||
<p class="label">Conclusão</p>
|
||||
<p class="value">${escapeHtml(report.conclusion || '-')}</p>
|
||||
</div>
|
||||
<div class="section box">
|
||||
<p class="label">Complemento</p>
|
||||
<p class="value">${escapeHtml(report.contentHtml || 'Nenhum complemento informado.')}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
printWindow.document.close()
|
||||
printWindow.focus()
|
||||
printWindow.print()
|
||||
}
|
||||
|
||||
function printDetail(label, value) {
|
||||
return `
|
||||
<div class="box">
|
||||
<p class="label">${escapeHtml(label)}</p>
|
||||
<p class="value">${escapeHtml(value || '-')}</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function ReportIcon({ className = 'size-4', name }) {
|
||||
const common = {
|
||||
className,
|
||||
@@ -924,6 +1050,24 @@ function ReportIcon({ className = 'size-4', name }) {
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'print') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M7 8V4h10v4" />
|
||||
<path d="M7 17H5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-2" />
|
||||
<path d="M7 14h10v7H7zM17 12h.01" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'check') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="m5 12 4 4L19 6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
|
||||
import { FeatureCallout } from '../components/FeatureState.jsx'
|
||||
import { settingsRepository } from '../repositories/settingsRepository.js'
|
||||
import { getStoredTheme, setStoredTheme } from '../utils/theme.js'
|
||||
|
||||
|
||||
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||
@@ -65,11 +66,15 @@ export function SettingsPage() {
|
||||
}
|
||||
|
||||
function AppearanceSection() {
|
||||
const [theme, setTheme] = useState('dark')
|
||||
const [theme, setTheme] = useState(() => getStoredTheme())
|
||||
const [compact, setCompact] = useState(false)
|
||||
const [contrast, setContrast] = useState(false)
|
||||
const [animations, setAnimations] = useState(true)
|
||||
|
||||
function handleThemeChange(nextTheme) {
|
||||
setTheme(setStoredTheme(nextTheme))
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionFrame description="Personalize a interface do MediConnect." title="Aparência">
|
||||
<div className="mb-8">
|
||||
@@ -84,7 +89,7 @@ function AppearanceSection() {
|
||||
theme === item.id ? 'border-[#3b82f6] bg-[#3b82f6]/5 shadow-md shadow-[#3b82f6]/20' : 'border-[#404040] bg-[#262626] hover:border-[#3b82f6]/40'
|
||||
}`}
|
||||
key={item.id}
|
||||
onClick={() => setTheme(item.id)}
|
||||
onClick={() => handleThemeChange(item.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className={`mb-3 flex h-20 flex-col gap-1.5 overflow-hidden rounded-xl border border-[#404040] p-2 ${item.preview}`}>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -39,18 +39,28 @@ export const reportRepository = {
|
||||
},
|
||||
|
||||
async create(uiData) {
|
||||
const response = await fetch(`${apiConfig.restUrl}/reports`, {
|
||||
method: 'POST',
|
||||
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
|
||||
body: JSON.stringify(reportMapper.toApi(uiData)),
|
||||
})
|
||||
let lastResponse = null
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Falha ao criar relatório médico.'))
|
||||
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(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()
|
||||
return reportMapper.toUi(normalizeItem(data))
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
27
src/utils/theme.js
Normal 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
8
vercel.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user