fix(principal): integra auth, agenda e laudos com a api

This commit is contained in:
EdilbertoC
2026-04-28 10:22:54 -03:00
parent d576fb9784
commit 7199c107f2
20 changed files with 1121 additions and 331 deletions

View File

@@ -16,30 +16,38 @@ const viewFilters = ['Dia', 'Semana', 'Mês']
export function AgendaPage({ navigate }) {
const [patients, setPatients] = useState([])
const professionals = professionalRepository.getAll()
const [professionals, setProfessionals] = useState([])
const queue = appointmentRepository.getPredictiveQueueSummary()
const timeline = appointmentRepository.getTodayTimeline()
const weekDays = appointmentRepository.getWeekDays()
const [activeView, setActiveView] = useState('Dia')
const [status, setStatus] = useState('Todos')
const [modalOpen, setModalOpen] = useState(false)
const [localAppointments, setLocalAppointments] = useState(() => appointmentRepository.getAll())
const [localAppointments, setLocalAppointments] = useState([])
const [form, setForm] = useState({
patientId: '',
professional: professionals[0]?.name || '',
professionalId: '',
type: 'Retorno',
time: '15:30',
mode: 'Teleconsulta',
})
useEffect(() => {
patientRepository.getAll().then((data) => {
setPatients(data)
Promise.all([
patientRepository.getAll(),
appointmentRepository.getAll(),
professionalRepository.getAll()
]).then(([patientsData, appointmentsData, professionalsData]) => {
setPatients(patientsData)
setLocalAppointments(appointmentsData || [])
setProfessionals(professionalsData || [])
setForm((current) => ({
...current,
patientId: data[0]?.id || '',
patientId: patientsData?.length ? patientsData[0].id : '',
professionalId: professionalsData?.length ? professionalsData[0].id : '',
}))
})
}).catch(e => console.error(e))
}, [])
const visibleAppointments = useMemo(() => {
@@ -54,26 +62,28 @@ useEffect(() => {
setForm((current) => ({ ...current, [field]: value }))
}
function handleCreate(event) {
async function handleCreate(event) {
event.preventDefault()
const patient = patients.find((item) => item.id === form.patientId) || patients[0]
setLocalAppointments((current) => [
...current,
{
id: `apt-local-${current.length + 1}`,
date: '2026-04-07',
patient: patient.name,
patientId: patient.id,
professional: form.professional,
room: form.mode === 'Teleconsulta' ? 'Sala virtual 3' : 'Sala 02',
status: 'Confirmada',
// Fallback date and time
const today = new Date().toISOString().split('T')[0]
try {
const created = await appointmentRepository.create({
patientId: form.patientId,
date: today,
time: form.time,
type: form.type,
mode: form.mode,
},
])
setModalOpen(false)
room: form.mode === 'Teleconsulta' ? 'Virtual' : 'Consultório 1',
professionalId: form.professionalId,
})
setLocalAppointments((current) => [...current, created])
setModalOpen(false)
} catch(err) {
alert(err.message || 'Erro ao criar agendamento.')
}
}
return (
@@ -244,7 +254,7 @@ useEffect(() => {
>
{patients.map((patient) => (
<option key={patient.id} value={patient.id}>
{patient.name}
{patient.name || patient.full_name || patient.nome}
</option>
))}
</select>
@@ -274,11 +284,11 @@ useEffect(() => {
<DarkField label="Profissional">
<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('professional', event.target.value)}
value={form.professional}
onChange={(event) => updateForm('professionalId', event.target.value)}
value={form.professionalId}
>
{professionals.map((professional) => (
<option key={professional.id}>{professional.name}</option>
<option key={professional.id} value={professional.id}>{professional.name}</option>
))}
</select>
</DarkField>

View File

@@ -1,5 +1,7 @@
import { useState } from 'react'
import { authRepository } from '../repositories/authRepository.js'
import { BrandLogo } from '../components/Brand.jsx'
import loginClinicImage from '../assets/figma/login-clinic.png'
@@ -9,14 +11,26 @@ export function LoginPage({ navigate }) {
password: '',
})
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
function updateField(field, value) {
setForm((current) => ({ ...current, [field]: value }))
}
function handleSubmit(event) {
async function handleSubmit(event) {
event.preventDefault()
navigate('/inicio')
setLoading(true)
setError('')
try {
await authRepository.login(form)
navigate('/inicio')
} catch (err) {
setError(err.message || 'Erro de autenticação')
} finally {
setLoading(false)
}
}
return (
@@ -74,6 +88,12 @@ export function LoginPage({ navigate }) {
</p>
</div>
{error && (
<div className="mt-4 rounded bg-red-500/10 p-3 text-sm font-semibold text-red-500 border border-red-500/20">
{error}
</div>
)}
<form className="mt-8 grid gap-5" onSubmit={handleSubmit}>
<LoginField htmlFor="login-email" label="E-mail">
<input
@@ -122,10 +142,11 @@ export function LoginPage({ navigate }) {
</LoginField>
<button
className="inline-flex h-11 w-full items-center justify-center rounded-[6px] border border-[#3b82f6] bg-[#3b82f6] px-4 py-2 text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.2),0_4px_6px_rgba(59,130,246,0.2)] transition hover:bg-[#3478ed] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#3b82f6]"
className="inline-flex h-11 w-full items-center justify-center rounded-[6px] border border-[#3b82f6] bg-[#3b82f6] px-4 py-2 text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.2),0_4px_6px_rgba(59,130,246,0.2)] transition hover:bg-[#3478ed] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#3b82f6] disabled:opacity-50"
disabled={loading}
type="submit"
>
Entrar
{loading ? 'Entrando...' : 'Entrar'}
</button>
</form>
</div>
@@ -204,29 +225,52 @@ export function RegisterPage({ navigate }) {
export function ForgotPasswordPage({ navigate }) {
const [sent, setSent] = useState(false)
const [email, setEmail] = useState('recepcao@mediconnect.com')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(event) {
event.preventDefault()
setLoading(true)
setError('')
try {
await authRepository.requestPasswordReset(email)
setSent(true)
} catch (err) {
setError(err.message || 'Erro ao comunicar com o servidor.')
} finally {
setLoading(false)
}
}
return (
<AuthLayout
description="Informe o e-mail cadastrado para receber um link mockado."
description="Informe o e-mail cadastrado para receber o link de acesso."
title="Recuperar senha"
>
{sent ? (
<div className="mt-8 rounded-[6px] border border-emerald-500/30 bg-emerald-500/10 p-4 text-sm leading-6 text-emerald-300">
Link de recuperação mockado enviado para o e-mail informado.
Link de recuperação enviado para o e-mail informado. Siga as instruções do link!
</div>
) : (
<form
className="mt-8 grid gap-5"
onSubmit={(event) => {
event.preventDefault()
setSent(true)
}}
onSubmit={handleSubmit}
>
{error && (
<div className="rounded bg-red-500/10 p-3 text-sm font-semibold text-red-500 border border-red-500/20">
{error}
</div>
)}
<AuthField label="E-mail cadastrado">
<input autoComplete="email" className={authInputClass} defaultValue="recepcao@mediconnect.com" type="email" />
<input autoComplete="email" className={authInputClass} onChange={e => setEmail(e.target.value)} value={email} type="email" />
</AuthField>
<button className="inline-flex h-11 w-full items-center justify-center rounded-[6px] bg-[#3b82f6] text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.2)] transition hover:bg-[#3478ed]" type="submit">
Enviar link
<button
className="inline-flex h-11 w-full items-center justify-center rounded-[6px] bg-[#3b82f6] text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.2)] transition hover:bg-[#3478ed] disabled:opacity-50"
disabled={loading}
type="submit"
>
{loading ? "Enviando..." : "Enviar link"}
</button>
</form>
)}

View File

@@ -18,6 +18,7 @@ const statusConfig = {
const emptyMessage = {
patient: '',
phone: '',
channel: 'whatsapp',
template: 'Lembrete 48h',
content: '',
@@ -79,6 +80,7 @@ export function MessagesPage() {
function openTemplate(template) {
setComposer({
patient: '',
phone: '',
channel: template.channel,
template: template.name,
content: template.content,
@@ -86,13 +88,33 @@ export function MessagesPage() {
setComposerOpen(true)
}
function submitMessage(event) {
async function submitMessage(event) {
event.preventDefault()
if (!composer.patient.trim()) {
return
}
let smsSent = false
if (composer.channel === 'sms') {
if (!composer.phone.trim()) {
alert('Informe o telefone para enviar SMS.')
return
}
try {
await communicationRepository.sendSms({
patientName: composer.patient.trim(),
phone: composer.phone.trim(),
content: composer.content,
})
smsSent = true
} catch (e) {
alert('Falha ao disparar SMS: ' + e.message)
}
}
setMessages((current) => [
{
id: `local-${Date.now()}`,
@@ -100,7 +122,7 @@ export function MessagesPage() {
channel: composer.channel,
template: composer.template.trim() || 'Mensagem avulsa',
sentAt: 'Agora',
status: 'pendente',
status: composer.channel === 'sms' ? (smsSent ? 'entregue' : 'falha') : 'pendente',
response: '',
},
...current,
@@ -300,6 +322,7 @@ export function MessagesPage() {
onClick={() => {
setComposer({
patient: campaign.count,
phone: '',
channel: 'whatsapp',
template: campaign.title,
content: campaign.desc,
@@ -470,6 +493,17 @@ function MessageComposer({ draft, onChange, onClose, onSubmit, templates }) {
</DarkField>
</div>
{draft.channel === 'sms' ? (
<DarkField label="Telefone">
<input
className={inputClass}
onChange={(event) => update('phone', event.target.value)}
placeholder="(81) 99999-9999"
value={draft.phone}
/>
</DarkField>
) : null}
<DarkField label="Template">
<select className={inputClass} onChange={(event) => applyTemplate(event.target.value)} value={draft.template}>
<option value="Mensagem avulsa">Mensagem avulsa</option>

View File

@@ -1,20 +1,62 @@
import { useState } from 'react'
import { useRef, useState, useEffect } from 'react'
import { profileRepository } from '../repositories/profileRepository.js'
import { authRepository } from '../repositories/authRepository.js'
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
const inputClass =
'h-10 rounded-sm border border-[#404040] bg-[#171717] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
export function ProfilePage() {
export function ProfilePage({ navigate }) {
const [saved, setSaved] = useState(false)
const [profile, setProfile] = useState(() => profileRepository.getCurrentUserProfile())
const [profile, setProfile] = useState({ name: '', role: '', email: '', phone: '', unit: '', avatarUrl: '' })
const [loading, setLoading] = useState(true)
const [uploadingAvatar, setUploadingAvatar] = useState(false)
const [avatarError, setAvatarError] = useState('')
const fileInputRef = useRef(null)
useEffect(() => {
profileRepository.getCurrentUserProfile().then(data => {
setProfile(data)
setLoading(false)
}).catch(() => setLoading(false))
}, [])
function update(field, value) {
setSaved(false)
setProfile((current) => ({ ...current, [field]: value }))
}
async function handleLogout() {
await authRepository.logout()
navigate('/login')
}
async function handleAvatarChange(event) {
const file = event.target.files?.[0]
if (!file) return
setUploadingAvatar(true)
setAvatarError('')
try {
const result = await profileRepository.updateAvatar(file)
setProfile((current) => ({
...current,
avatarUrl: result.avatarUrl || URL.createObjectURL(file),
}))
} catch (err) {
setAvatarError(err.message || 'Erro ao enviar avatar.')
} finally {
setUploadingAvatar(false)
event.target.value = ''
}
}
if (loading) {
return <div className="text-center pt-20 text-[#a3a3a3]">Localizando dados do paciente...</div>
}
return (
<div className="mx-auto max-w-6xl space-y-6">
<header>
@@ -25,15 +67,36 @@ export function ProfilePage() {
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
<section className={`${cardClass} p-6`}>
<div className="mb-6 flex items-center gap-4">
<div className="grid size-16 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 text-xl font-bold text-[#3b82f6]">
HC
</div>
{profile.avatarUrl ? (
<img
alt=""
className="size-16 rounded-full border border-[#3b82f6]/30 object-cover"
src={profile.avatarUrl}
/>
) : (
<div className="grid size-16 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 text-xl font-bold text-[#3b82f6]">
{initials(profile.name)}
</div>
)}
<div>
<h2 className="text-lg font-bold text-[#f5f5f5]">{profile.name}</h2>
<p className="mt-1 text-sm text-[#a3a3a3]">{profile.role}</p>
<button className="mt-1 text-xs font-semibold text-[#3b82f6]" type="button">
Alterar foto
<button
className="mt-1 text-xs font-semibold text-[#3b82f6] disabled:opacity-60"
disabled={uploadingAvatar}
onClick={() => fileInputRef.current?.click()}
type="button"
>
{uploadingAvatar ? 'Enviando...' : 'Alterar foto'}
</button>
<input
accept="image/*"
className="hidden"
onChange={handleAvatarChange}
ref={fileInputRef}
type="file"
/>
{avatarError ? <p className="mt-1 text-xs font-semibold text-red-400">{avatarError}</p> : null}
</div>
</div>
@@ -79,10 +142,18 @@ export function ProfilePage() {
<aside className={`${cardClass} p-6`}>
<h2 className="text-xl font-bold text-[#f5f5f5]">Resumo de acesso</h2>
<dl className="mt-5 grid gap-4 text-sm">
<Info label="Perfil" value="Administrador da clínica" />
<Info label="Último acesso" value="07 abr 2026, 09:15" />
<Info label="Perfil" value={profile.role} />
<Info label="E-mail principal" value={profile.email} />
<Info label="Permissões" value="Agenda, pacientes, comunicação e configurações" />
</dl>
<div className="mt-8 border-t border-[#404040] pt-6">
<button
className="w-full h-10 rounded-sm border border-red-500/30 text-red-500 font-semibold text-sm transition hover:bg-red-500/10"
onClick={handleLogout}
>
Sair da conta
</button>
</div>
</aside>
</div>
</div>
@@ -106,3 +177,13 @@ function Info({ label, value }) {
</div>
)
}
function initials(name) {
return String(name || 'US')
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase()
}

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { reportRepository } from '../repositories/reportRepository.js'
import { patientRepository } from '../repositories/patientRepository.js'
const statusConfig = {
@@ -43,7 +44,14 @@ const labelClass = 'mb-1.5 block text-xs font-medium text-[#e5e5e5]'
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
export function ReportsPage() {
const [reports, setReports] = useState(() => reportRepository.getInitialReports())
const [reports, setReports] = useState([])
const [patients, setPatients] = useState([])
useEffect(() => {
reportRepository.getInitialReports().then(setReports).catch(console.error)
patientRepository.getAll().then(setPatients).catch(console.error)
}, [])
const [search, setSearch] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [openMenuId, setOpenMenuId] = useState(null)
@@ -104,64 +112,34 @@ export function ReportsPage() {
setEditorOpen(true)
}
function saveReport(status) {
if (!editor.patient.trim() || !editor.content.trim()) {
return
}
async function saveReport(status) {
if (!editor.patient.trim() || !editor.content.trim()) return
try {
const selectedPatient = patients.find(p => p.name === editor.patient || p.full_name === editor.patient)
const patientId = selectedPatient?.id || null
const date = new Date().toLocaleDateString('pt-BR')
setReports((currentReports) => {
if (editor.id) {
return currentReports.map((report) =>
report.id === editor.id
? {
...report,
type: editor.type,
patient: editor.patient,
doctor: editor.doctor,
content: editor.content,
showDate: editor.showDate,
signDigital: editor.signDigital,
status,
versions: [
...report.versions,
{
version: report.versions.length + 1,
action: status === 'finalizado' ? 'Liberado' : 'Rascunho',
user: currentUser,
summary: status === 'finalizado' ? 'Laudo liberado' : 'Rascunho salvo',
},
],
}
: report,
)
}
return [
{
id: `report-${Date.now()}`,
const updated = await reportRepository.update(editor.id, {
type: editor.type,
patient: editor.patient,
doctor: editor.doctor,
date,
status,
content: editor.content,
showDate: editor.showDate,
signDigital: editor.signDigital,
versions: [
{ version: 1, action: 'Criado', user: currentUser, summary: 'Laudo criado localmente' },
{
version: 2,
action: status === 'finalizado' ? 'Liberado' : 'Rascunho',
user: currentUser,
summary: status === 'finalizado' ? 'Laudo liberado' : 'Rascunho salvo',
},
],
},
...currentReports,
]
})
setEditorOpen(false)
patientId: patientId,
status,
})
setReports(curr => curr.map(r => r.id == updated.id ? { ...updated, status } : r))
} else {
const created = await reportRepository.create({
type: editor.type,
content: editor.content,
patientId: patientId,
status,
})
setReports(curr => [{ ...created, status }, ...curr])
}
setEditorOpen(false)
} catch(e) {
alert(e.message || 'Erro ao persistir na Base de Dados')
}
}
function releaseReport(reportId) {
@@ -391,7 +369,7 @@ function ReportRow({
type="button"
>
<ReportIcon className="size-3.5" name="history" />
v{report.versions.length}
v{report.versions ? report.versions.length : 1}
</button>
</td>
<td className="relative px-4 py-3 text-right">

View File

@@ -1,11 +1,16 @@
import { useState, useEffect } from 'react'
import { professionalRepository } from '../repositories/professionalRepository.js'
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
export function TeamPage({ navigate }) {
const professionals = professionalRepository.getAll()
const [professionals, setProfessionals] = useState([])
const { slots, weekdays } = professionalRepository.getCoverageMap()
useEffect(() => {
professionalRepository.getAll().then(setProfessionals).catch(console.error)
}, [])
return (
<div className="mx-auto max-w-7xl space-y-6">
<header className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">