new file: .gitignore

new file:   src/App.css
new file:   src/App.jsx
new file:   src/assets/figma/login-clinic.png
new file:   src/assets/hero.png
new file:   src/assets/react.svg
new file:   src/assets/vite.svg
new file:   src/components/AppShell.jsx
new file:   src/components/Brand.jsx
new file:   src/components/ui.jsx
new file:   src/data/mockData.js
new file:   src/index.css
new file:   src/main.jsx
new file:   src/pages/AgendaPage.jsx
new file:   src/pages/AnalyticsPage.jsx
new file:   src/pages/AuthPages.jsx
new file:   src/pages/HomePage.jsx
new file:   src/pages/MedicalRecordsPage.jsx
new file:   src/pages/MessagesPage.jsx
new file:   src/pages/NotFoundPage.jsx
new file:   src/pages/PatientsPage.jsx
new file:   src/pages/ProfilePage.jsx
new file:   src/pages/ReportsPage.jsx
new file:   src/pages/SettingsPage.jsx
new file:   src/pages/TeamPage.jsx
new file:   src/pages/VisitsPage.jsx
new file:   src/repositories/analyticsRepository.js
new file:   src/repositories/appointmentRepository.js
new file:   src/repositories/communicationRepository.js
new file:   src/repositories/homeRepository.js
new file:   src/repositories/medicalRecordRepository.js
new file:   src/repositories/patientRepository.js
new file:   src/repositories/professionalRepository.js
new file:   src/repositories/profileRepository.js
new file:   src/repositories/reportRepository.js
new file:   src/repositories/settingsRepository.js
new file:   src/repositories/visitRepository.js
new file:   src/services/analyticsService.js
new file:   src/services/appointmentService.js
new file:   src/services/communicationService.js
new file:   src/services/homeService.js
new file:   src/services/medicalRecordService.js
new file:   src/services/patientService.js
new file:   src/services/professionalService.js
new file:   src/services/profileService.js
new file:   src/services/reportService.js
new file:   src/services/settingsService.js
This commit is contained in:
2026-04-27 00:47:58 -03:00
parent 27226b3df8
commit db2d1562e0
37 changed files with 7324 additions and 0 deletions

405
src/pages/AgendaPage.jsx Normal file
View File

@@ -0,0 +1,405 @@
import { useEffect, useMemo, useState } from 'react'
import { appointmentRepository } from '../repositories/appointmentRepository.js'
import { patientRepository } from '../repositories/patientRepository.js'
import { professionalRepository } from '../repositories/professionalRepository.js'
const statusFilters = [
{ label: 'Todos', value: 'Todos' },
{ label: 'Confirmadas', value: 'Confirmada' },
{ label: 'Em triagem', value: 'Em triagem' },
{ label: 'Aguardando', value: 'Aguardando' },
]
const viewFilters = ['Dia', 'Semana', 'Mês']
export function AgendaPage({ navigate }) {
const [patients, setPatients] = useState([])
const professionals = professionalRepository.getAll()
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 [form, setForm] = useState({
patientId: '',
professional: professionals[0]?.name || '',
type: 'Retorno',
time: '15:30',
mode: 'Teleconsulta',
})
useEffect(() => {
patientRepository.getAll().then((data) => {
setPatients(data)
setForm((current) => ({
...current,
patientId: data[0]?.id || '',
}))
})
}, [])
const visibleAppointments = useMemo(() => {
if (status === 'Todos') {
return localAppointments
}
return localAppointments.filter((appointment) => appointment.status === status)
}, [localAppointments, status])
function updateForm(field, value) {
setForm((current) => ({ ...current, [field]: value }))
}
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',
time: form.time,
type: form.type,
mode: form.mode,
},
])
setModalOpen(false)
}
return (
<div className="mx-auto flex max-w-[1180px] flex-col gap-8 text-[#e5e5e5]">
<section className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-[32px] font-bold leading-8 tracking-[-0.02em] text-[#e5e5e5]">
Agenda
</h1>
<p className="mt-2 text-sm leading-5 text-[#a3a3a3]">
Organize consultas, retornos e teleatendimentos do dia.
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
className="h-9 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#303030]"
onClick={() => setStatus('Todos')}
type="button"
>
Hoje
</button>
<button
className="h-9 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.16)] transition hover:bg-[#3478ed]"
onClick={() => setModalOpen(true)}
type="button"
>
+ Nova consulta
</button>
</div>
</section>
<section className="grid gap-4 lg:grid-cols-5">
{weekDays.map((day) => (
<button
className={`rounded-2xl border p-4 text-left transition ${
day.active
? 'border-[#3b82f6] bg-[#3b82f6]/10'
: 'border-[#404040] bg-[#262626] hover:border-[#525252]'
}`}
key={`${day.label}-${day.day}`}
type="button"
>
<span className="block text-xs font-semibold uppercase tracking-[0.16em] text-[#a3a3a3]">
{day.label}
</span>
<span className="mt-2 block text-[32px] font-bold leading-8 text-[#e5e5e5]">{day.day}</span>
<span className="mt-3 block text-sm text-[#3b82f6]">{day.count} consultas</span>
</button>
))}
</section>
<section className="grid gap-6 xl:grid-cols-[1.45fr_0.85fr]">
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-base font-bold leading-6 text-[#e5e5e5]">Terça, 07 abril</h2>
<p className="mt-1 text-sm leading-5 text-[#a3a3a3]">
Visualização: {activeView.toLowerCase()} | {visibleAppointments.length} registros no filtro
</p>
</div>
<div className="flex flex-wrap gap-2">
{viewFilters.map((view) => (
<button
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
activeView === view
? 'border-[#3b82f6] bg-[#3b82f6] text-white'
: 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]'
}`}
key={view}
onClick={() => setActiveView(view)}
type="button"
>
{view}
</button>
))}
</div>
</div>
<div className="mt-5 flex flex-wrap gap-2">
{statusFilters.map((filter) => (
<button
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
status === filter.value
? 'border-[#3b82f6] bg-[#3b82f6]/10 text-[#3b82f6]'
: 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]'
}`}
key={filter.value}
onClick={() => setStatus(filter.value)}
type="button"
>
{filter.label}
</button>
))}
</div>
<div className="mt-6 grid gap-3">
{visibleAppointments.length ? (
visibleAppointments.map((appointment) => (
<AgendaListItem
appointment={appointment}
key={appointment.id}
navigate={navigate}
/>
))
) : (
<div className="rounded-xl border border-dashed border-[#404040] bg-[#1f1f1f] p-8 text-center">
<h3 className="text-base font-bold text-[#e5e5e5]">Nenhum horário encontrado</h3>
<p className="mt-2 text-sm leading-6 text-[#a3a3a3]">
Ajuste o filtro ou crie uma consulta mockada para este período.
</p>
</div>
)}
</div>
</div>
<div className="grid gap-6">
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
<h2 className="text-base font-bold text-[#e5e5e5]">Linha do tempo</h2>
<div className="mt-5 grid gap-1">
{timeline.map((item) => (
<button
className="grid grid-cols-[58px_1fr] gap-4 rounded-md px-2 py-3 text-left transition hover:bg-[#303030]"
disabled={!item.patientId}
key={`${item.hour}-${item.patient}`}
onClick={() => item.patientId && navigate(`/pacientes/${item.patientId}`)}
type="button"
>
<span className="text-sm font-bold text-[#3b82f6]">{item.hour}</span>
<span className="border-l border-[#404040] pl-4">
<span className="block text-sm font-semibold text-[#e5e5e5]">{item.patient}</span>
<span className="mt-1 block text-xs text-[#a3a3a3]">{item.type}</span>
</span>
</button>
))}
</div>
</div>
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
<h2 className="text-base font-bold text-[#e5e5e5]">Resumo preditivo</h2>
<div className="mt-5 grid gap-3">
{queue.map((item) => (
<div className="flex items-center justify-between rounded-md bg-[#2a2a2a] px-4 py-3" key={item.label}>
<span className="text-sm font-medium text-[#a3a3a3]">{item.label}</span>
<span className={`text-lg font-bold ${queueTone(item.tone)}`}>{item.value}</span>
</div>
))}
</div>
<button
className="mt-5 h-9 rounded-sm border border-[#404040] bg-[#303030] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:border-[#3b82f6] hover:text-[#3b82f6]"
onClick={() => navigate('/mensagens')}
type="button"
>
Confirmar presenças
</button>
</div>
</div>
</section>
<DarkModal onClose={() => setModalOpen(false)} open={modalOpen} title="Nova consulta">
<form className="grid gap-4" onSubmit={handleCreate}>
<DarkField label="Paciente">
<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}
>
{patients.map((patient) => (
<option key={patient.id} value={patient.id}>
{patient.name}
</option>
))}
</select>
</DarkField>
<div className="grid gap-4 sm:grid-cols-2">
<DarkField label="Horário">
<input
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
onChange={(event) => updateForm('time', event.target.value)}
type="time"
value={form.time}
/>
</DarkField>
<DarkField label="Formato">
<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('mode', event.target.value)}
value={form.mode}
>
<option>Teleconsulta</option>
<option>Presencial</option>
</select>
</DarkField>
</div>
<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}
>
{professionals.map((professional) => (
<option key={professional.id}>{professional.name}</option>
))}
</select>
</DarkField>
<DarkField label="Tipo de consulta">
<input
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}
/>
</DarkField>
<div className="flex flex-wrap justify-end gap-3 pt-2">
<button
className="h-10 rounded-sm border border-[#404040] bg-[#303030] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#333333]"
onClick={() => setModalOpen(false)}
type="button"
>
Cancelar
</button>
<button
className="h-10 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#3478ed]"
type="submit"
>
Salvar consulta
</button>
</div>
</form>
</DarkModal>
</div>
)
}
function AgendaListItem({ appointment, navigate }) {
return (
<article className="grid gap-4 rounded-xl border border-[#404040] bg-[#1f1f1f] p-4 md:grid-cols-[72px_1fr_auto]">
<div>
<p className="text-xl font-bold leading-7 text-[#e5e5e5]">{appointment.time}</p>
<p className="mt-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-[#737373]">
{appointment.mode}
</p>
</div>
<div>
<button
className="text-left text-base font-bold text-[#e5e5e5] transition hover:text-[#3b82f6]"
onClick={() => navigate(`/pacientes/${appointment.patientId}`)}
type="button"
>
{appointment.patient}
</button>
<p className="mt-1 text-sm text-[#a3a3a3]">
{appointment.type} com {appointment.professional}
</p>
<p className="mt-2 text-xs font-medium text-[#737373]">{appointment.room}</p>
</div>
<div className="flex items-start justify-between gap-3 md:justify-end">
<StatusPill status={appointment.status} />
</div>
</article>
)
}
function DarkField({ children, label }) {
return (
<label className="grid gap-2 text-sm font-semibold text-[#a3a3a3]">
<span>{label}</span>
{children}
</label>
)
}
function DarkModal({ children, onClose, open, title }) {
if (!open) {
return null
}
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-4 sm:items-center">
<div className="w-full max-w-xl rounded-2xl border border-[#404040] bg-[#262626] shadow-2xl">
<div className="flex items-center justify-between gap-4 border-b border-[#404040] px-5 py-4">
<h2 className="text-lg font-bold text-[#e5e5e5]">{title}</h2>
<button
aria-label="Fechar"
className="grid size-8 place-items-center rounded-sm text-xl leading-none text-[#a3a3a3] transition hover:bg-[#303030] hover:text-[#e5e5e5]"
onClick={onClose}
type="button"
>
x
</button>
</div>
<div className="p-5">{children}</div>
</div>
</div>
)
}
function StatusPill({ status }) {
const classes = {
Confirmada: 'border-[#14532d] bg-[#052e1a] text-[#10b981]',
'Em triagem': 'border-[#78350f] bg-[#2d1e05] text-[#f59e0b]',
Aguardando: 'border-[#404040] bg-[#303030] text-[#a3a3a3]',
Bloqueado: 'border-[#404040] bg-[#303030] text-[#737373]',
}
return (
<span className={`rounded-full border px-3 py-1 text-xs font-bold ${classes[status] || classes.Aguardando}`}>
{status}
</span>
)
}
function queueTone(tone) {
if (tone === 'red') {
return 'text-[#ef4444]'
}
if (tone === 'amber') {
return 'text-[#f59e0b]'
}
return 'text-[#3b82f6]'
}

385
src/pages/AnalyticsPage.jsx Normal file
View File

