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:
405
src/pages/AgendaPage.jsx
Normal file
405
src/pages/AgendaPage.jsx
Normal 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
385
src/pages/AnalyticsPage.jsx
Normal 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
344
src/pages/AuthPages.jsx
Normal 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
279
src/pages/HomePage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
436
src/pages/MedicalRecordsPage.jsx
Normal file
436
src/pages/MedicalRecordsPage.jsx
Normal 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
690
src/pages/MessagesPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
src/pages/NotFoundPage.jsx
Normal file
25
src/pages/NotFoundPage.jsx
Normal 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
1279
src/pages/PatientsPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
108
src/pages/ProfilePage.jsx
Normal file
108
src/pages/ProfilePage.jsx
Normal 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
936
src/pages/ReportsPage.jsx
Normal 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
481
src/pages/SettingsPage.jsx
Normal 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
125
src/pages/TeamPage.jsx
Normal 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
155
src/pages/VisitsPage.jsx
Normal 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>
|
||||
}
|
||||
Reference in New Issue
Block a user