@@ -0,0 +1,385 @@
import { useState } from 'react'
import { analyticsRepository } from '../repositories/analyticsRepository.js'
const periods = [
['1m', '1 Mes'],
['3m', '3 Meses'],
['6m', '6 Meses'],
['1a', '1 Ano'],
]
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
export function AnalyticsPage() {
const {
absenteeismData,
consultationsData,
doctorPerformance,
insuranceData,
kpis,
revenueData,
topPatients,
} = analyticsRepository.getDashboardData()
const [period, setPeriod] = useState('6m')
return (
<div className="mx-auto max-w-7xl space-y-6">
<section className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div>
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Relatórios & Analytics</h1>
<p className="mt-1 text-sm text-[#b8b8b8]">Dashboard executivo com métricas de desempenho</p>
</div>
<div className="flex overflow-hidden rounded-sm border border-[#404040] bg-[#171717]">
{periods.map(([key, label]) => (
<button
className={`h-9 px-4 text-xs font-semibold transition ${
period === key ? 'bg-[#3b82f6] text-white' : 'text-[#b8b8b8] hover:bg-[#303030] hover:text-[#e5e5e5]'
}`}
key={key}
onClick={() => setPeriod(key)}
type="button"
>
{label}
</button>
))}
</div>
</section>
<section className="grid grid-cols-2 gap-4 md:grid-cols-4" aria-label="Indicadores principais">
{kpis.map((kpi) => (
<KpiCard key={kpi.label} kpi={kpi} />
))}
</section>
<section className="grid gap-6 lg:grid-cols-2" aria-label="Gráficos principais">
<ChartCard description="Evolução mensal vs meta" title="Taxa de Absenteísmo">
<AreaMetricChart data={absenteeismData} />
</ChartCard>
<ChartCard description="Agendadas vs realizadas" title="Consultas por Período">
<GroupedBarChart data={consultationsData} />
</ChartCard>
</section>
<section className="grid gap-6 lg:grid-cols-3" aria-label="Relatórios complementares">
<ChartCard description="Evolução de receita" title="Faturamento Mensal">
<RevenueChart data={revenueData} />
</ChartCard>
<ChartCard description="Distribuição de atendimentos" title="Convênios">
<InsuranceBreakdown insuranceData={insuranceData} />
</ChartCard>
<ChartCard description="Mais atendidos no período" title="Top Pacientes">
<div className="space-y-3 pt-1">
{topPatients.map((patient, index) => (
<div className="flex items-center gap-3" key={patient.name}>
<span className="w-4 text-xs font-bold text-[#a3a3a3]">{index + 1}.</span>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-[#f5f5f5]">{patient.name}</p>
<p className="mt-0.5 text-[10px] text-[#a3a3a3]">
{patient.visits} visitas R$ {patient.revenue.toLocaleString('pt-BR')}
</p>
</div>
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-[#303030]">
<div className="h-full rounded-full bg-[#3b82f6]" style={{ width: `${(patient.visits / 12) * 100}%` }} />
</div>
</div>
))}
</div>
</ChartCard>
</section>
<section className={`${cardClass} p-6`} aria-label="Performance por médico">
<h2 className="mb-4 text-sm font-bold text-[#f5f5f5]">Performance por Médico</h2>
<div className="overflow-x-auto rounded-sm border border-[#404040]">
<table className="w-full min-w-[760px] text-left text-sm">
<thead className="bg-[#171717] text-xs font-semibold uppercase tracking-[0.02em] text-[#b8b8b8]">
<tr>
<th className="px-4 py-3">Profissional</th>
<th className="px-4 py-3">Consultas</th>
<th className="px-4 py-3">No-Show</th>
<th className="px-4 py-3">Taxa No-Show</th>
<th className="px-4 py-3">Satisfação</th>
</tr>
</thead>
<tbody className="divide-y divide-[#404040] bg-[#262626]">
{doctorPerformance.map((doctor) => {
const noShowRate = (doctor.noShow / doctor.consultas) * 100
return (
<tr className="transition hover:bg-[#303030]" key={doctor.name}>
<td className="px-4 py-3 font-semibold text-[#f5f5f5]">{doctor.name}</td>
<td className="px-4 py-3 text-[#e5e5e5]">{doctor.consultas}</td>
<td className="px-4 py-3 text-[#b8b8b8]">{doctor.noShow}</td>
<td className="px-4 py-3">
<span className={`text-xs font-semibold ${rateClass(noShowRate)}`}>{noShowRate.toFixed(1)}%</span>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center gap-1 text-sm font-semibold text-[#f5f5f5]">
<span className="text-amber-400"></span>
{doctor.satisfacao}
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</section>
</div>
)
}
function KpiCard({ kpi }) {
return (
<article className={`${cardClass} p-5`}>
<div className="flex items-center justify-between gap-4">
<p className="text-xs font-medium text-[#a3a3a3]">{kpi.label}</p>
<AnalyticsIcon className="size-4 text-[#a3a3a3]" name={kpi.icon} />
</div>
<p className="mt-2 text-2xl font-bold leading-none text-[#f5f5f5]">{kpi.value}</p>
<span className="mt-2 flex items-center gap-1 text-xs font-semibold text-emerald-500">
<AnalyticsIcon className="size-3.5" name={kpi.up ? 'arrow-up' : 'arrow-down'} />
{kpi.change} vs período anterior
</span>
</article>
)
}
function ChartCard({ children, description, title }) {
return (
<article className={`${cardClass} p-6`}>
<h2 className="text-sm font-bold text-[#f5f5f5]">{title}</h2>
<p className="mt-1 text-xs text-[#a3a3a3]">{description}</p>
<div className="mt-4">{children}</div>
</article>
)
}
function AreaMetricChart({ data }) {
const points = getLinePoints(data.map((item) => item.taxa), 0, 24)
const metaPoints = getLinePoints(data.map((item) => item.meta), 0, 24)
const area = `${points} 600,260 42,260`
return (
<svg className="h-[250px] w-full overflow-visible" role="img" viewBox="0 0 640 300">
<ChartGrid labels={[24, 18, 12, 6, 0]} />
<polygon fill="#3b82f6" opacity="0.12" points={area} />
<polyline fill="none" points={metaPoints} stroke="#64748b" strokeDasharray="6 8" strokeWidth="2" />
<polyline fill="none" points={points} stroke="#3b82f6" strokeLinecap="round" strokeLinejoin="round" strokeWidth="4" />
{data.map((item, index) => (
<text className="fill-[#94a3b8] text-[13px]" key={item.month} x={42 + index * 111.6} y="285" textAnchor="middle">
{item.month}
</text>
))}
</svg>
)
}
function GroupedBarChart({ data }) {
return (
<svg className="h-[250px] w-full overflow-visible" role="img" viewBox="0 0 640 300">
<ChartGrid labels={[600, 450, 300, 150, 0]} />
{data.map((item, index) => {
const x = 58 + index * 94
const totalHeight = (item.total / 600) * 220
const doneHeight = (item.realizadas / 600) * 220
return (
<g key={item.month}>
<rect fill="#475569" height={totalHeight} rx="5" width="32" x={x} y={260 - totalHeight} />
<rect fill="#3b82f6" height={doneHeight} rx="5" width="32" x={x + 38} y={260 - doneHeight} />
<text className="fill-[#94a3b8] text-[13px]" textAnchor="middle" x={x + 35} y="285">
{item.month}
</text>
</g>
)
})}
</svg>
)
}
function RevenueChart({ data }) {
const points = getLinePoints(
data.map((item) => item.valor),
30000,
60000,
{ left: 32, top: 18, width: 270, height: 160 },
)
return (
<svg className="h-[200px] w-full overflow-visible" role="img" viewBox="0 0 340 220">
{[0, 1, 2, 3].map((line) => (
<line key={line} stroke="#1e3a5f" strokeDasharray="3 5" x1="32" x2="320" y1={20 + line * 50} y2={20 + line * 50} />
))}
<polyline fill="none" points={points} stroke="#10b981" strokeLinecap="round" strokeLinejoin="round" strokeWidth="4" />
{points.split(' ').map((point, index) => {
const [x, y] = point.split(',').map(Number)
return <circle cx={x} cy={y} fill="#10b981" key={point} r={4 + (index === data.length - 1 ? 1 : 0)} />
})}
{data.map((item, index) => (
<text className="fill-[#94a3b8] text-[11px]" key={item.month} textAnchor="middle" x={32 + index * 54} y="205">
{item.month}
</text>
))}
</svg>
)
}
function InsuranceBreakdown({ insuranceData }) {
const radius = 42
const circumference = 2 * Math.PI * radius
const segments = insuranceData.reduce((items, item) => {
const dash = (item.value / 100) * circumference
const previous = items.at(-1)
const offset = previous ? previous.offset + previous.dash + 4 : 0
return [...items, { ...item, dash, offset }]
}, [])
return (
<div>
<div className="flex justify-center">
<svg className="h-[160px] w-[160px]" viewBox="0 0 120 120">
<circle cx="60" cy="60" fill="none" r={radius} stroke="#303030" strokeWidth="18" />
{segments.map((item) => (
<circle
cx="60"
cy="60"
fill="none"
key={item.name}
r={radius}
stroke={item.color}
strokeDasharray={`${item.dash} ${circumference - item.dash}`}
strokeDashoffset={-item.offset}
strokeLinecap="round"
strokeWidth="18"
transform="rotate(-90 60 60)"
/>
))}
<circle cx="60" cy="60" fill="#262626" r="25" />
</svg>
</div>
<div className="mt-2 space-y-1.5">
{insuranceData.map((item) => (
<div className="flex items-center justify-between text-xs" key={item.name}>
<span className="flex items-center gap-2 text-[#e5e5e5]">
<span className="size-2 rounded-full" style={{ backgroundColor: item.color }} />
{item.name}
</span>
<span className="text-[#a3a3a3]">{item.value}%</span>
</div>
))}
</div>
</div>
)
}
function ChartGrid({ labels }) {
return (
<>
{labels.map((label, index) => {
const y = 20 + index * 60
return (
<g key={label}>
<line stroke="#1e3a5f" strokeDasharray="3 5" x1="42" x2="600" y1={y} y2={y} />
<text className="fill-[#94a3b8] text-[13px]" textAnchor="end" x="24" y={y + 4}>
{label}
</text>
</g>
)
})}
</>
)
}
function getLinePoints(values, min, max, box = { left: 42, top: 20, width: 558, height: 240 }) {
return values
.map((value, index) => {
const x = box.left + (index / Math.max(values.length - 1, 1)) * box.width
const y = box.top + ((max - value) / (max - min)) * box.height
return `${x.toFixed(1)},${y.toFixed(1)}`
})
.join(' ')
}
function rateClass(rate) {
if (rate > 15) {
return 'text-red-400'
}
if (rate > 10) {
return 'text-amber-400'
}
return 'text-emerald-400'
}
function AnalyticsIcon({ className = 'size-4', name }) {
const common = {
className,
fill: 'none',
stroke: 'currentColor',
strokeLinecap: 'round',
strokeLinejoin: 'round',
strokeWidth: 1.9,
viewBox: '0 0 24 24',
}
if (name === 'calendar') {
return (
<svg {...common}>
<path d="M8 3v3M16 3v3M4 9h16M5 5h14a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z" />
</svg>
)
}
if (name === 'activity') {
return (
<svg {...common}>
<path d="M3 12h4l2-6 4 12 2-6h6" />
</svg>
)
}
if (name === 'dollar') {
return (
<svg {...common}>
<path d="M12 2v20M17 6.5C15.8 5.4 14.2 5 12.5 5 9.9 5 8 6.2 8 8s1.6 2.7 4.2 3.3C15 12 17 13 17 15.5S14.8 19 12 19c-2 0-3.8-.6-5-1.8" />
</svg>
)
}
if (name === 'users') {
return (
<svg {...common}>
<path d="M16 19a4 4 0 0 0-8 0M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM20 19a3 3 0 0 0-3-3M4 19a3 3 0 0 1 3-3" />
</svg>
)
}
if (name === 'arrow-up') {
return (
<svg {...common}>
<path d="M7 17 17 7M8 7h9v9" />
</svg>
)
}
if (name === 'arrow-down') {
return (
<svg {...common}>
<path d="M7 7 17 17M17 8v9H8" />
</svg>
)
}
return (
<svg {...common}>
<path d="M4 17 9 11l4 4 7-9" />
<path d="M4 20h16" />
</svg>
)
}

344
src/pages/AuthPages.jsx Normal file
View File

@@ -0,0 +1,344 @@
import { useState } from 'react'
import { BrandLogo } from '../components/Brand.jsx'
import loginClinicImage from '../assets/figma/login-clinic.png'
export function LoginPage({ navigate }) {
const [form, setForm] = useState({
email: '',
password: '',
})
const [showPassword, setShowPassword] = useState(false)
function updateField(field, value) {
setForm((current) => ({ ...current, [field]: value }))
}
function handleSubmit(event) {
event.preventDefault()
navigate('/inicio')
}
return (
<main className="min-h-screen bg-[#0a1628] text-white">
<div className="grid min-h-screen lg:grid-cols-2">
<section className="relative hidden min-h-screen overflow-hidden lg:block">
<img
alt=""
className="absolute inset-0 h-full w-full object-cover"
src={loginClinicImage}
/>
<div
aria-hidden="true"
className="absolute inset-0"
style={{
background:
'linear-gradient(126.72deg, rgba(10, 22, 40, 0.9) 0%, rgba(10, 22, 40, 0.6) 50%, rgba(59, 130, 246, 0.3) 100%)',
}}
/>
<div className="relative flex min-h-screen flex-col justify-between px-[43px] py-[43px] xl:px-12 xl:py-12">
<LoginLogo />
<div className="max-w-[488px] pb-0">
<h1 className="text-[32px] font-bold leading-[40px] tracking-[-0.02em] xl:text-4xl xl:leading-[45px]">
Gestão clínica
<br />
<span className="text-[#3b82f6]">inteligente</span> com IA
<br />
preditiva.
</h1>
<p className="mt-5 max-w-[352px] text-sm leading-[23px] text-white/60 xl:text-base xl:leading-[26px]">
Reduza o absenteísmo, organize sua agenda e melhore a experiência dos seus pacientes.
</p>
<dl className="mt-[38px] flex flex-wrap gap-8">
<LoginMetric label="Acurácia IA" value="87%" />
<LoginMetric label="Absenteísmo" value="↓42%" />
<LoginMetric label="Clínicas" value="+2.8k" />
</dl>
</div>
</div>
</section>
<section className="relative flex min-h-screen items-center justify-center px-6 py-12 sm:px-10 lg:px-[60px] xl:px-[68px]">
<div className="w-full max-w-[448px] lg:translate-y-3">
<div className="mb-12 lg:hidden">
<LoginLogo />
</div>
<div>
<h2 className="text-[30px] font-bold leading-9 text-white">Entrar</h2>
<p className="mt-1 text-sm leading-5 text-white/40">
Bem-vindo(a) de volta! Acesse sua conta.
</p>
</div>
<form className="mt-8 grid gap-5" onSubmit={handleSubmit}>
<LoginField htmlFor="login-email" label="E-mail">
<input
autoComplete="email"
className="h-11 w-full rounded-[6px] border border-white/10 bg-white/[0.05] px-4 text-sm text-white outline-none transition placeholder:text-white/30 focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20"
id="login-email"
onChange={(event) => updateField('email', event.target.value)}
placeholder="seu@email.com"
type="email"
value={form.email}
/>
</LoginField>
<LoginField
action={
<button
className="text-xs font-medium leading-4 text-[#3b82f6] transition hover:text-[#66a3ff]"
onClick={() => navigate('/recuperar-senha')}
type="button"
>
Esqueceu a senha?
</button>
}
htmlFor="login-password"
label="Senha"
>
<div className="relative">
<input
autoComplete="current-password"
className="h-11 w-full rounded-[6px] border border-white/10 bg-white/[0.05] py-2 pl-4 pr-11 text-sm text-white outline-none transition placeholder:text-white/30 focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20"
id="login-password"
onChange={(event) => updateField('password', event.target.value)}
placeholder="••••••••"
type={showPassword ? 'text' : 'password'}
value={form.password}
/>
<button
aria-label={showPassword ? 'Ocultar senha' : 'Mostrar senha'}
className="absolute right-3 top-1/2 grid size-5 -translate-y-1/2 place-items-center text-white/30 transition hover:text-white/60"
onClick={() => setShowPassword((current) => !current)}
type="button"
>
<EyeIcon />
</button>
</div>
</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]"
type="submit"
>
Entrar
</button>
</form>
</div>
<button
className="absolute bottom-4 right-4 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={() => {
setForm({
email: 'recepcao@mediconnect.com',
password: 'demo123',
})
}}
title="Preencher credenciais mockadas"
type="button"
>
dev · credenciais
<span aria-hidden="true" className="text-[9px]">
^
</span>
</button>
</section>
</div>
</main>
)
}
export function RegisterPage({ navigate }) {
const [role, setRole] = useState('Clinica')
return (
<AuthLayout
description="Crie um acesso mockado para navegar pelo ambiente da clínica."
title="Criar acesso"
>
<form
className="mt-8 grid gap-5"
onSubmit={(event) => {
event.preventDefault()
navigate('/inicio')
}}
>
<AuthField label="Nome da organização">
<input className={authInputClass} defaultValue="Clínica Boa Vista" />
</AuthField>
<AuthField label="Responsável">
<input className={authInputClass} defaultValue="Marina Lopes" />
</AuthField>
<AuthField label="Tipo de conta">
<div className="grid grid-cols-2 gap-2">
{['Clinica', 'Profissional'].map((option) => (
<button
className={`h-11 rounded-[6px] border px-3 text-sm font-semibold transition ${
role === option
? 'border-[#3b82f6] bg-[#3b82f6]/15 text-[#3b82f6]'
: 'border-white/10 bg-white/[0.05] text-white/50 hover:text-white'
}`}
key={option}
onClick={() => setRole(option)}
type="button"
>
{option}
</button>
))}
</div>
</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">
Continuar
</button>
</form>
<button className="mt-5 text-sm font-semibold text-[#3b82f6]" onClick={() => navigate('/login')} type="button">
Voltar para login
</button>
</AuthLayout>
)
}
export function ForgotPasswordPage({ navigate }) {
const [sent, setSent] = useState(false)
return (
<AuthLayout
description="Informe o e-mail cadastrado para receber um link mockado."
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.
</div>
) : (
<form
className="mt-8 grid gap-5"
onSubmit={(event) => {
event.preventDefault()
setSent(true)
}}
>
<AuthField label="E-mail cadastrado">
<input autoComplete="email" className={authInputClass} defaultValue="recepcao@mediconnect.com" 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>
</form>
)}
<button className="mt-5 text-sm font-semibold text-[#3b82f6]" onClick={() => navigate('/login')} type="button">
Voltar para login
</button>
</AuthLayout>
)
}
function AuthLayout({ children, description, title }) {
return (
<main className="min-h-screen bg-[#0a1628] text-white">
<div className="grid min-h-screen lg:grid-cols-2">
<section className="relative hidden min-h-screen overflow-hidden lg:block">
<img alt="" className="absolute inset-0 h-full w-full object-cover" src={loginClinicImage} />
<div
aria-hidden="true"
className="absolute inset-0"
style={{
background:
'linear-gradient(126.72deg, rgba(10, 22, 40, 0.9) 0%, rgba(10, 22, 40, 0.6) 50%, rgba(59, 130, 246, 0.3) 100%)',
}}
/>
<div className="relative flex min-h-screen flex-col justify-between px-[43px] py-[43px] xl:px-12 xl:py-12">
<LoginLogo />
<div className="max-w-[488px]">
<h1 className="text-[32px] font-bold leading-[40px] tracking-[-0.02em] xl:text-4xl xl:leading-[45px]">
Cuidado conectado
<br />
para equipes de
<br />
<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.
</p>
</div>
</div>
</section>
<section className="flex min-h-screen items-center justify-center px-6 py-12 sm:px-10 lg:px-[60px] xl:px-[68px]">
<div className="w-full max-w-[448px]">
<div className="mb-12 lg:hidden">
<LoginLogo />
</div>
<h2 className="text-[30px] font-bold leading-9 text-white">{title}</h2>
<p className="mt-1 text-sm leading-5 text-white/40">{description}</p>
{children}
</div>
</section>
</div>
</main>
)
}
const authInputClass =
'h-11 w-full rounded-[6px] border border-white/10 bg-white/[0.05] px-4 text-sm text-white outline-none transition placeholder:text-white/30 focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
function AuthField({ children, label }) {
return (
<label className="grid gap-1.5 text-xs font-medium leading-4 text-white/50">
<span>{label}</span>
{children}
</label>
)
}
function LoginField({ action, children, htmlFor, label }) {
return (
<div className="grid gap-1.5">
<span className="flex min-h-4 items-center justify-between gap-4 text-xs font-medium leading-4 text-white/50">
<label htmlFor={htmlFor}>{label}</label>
{action}
</span>
{children}
</div>
)
}
function LoginLogo() {
return (
<BrandLogo />
)
}
function LoginMetric({ label, value }) {
return (
<div>
<dt className="text-[21px] font-bold leading-7 text-[#3b82f6] xl:text-2xl xl:leading-8">{value}</dt>
<dd className="mt-0.5 text-[11px] leading-4 text-white/50 xl:text-xs">{label}</dd>
</div>
)
}
function EyeIcon() {
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16">
<path
d="M1.375 8.23c-.06-.16-.06-.34 0-.5a7.16 7.16 0 0 1 13.25 0c.06.16.06.34 0 .5a7.16 7.16 0 0 1-13.25 0Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.33"
/>
<path
d="M8 10a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.33"
/>
</svg>
)
}

279
src/pages/HomePage.jsx Normal file
View File

@@ -0,0 +1,279 @@
import loginClinicImage from '../assets/figma/login-clinic.png'
import { homeRepository } from '../repositories/homeRepository.js'
export function HomePage({ navigate }) {
const { appointmentsToday, metrics, reportCards } = homeRepository.getDashboardOverview()
return (
<div className="mx-auto flex max-w-[1180px] flex-col gap-8 text-[#e5e5e5]">
<section className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-[32px] font-bold leading-8 tracking-[-0.02em] text-[#e5e5e5]">
Visão Geral da Clínica
</h1>
<p className="mt-2 text-sm leading-5 text-[#a3a3a3]">
Bem-vindo, Dr. Henrique. Aqui está o resumo da sua clínica hoje.
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
className="h-9 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#303030]"
onClick={() => navigate('/relatorios')}
type="button"
>
Exportar
</button>
<button
className="h-9 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.16)] transition hover:bg-[#3478ed]"
onClick={() => navigate('/agenda')}
type="button"
>
+ Novo
</button>
</div>
</section>
<section className="grid gap-6 lg:grid-cols-3">
{metrics.map((metric) => (
<MetricCard key={metric.label} metric={metric} />
))}
</section>
<section className="grid gap-6 xl:grid-cols-[1.7fr_0.9fr]">
<div className="rounded-2xl border border-[#3b82f6] bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)]">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="grid size-12 shrink-0 place-items-center rounded-md bg-[#3b82f6] text-white">
<SparkLineIcon className="size-6" />
</div>
<div>
<h2 className="text-base font-bold leading-6 text-[#3b82f6]">Insights de IA</h2>
<p className="mt-1 text-sm font-medium leading-5 text-[#a3a3a3]">
Evolução de absenteísmo e risco da semana
</p>
</div>
</div>
<span className="grid size-8 place-items-center rounded-full bg-[#2a2a2a] text-[#a3a3a3]">
<ChevronRightIcon className="size-5" />
</span>
</div>
<div className="mt-6 h-[260px] rounded-lg bg-[#1f1f1f] px-4 py-5">
<LineChart />
</div>
</div>
<div className="grid gap-4">
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
<h2 className="text-base font-bold text-[#e5e5e5]">Pacientes de hoje</h2>
<div className="mt-4 grid gap-3">
{appointmentsToday.map((item) => (
<button
className="flex items-center justify-between gap-4 rounded-md bg-[#2a2a2a] px-4 py-3 text-left transition hover:bg-[#303030]"
key={`${item.time}-${item.name}`}
onClick={() => navigate(`/pacientes/${item.patientId}`)}
type="button"
>
<span>
<span className="block text-sm font-semibold text-[#e5e5e5]">{item.name}</span>
<span className="mt-1 block text-xs text-[#a3a3a3]">{item.status}</span>
</span>
<span className="text-sm font-bold text-[#3b82f6]">{item.time}</span>
</button>
))}
</div>
</div>
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
<h2 className="text-base font-bold text-[#e5e5e5]">Alerta preditivo</h2>
<p className="mt-3 text-sm leading-6 text-[#a3a3a3]">
3 pacientes apresentam risco de falta. Recomenda-se confirmar presença antes das 16h.
</p>
<button
className="mt-5 h-9 rounded-sm border border-[#404040] bg-[#303030] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:border-[#3b82f6] hover:text-[#3b82f6]"
onClick={() => navigate('/mensagens')}
type="button"
>
Abrir comunicação
</button>
</div>
</div>
</section>
<section className="grid gap-4" id="relatorios">
<h2 className="text-base font-bold text-[#e5e5e5]">Relatórios e Análises</h2>
<div className="grid gap-4 lg:grid-cols-2">
<button
className="relative min-h-[164px] overflow-hidden rounded-2xl border border-[#3b82f6] bg-[#262626] p-5 text-left shadow-[0_1px_3px_rgba(0,0,0,0.2)]"
onClick={() => navigate('/relatorios')}
type="button"
>
<img alt="" className="absolute inset-0 h-full w-full object-cover opacity-40" src={loginClinicImage} />
<span className="absolute inset-0 bg-[#0a1628]/60" aria-hidden="true" />
<span className="relative flex items-start gap-4">
<span className="grid size-12 place-items-center rounded-md bg-[#3b82f6] text-white">
<SparkLineIcon className="size-6" />
</span>
<span>
<span className="block text-base font-bold text-[#3b82f6]">Evolução do Absenteísmo</span>
<span className="mt-1 block text-sm font-medium text-[#d4d4d4]">
Taxa de faltas e metas da semana
</span>
</span>
</span>
</button>
<div className="grid gap-4">
{reportCards.slice(0, 2).map((card) => (
<ReportAction key={card.title} card={card} navigate={navigate} />
))}
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{reportCards.slice(2).map((card) => (
<ReportAction key={card.title} card={card} navigate={navigate} />
))}
</div>
</section>
</div>
)
}
function MetricCard({ metric }) {
return (
<article
className={`rounded-2xl border bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)] ${
metric.tone === 'violet' ? 'border-[#5b4b75]' : 'border-[#404040]'
}`}
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium leading-5 text-[#a3a3a3]">{metric.label}</p>
<p className="mt-3 text-[32px] font-bold leading-8 text-[#e5e5e5]">{metric.value}</p>
</div>
<span className={`grid size-9 place-items-center rounded-md ${metricTone(metric.tone)}`}>
<SparkLineIcon className="size-5" />
</span>
</div>
<p className={`mt-4 text-sm font-semibold ${metric.change.startsWith('-') ? 'text-[#10b981]' : 'text-[#10b981]'}`}>
{metric.change}
</p>
</article>
)
}
function ReportAction({ card, navigate }) {
return (
<button
className="flex min-h-[90px] items-center justify-between gap-4 rounded-2xl border border-[#404040] bg-[#262626] px-5 py-4 text-left shadow-[0_1px_3px_rgba(0,0,0,0.2)] transition hover:border-[#3b82f6]"
onClick={() => navigate(card.icon === 'calendar' ? '/agenda' : card.icon === 'users' ? '/pacientes' : '/relatorios')}
type="button"
>
<span className="flex items-center gap-4">
<span className="grid size-12 shrink-0 place-items-center rounded-md bg-[#2a2a2a] text-[#3b82f6]">
<ReportIcon className="size-6" name={card.icon} />
</span>
<span>
<span className="block text-base font-bold leading-6 text-[#e5e5e5]">{card.title}</span>
<span className="mt-0.5 block text-sm font-medium leading-5 text-[#a3a3a3]">{card.description}</span>
</span>
</span>
<span className="grid size-8 shrink-0 place-items-center rounded-full bg-[#2a2a2a] text-[#a3a3a3]">
<ChevronRightIcon className="size-5" />
</span>
</button>
)
}
function LineChart() {
return (
<svg aria-label="Grafico mockado de absenteismo" className="h-full w-full" role="img" viewBox="0 0 732 260">
<defs>
<linearGradient id="home-chart-fill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.24" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0" />
</linearGradient>
</defs>
{[48, 112, 176, 232].map((y) => (
<line key={y} stroke="#1d4ed8" strokeDasharray="3 5" strokeOpacity="0.45" x1="40" x2="710" y1={y} y2={y} />
))}
<text fill="#a3a3a3" fontSize="12" x="22" y="52">18</text>
<text fill="#a3a3a3" fontSize="12" x="22" y="116">12</text>
<text fill="#a3a3a3" fontSize="12" x="28" y="180">6</text>
<path
d="M40 128 C120 78 164 112 220 108 C290 104 302 34 360 34 C425 34 418 134 482 156 C560 182 610 154 710 188"
fill="none"
stroke="#3b82f6"
strokeLinecap="round"
strokeWidth="3"
/>
<path
d="M40 128 C120 78 164 112 220 108 C290 104 302 34 360 34 C425 34 418 134 482 156 C560 182 610 154 710 188 L710 244 L40 244 Z"
fill="url(#home-chart-fill)"
/>
</svg>
)
}
function metricTone(tone) {
if (tone === 'violet') {
return 'bg-[#322b3d] text-[#8b5cf6]'
}
if (tone === 'green') {
return 'bg-[#123328] text-[#10b981]'
}
return 'bg-[#1d2f4f] text-[#3b82f6]'
}
function ReportIcon({ className = 'size-6', name }) {
if (name === 'users') {
return (
<svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24">
<path d="M16 19a4 4 0 0 0-8 0M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM20 19a3 3 0 0 0-3-3M4 19a3 3 0 0 1 3-3" />
</svg>
)
}
if (name === 'building') {
return (
<svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24">
<path d="M4 21V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v16M8 8h1M8 12h1M8 16h1M15 8h1M15 12h1M15 16h1M3 21h18" />
</svg>
)
}
if (name === 'brand') {
return (
<svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24">
<path d="M6 4H5a2 2 0 0 0-2 2v4a6 6 0 0 0 12 0V6a2 2 0 0 0-2-2h-1M3 9a6 6 0 0 0 12 0V4M18 11a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z" />
</svg>
)
}
return (
<svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24">
<path d="M8 3v3M16 3v3M4 9h16M5 5h14a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z" />
</svg>
)
}
function SparkLineIcon({ className = 'size-6' }) {
return (
<svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24">
<path d="M4 17 9 11l4 4 7-9" />
<path d="M15 6h5v5" />
</svg>
)
}
function ChevronRightIcon({ className = 'size-5' }) {
return (
<svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24">
<path d="m9 18 6-6-6-6" />
</svg>
)
}

View File

@@ -0,0 +1,436 @@
import { useMemo, useState } from 'react'
import { medicalRecordRepository } from '../repositories/medicalRecordRepository.js'
const inputClass =
'h-10 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]'
const labelClass = 'mb-1 block text-xs font-medium text-[#e5e5e5]'
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 [search, setSearch] = useState('')
const [filterType, setFilterType] = useState('')
const [editorOpen, setEditorOpen] = useState(false)
const filteredRecords = useMemo(() => {
return records.filter((record) => {
const matchesSearch = [record.patient, record.cid, record.doctor]
.join(' ')
.toLowerCase()
.includes(search.toLowerCase())
const matchesType = !filterType || record.type === filterType
return matchesSearch && matchesType
})
}, [filterType, records, search])
function handleCreateRecord(record) {
setRecords((currentRecords) => [record, ...currentRecords])
setEditorOpen(false)
}
return (
<div className="mx-auto max-w-7xl space-y-6 text-[#e5e5e5]">
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div>
<h1 className="text-2xl font-bold tracking-tight text-[#e5e5e5]">Prontuário Médico</h1>
<p className="mt-1 text-sm text-[#a3a3a3]">Registro de consultas, diagnósticos e evolução</p>
</div>
<button
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-[#3b82f6] px-4 text-sm font-medium text-white transition hover:bg-[#2563eb]"
onClick={() => setEditorOpen(true)}
type="button"
>
<RecordIcon name="plus" />
Nova Consulta
</button>
</div>
<section className={`${cardClass} p-4`}>
<div className="flex flex-col gap-4 md:flex-row">
<div className="relative flex-1">
<RecordIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#a3a3a3]" name="search" />
<input
className="h-10 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] py-2 pl-10 pr-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]"
onChange={(event) => setSearch(event.target.value)}
placeholder="Buscar por paciente ou CID..."
value={search}
/>
</div>
<div className="relative min-w-48">
<select
className="h-10 w-full appearance-none rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 pr-9 text-sm font-semibold text-[#e5e5e5] outline-none transition focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]"
onChange={(event) => setFilterType(event.target.value)}
value={filterType}
>
<option value="">Todos os Tipos</option>
{recordTypes.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
<RecordIcon className="pointer-events-none absolute right-3 top-3 size-4 text-[#a3a3a3]" name="chevron-down" />
</div>
</div>
</section>
<div className="space-y-3">
{filteredRecords.length ? (
filteredRecords.map((record) => <RecordCard key={record.id} record={record} />)
) : (
<div className={`${cardClass} p-8 text-center text-sm text-[#a3a3a3]`}>
Nenhum registro encontrado nos dados locais.
</div>
)}
</div>
{editorOpen ? (
<RecordEditorModal
onClose={() => setEditorOpen(false)}
onSave={handleCreateRecord}
recordTypes={recordTypes}
/>
) : null}
</div>
)
}
function RecordCard({ record }) {
const statusClass =
record.status === 'completo'
? 'bg-emerald-500/20 text-emerald-400'
: 'bg-amber-500/20 text-amber-400'
return (
<article className={`${cardClass} cursor-pointer p-5 transition hover:border-[#3b82f6]/30`}>
<div className="flex flex-col justify-between gap-3 md:flex-row md:items-center">
<div className="flex items-start gap-4">
<div className="grid size-10 shrink-0 place-items-center rounded-full bg-[#3b82f6]/10 text-[#3b82f6]">
<RecordIcon className="size-5" name="file" />
</div>
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-sm font-bold text-[#e5e5e5]">{record.patient}</h2>
<span className={`rounded px-2 py-0.5 text-[10px] font-bold ${statusClass}`}>
{record.status === 'completo' ? 'Completo' : 'Rascunho'}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-[#a3a3a3]">
<span className="flex items-center gap-1">
<RecordIcon className="size-3" name="calendar" />
{record.date}
</span>
<span className="flex items-center gap-1">
<RecordIcon className="size-3" name="user" />
{record.doctor}
</span>
<span className="flex items-center gap-1">
<RecordIcon className="size-3" name="activity" />
{record.type}
</span>
</div>
<p className="mt-2 inline-block rounded bg-[#1a1a1a] px-2 py-1 text-xs text-[#a3a3a3]">{record.cid}</p>
<p className="mt-2 text-xs leading-5 text-[#a3a3a3]">{record.summary}</p>
</div>
</div>
<div className="ml-14 flex items-center gap-2 md:ml-0">
<IconButton label="Visualizar" name="eye" />
<IconButton label="Editar" name="edit" />
<IconButton label="Imprimir" name="printer" />
</div>
</div>
</article>
)
}
function IconButton({ label, name }) {
return (
<button
aria-label={label}
className="grid size-9 place-items-center rounded-lg border border-[#404040] bg-[#1a1a1a] text-[#a3a3a3] transition hover:bg-[#2a2a2a] hover:text-[#e5e5e5]"
title={label}
type="button"
>
<RecordIcon className="size-4" name={name} />
</button>
)
}
function RecordEditorModal({ onClose, onSave, recordTypes }) {
const [formData, setFormData] = useState({
patient: '',
date: '',
type: 'Primeira Consulta',
cid: '',
anamnesis: '',
physicalExam: '',
conduct: '',
prescriptions: '',
returnDate: '',
status: 'completo',
})
function updateField(event) {
const { name, value } = event.target
setFormData((currentData) => ({ ...currentData, [name]: value }))
}
function handleSubmit(event) {
event.preventDefault()
const submitter = event.nativeEvent.submitter
const status = submitter?.value || formData.status
onSave({
id: `record-${Date.now()}`,
patient: formData.patient || 'Paciente sem nome',
date: formData.date ? formatDate(formData.date) : '07/04/2026',
doctor: 'Dr. Henrique Cardoso',
type: formData.type,
cid: formData.cid || 'CID nao informado',
status,
summary: formData.conduct || formData.anamnesis || 'Registro criado localmente para simulação.',
})
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<form
className="max-h-[90vh] w-full max-w-3xl overflow-y-auto rounded-2xl border border-[#404040] bg-[#262626] p-6 shadow-xl"
onClick={(event) => event.stopPropagation()}
onSubmit={handleSubmit}
>
<h2 className="mb-6 text-lg font-bold text-[#e5e5e5]">Novo Registro de Consulta</h2>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<DarkField label="Paciente">
<input
className={inputClass}
name="patient"
onChange={updateField}
placeholder="Buscar paciente..."
value={formData.patient}
/>
</DarkField>
<DarkField label="Data da Consulta">
<input
className={`${inputClass} [color-scheme:dark]`}
name="date"
onChange={updateField}
type="date"
value={formData.date}
/>
</DarkField>
</div>
<DarkField label="Anamnese">
<textarea
className={`${inputClass} min-h-24 py-2`}
name="anamnesis"
onChange={updateField}
placeholder="Queixa principal, história da doença atual..."
value={formData.anamnesis}
/>
</DarkField>
<DarkField label="Exame Físico">
<textarea
className={`${inputClass} min-h-24 py-2`}
name="physicalExam"
onChange={updateField}
placeholder="Achados do exame físico..."
value={formData.physicalExam}
/>
</DarkField>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<DarkField label="Hipóteses Diagnósticas (CID-10)">
<input
className={inputClass}
name="cid"
onChange={updateField}
placeholder="Ex: I10, E11.9..."
value={formData.cid}
/>
</DarkField>
<DarkField label="Tipo de Consulta">
<select className={inputClass} name="type" onChange={updateField} value={formData.type}>
{recordTypes.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</DarkField>
</div>
<DarkField label="Conduta Médica">
<textarea
className={`${inputClass} min-h-24 py-2`}
name="conduct"
onChange={updateField}
placeholder="Plano terapêutico, orientações..."
value={formData.conduct}
/>
</DarkField>
<DarkField label="Prescrições">
<textarea
className={`${inputClass} min-h-20 py-2`}
name="prescriptions"
onChange={updateField}
placeholder="Medicamentos, posologia..."
value={formData.prescriptions}
/>
</DarkField>
<DarkField label="Retorno Agendado">
<input
className={`${inputClass} [color-scheme:dark]`}
name="returnDate"
onChange={updateField}
type="date"
value={formData.returnDate}
/>
</DarkField>
</div>
<div className="mt-6 flex flex-wrap justify-end gap-3">
<button
className="rounded-lg border border-[#404040] bg-[#262626] px-4 py-2 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#333333]"
onClick={onClose}
type="button"
>
Cancelar
</button>
<button
className="rounded-lg border border-[#404040] bg-[#2a2a2a] px-4 py-2 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#333333]"
type="submit"
value="rascunho"
>
Salvar Rascunho
</button>
<button
className="rounded-lg bg-[#3b82f6] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#2563eb]"
type="submit"
value="completo"
>
Finalizar
</button>
</div>
</form>
</div>
)
}
function DarkField({ children, label }) {
return (
<label className="block">
<span className={labelClass}>{label}</span>
{children}
</label>
)
}
function formatDate(value) {
const [year, month, day] = value.split('-')
return `${day}/${month}/${year}`
}
function RecordIcon({ className = 'size-4', name }) {
const common = {
className,
fill: 'none',
stroke: 'currentColor',
strokeLinecap: 'round',
strokeLinejoin: 'round',
strokeWidth: 1.8,
viewBox: '0 0 24 24',
}
if (name === 'search') {
return (
<svg {...common}>
<path d="m21 21-4.3-4.3" />
<circle cx="11" cy="11" r="7" />
</svg>
)
}
if (name === 'plus') {
return (
<svg {...common}>
<path d="M12 5v14M5 12h14" />
</svg>
)
}
if (name === 'file') {
return (
<svg {...common}>
<path d="M7 3h7l4 4v14H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z" />
<path d="M14 3v5h5M9 13h6M9 17h6" />
</svg>
)
}
if (name === 'calendar') {
return (
<svg {...common}>
<path d="M8 3v3M16 3v3M4 9h16M5 5h14a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z" />
</svg>
)
}
if (name === 'user') {
return (
<svg {...common}>
<path d="M20 21a8 8 0 0 0-16 0M12 13a5 5 0 1 0 0-10 5 5 0 0 0 0 10Z" />
</svg>
)
}
if (name === 'activity') {
return (
<svg {...common}>
<path d="M3 12h4l2-5 4 10 2-5h6" />
</svg>
)
}
if (name === 'eye') {
return (
<svg {...common}>
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z" />
<circle cx="12" cy="12" r="3" />
</svg>
)
}
if (name === 'edit') {
return (
<svg {...common}>
<path d="m16 3 5 5L8 21H3v-5L16 3Z" />
</svg>
)
}
if (name === 'printer') {
return (
<svg {...common}>
<path d="M7 8V3h10v5M7 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>
)
}
return (
<svg {...common}>
<path d="m6 9 6 6 6-6" />
</svg>
)
}

690
src/pages/MessagesPage.jsx Normal file
View File

@@ -0,0 +1,690 @@
import { useMemo, useState } from 'react'
import { communicationRepository } from '../repositories/communicationRepository.js'
const channels = {
whatsapp: { label: 'WhatsApp', className: 'bg-emerald-500/20 text-emerald-400', icon: 'message' },
email: { label: 'E-mail', className: 'bg-blue-500/20 text-blue-400', icon: 'mail' },
sms: { label: 'SMS', className: 'bg-purple-500/20 text-purple-400', icon: 'phone' },
}
const statusConfig = {
entregue: { label: 'Entregue', className: 'text-emerald-400', icon: 'check' },
lida: { label: 'Lida', className: 'text-blue-400', icon: 'eye' },
falha: { label: 'Falha', className: 'text-red-400', icon: 'x-circle' },
pendente: { label: 'Pendente', className: 'text-amber-400', icon: 'clock' },
}
const emptyMessage = {
patient: '',
channel: 'whatsapp',
template: 'Lembrete 48h',
content: '',
}
const emptyTemplate = {
name: '',
channel: 'whatsapp',
category: 'Lembrete',
content: '',
}
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
const inputClass =
'h-10 w-full 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'
const textareaClass =
'min-h-28 w-full resize-y rounded-sm border border-[#404040] bg-[#171717] px-3 py-2 text-sm leading-6 text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
const labelClass = 'text-xs font-semibold uppercase tracking-[0.08em] text-[#a3a3a3]'
export function MessagesPage() {
const campaigns = communicationRepository.getCampaigns()
const [messages, setMessages] = useState(() => communicationRepository.getInitialMessages())
const [templates, setTemplates] = useState(() => communicationRepository.getInitialTemplates())
const [activeTab, setActiveTab] = useState('historico')
const [channelFilter, setChannelFilter] = useState('todos')
const [search, setSearch] = useState('')
const [composerOpen, setComposerOpen] = useState(false)
const [templateEditorOpen, setTemplateEditorOpen] = useState(false)
const [composer, setComposer] = useState(emptyMessage)
const [templateDraft, setTemplateDraft] = useState(emptyTemplate)
const filteredMessages = useMemo(
() =>
messages.filter((message) => {
const matchesChannel = channelFilter === 'todos' || message.channel === channelFilter
const query = search.trim().toLowerCase()
const matchesSearch =
!query ||
[message.patient, message.template, channels[message.channel].label]
.join(' ')
.toLowerCase()
.includes(query)
return matchesChannel && matchesSearch
}),
[channelFilter, messages, search],
)
const stats = useMemo(
() => ({
total: messages.length,
delivered: messages.filter((message) => message.status === 'entregue' || message.status === 'lida').length,
read: messages.filter((message) => message.status === 'lida').length,
failed: messages.filter((message) => message.status === 'falha').length,
}),
[messages],
)
function openTemplate(template) {
setComposer({
patient: '',
channel: template.channel,
template: template.name,
content: template.content,
})
setComposerOpen(true)
}
function submitMessage(event) {
event.preventDefault()
if (!composer.patient.trim()) {
return
}
setMessages((current) => [
{
id: `local-${Date.now()}`,
patient: composer.patient.trim(),
channel: composer.channel,
template: composer.template.trim() || 'Mensagem avulsa',
sentAt: 'Agora',
status: 'pendente',
response: '',
},
...current,
])
setComposer(emptyMessage)
setComposerOpen(false)
setActiveTab('historico')
}
function submitTemplate(event) {
event.preventDefault()
if (!templateDraft.name.trim() || !templateDraft.content.trim()) {
return
}
setTemplates((current) => [
{
id: `template-${Date.now()}`,
name: templateDraft.name.trim(),
channel: templateDraft.channel,
content: templateDraft.content.trim(),
category: templateDraft.category.trim() || 'Personalizado',
},
...current,
])
setTemplateDraft(emptyTemplate)
setTemplateEditorOpen(false)
}
return (
<div className="mx-auto max-w-7xl space-y-6">
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div>
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Comunicação</h1>
<p className="mt-1 text-sm text-[#b8b8b8]">WhatsApp, E-mail e SMS - histórico e campanhas</p>
</div>
<div className="flex flex-wrap gap-3">
<button
className="inline-flex h-12 items-center gap-2 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
onClick={() => setActiveTab('campanha')}
type="button"
>
<CommIcon className="size-4" name="send" />
Envio em Massa
</button>
<button
className="inline-flex h-12 items-center gap-2 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
onClick={() => setComposerOpen(true)}
type="button"
>
<CommIcon className="size-4" name="plus" />
Nova Mensagem
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard label="Total Enviadas" value={stats.total} />
<StatCard label="Entregues" value={stats.delivered} valueClassName="text-emerald-400" />
<StatCard label="Lidas" value={stats.read} valueClassName="text-[#3b82f6]" />
<StatCard label="Falhas" value={stats.failed} valueClassName="text-red-400" />
</div>
<div className="flex gap-4 border-b border-[#404040]">
{[
['historico', 'Histórico'],
['templates', 'Templates'],
['campanha', 'Campanhas'],
].map(([key, label]) => (
<button
className={`border-b-2 px-2 pb-3 text-sm font-semibold transition ${
activeTab === key
? 'border-[#3b82f6] text-[#3b82f6]'
: 'border-transparent text-[#b8b8b8] hover:text-[#e5e5e5]'
}`}
key={key}
onClick={() => setActiveTab(key)}
type="button"
>
{label}
</button>
))}
</div>
{activeTab === 'historico' ? (
<section className={`${cardClass} p-5 md:p-6`} aria-label="Histórico de comunicação">
<div className="mb-6 flex flex-col gap-3 md:flex-row">
<label className="relative flex-1">
<span className="sr-only">Buscar comunicação</span>
<CommIcon
className="pointer-events-none absolute left-4 top-1/2 size-4 -translate-y-1/2 text-[#a3a3a3]"
name="search"
/>
<input
className={`${inputClass} h-12 pl-12`}
onChange={(event) => setSearch(event.target.value)}
placeholder="Buscar paciente..."
type="search"
value={search}
/>
</label>
<div className="flex flex-wrap gap-2">
{[
['todos', 'Todos'],
['whatsapp', 'Whatsapp'],
['email', 'E-mail'],
['sms', 'Sms'],
].map(([key, label]) => (
<button
className={`h-12 rounded-sm border px-4 text-xs font-semibold transition ${
channelFilter === key
? 'border-[#3b82f6] bg-[#3b82f6] text-white'
: 'border-[#404040] bg-[#171717] text-[#b8b8b8] hover:text-[#e5e5e5]'
}`}
key={key}
onClick={() => setChannelFilter(key)}
type="button"
>
{label}
</button>
))}
</div>
</div>
<div className="overflow-x-auto rounded-sm border border-[#404040]">
<table className="w-full min-w-[920px] text-left text-sm">
<thead className="bg-[#171717] text-xs font-semibold uppercase tracking-[0.02em] text-[#b8b8b8]">
<tr>
<th className="px-5 py-4">Paciente</th>
<th className="px-5 py-4">Canal</th>
<th className="px-5 py-4">Template</th>
<th className="px-5 py-4">Enviado em</th>
<th className="px-5 py-4">Status</th>
<th className="px-5 py-4">Resposta</th>
</tr>
</thead>
<tbody className="divide-y divide-[#404040] bg-[#262626]">
{filteredMessages.map((message) => (
<MessageRow key={message.id} message={message} />
))}
</tbody>
</table>
</div>
{filteredMessages.length === 0 ? (
<div className="rounded-b-sm border-x border-b border-[#404040] bg-[#171717] px-4 py-8 text-center text-sm text-[#a3a3a3]">
Nenhuma comunicação encontrada com os filtros atuais.
</div>
) : null}
</section>
) : null}
{activeTab === 'templates' ? (
<section className="space-y-4" aria-label="Templates de comunicação">
<div className="flex justify-end">
<button
className="inline-flex h-10 items-center gap-2 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
onClick={() => setTemplateEditorOpen(true)}
type="button"
>
<CommIcon className="size-4" name="plus" />
Novo Template
</button>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{templates.map((template) => (
<TemplateCard key={template.id} onUse={openTemplate} template={template} />
))}
</div>
</section>
) : null}
{activeTab === 'campanha' ? (
<section className={`${cardClass} p-6`} aria-label="Campanhas inteligentes">
<div className="py-8 text-center">
<div className="mx-auto mb-4 grid size-16 place-items-center rounded-full bg-[#303030]">
<CommIcon className="size-8 text-[#51a2ff]" name="send" />
</div>
<h2 className="text-lg font-bold text-[#f5f5f5]">Campanhas Inteligentes</h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-[#a3a3a3]">
Crie campanhas segmentadas por perfil comportamental. A IA sugere os melhores horários e canais para
cada paciente.
</p>
<div className="mx-auto mt-6 grid max-w-2xl gap-4 md:grid-cols-3">
{campaigns.map((campaign) => (
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4 text-left" key={campaign.title}>
<h3 className="text-sm font-bold text-[#f5f5f5]">{campaign.title}</h3>
<p className="mt-1 text-xs leading-5 text-[#a3a3a3]">{campaign.desc}</p>
<p className="mt-2 text-[10px] font-semibold text-[#51a2ff]">{campaign.count}</p>
<button
className="mt-3 h-8 w-full rounded-sm bg-[#3b82f6] text-xs font-semibold text-white transition hover:bg-[#2563eb]"
onClick={() => {
setComposer({
patient: campaign.count,
channel: 'whatsapp',
template: campaign.title,
content: campaign.desc,
})
setComposerOpen(true)
}}
type="button"
>
Disparar
</button>
</div>
))}
</div>
</div>
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4">
<div className="mb-3 flex items-center gap-2">
<span className="rounded bg-indigo-500/20 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.12em] text-indigo-400">
LGPD
</span>
<span className="text-sm font-bold text-[#f5f5f5]">Conformidade</span>
</div>
<p className="text-xs leading-6 text-[#a3a3a3]">
Todas as comunicações respeitam as preferências de Opt-in/Opt-out dos pacientes. Os pacientes podem
cancelar o recebimento de mensagens a qualquer momento, conforme exigido pela LGPD.
</p>
</div>
</section>
) : null}
{composerOpen ? (
<MessageComposer
draft={composer}
onChange={setComposer}
onClose={() => {
setComposerOpen(false)
setComposer(emptyMessage)
}}
onSubmit={submitMessage}
templates={templates}
/>
) : null}
{templateEditorOpen ? (
<TemplateEditor
draft={templateDraft}
onChange={setTemplateDraft}
onClose={() => {
setTemplateEditorOpen(false)
setTemplateDraft(emptyTemplate)
}}
onSubmit={submitTemplate}
/>
) : null}
</div>
)
}
function StatCard({ label, value, valueClassName = 'text-[#f5f5f5]' }) {
return (
<div className={`${cardClass} p-5`}>
<p className="text-sm text-[#b8b8b8]">{label}</p>
<p className={`mt-2 text-3xl font-bold leading-none ${valueClassName}`}>{value}</p>
</div>
)
}
function MessageRow({ message }) {
const channel = channels[message.channel]
const status = statusConfig[message.status]
return (
<tr className="transition hover:bg-[#303030]">
<td className="px-5 py-4 font-semibold text-[#f5f5f5]">{message.patient}</td>
<td className="px-5 py-4">
<span className={`inline-flex items-center gap-1.5 rounded px-2 py-1 text-[10px] font-bold ${channel.className}`}>
<CommIcon className="size-3.5" name={channel.icon} />
{channel.label}
</span>
</td>
<td className="px-5 py-4 text-[#b8b8b8]">{message.template}</td>
<td className="px-5 py-4 text-[#b8b8b8]">{message.sentAt}</td>
<td className="px-5 py-4">
<span className={`inline-flex items-center gap-1.5 text-xs font-semibold ${status.className}`}>
<CommIcon className="size-3.5" name={status.icon} />
{status.label}
</span>
</td>
<td className="px-5 py-4 text-[#b8b8b8]">{message.response || '-'}</td>
</tr>
)
}
function TemplateCard({ onUse, template }) {
const channel = channels[template.channel]
return (
<article className={`${cardClass} p-5`}>
<div className="mb-3 flex items-center justify-between gap-3">
<span className={`inline-flex items-center gap-1.5 rounded px-2 py-1 text-[10px] font-bold ${channel.className}`}>
<CommIcon className="size-3.5" name={channel.icon} />
{channel.label}
</span>
<span className="rounded bg-[#303030] px-2 py-0.5 text-[10px] font-semibold text-[#a3a3a3]">
{template.category}
</span>
</div>
<h3 className="text-sm font-bold text-[#f5f5f5]">{template.name}</h3>
<p className="mt-2 min-h-[72px] text-xs leading-6 text-[#a3a3a3]">{template.content}</p>
<div className="mt-4 flex gap-2">
<button
className="h-9 flex-1 rounded-sm border border-[#404040] bg-[#171717] text-xs font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
type="button"
>
Editar
</button>
<button
className="h-9 flex-1 rounded-sm bg-[#3b82f6]/10 text-xs font-semibold text-[#3b82f6] transition hover:bg-[#3b82f6]/20"
onClick={() => onUse(template)}
type="button"
>
Usar
</button>
</div>
</article>
)
}
function MessageComposer({ draft, onChange, onClose, onSubmit, templates }) {
function update(field, value) {
onChange((current) => ({ ...current, [field]: value }))
}
function applyTemplate(templateName) {
const template = templates.find((item) => item.name === templateName)
if (!template) {
update('template', templateName)
return
}
onChange((current) => ({
...current,
channel: template.channel,
template: template.name,
content: template.content,
}))
}
return (
<ModalFrame onClose={onClose} title="Nova Mensagem">
<form className="space-y-4" onSubmit={onSubmit}>
<div className="grid gap-4 md:grid-cols-2">
<DarkField label="Paciente">
<input
className={inputClass}
onChange={(event) => update('patient', event.target.value)}
placeholder="Nome do paciente"
value={draft.patient}
/>
</DarkField>
<DarkField label="Canal">
<select className={inputClass} onChange={(event) => update('channel', event.target.value)} value={draft.channel}>
<option value="whatsapp">WhatsApp</option>
<option value="email">E-mail</option>
<option value="sms">SMS</option>
</select>
</DarkField>
</div>
<DarkField label="Template">
<select className={inputClass} onChange={(event) => applyTemplate(event.target.value)} value={draft.template}>
<option value="Mensagem avulsa">Mensagem avulsa</option>
{templates.map((template) => (
<option key={template.id} value={template.name}>
{template.name}
</option>
))}
</select>
</DarkField>
<DarkField label="Mensagem">
<textarea
className={textareaClass}
onChange={(event) => update('content', event.target.value)}
placeholder="Escreva a mensagem mockada..."
value={draft.content}
/>
</DarkField>
<div className="flex justify-end gap-3 border-t border-[#404040] pt-4">
<button className="h-10 rounded-sm border border-[#404040] px-4 text-sm font-semibold text-[#e5e5e5]" onClick={onClose} type="button">
Cancelar
</button>
<button
className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
disabled={!draft.patient.trim()}
type="submit"
>
Enviar
</button>
</div>
</form>
</ModalFrame>
)
}
function TemplateEditor({ draft, onChange, onClose, onSubmit }) {
function update(field, value) {
onChange((current) => ({ ...current, [field]: value }))
}
return (
<ModalFrame onClose={onClose} title="Novo Template">
<form className="space-y-4" onSubmit={onSubmit}>
<div className="grid gap-4 md:grid-cols-2">
<DarkField label="Nome">
<input className={inputClass} onChange={(event) => update('name', event.target.value)} value={draft.name} />
</DarkField>
<DarkField label="Canal">
<select className={inputClass} onChange={(event) => update('channel', event.target.value)} value={draft.channel}>
<option value="whatsapp">WhatsApp</option>
<option value="email">E-mail</option>
<option value="sms">SMS</option>
</select>
</DarkField>
</div>
<DarkField label="Categoria">
<input className={inputClass} onChange={(event) => update('category', event.target.value)} value={draft.category} />
</DarkField>
<DarkField label="Conteúdo">
<textarea className={textareaClass} onChange={(event) => update('content', event.target.value)} value={draft.content} />
</DarkField>
<div className="flex justify-end gap-3 border-t border-[#404040] pt-4">
<button className="h-10 rounded-sm border border-[#404040] px-4 text-sm font-semibold text-[#e5e5e5]" onClick={onClose} type="button">
Cancelar
</button>
<button
className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
disabled={!draft.name.trim() || !draft.content.trim()}
type="submit"
>
Salvar Template
</button>
</div>
</form>
</ModalFrame>
)
}
function ModalFrame({ children, onClose, title }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div className="w-full max-w-2xl rounded-2xl border border-[#404040] bg-[#262626] shadow-2xl">
<div className="flex items-center justify-between border-b border-[#404040] px-5 py-4">
<h2 className="text-lg font-bold text-[#f5f5f5]">{title}</h2>
<button className="grid size-9 place-items-center rounded-sm text-[#a3a3a3] hover:bg-[#303030]" onClick={onClose} type="button">
<CommIcon className="size-5" name="x" />
</button>
</div>
<div className="p-5">{children}</div>
</div>
</div>
)
}
function DarkField({ children, label }) {
return (
<label className="space-y-2">
<span className={labelClass}>{label}</span>
{children}
</label>
)
}
function CommIcon({ className = 'size-4', name }) {
const common = {
className,
fill: 'none',
stroke: 'currentColor',
strokeLinecap: 'round',
strokeLinejoin: 'round',
strokeWidth: 1.9,
viewBox: '0 0 24 24',
}
if (name === 'message') {
return (
<svg {...common}>
<path d="M5 5h14v10H8l-4 4V6a1 1 0 0 1 1-1Z" />
</svg>
)
}
if (name === 'mail') {
return (
<svg {...common}>
<path d="M4 6h16v12H4z" />
<path d="m4 7 8 6 8-6" />
</svg>
)
}
if (name === 'phone') {
return (
<svg {...common}>
<path d="M7 4h10v16H7z" />
<path d="M11 17h2" />
</svg>
)
}
if (name === 'send') {
return (
<svg {...common}>
<path d="m22 2-7 20-4-9-9-4 20-7Z" />
<path d="M22 2 11 13" />
</svg>
)
}
if (name === 'plus') {
return (
<svg {...common}>
<path d="M12 5v14M5 12h14" />
</svg>
)
}
if (name === 'search') {
return (
<svg {...common}>
<path d="m21 21-4.3-4.3" />
<circle cx="11" cy="11" r="7" />
</svg>
)
}
if (name === 'check') {
return (
<svg {...common}>
<circle cx="12" cy="12" r="9" />
<path d="m8 12 2.5 2.5L16 9" />
</svg>
)
}
if (name === 'eye') {
return (
<svg {...common}>
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z" />
<circle cx="12" cy="12" r="2.5" />
</svg>
)
}
if (name === 'x-circle') {
return (
<svg {...common}>
<circle cx="12" cy="12" r="9" />
<path d="m9 9 6 6M15 9l-6 6" />
</svg>
)
}
if (name === 'clock') {
return (
<svg {...common}>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
)
}
if (name === 'x') {
return (
<svg {...common}>
<path d="m18 6-12 12M6 6l12 12" />
</svg>
)
}
return (
<svg {...common}>
<path d="M3 12h4l2-5 4 10 2-5h6" />
</svg>
)
}

View File

@@ -0,0 +1,25 @@
import { Button, Card, PageHeader } from '../components/ui.jsx'
export function NotFoundPage({ navigate }) {
return (
<div className="grid gap-6">
<PageHeader
description="A rota acessada nao faz parte do shell navegavel deste prototipo."
eyebrow="404"
title="Tela nao encontrada"
/>
<Card className="p-6">
<p className="max-w-2xl text-sm leading-6 text-slate-600">
Volte para o dashboard ou escolha uma area na navegacao lateral. Esta tela tambem ajuda a validar links
quebrados durante a evolucao do app.
</p>
<div className="mt-5 flex flex-wrap gap-2">
<Button onClick={() => navigate('/dashboard')}>Ir para dashboard</Button>
<Button onClick={() => navigate('/login')} variant="secondary">
Voltar ao login
</Button>
</div>
</Card>
</div>
)
}

1279
src/pages/PatientsPage.jsx Normal file

File diff suppressed because it is too large Load Diff

108
src/pages/ProfilePage.jsx Normal file
View File

@@ -0,0 +1,108 @@
import { useState } from 'react'
import { profileRepository } from '../repositories/profileRepository.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() {
const [saved, setSaved] = useState(false)
const [profile, setProfile] = useState(() => profileRepository.getCurrentUserProfile())
function update(field, value) {
setSaved(false)
setProfile((current) => ({ ...current, [field]: value }))
}
return (
<div className="mx-auto max-w-6xl space-y-6">
<header>
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Perfil</h1>
<p className="mt-1 text-sm text-[#b8b8b8]">Dados locais do usuário logado e preferências básicas do shell.</p>
</header>
<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>
<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>
</div>
</div>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault()
setSaved(true)
}}
>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Nome">
<input className={inputClass} onChange={(event) => update('name', event.target.value)} value={profile.name} />
</Field>
<Field label="Cargo">
<input className={inputClass} onChange={(event) => update('role', event.target.value)} value={profile.role} />
</Field>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="E-mail">
<input className={inputClass} onChange={(event) => update('email', event.target.value)} type="email" value={profile.email} />
</Field>
<Field label="Telefone">
<input className={inputClass} onChange={(event) => update('phone', event.target.value)} value={profile.phone} />
</Field>
</div>
<Field label="Unidade padrão">
<select className={inputClass} onChange={(event) => update('unit', event.target.value)} value={profile.unit}>
<option>Clínica Boa Vista</option>
<option>Unidade Centro</option>
<option>Unidade Sul</option>
</select>
</Field>
<div className="flex flex-wrap items-center gap-3">
<button className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white" type="submit">
Salvar alterações
</button>
{saved ? <span className="rounded bg-emerald-500/20 px-2.5 py-1 text-xs font-bold text-emerald-400">Preferências salvas localmente</span> : null}
</div>
</form>
</section>
<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="Permissões" value="Agenda, pacientes, comunicação e configurações" />
</dl>
</aside>
</div>
</div>
)
}
function Field({ children, label }) {
return (
<label className="grid gap-2">
<span className="text-xs font-semibold text-[#a3a3a3]">{label}</span>
{children}
</label>
)
}
function Info({ label, value }) {
return (
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4">
<dt className="font-semibold text-[#a3a3a3]">{label}</dt>
<dd className="mt-1 text-[#e5e5e5]">{value}</dd>
</div>
)
}

936
src/pages/ReportsPage.jsx Normal file
View File

@@ -0,0 +1,936 @@
import { useMemo, useState } from 'react'
import { reportRepository } from '../repositories/reportRepository.js'
const statusConfig = {
rascunho: {
label: 'Rascunho',
pill: 'bg-amber-500/20 text-amber-400',
stat: 'text-amber-400',
},
finalizado: {
label: 'Finalizado',
pill: 'bg-emerald-500/20 text-emerald-400',
stat: 'text-emerald-400',
},
enviado: {
label: 'Enviado',
pill: 'bg-blue-500/20 text-blue-400',
stat: 'text-blue-400',
},
}
const adminUsers = reportRepository.getAdminUsers()
const currentUser = reportRepository.getCurrentUser()
const doctors = reportRepository.getDoctors()
const reportTypes = reportRepository.getReportTypes()
const templates = reportRepository.getTemplates()
const emptyEditor = {
id: null,
type: reportTypes[0],
patient: '',
doctor: doctors[0],
content: '',
showDate: true,
signDigital: true,
}
const inputClass =
'h-10 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]'
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 [search, setSearch] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [openMenuId, setOpenMenuId] = useState(null)
const [editorOpen, setEditorOpen] = useState(false)
const [templatesOpen, setTemplatesOpen] = useState(false)
const [historyReport, setHistoryReport] = useState(null)
const [confirmRelease, setConfirmRelease] = useState(null)
const [deliveryReport, setDeliveryReport] = useState(null)
const [confirmDelete, setConfirmDelete] = useState(null)
const [deleteConfirmText, setDeleteConfirmText] = useState('')
const [preview, setPreview] = useState(false)
const [editor, setEditor] = useState(emptyEditor)
const filteredReports = useMemo(() => {
return reports.filter((report) => {
const matchesSearch = [report.patient, report.type]
.join(' ')
.toLowerCase()
.includes(search.toLowerCase())
const matchesStatus = !filterStatus || report.status === filterStatus
return matchesSearch && matchesStatus
})
}, [filterStatus, reports, search])
const stats = [
{ label: 'Rascunhos', status: 'rascunho' },
{ label: 'Finalizados', status: 'finalizado' },
{ label: 'Enviados', status: 'enviado' },
].map((stat) => ({
...stat,
value: reports.filter((report) => report.status === stat.status).length,
}))
function openNew(template = null) {
setEditor({
...emptyEditor,
type: template?.type || emptyEditor.type,
content: template?.content || '',
})
setPreview(false)
setTemplatesOpen(false)
setEditorOpen(true)
}
function openEdit(report) {
setEditor({
id: report.id,
type: report.type,
patient: report.patient,
doctor: report.doctor,
content: report.content,
showDate: report.showDate,
signDigital: report.signDigital,
})
setOpenMenuId(null)
setPreview(false)
setEditorOpen(true)
}
function saveReport(status) {
if (!editor.patient.trim() || !editor.content.trim()) {
return
}
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()}`,
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)
}
function releaseReport(reportId) {
setReports((currentReports) =>
currentReports.map((report) =>
report.id === reportId
? {
...report,
status: 'finalizado',
versions: [
...report.versions,
{ version: report.versions.length + 1, action: 'Liberado', user: currentUser, summary: 'Laudo liberado' },
],
}
: report,
),
)
setConfirmRelease(null)
}
function sendReport(reportId) {
setReports((currentReports) =>
currentReports.map((report) =>
report.id === reportId
? {
...report,
status: 'enviado',
versions: [
...report.versions,
{ version: report.versions.length + 1, action: 'Enviado', user: currentUser, summary: 'Laudo enviado ao paciente' },
],
}
: report,
),
)
setOpenMenuId(null)
}
function deleteReport(reportId) {
setReports((currentReports) => currentReports.filter((report) => report.id !== reportId))
setConfirmDelete(null)
setDeleteConfirmText('')
}
return (
<div className="mx-auto max-w-7xl space-y-6 text-[#e5e5e5]">
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div>
<h1 className="text-2xl font-bold tracking-tight text-[#e5e5e5]">Gestão de Laudos</h1>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
<button
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-[#404040] bg-[#262626] px-4 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#2a2a2a]"
onClick={() => setTemplatesOpen(true)}
type="button"
>
<ReportIcon className="size-4 text-[#3b82f6]" name="template" />
Templates
</button>
<button
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-[#3b82f6] px-4 text-sm font-medium text-white transition hover:bg-[#2563eb]"
onClick={() => openNew()}
type="button"
>
<ReportIcon name="plus" />
Novo Laudo
</button>
</div>
</div>
<section className="grid gap-4 md:grid-cols-3">
{stats.map((stat) => (
<div className={`${cardClass} p-4`} key={stat.status}>
<p className="text-xs font-semibold text-[#a3a3a3]">{stat.label}</p>
<p className={`mt-1 text-2xl font-bold ${statusConfig[stat.status].stat}`}>{stat.value}</p>
</div>
))}
</section>
<section className={`${cardClass} p-6`}>
<div className="mb-6 flex flex-col gap-4 md:flex-row">
<div className="relative flex-1">
<ReportIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#a3a3a3]" name="search" />
<input
className="h-10 w-full rounded-none border border-[#404040] bg-[#1a1a1a] py-2 pl-10 pr-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]"
onChange={(event) => setSearch(event.target.value)}
placeholder="Buscar por paciente ou tipo..."
value={search}
/>
</div>
<select
className="h-10 rounded-none border border-[#404040] bg-[#1a1a1a] px-3 text-sm font-semibold text-[#e5e5e5] outline-none transition focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]"
onChange={(event) => setFilterStatus(event.target.value)}
value={filterStatus}
>
<option value="">Todos os Status</option>
<option value="rascunho">Rascunho</option>
<option value="finalizado">Finalizado</option>
<option value="enviado">Enviado</option>
</select>
</div>
<div className="overflow-x-auto rounded-none border border-[#404040]">
<table className="w-full whitespace-nowrap text-left text-sm">
<thead className="bg-[#171717] text-xs font-semibold uppercase text-[#a3a3a3]">
<tr>
<th className="px-4 py-3">Tipo</th>
<th className="px-4 py-3">Paciente</th>
<th className="px-4 py-3">Médico</th>
<th className="px-4 py-3">Data</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Versões</th>
<th className="px-4 py-3 text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-[#404040] bg-[#262626]">
{filteredReports.length ? (
filteredReports.map((report) => (
<ReportRow
key={report.id}
onDelete={() => {
setConfirmDelete({ report })
setDeleteConfirmText('')
setOpenMenuId(null)
}}
onDelivery={() => {
setDeliveryReport(report)
setOpenMenuId(null)
}}
onEdit={() => openEdit(report)}
onHistory={() => {
setHistoryReport(report)
setOpenMenuId(null)
}}
onPrint={() => {
window.print()
setOpenMenuId(null)
}}
onRelease={() => {
setConfirmRelease(report)
setOpenMenuId(null)
}}
onSend={() => sendReport(report.id)}
open={openMenuId === report.id}
report={report}
setOpenMenuId={setOpenMenuId}
/>
))
) : (
<tr>
<td className="px-4 py-8 text-center text-sm text-[#a3a3a3]" colSpan={7}>
Nenhum laudo encontrado.
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
{templatesOpen ? <TemplatesModal onClose={() => setTemplatesOpen(false)} onUseTemplate={openNew} /> : null}
{historyReport ? <HistoryModal onClose={() => setHistoryReport(null)} report={historyReport} /> : null}
{deliveryReport ? <DeliveryModal onClose={() => setDeliveryReport(null)} report={deliveryReport} /> : null}
{confirmRelease ? (
<ConfirmReleaseModal
onClose={() => setConfirmRelease(null)}
onConfirm={() => releaseReport(confirmRelease.id)}
report={confirmRelease}
/>
) : null}
{confirmDelete ? (
<DeleteModal
confirmText={deleteConfirmText}
onClose={() => setConfirmDelete(null)}
onConfirm={() => deleteReport(confirmDelete.report.id)}
report={confirmDelete.report}
setConfirmText={setDeleteConfirmText}
/>
) : null}
{editorOpen ? (
<ReportEditorModal
editor={editor}
onClose={() => setEditorOpen(false)}
onSave={saveReport}
preview={preview}
setEditor={setEditor}
setPreview={setPreview}
/>
) : null}
</div>
)
}
function ReportRow({
onDelete,
onDelivery,
onEdit,
onHistory,
onPrint,
onRelease,
onSend,
open,
report,
setOpenMenuId,
}) {
return (
<tr className="transition hover:bg-[#303030]">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<ReportIcon className="size-4 text-[#3b82f6]" name="file" />
<span className="font-medium text-[#e5e5e5]">{report.type}</span>
</div>
</td>
<td className="px-4 py-3 text-[#e5e5e5]">{report.patient}</td>
<td className="px-4 py-3 text-[#a3a3a3]">{report.doctor}</td>
<td className="px-4 py-3 text-[#a3a3a3]">{report.date}</td>
<td className="px-4 py-3">
<span className={`rounded px-2 py-1 text-[10px] font-bold ${statusConfig[report.status].pill}`}>
{statusConfig[report.status].label}
</span>
</td>
<td className="px-4 py-3">
<button
className="inline-flex items-center gap-1.5 rounded-md bg-[#2a2a2a] px-2 py-1 text-xs font-medium text-[#a3a3a3] transition hover:bg-[#3b82f6]/10 hover:text-[#3b82f6]"
onClick={onHistory}
title="Ver histórico de versões"
type="button"
>
<ReportIcon className="size-3.5" name="history" />
v{report.versions.length}
</button>
</td>
<td className="relative px-4 py-3 text-right">
<button
aria-label={`Ações de ${report.type} de ${report.patient}`}
className="rounded p-1 text-[#a3a3a3] transition hover:bg-[#2a2a2a] hover:text-[#e5e5e5]"
onClick={() => setOpenMenuId(open ? null : report.id)}
type="button"
>
<ReportIcon className="size-5" name="more" />
</button>
{open ? (
<>
<button
aria-label="Fechar menu"
className="fixed inset-0 z-10 cursor-default"
onClick={() => setOpenMenuId(null)}
type="button"
/>
<div className="absolute right-8 top-10 z-20 w-56 rounded-lg border border-[#404040] bg-[#303030] py-1 text-left shadow-lg">
<MenuItem icon="edit" label="Editar" onClick={onEdit} />
<MenuItem icon="history" label="Histórico de Versões" onClick={onHistory} />
<MenuItem icon="printer" label="Imprimir" onClick={onPrint} />
{(report.status === 'finalizado' || report.status === 'enviado') ? (
<MenuItem icon="clipboard" label="Protocolo de Entrega" onClick={onDelivery} />
) : null}
<div className="my-1 border-t border-[#404040]" />
{report.status === 'rascunho' ? <MenuItem icon="check" label="Liberar Laudo" onClick={onRelease} tone="green" /> : null}
{report.status === 'finalizado' ? <MenuItem icon="send" label="Enviar ao Paciente" onClick={onSend} tone="blue" /> : null}
<div className="my-1 border-t border-[#404040]" />
{canDelete(report) ? (
<MenuItem icon="trash" label="Excluir" onClick={onDelete} tone="danger" />
) : (
<div className="flex w-full cursor-not-allowed items-center gap-2 px-4 py-2 text-sm text-[#737373]">
<ReportIcon className="size-4" name="shield-off" />
Excluir
<span className="ml-auto rounded bg-[#2a2a2a] px-1.5 py-0.5 text-[10px]">Sem permissão</span>
</div>
)}
</div>
</>
) : null}
</td>
</tr>
)
}
function ReportEditorModal({ editor, onClose, onSave, preview, setEditor, setPreview }) {
const isValid = editor.patient.trim() && editor.content.trim()
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<div
className="flex max-h-[92vh] w-full max-w-4xl flex-col rounded-2xl border border-[#404040] bg-[#262626] shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[#404040] px-6 py-4">
<h2 className="text-lg font-bold text-[#e5e5e5]">{editor.id ? 'Editar Laudo' : 'Novo Laudo'}</h2>
<div className="flex items-center gap-2">
<button
className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition ${
preview
? 'border-[#3b82f6] bg-[#3b82f6]/10 text-[#3b82f6]'
: 'border-[#404040] text-[#a3a3a3] hover:bg-[#2a2a2a]'
}`}
onClick={() => setPreview((current) => !current)}
type="button"
>
<ReportIcon className="size-3.5" name="eye" />
{preview ? 'Editar' : 'Pré-visualizar'}
</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">
{preview ? (
<div className="min-h-[400px] rounded-xl bg-white p-8 text-gray-900 shadow-inner">
<div className="mb-6 border-b border-gray-200 pb-4 text-center">
<h3 className="text-xl font-bold">{editor.type}</h3>
{editor.showDate ? <p className="mt-1 text-sm text-gray-500">{new Date().toLocaleDateString('pt-BR')}</p> : null}
</div>
<p className="text-sm"><strong>Paciente:</strong> {editor.patient || '-'}</p>
<p className="mt-1 text-sm"><strong>Médico(a):</strong> {editor.doctor}</p>
<p className="mt-6 whitespace-pre-wrap text-sm leading-6">
{editor.content || 'Nenhum conteúdo inserido.'}
</p>
{editor.signDigital ? (
<div className="mt-12 border-t border-gray-200 pt-6 text-center">
<p className="text-sm font-medium text-gray-700">{editor.doctor}</p>
<p className="mt-1 text-xs text-gray-400">Assinatura Digital - MediConnect</p>
</div>
) : null}
</div>
) : (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<DarkField label="Tipo de Laudo *">
<select className={inputClass} onChange={(event) => setEditorValue(setEditor, 'type', event.target.value)} value={editor.type}>
{reportTypes.map((type) => (
<option key={type}>{type}</option>
))}
</select>
</DarkField>
<DarkField label="Paciente *">
<input
className={inputClass}
onChange={(event) => setEditorValue(setEditor, 'patient', event.target.value)}
placeholder="Digite o nome do paciente..."
value={editor.patient}
/>
</DarkField>
</div>
<DarkField label="Médico Responsável">
<select className={inputClass} onChange={(event) => setEditorValue(setEditor, 'doctor', event.target.value)} value={editor.doctor}>
{doctors.map((doctor) => (
<option key={doctor}>{doctor}</option>
))}
</select>
</DarkField>
<DarkField label="Conteúdo *">
<textarea
className={`${inputClass} min-h-72 py-3 leading-6`}
onChange={(event) => setEditorValue(setEditor, 'content', event.target.value)}
placeholder="Digite o conteúdo do laudo aqui..."
value={editor.content}
/>
</DarkField>
<div className="flex flex-wrap items-center gap-6">
<label className="flex cursor-pointer items-center gap-2 text-sm text-[#e5e5e5]">
<input
checked={editor.showDate}
className="size-4 accent-[#3b82f6]"
onChange={(event) => setEditorValue(setEditor, 'showDate', event.target.checked)}
type="checkbox"
/>
Exibir data no laudo
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm text-[#e5e5e5]">
<input
checked={editor.signDigital}
className="size-4 accent-[#3b82f6]"
onChange={(event) => setEditorValue(setEditor, 'signDigital', event.target.checked)}
type="checkbox"
/>
Incluir assinatura digital
</label>
</div>
{!isValid ? <p className="text-xs text-amber-400">* Preencha o paciente e o conteúdo do laudo para salvar.</p> : null}
</div>
)}
</div>
<div className="flex items-center justify-between border-t border-[#404040] px-6 py-4">
<button
className="rounded-lg border border-[#404040] bg-[#262626] px-4 py-2 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#2a2a2a]"
onClick={onClose}
type="button"
>
Cancelar
</button>
<div className="flex gap-3">
<button
className="rounded-lg border border-[#404040] bg-[#2a2a2a] px-4 py-2 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#333333] disabled:cursor-not-allowed disabled:opacity-40"
disabled={!isValid}
onClick={() => onSave('rascunho')}
type="button"
>
Salvar Rascunho
</button>
<button
className="inline-flex items-center gap-2 rounded-lg bg-[#3b82f6] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#2563eb] disabled:cursor-not-allowed disabled:opacity-40"
disabled={!isValid}
onClick={() => onSave('finalizado')}
type="button"
>
<ReportIcon className="size-3.5" name="lock" />
Liberar Laudo
</button>
</div>
</div>
</div>
</div>
)
}
function TemplatesModal({ onClose, onUseTemplate }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<div
className="w-full max-w-3xl rounded-2xl border border-[#404040] bg-[#262626] p-6 shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-5 flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-[#e5e5e5]">Templates de Laudo</h2>
<p className="mt-1 text-xs text-[#a3a3a3]">Modelos locais para acelerar a escrita do laudo.</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>
<div className="grid gap-3 md:grid-cols-3">
{templates.map((template) => (
<button
className="rounded-xl border border-[#404040] bg-[#1a1a1a] p-4 text-left transition hover:border-[#3b82f6]/50 hover:bg-[#2a2a2a]"
key={template.id}
onClick={() => onUseTemplate(template)}
type="button"
>
<p className="text-sm font-bold text-[#e5e5e5]">{template.name}</p>
<p className="mt-1 text-xs font-medium text-[#3b82f6]">{template.type}</p>
<p className="mt-3 text-xs leading-5 text-[#a3a3a3]">{template.description}</p>
</button>
))}
</div>
</div>
</div>
)
}
function HistoryModal({ onClose, report }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<div
className="w-full max-w-2xl rounded-2xl border border-[#404040] bg-[#262626] p-6 shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-5 flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-[#e5e5e5]">Histórico de Versões</h2>
<p className="mt-1 text-xs text-[#a3a3a3]">{report.type} - {report.patient}</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>
<div className="space-y-3">
{[...report.versions].reverse().map((version) => (
<div className="rounded-xl border border-[#404040] bg-[#1a1a1a] p-4" key={version.version}>
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-bold text-[#e5e5e5]">v{version.version} - {version.action}</p>
<p className="text-xs text-[#a3a3a3]">{version.user}</p>
</div>
<p className="mt-2 text-xs text-[#a3a3a3]">{version.summary}</p>
</div>
))}
</div>
</div>
</div>
)
}
function ConfirmReleaseModal({ onClose, onConfirm, report }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<div className="w-full max-w-sm rounded-2xl border border-[#404040] bg-[#262626] p-6 shadow-xl" onClick={(event) => event.stopPropagation()}>
<div className="mb-4 flex items-center gap-3">
<div className="rounded-lg bg-emerald-500/10 p-2 text-emerald-400">
<ReportIcon className="size-5" name="check" />
</div>
<div>
<h3 className="text-sm font-bold text-[#e5e5e5]">Liberar Laudo?</h3>
<p className="mt-0.5 text-xs text-[#a3a3a3]">{report.type} - {report.patient}</p>
</div>
</div>
<p className="mb-5 text-sm leading-6 text-[#a3a3a3]">
Ao liberar, o laudo ficará com status <strong className="text-emerald-400">Finalizado</strong> e poderá ser impresso ou enviado.
</p>
<div className="flex justify-end gap-3">
<button className="rounded-lg border border-[#404040] px-4 py-2 text-sm text-[#e5e5e5] transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
Cancelar
</button>
<button className="rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-emerald-600" onClick={onConfirm} type="button">
Confirmar Liberação
</button>
</div>
</div>
</div>
)
}
function DeliveryModal({ onClose, report }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<div className="w-full max-w-md rounded-2xl border border-[#404040] bg-[#262626] p-6 shadow-xl" onClick={(event) => event.stopPropagation()}>
<h2 className="text-lg font-bold text-[#e5e5e5]">Protocolo de Entrega</h2>
<p className="mt-2 text-sm leading-6 text-[#a3a3a3]">
Entrega mockada para {report.patient}, referente a {report.type} de {report.date}.
</p>
<div className="mt-5 grid gap-3">
<DarkField label="Canal">
<select className={inputClass} defaultValue="Portal do paciente">
<option>Portal do paciente</option>
<option>E-mail</option>
<option>Impresso</option>
</select>
</DarkField>
<DarkField label="Observação">
<textarea className={`${inputClass} min-h-20 py-2`} placeholder="Observação local do protocolo..." />
</DarkField>
</div>
<div className="mt-6 flex justify-end gap-3">
<button className="rounded-lg border border-[#404040] px-4 py-2 text-sm text-[#e5e5e5] transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
Cancelar
</button>
<button className="rounded-lg bg-[#3b82f6] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#2563eb]" onClick={onClose} type="button">
Registrar Protocolo
</button>
</div>
</div>
</div>
)
}
function DeleteModal({ confirmText, onClose, onConfirm, report, setConfirmText }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<div className="w-full max-w-sm rounded-2xl border border-[#404040] bg-[#262626] p-6 shadow-xl" onClick={(event) => event.stopPropagation()}>
<div className="mb-4 flex items-center gap-3">
<div className="rounded-lg bg-[#ef4444]/10 p-2 text-[#ef4444]">
<ReportIcon className="size-5" name="trash" />
</div>
<div>
<h3 className="text-sm font-bold text-[#e5e5e5]">Excluir laudo?</h3>
<p className="mt-0.5 text-xs text-[#a3a3a3]">{report.type} - {report.patient}</p>
</div>
</div>
<p className="mb-3 text-sm leading-6 text-[#a3a3a3]">Para confirmar, digite EXCLUIR no campo abaixo.</p>
<input
autoFocus
className="mb-4 h-10 w-full rounded-lg border border-[#ef4444]/40 bg-[#1a1a1a] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#ef4444]"
onChange={(event) => setConfirmText(event.target.value)}
placeholder="Digite EXCLUIR"
value={confirmText}
/>
<div className="flex justify-end gap-3">
<button className="rounded-lg border border-[#404040] px-4 py-2 text-sm text-[#e5e5e5] transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
Cancelar
</button>
<button
className="rounded-lg bg-[#ef4444] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#dc2626] disabled:cursor-not-allowed disabled:opacity-40"
disabled={confirmText !== 'EXCLUIR'}
onClick={onConfirm}
type="button"
>
Excluir Permanentemente
</button>
</div>
</div>
</div>
)
}
function MenuItem({ icon, label, onClick, tone = 'default' }) {
const colors = {
blue: 'text-blue-400 hover:bg-blue-500/10',
danger: 'text-[#ef4444] hover:bg-[#ef4444]/10',
default: 'text-[#e5e5e5] hover:bg-[#2a2a2a]',
green: 'text-emerald-400 hover:bg-emerald-500/10',
}
return (
<button className={`flex w-full items-center gap-2 px-4 py-2 text-sm transition ${colors[tone]}`} onClick={onClick} type="button">
<ReportIcon className="size-4" name={icon} />
{label}
</button>
)
}
function DarkField({ children, label }) {
return (
<label className="block">
<span className={labelClass}>{label}</span>
{children}
</label>
)
}
function setEditorValue(setEditor, key, value) {
setEditor((currentEditor) => ({ ...currentEditor, [key]: value }))
}
function canDelete(report) {
return adminUsers.includes(currentUser) || (report.status === 'rascunho' && report.doctor === currentUser)
}
function ReportIcon({ className = 'size-4', name }) {
const common = {
className,
fill: 'none',
stroke: 'currentColor',
strokeLinecap: 'round',
strokeLinejoin: 'round',
strokeWidth: 1.8,
viewBox: '0 0 24 24',
}
if (name === 'search') {
return (
<svg {...common}>
<path d="m21 21-4.3-4.3" />
<circle cx="11" cy="11" r="7" />
</svg>
)
}
if (name === 'plus') {
return (
<svg {...common}>
<path d="M12 5v14M5 12h14" />
</svg>
)
}
if (name === 'file') {
return (
<svg {...common}>
<path d="M7 3h7l4 4v14H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z" />
<path d="M14 3v5h5M9 13h6M9 17h6" />
</svg>
)
}
if (name === 'template') {
return (
<svg {...common}>
<path d="M4 5h16M4 12h7M13 12h7M4 19h16" />
<path d="M4 5v14M20 5v14M11 12v7M13 12V5" />
</svg>
)
}
if (name === 'history') {
return (
<svg {...common}>
<path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
<path d="M3 4v4h4M12 7v5l3 2" />
</svg>
)
}
if (name === 'more') {
return (
<svg {...common}>
<path d="M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2ZM19 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2ZM5 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" />
</svg>
)
}
if (name === 'edit') {
return (
<svg {...common}>
<path d="m16 3 5 5L8 21H3v-5L16 3Z" />
</svg>
)
}
if (name === 'printer') {
return (
<svg {...common}>
<path d="M7 8V3h10v5M7 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 === 'clipboard') {
return (
<svg {...common}>
<path d="M9 5h6M9 5a3 3 0 0 1 6 0M8 6H6a1 1 0 0 0-1 1v13a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1h-2M8 13h8M8 17h5" />
</svg>
)
}
if (name === 'check') {
return (
<svg {...common}>
<path d="m5 12 4 4L19 6" />
</svg>
)
}
if (name === 'send') {
return (
<svg {...common}>
<path d="m22 2-7 20-4-9-9-4 20-7Z" />
<path d="M22 2 11 13" />
</svg>
)
}
if (name === 'trash') {
return (
<svg {...common}>
<path d="M3 6h18M8 6V4h8v2M6 6l1 15h10l1-15M10 11v6M14 11v6" />
</svg>
)
}
if (name === 'shield-off') {
return (
<svg {...common}>
<path d="M12 3 5 6v5c0 4.5 3 8.5 7 10 1.1-.4 2.1-1 3-1.7M19 13.5V6l-7-3-4.2 1.8M3 3l18 18" />
</svg>
)
}
if (name === 'eye') {
return (
<svg {...common}>
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z" />
<circle cx="12" cy="12" r="3" />
</svg>
)
}
if (name === 'x') {
return (
<svg {...common}>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
)
}
if (name === 'lock') {
return (
<svg {...common}>
<rect height="10" rx="2" width="16" x="4" y="11" />
<path d="M8 11V8a4 4 0 1 1 8 0v3" />
</svg>
)
}
return (
<svg {...common}>
<path d="m6 9 6 6 6-6" />
</svg>
)
}

481
src/pages/SettingsPage.jsx Normal file
View File

@@ -0,0 +1,481 @@
import { useState } from 'react'
import { settingsRepository } from '../repositories/settingsRepository.js'
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
const rowClass = 'flex items-center justify-between gap-6 border-b border-[#404040] py-4 last:border-0'
const inputClass =
'h-10 rounded-sm border border-[#404040] bg-[#171717] px-3 text-sm text-[#e5e5e5] outline-none transition focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
export function SettingsPage() {
const sections = settingsRepository.getSections()
const [activeSection, setActiveSection] = useState('aparencia')
return (
<div className="mx-auto max-w-5xl">
<header className="mb-8">
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Configurações</h1>
<p className="mt-1 text-sm text-[#b8b8b8]">Gerencie preferências, segurança e integrações do MediConnect</p>
</header>
<div className="flex flex-col gap-6 lg:flex-row">
<nav className="lg:w-64" aria-label="Seções de configuração">
<div className={`${cardClass} flex flex-col gap-1 p-2`}>
{sections.map((item) => (
<button
className={`flex items-center gap-3 rounded-xl px-3 py-3 text-left transition ${
activeSection === item.id
? 'bg-[#3b82f6]/10 text-[#3b82f6]'
: 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
}`}
key={item.id}
onClick={() => setActiveSection(item.id)}
type="button"
>
<SettingsIcon className="size-4 shrink-0" name={item.icon} />
<span className="min-w-0">
<span className="block text-sm font-semibold leading-none">{item.label}</span>
<span className="mt-1 block truncate text-[11px] opacity-70">{item.description}</span>
</span>
</button>
))}
</div>
</nav>
<section className={`${cardClass} min-w-0 flex-1 p-6 lg:p-8`}>
{activeSection === 'aparencia' ? <AppearanceSection /> : null}
{activeSection === 'notificacoes' ? <NotificationsSection /> : null}
{activeSection === 'privacidade' ? <PrivacySection /> : null}
{activeSection === 'conta' ? <AccountSection /> : null}
{activeSection === 'integracoes' ? <IntegrationsSection /> : null}
{activeSection === 'dados' ? <DataSection /> : null}
</section>
</div>
</div>
)
}
function AppearanceSection() {
const [theme, setTheme] = useState('dark')
const [compact, setCompact] = useState(false)
const [contrast, setContrast] = useState(false)
const [animations, setAnimations] = useState(true)
return (
<SectionFrame description="Personalize a interface do MediConnect." title="Aparência">
<div className="mb-8">
<p className="mb-4 text-sm font-semibold text-[#e5e5e5]">Tema da Interface</p>
<div className="grid max-w-xl gap-4 sm:grid-cols-2">
{[
{ id: 'dark', label: 'Escuro', preview: 'bg-[#0a1628]' },
{ id: 'light', label: 'Claro', preview: 'bg-[#f4f7fb]' },
].map((item) => (
<button
className={`rounded-2xl border-2 p-4 text-left transition ${
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)}
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={`h-2.5 rounded ${item.id === 'dark' ? 'bg-[#1a3050]' : 'bg-white'}`} />
<span className="flex flex-1 gap-1">
<span className={`w-8 rounded ${item.id === 'dark' ? 'bg-[#0f1f36]' : 'bg-white'}`} />
<span className="flex flex-1 flex-col justify-center gap-1">
<span className={`h-1.5 w-3/4 rounded-full ${item.id === 'dark' ? 'bg-[#1e3a5f]' : 'bg-[#dde8f7]'}`} />
<span className={`h-1.5 w-1/2 rounded-full ${item.id === 'dark' ? 'bg-[#1e3a5f]' : 'bg-[#dde8f7]'}`} />
</span>
</span>
</span>
<span className="flex items-center justify-between">
<span className="text-sm font-semibold text-[#e5e5e5]">{item.label}</span>
{theme === item.id ? <span className="grid size-5 place-items-center rounded-full bg-[#3b82f6] text-[11px] text-white"></span> : null}
</span>
</button>
))}
</div>
<p className="mt-3 text-xs text-[#a3a3a3]">A preferência de tema é salva localmente neste protótipo.</p>
</div>
<SettingsGroup>
<SettingRow description="Transições suaves entre telas e componentes" label="Animações de interface">
<ToggleSwitch checked={animations} onChange={setAnimations} />
</SettingRow>
<SettingRow description="Aumenta o contraste dos elementos para melhor acessibilidade" label="Modo de alto contraste">
<ToggleSwitch checked={contrast} onChange={setContrast} />
</SettingRow>
<SettingRow description="Reduz o espaçamento para exibir mais informações na tela" label="Densidade compacta">
<ToggleSwitch checked={compact} onChange={setCompact} />
</SettingRow>
<SettingRow label="Idioma do sistema">
<select className={inputClass} defaultValue="pt-br">
<option value="pt-br">Português (BR)</option>
<option value="en-us">English (US)</option>
<option value="es">Español</option>
</select>
</SettingRow>
</SettingsGroup>
</SectionFrame>
)
}
function NotificationsSection() {
const [settings, setSettings] = useState({
email: true,
sms: true,
whatsapp: true,
push: false,
ai: true,
appointment: true,
report: true,
noShow: true,
})
return (
<SectionFrame description="Configure como e quando deseja receber alertas." title="Notificações">
<Subsection title="Canais de Comunicação">
<ToggleRow checked={settings.email} description="Receba resumos e alertas via e-mail" label="Notificações por E-mail" onChange={(value) => setSettings((current) => ({ ...current, email: value }))} />
<ToggleRow checked={settings.sms} description="Alertas urgentes via mensagem de texto" label="SMS" onChange={(value) => setSettings((current) => ({ ...current, sms: value }))} />
<ToggleRow checked={settings.whatsapp} description="Integração com WhatsApp Business para lembretes" label="WhatsApp" onChange={(value) => setSettings((current) => ({ ...current, whatsapp: value }))} />
<ToggleRow checked={settings.push} description="Notificações no navegador em tempo real" label="Push (navegador)" onChange={(value) => setSettings((current) => ({ ...current, push: value }))} />
</Subsection>
<Subsection title="Tipos de Alerta">
<ToggleRow checked={settings.ai} description="Alerta preditivo quando paciente tem alto risco de faltar" label="Risco de No-Show (IA)" onChange={(value) => setSettings((current) => ({ ...current, ai: value }))} />
<ToggleRow checked={settings.appointment} description="Lembre pacientes 24h e 1h antes da consulta" label="Lembrete de Consulta" onChange={(value) => setSettings((current) => ({ ...current, appointment: value }))} />
<ToggleRow checked={settings.report} description="Notificar quando relatórios mensais estiverem prontos" label="Relatório Disponível" onChange={(value) => setSettings((current) => ({ ...current, report: value }))} />
<ToggleRow checked={settings.noShow} description="Confirmar quando uma falta é registrada no sistema" label="No-Show registrado" onChange={(value) => setSettings((current) => ({ ...current, noShow: value }))} />
</Subsection>
<Subsection title="Horário Silencioso">
<SettingRow description="Sem notificações push entre 22h e 7h" label="Ativar horário silencioso">
<ToggleSwitch checked onChange={() => {}} />
</SettingRow>
<SettingRow label="Horário de início / fim">
<div className="flex items-center gap-2">
<input className={`${inputClass} w-28`} defaultValue="22:00" type="time" />
<span className="text-sm text-[#a3a3a3]">até</span>
<input className={`${inputClass} w-28`} defaultValue="07:00" type="time" />
</div>
</SettingRow>
</Subsection>
</SectionFrame>
)
}
function PrivacySection() {
const [twoFactor, setTwoFactor] = useState(false)
const [audit, setAudit] = useState(true)
const [anonymous, setAnonymous] = useState(false)
return (
<SectionFrame description="Gerencie conformidade com a Lei Geral de Proteção de Dados." title="Privacidade & LGPD">
<div className="mb-6 flex gap-3 rounded-xl border border-amber-500/30 bg-amber-500/10 p-4">
<SettingsIcon className="mt-0.5 size-5 shrink-0 text-amber-400" name="alert" />
<div>
<p className="text-sm font-semibold text-amber-400">Conformidade LGPD Ativa</p>
<p className="mt-1 text-xs leading-5 text-[#a3a3a3]">
Dados de pacientes são tratados com finalidade legítima e armazenados com segurança neste protótipo.
</p>
</div>
</div>
<Subsection title="Segurança de Acesso">
<ToggleRow checked={twoFactor} description="Adiciona uma camada extra de segurança ao login" label="Autenticação de Dois Fatores (2FA)" onChange={setTwoFactor} />
<SettingRow description="Desconectar automaticamente após inatividade" label="Tempo de sessão">
<select className={inputClass} defaultValue="30">
<option value="30">30 minutos</option>
<option value="60">1 hora</option>
<option value="240">4 horas</option>
</select>
</SettingRow>
<ToggleRow checked={audit} description="Registrar todas as ações realizadas no sistema" label="Log de auditoria" onChange={setAudit} />
</Subsection>
<Subsection title="Dados dos Pacientes">
<ToggleRow checked={anonymous} description="Ocultar dados pessoais identificáveis nos relatórios exportados" label="Anonimizar em relatórios" onChange={setAnonymous} />
<SettingRow description="Período de armazenamento de dados inativos" label="Retenção de dados">
<select className={inputClass} defaultValue="5">
<option value="1">1 ano</option>
<option value="3">3 anos</option>
<option value="5">5 anos (padrão)</option>
<option value="10">10 anos</option>
</select>
</SettingRow>
<SettingRow description="Gerar relatório completo para atender solicitação de titular" label="Exportar dados do paciente">
<button className="h-9 rounded-sm border border-[#404040] bg-[#303030] px-3 text-sm font-semibold text-[#e5e5e5]" type="button">
Exportar
</button>
</SettingRow>
</Subsection>
</SectionFrame>
)
}
function AccountSection() {
const [profile, setProfile] = useState({
name: 'Dra. Ana Silva',
email: 'ana.silva@mediconnect.com.br',
role: 'Coordenação Médica',
crm: 'CRM/SE 12345',
})
function update(field, value) {
setProfile((current) => ({ ...current, [field]: value }))
}
return (
<SectionFrame description="Gerencie suas informações pessoais e credenciais." title="Conta & Perfil">
<div className="mb-6 flex items-center gap-4 rounded-xl border border-[#404040] bg-[#171717] p-5">
<div className="grid size-16 place-items-center rounded-full border-2 border-[#3b82f6]/20 bg-[#3b82f6]/10 text-xl font-bold text-[#3b82f6]">
AS
</div>
<div>
<p className="text-sm font-bold text-[#f5f5f5]">{profile.name}</p>
<p className="text-xs text-[#a3a3a3]">{profile.role}</p>
<button className="mt-1 text-xs font-semibold text-[#3b82f6]" type="button">
Alterar foto
</button>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<TextField label="Nome completo" onChange={(value) => update('name', value)} value={profile.name} />
<TextField label="E-mail" onChange={(value) => update('email', value)} value={profile.email} />
<TextField label="Cargo / Função" onChange={(value) => update('role', value)} value={profile.role} />
<TextField label="CRM / Registro" onChange={(value) => update('crm', value)} value={profile.crm} />
</div>
<Subsection title="Segurança">
<SettingRow description="Última alteração há 45 dias" label="Alterar senha">
<button className="h-9 rounded-sm border border-[#404040] bg-[#303030] px-3 text-sm font-semibold text-[#e5e5e5]" type="button">
Alterar
</button>
</SettingRow>
<SettingRow description="Gerenciar dispositivos conectados" label="Sessões ativas">
<button className="text-sm font-semibold text-[#3b82f6]" type="button">
Ver sessões
</button>
</SettingRow>
</Subsection>
</SectionFrame>
)
}
function IntegrationsSection() {
const integrations = settingsRepository.getIntegrations()
return (
<SectionFrame description="Conecte o MediConnect com sistemas e serviços externos." title="Integrações">
<div className="space-y-3">
{integrations.map(([name, desc, connected, color]) => (
<div className="flex items-center gap-4 rounded-xl border border-[#404040] bg-[#171717] p-4" key={name}>
<div className={`grid size-10 shrink-0 place-items-center rounded-lg ${color}`}>
<SettingsIcon className="size-5 text-white" name="globe" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-[#f5f5f5]">{name}</p>
<p className="text-xs text-[#a3a3a3]">{desc}</p>
</div>
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${connected ? 'bg-emerald-500/10 text-emerald-400' : 'bg-[#303030] text-[#a3a3a3]'}`}>
{connected ? 'Conectado' : 'Desconectado'}
</span>
</div>
))}
</div>
</SectionFrame>
)
}
function DataSection() {
return (
<SectionFrame description="Exporte, importe e gerencie backups do sistema." title="Dados & Backup">
<Subsection title="Exportação de Dados">
<div className="grid gap-4 sm:grid-cols-2">
{[
['Pacientes (CSV)', 'Lista completa com dados cadastrais'],
['Prontuários (PDF)', 'Registros médicos do período'],
['Relatório Geral (PDF)', 'Dashboard executivo completo'],
].map(([label, desc]) => (
<button className="flex items-center gap-3 rounded-xl border border-[#404040] bg-[#171717] p-4 text-left transition hover:border-[#3b82f6]/40" key={label} type="button">
<span className="grid size-9 place-items-center rounded-lg bg-[#3b82f6]/10 text-[#3b82f6]">
<SettingsIcon className="size-4" name="download" />
</span>
<span>
<span className="block text-sm font-semibold text-[#f5f5f5]">{label}</span>
<span className="block text-xs text-[#a3a3a3]">{desc}</span>
</span>
</button>
))}
</div>
</Subsection>
<Subsection title="Backup Automático">
<SettingRow description="Salvar snapshot diário dos dados" label="Backup automático">
<ToggleSwitch checked onChange={() => {}} />
</SettingRow>
<SettingRow description="Com que frequência o backup é realizado" label="Frequência">
<select className={inputClass} defaultValue="daily">
<option value="daily">Diário (00h)</option>
<option value="12h">A cada 12h</option>
<option value="weekly">Semanal</option>
</select>
</SettingRow>
<SettingRow description="30/03/2026 às 00:15" label="Último backup">
<button className="h-9 rounded-sm border border-[#404040] bg-[#303030] px-3 text-sm font-semibold text-[#e5e5e5]" type="button">
Baixar
</button>
</SettingRow>
</Subsection>
</SectionFrame>
)
}
function SectionFrame({ children, description, title }) {
return (
<div>
<div className="mb-6">
<h2 className="text-lg font-bold text-[#f5f5f5]">{title}</h2>
<p className="mt-1 text-sm text-[#a3a3a3]">{description}</p>
</div>
<div className="space-y-6">{children}</div>
</div>
)
}
function Subsection({ children, title }) {
return (
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.12em] text-[#a3a3a3]">{title}</p>
<SettingsGroup>{children}</SettingsGroup>
</div>
)
}
function SettingsGroup({ children }) {
return <div className="rounded-xl border border-[#404040] bg-[#171717] px-6">{children}</div>
}
function ToggleRow({ checked, description, label, onChange }) {
return (
<SettingRow description={description} label={label}>
<ToggleSwitch checked={checked} onChange={onChange} />
</SettingRow>
)
}
function SettingRow({ children, description, label }) {
return (
<div className={rowClass}>
<div className="min-w-0 flex-1 pr-4">
<p className="text-sm font-semibold text-[#e5e5e5]">{label}</p>
{description ? <p className="mt-1 text-xs leading-5 text-[#a3a3a3]">{description}</p> : null}
</div>
<div className="shrink-0">{children}</div>
</div>
)
}
function TextField({ label, onChange, value }) {
return (
<label className="grid gap-2">
<span className="text-xs font-semibold text-[#a3a3a3]">{label}</span>
<input className={`${inputClass} w-full`} onChange={(event) => onChange(event.target.value)} value={value} />
</label>
)
}
function ToggleSwitch({ checked, onChange }) {
return (
<button
aria-checked={checked}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition ${checked ? 'bg-[#3b82f6]' : 'bg-[#303030]'}`}
onClick={() => onChange(!checked)}
role="switch"
type="button"
>
<span className={`inline-block size-4 rounded-full bg-white shadow-md transition-transform ${checked ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
)
}
function SettingsIcon({ className = 'size-4', name }) {
const common = {
className,
fill: 'none',
stroke: 'currentColor',
strokeLinecap: 'round',
strokeLinejoin: 'round',
strokeWidth: 1.9,
viewBox: '0 0 24 24',
}
if (name === 'bell') {
return (
<svg {...common}>
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9" />
<path d="M10 21h4" />
</svg>
)
}
if (name === 'shield') {
return (
<svg {...common}>
<path d="M12 3 5 6v5c0 4 3 7.5 7 10 4-2.5 7-6 7-10V6l-7-3Z" />
</svg>
)
}
if (name === 'user') {
return (
<svg {...common}>
<path d="M16 19a4 4 0 0 0-8 0M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" />
</svg>
)
}
if (name === 'globe') {
return (
<svg {...common}>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18" />
</svg>
)
}
if (name === 'database') {
return (
<svg {...common}>
<ellipse cx="12" cy="5" rx="7" ry="3" />
<path d="M5 5v14c0 1.7 3.1 3 7 3s7-1.3 7-3V5M5 12c0 1.7 3.1 3 7 3s7-1.3 7-3" />
</svg>
)
}
if (name === 'download') {
return (
<svg {...common}>
<path d="M12 3v12M7 10l5 5 5-5M5 21h14" />
</svg>
)
}
if (name === 'alert') {
return (
<svg {...common}>
<path d="M12 3 2 21h20L12 3Z" />
<path d="M12 9v5M12 18h.01" />
</svg>
)
}
return (
<svg {...common}>
<path d="M12 3v3M12 18v3M4.9 4.9 7 7M17 17l2.1 2.1M3 12h3M18 12h3M4.9 19.1 7 17M17 7l2.1-2.1" />
<circle cx="12" cy="12" r="3" />
</svg>
)
}

125
src/pages/TeamPage.jsx Normal file
View File

@@ -0,0 +1,125 @@
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 { slots, weekdays } = professionalRepository.getCoverageMap()
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">
<div>
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Profissionais</h1>
<p className="mt-1 text-sm text-[#b8b8b8]">Equipe, agenda e cobertura operacional da clínica.</p>
</div>
<button
className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
onClick={() => navigate('/agenda')}
type="button"
>
Ver disponibilidade
</button>
</header>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4" aria-label="Equipe médica">
{professionals.map((professional) => (
<article className={`${cardClass} p-5`} key={professional.id}>
<div className="flex items-start justify-between gap-3">
<div>
<div className="grid size-11 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 text-sm font-bold text-[#3b82f6]">
{initials(professional.name)}
</div>
<h2 className="mt-4 text-lg font-bold text-[#f5f5f5]">{professional.name}</h2>
<p className="mt-1 text-sm text-[#a3a3a3]">{professional.role}</p>
</div>
<StatusPill status={professional.status} />
</div>
<dl className="mt-5 grid gap-3 text-sm">
<Info label="Agenda" value={professional.schedule} />
<Info label="Próximo horário" value={professional.nextSlot} />
<Info label="Pacientes ativos" value={professional.patients} />
</dl>
</article>
))}
</section>
<section className={`${cardClass} p-5`}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-bold text-[#f5f5f5]">Mapa de cobertura</h2>
<p className="mt-1 text-sm text-[#a3a3a3]">
Matriz simples para preparar o fluxo de agenda, plantão e disponibilidade.
</p>
</div>
<button
className="h-10 rounded-sm border border-[#404040] bg-[#303030] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:border-[#3b82f6]"
onClick={() => navigate('/configuracoes')}
type="button"
>
Configurar regras
</button>
</div>
<div className="mt-5 overflow-x-auto rounded-sm border border-[#404040]">
<div className="grid min-w-[720px] grid-cols-[1.2fr_repeat(5,1fr)] bg-[#171717] text-xs font-bold uppercase tracking-[0.16em] text-[#a3a3a3]">
{['Profissional', ...weekdays].map((label) => (
<div className="border-b border-[#404040] px-4 py-3" key={label}>
{label}
</div>
))}
</div>
{professionals.map((professional, rowIndex) => (
<div className="grid min-w-[720px] grid-cols-[1.2fr_repeat(5,1fr)] text-sm" key={professional.id}>
<div className="border-b border-[#404040] px-4 py-3 font-semibold text-[#f5f5f5]">{professional.name}</div>
{slots.map((slot, index) => (
<div className="border-b border-[#404040] px-4 py-3 text-[#b8b8b8]" key={`${professional.id}-${slot}`}>
{shiftSlot(slot, rowIndex + index)}
</div>
))}
</div>
))}
</div>
</section>
</div>
)
}
function Info({ label, value }) {
return (
<div>
<dt className="text-xs font-semibold text-[#737373]">{label}</dt>
<dd className="mt-1 text-[#e5e5e5]">{value}</dd>
</div>
)
}
function StatusPill({ status }) {
const className =
status === 'Disponivel'
? 'bg-emerald-500/20 text-emerald-400'
: status === 'Em atendimento'
? 'bg-amber-500/20 text-amber-400'
: 'bg-blue-500/20 text-blue-400'
return <span className={`rounded px-2 py-1 text-[10px] font-bold ${className}`}>{status}</span>
}
function initials(name) {
return name
.replace(/^(Dr\.|Dra\.|Nutri\.|Enf\.)\s+/i, '')
.split(' ')
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase()
}
function shiftSlot(slot, index) {
if (index % 4 === 0) {
return 'Bloqueado'
}
return slot
}

155
src/pages/VisitsPage.jsx Normal file
View File

@@ -0,0 +1,155 @@
import { useMemo, useState } from 'react'
import { visitRepository } from '../repositories/visitRepository.js'
const tabs = [
{ label: 'Fila ativa', value: 'ativa' },
{ label: 'Em atendimento', value: 'atendimento' },
{ label: 'Finalizadas', value: 'finalizadas' },
]
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
export function VisitsPage({ navigate }) {
const careQueue = useMemo(() => visitRepository.getCareQueue(), [])
const stages = useMemo(() => visitRepository.getStages(), [])
const [activeTab, setActiveTab] = useState('ativa')
const visibleQueue = useMemo(() => {
if (activeTab === 'finalizadas') {
return careQueue.filter((item) => item.status === 'Finalizada')
}
if (activeTab === 'atendimento') {
return careQueue.filter((item) => item.status !== 'Finalizada' && item.status !== 'Aguardando medico')
}
return careQueue.filter((item) => item.status !== 'Finalizada')
}, [activeTab, careQueue])
const summary = [
{ label: 'Na fila', value: careQueue.filter((item) => item.status !== 'Finalizada').length, tone: 'text-[#3b82f6]' },
{ label: 'Alta prioridade', value: careQueue.filter((item) => item.priority === 'Alta').length, tone: 'text-red-400' },
{ label: 'Finalizadas', value: careQueue.filter((item) => item.status === 'Finalizada').length, tone: 'text-emerald-400' },
]
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">
<div>
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Consultas</h1>
<p className="mt-1 text-sm text-[#b8b8b8]">Fila de atendimento, triagem e acompanhamento clínico local.</p>
</div>
<div className="flex flex-wrap gap-3">
<button
className="h-10 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
onClick={() => navigate('/agenda')}
type="button"
>
Abrir agenda
</button>
<button
className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
onClick={() => navigate('/prontuario')}
type="button"
>
Novo registro
</button>
</div>
</header>
<section className="grid gap-4 md:grid-cols-3" aria-label="Resumo da fila">
{summary.map((item) => (
<article className={`${cardClass} p-5`} key={item.label}>
<p className="text-sm text-[#a3a3a3]">{item.label}</p>
<p className={`mt-2 text-3xl font-bold leading-none ${item.tone}`}>{item.value}</p>
</article>
))}
</section>
<section className={`${cardClass} p-5`}>
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="flex flex-wrap gap-2 rounded-sm border border-[#404040] bg-[#171717] p-1">
{tabs.map((tab) => (
<button
className={`h-9 rounded-sm px-3 text-sm font-semibold transition ${
activeTab === tab.value ? 'bg-[#3b82f6] text-white' : 'text-[#b8b8b8] hover:bg-[#303030] hover:text-[#e5e5e5]'
}`}
key={tab.value}
onClick={() => setActiveTab(tab.value)}
type="button"
>
{tab.label}
</button>
))}
</div>
<p className="text-sm text-[#a3a3a3]">{visibleQueue.length} registros no filtro atual</p>
</div>
<div className="mt-5 grid gap-3">
{visibleQueue.map((item) => (
<article
className="grid gap-4 rounded-xl border border-[#404040] bg-[#171717] p-4 lg:grid-cols-[1fr_180px_160px_auto]"
key={item.id}
>
<div>
<button
className="text-left text-lg font-bold text-[#f5f5f5] transition hover:text-[#3b82f6]"
onClick={() => navigate(`/pacientes/${item.patientId}`)}
type="button"
>
{item.patient}
</button>
<p className="mt-1 text-sm text-[#a3a3a3]">{item.reason}</p>
</div>
<Info label="Status" value={item.status} />
<Info label="Espera" value={item.wait} />
<div className="flex items-start lg:justify-end">
<PriorityPill priority={item.priority} />
</div>
</article>
))}
{visibleQueue.length === 0 ? (
<div className="rounded-xl border border-dashed border-[#404040] bg-[#171717] p-8 text-center">
<h2 className="text-lg font-bold text-[#f5f5f5]">Fila vazia</h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-[#a3a3a3]">
Nenhuma consulta caiu neste estado. Troque de aba para ver a fila mockada.
</p>
</div>
) : null}
</div>
</section>
<section className="grid gap-6 lg:grid-cols-3">
{stages.map((stage, index) => (
<article className={`${cardClass} p-5`} key={stage.title}>
<p className="text-sm font-bold uppercase tracking-[0.16em] text-[#3b82f6]">Etapa {index + 1}</p>
<h2 className="mt-2 text-xl font-bold text-[#f5f5f5]">{stage.title}</h2>
<p className="mt-3 text-sm leading-6 text-[#a3a3a3]">{stage.description}</p>
</article>
))}
</section>
</div>
)
}
function Info({ label, value }) {
return (
<div>
<p className="text-xs font-bold uppercase tracking-[0.16em] text-[#737373]">{label}</p>
<p className="mt-2 text-sm font-semibold text-[#e5e5e5]">{value}</p>
</div>
)
}
function PriorityPill({ priority }) {
const className =
priority === 'Alta'
? 'bg-red-500/20 text-red-400'
: priority === 'Baixa'
? 'bg-emerald-500/20 text-emerald-400'
: 'bg-amber-500/20 text-amber-400'
return <span className={`rounded px-2.5 py-1 text-xs font-bold ${className}`}>{priority}</span>
}