forked from RiseUP/riseup_squad_03
Compare commits
2 Commits
squad03_vi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bcee06b908 | |||
| 94dab58d85 |
10
public/favicon.svg
Normal file
10
public/favicon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<rect width="48" height="48" rx="8" fill="#3b82f6"/>
|
||||
<g fill="none" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4">
|
||||
<path d="M22 7v5"/>
|
||||
<path d="M12 7v5"/>
|
||||
<path d="M12 9h-2a4 4 0 0 0-4 4v8a12 12 0 0 0 24 0v-8a4 4 0 0 0-4-4h-2"/>
|
||||
<path d="M18 34a12 12 0 0 0 24 0v-6"/>
|
||||
<circle cx="42" cy="24" r="4"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 431 B |
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
@@ -149,7 +149,7 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
||||
</a>
|
||||
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-40 flex w-64 -translate-x-full flex-col border-r border-[#404040] bg-[#262626] transition-transform duration-200 lg:translate-x-0 ${
|
||||
className={`fixed inset-y-0 left-0 z-40 flex w-56 -translate-x-full flex-col border-r border-[#404040] bg-[#262626] transition-transform duration-200 lg:translate-x-0 ${
|
||||
menuOpen ? 'translate-x-0' : ''
|
||||
}`}
|
||||
>
|
||||
@@ -195,7 +195,7 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="lg:pl-64">
|
||||
<div className="lg:pl-56">
|
||||
<header className="sticky top-0 z-20 h-auto border-b border-[#404040] bg-[#262626] px-4 py-3 md:px-8 lg:h-16 lg:py-0">
|
||||
<div className="flex h-full flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import React from 'react'
|
||||
import { format, isToday } from 'date-fns'
|
||||
import { ptBR } from 'date-fns/locale'
|
||||
|
||||
import { sortAppointmentsByTime } from '../../utils/agendaDate.js'
|
||||
|
||||
export function AgendaDailyView({ baseDate, appointments, onAppointmentClick }) {
|
||||
const DAY_START = '07:00'
|
||||
const DAY_END = '19:00'
|
||||
const SLOT_MINUTES = 30
|
||||
|
||||
export function AgendaDailyView({ baseDate, appointments, canCreateAppointment = true, onAppointmentClick, onSlotCreate }) {
|
||||
const dailyAppointments = sortAppointmentsByTime(appointments)
|
||||
const appointmentsByTime = groupAppointmentsByTime(dailyAppointments)
|
||||
const slots = mergeSlotsWithAppointmentTimes(generateSlots(DAY_START, DAY_END, SLOT_MINUTES), dailyAppointments)
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
||||
<div className="flex flex-col gap-3 border-b border-[#404040] pb-4 md:flex-row md:items-end md:justify-between">
|
||||
<div className="agenda-calendar-shell rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
||||
<div className="agenda-calendar-header flex flex-col gap-3 border-b border-[#404040] pb-4 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-[#737373]">
|
||||
Vista ampliada do dia
|
||||
Grade de horários do dia
|
||||
</span>
|
||||
<h3 className="mt-2 text-xl font-bold text-[#e5e5e5]">
|
||||
{format(baseDate, "EEEE, dd 'de' MMMM", { locale: ptBR })}
|
||||
@@ -20,9 +25,15 @@ export function AgendaDailyView({ baseDate, appointments, onAppointmentClick })
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-[#404040] bg-[#1f1f1f] px-3 py-1 text-xs font-semibold text-[#a3a3a3]">
|
||||
<span className="agenda-legend-pill rounded-full border border-[#404040] bg-[#1f1f1f] px-3 py-1 text-xs font-semibold text-[#a3a3a3]">
|
||||
{dailyAppointments.length} {dailyAppointments.length === 1 ? 'agendamento' : 'agendamentos'}
|
||||
</span>
|
||||
<span className="agenda-legend-pill agenda-legend-free rounded-full border border-emerald-700/40 bg-emerald-950/30 px-3 py-1 text-xs font-semibold text-emerald-200 shadow-sm">
|
||||
Livre
|
||||
</span>
|
||||
<span className="agenda-legend-pill agenda-legend-booked rounded-full border border-red-700/40 bg-red-950/30 px-3 py-1 text-xs font-semibold text-red-200 shadow-sm">
|
||||
Agendado
|
||||
</span>
|
||||
{isToday(baseDate) && (
|
||||
<span className="rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 px-3 py-1 text-xs font-semibold text-[#93c5fd]">
|
||||
Hoje
|
||||
@@ -31,70 +42,134 @@ export function AgendaDailyView({ baseDate, appointments, onAppointmentClick })
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dailyAppointments.length === 0 ? (
|
||||
<div className="mt-4 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 altere o período no calendário.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 grid gap-3">
|
||||
{dailyAppointments.map((appointment) => (
|
||||
<div className="agenda-day-grid mt-4 grid gap-2">
|
||||
{slots.map((time) => {
|
||||
const slotAppointments = appointmentsByTime.get(time) || []
|
||||
const primaryAppointment = slotAppointments[0]
|
||||
const isBooked = Boolean(primaryAppointment)
|
||||
|
||||
return (
|
||||
<article
|
||||
key={appointment.id}
|
||||
className={`grid gap-4 rounded-xl border p-4 md:grid-cols-[96px_1fr_auto] ${getStatusColors(appointment.status)}`}
|
||||
className={`agenda-slot ${isBooked ? getDailyToneClass(primaryAppointment.status) : 'agenda-slot-free'} grid gap-3 rounded-xl border px-4 py-3 shadow-[0_8px_18px_rgba(0,0,0,0.16)] md:grid-cols-[84px_1fr_auto] ${
|
||||
isBooked
|
||||
? 'border-red-700/50 bg-red-950/35 text-red-50'
|
||||
: 'border-emerald-700/50 bg-emerald-950/35 text-emerald-50'
|
||||
}`}
|
||||
key={time}
|
||||
>
|
||||
<div>
|
||||
<p className="text-2xl font-bold leading-none">{appointment.time || '--:--'}</p>
|
||||
<p className="mt-2 text-[11px] font-semibold uppercase tracking-[0.14em] opacity-80">
|
||||
{appointment.mode}
|
||||
<p className="text-xl font-bold leading-none">{time}</p>
|
||||
<p className="mt-1 text-[11px] font-semibold uppercase tracking-[0.12em] opacity-80">
|
||||
{isBooked ? 'Agendado' : 'Disponível'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
className="text-left text-base font-bold transition hover:opacity-85"
|
||||
onClick={() => onAppointmentClick && onAppointmentClick(appointment)}
|
||||
type="button"
|
||||
>
|
||||
{appointment.patient}
|
||||
</button>
|
||||
<p className="mt-1 text-sm opacity-90">
|
||||
{appointment.type} com {appointment.professional}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs font-medium opacity-80">
|
||||
<span className="rounded-full bg-black/15 px-2.5 py-1">{appointment.room}</span>
|
||||
<span className="rounded-full bg-black/15 px-2.5 py-1">{appointment.type}</span>
|
||||
{isBooked ? (
|
||||
<div>
|
||||
<button
|
||||
className="text-left text-sm font-bold transition hover:opacity-85"
|
||||
onClick={() => onAppointmentClick?.(primaryAppointment)}
|
||||
type="button"
|
||||
>
|
||||
{primaryAppointment.patient}
|
||||
</button>
|
||||
<p className="mt-1 text-sm opacity-90">
|
||||
{primaryAppointment.type} com {primaryAppointment.professional}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs font-medium opacity-80">
|
||||
{primaryAppointment.room ? <span className="agenda-slot-chip rounded-full bg-black/25 px-2.5 py-1 shadow-sm">{primaryAppointment.room}</span> : null}
|
||||
{primaryAppointment.mode ? <span className="agenda-slot-chip rounded-full bg-black/25 px-2.5 py-1 shadow-sm">{primaryAppointment.mode}</span> : null}
|
||||
{slotAppointments.length > 1 ? <span className="agenda-slot-chip rounded-full bg-black/25 px-2.5 py-1 shadow-sm">+{slotAppointments.length - 1}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-sm font-medium opacity-90">
|
||||
Horário disponível para novo agendamento.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-start md:justify-end">
|
||||
<span className="rounded-full border border-current/20 bg-black/10 px-3 py-1 text-xs font-bold">
|
||||
{appointment.status}
|
||||
<div className="flex flex-wrap items-start justify-start gap-2 md:justify-end">
|
||||
<span className="agenda-slot-status rounded-full border border-current/30 bg-black/25 px-3 py-1 text-xs font-bold shadow-sm">
|
||||
{isBooked ? primaryAppointment.status : 'Livre'}
|
||||
</span>
|
||||
{canCreateAppointment ? (
|
||||
<button
|
||||
aria-label={`Criar agendamento às ${time}`}
|
||||
className="agenda-slot-add grid size-8 place-items-center rounded-full border border-current/30 bg-black/30 text-base font-bold leading-none shadow-sm transition hover:bg-black/45"
|
||||
onClick={() => onSlotCreate?.(time)}
|
||||
title={`Novo agendamento às ${time}`}
|
||||
type="button"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getStatusColors(status) {
|
||||
function getDailyToneClass(status) {
|
||||
switch (status) {
|
||||
case 'Confirmada':
|
||||
return 'border-[#14532d] bg-[#052e1a] text-[#a7f3d0]'
|
||||
return 'agenda-slot-confirmed'
|
||||
case 'Em triagem':
|
||||
return 'border-[#78350f] bg-[#2d1e05] text-[#fde68a]'
|
||||
case 'Concluida':
|
||||
case 'Concluída':
|
||||
return 'border-[#1e3a8a] bg-[#172554] text-[#bfdbfe]'
|
||||
return 'agenda-slot-triage'
|
||||
case 'Cancelada':
|
||||
return 'border-[#7f1d1d] bg-[#450a0a] text-[#fecaca]'
|
||||
return 'agenda-slot-cancelled'
|
||||
case 'Bloqueado':
|
||||
return 'agenda-slot-blocked'
|
||||
case 'Aguardando':
|
||||
default:
|
||||
return 'border-[#404040] bg-[#1f1f1f] text-[#e5e5e5]'
|
||||
return 'agenda-slot-waiting'
|
||||
}
|
||||
}
|
||||
|
||||
function generateSlots(start, end, intervalMinutes) {
|
||||
const [startHour, startMinute] = start.split(':').map(Number)
|
||||
const [endHour, endMinute] = end.split(':').map(Number)
|
||||
const slots = []
|
||||
let cursor = startHour * 60 + startMinute
|
||||
const last = endHour * 60 + endMinute
|
||||
|
||||
while (cursor < last) {
|
||||
slots.push(formatMinutes(cursor))
|
||||
cursor += intervalMinutes
|
||||
}
|
||||
|
||||
return slots
|
||||
}
|
||||
|
||||
function groupAppointmentsByTime(appointments) {
|
||||
return appointments.reduce((map, appointment) => {
|
||||
const time = normalizeTime(appointment.time)
|
||||
if (!time) return map
|
||||
map.set(time, [...(map.get(time) || []), appointment])
|
||||
return map
|
||||
}, new Map())
|
||||
}
|
||||
|
||||
function mergeSlotsWithAppointmentTimes(slots, appointments) {
|
||||
return [...new Set([...slots, ...appointments.map((appointment) => normalizeTime(appointment.time)).filter(Boolean)])]
|
||||
.sort((first, second) => minutesFromTime(first) - minutesFromTime(second))
|
||||
}
|
||||
|
||||
function normalizeTime(value) {
|
||||
const match = String(value || '').match(/^(\d{1,2}):(\d{2})/)
|
||||
if (!match) return ''
|
||||
return `${match[1].padStart(2, '0')}:${match[2]}`
|
||||
}
|
||||
|
||||
function minutesFromTime(value) {
|
||||
const [hours, minutes] = normalizeTime(value).split(':').map(Number)
|
||||
return hours * 60 + minutes
|
||||
}
|
||||
|
||||
function formatMinutes(totalMinutes) {
|
||||
const hours = String(Math.floor(totalMinutes / 60)).padStart(2, '0')
|
||||
const minutes = String(totalMinutes % 60).padStart(2, '0')
|
||||
return `${hours}:${minutes}`
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ export function AgendaMonthlyView({ baseDate, appointments, onDayClick }) {
|
||||
const weekDays = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb']
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
||||
<div className="grid grid-cols-7 gap-px border-b border-[#404040] pb-4">
|
||||
<div className="agenda-calendar-shell rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
||||
<div className="agenda-calendar-header grid grid-cols-7 gap-px border-b border-[#404040] pb-4">
|
||||
{weekDays.map((day) => (
|
||||
<div key={day} className="text-center text-xs font-semibold uppercase tracking-widest text-[#a3a3a3]">
|
||||
{day}
|
||||
@@ -49,7 +49,7 @@ export function AgendaMonthlyView({ baseDate, appointments, onDayClick }) {
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
onClick={() => onDayClick && onDayClick(day)}
|
||||
className={`flex min-h-[100px] flex-col rounded-xl border p-2 text-left transition hover:border-[#525252] ${
|
||||
className={`agenda-month-day flex min-h-[100px] flex-col rounded-xl border p-2 text-left transition hover:border-[#525252] ${
|
||||
isCurrentMonth
|
||||
? 'border-[#404040] bg-[#1f1f1f]'
|
||||
: 'border-transparent bg-transparent opacity-40 hover:opacity-80'
|
||||
@@ -69,7 +69,7 @@ export function AgendaMonthlyView({ baseDate, appointments, onDayClick }) {
|
||||
{dayAppointments.slice(0, 3).map((appointment) => (
|
||||
<div
|
||||
key={appointment.id}
|
||||
className="flex items-center gap-1.5 truncate rounded bg-[#303030] px-1.5 py-1 text-[10px] font-semibold text-[#a3a3a3]"
|
||||
className={`agenda-month-event ${getStatusToneClass(appointment.status)} flex items-center gap-1.5 truncate rounded bg-[#303030] px-1.5 py-1 text-[10px] font-semibold text-[#a3a3a3]`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${getDotColor(appointment.status)}`} />
|
||||
<span className="truncate">
|
||||
@@ -91,6 +91,22 @@ export function AgendaMonthlyView({ baseDate, appointments, onDayClick }) {
|
||||
)
|
||||
}
|
||||
|
||||
function getStatusToneClass(status) {
|
||||
switch (status) {
|
||||
case 'Confirmada':
|
||||
return 'agenda-event-confirmed'
|
||||
case 'Em triagem':
|
||||
return 'agenda-event-triage'
|
||||
case 'Cancelada':
|
||||
return 'agenda-event-cancelled'
|
||||
case 'Bloqueado':
|
||||
return 'agenda-event-blocked'
|
||||
case 'Aguardando':
|
||||
default:
|
||||
return 'agenda-event-waiting'
|
||||
}
|
||||
}
|
||||
|
||||
function getDotColor(status) {
|
||||
switch (status) {
|
||||
case 'Confirmada':
|
||||
|
||||
@@ -26,8 +26,8 @@ export function AgendaWeeklyView({ baseDate, appointments, onAppointmentClick })
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
||||
<div className="grid grid-cols-7 gap-4 border-b border-[#404040] pb-4">
|
||||
<div className="agenda-calendar-shell rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
||||
<div className="agenda-calendar-header grid grid-cols-7 gap-4 border-b border-[#404040] pb-4">
|
||||
{days.map((day) => {
|
||||
const isWeekend = day.getDay() === 0
|
||||
|
||||
@@ -60,7 +60,7 @@ export function AgendaWeeklyView({ baseDate, appointments, onAppointmentClick })
|
||||
return (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className="flex h-full flex-col gap-2 rounded-lg border border-[#404040]/50 bg-[#1f1f1f] p-2"
|
||||
className="agenda-week-day flex h-full min-w-0 flex-col gap-2 rounded-lg border border-[#404040]/50 bg-[#1f1f1f] p-2"
|
||||
>
|
||||
{dayAppointments.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
@@ -71,21 +71,21 @@ export function AgendaWeeklyView({ baseDate, appointments, onAppointmentClick })
|
||||
<button
|
||||
key={appointment.id}
|
||||
onClick={() => onAppointmentClick && onAppointmentClick(appointment)}
|
||||
className={`flex w-full flex-col items-start rounded-md border p-2 text-left shadow-sm transition hover:brightness-110 ${getStatusColors(appointment.status)}`}
|
||||
className={`agenda-event ${getStatusToneClass(appointment.status)} flex w-full min-w-0 flex-col items-start overflow-hidden rounded-md border p-2 text-left shadow-sm transition hover:brightness-110 ${getStatusColors(appointment.status)}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="rounded bg-black/20 px-1.5 py-0.5 text-xs font-bold leading-none">
|
||||
<div className="mb-1 flex w-full min-w-0 items-center gap-1.5 overflow-hidden">
|
||||
<span className="shrink-0 rounded bg-black/20 px-1.5 py-0.5 text-[10px] font-bold leading-none">
|
||||
{appointment.time}
|
||||
</span>
|
||||
<span className="truncate text-[10px] font-semibold uppercase tracking-wider opacity-80">
|
||||
<span className="min-w-0 flex-1 truncate text-[9px] font-semibold uppercase tracking-normal opacity-80">
|
||||
{appointment.mode}
|
||||
</span>
|
||||
</div>
|
||||
<span className="w-full truncate text-xs font-bold leading-tight" title={appointment.patient}>
|
||||
<span className="block w-full min-w-0 truncate text-xs font-bold leading-tight" title={appointment.patient}>
|
||||
{appointment.patient}
|
||||
</span>
|
||||
<span
|
||||
className="mt-0.5 w-full truncate text-[10px] font-medium opacity-80"
|
||||
className="mt-0.5 block w-full min-w-0 truncate text-[10px] font-medium opacity-80"
|
||||
title={appointment.professional}
|
||||
>
|
||||
Dr(a). {appointment.professional?.split(' ')[0]}
|
||||
@@ -101,6 +101,25 @@ export function AgendaWeeklyView({ baseDate, appointments, onAppointmentClick })
|
||||
)
|
||||
}
|
||||
|
||||
function getStatusToneClass(status) {
|
||||
switch (status) {
|
||||
case 'Confirmada':
|
||||
return 'agenda-event-confirmed'
|
||||
case 'Em triagem':
|
||||
return 'agenda-event-triage'
|
||||
case 'Concluida':
|
||||
case 'Concluída':
|
||||
return 'agenda-event-finished'
|
||||
case 'Cancelada':
|
||||
return 'agenda-event-cancelled'
|
||||
case 'Bloqueado':
|
||||
return 'agenda-event-blocked'
|
||||
case 'Aguardando':
|
||||
default:
|
||||
return 'agenda-event-waiting'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColors(status) {
|
||||
switch (status) {
|
||||
case 'Confirmada':
|
||||
|
||||
@@ -6,21 +6,21 @@ export const featureStateStyles = {
|
||||
label: '',
|
||||
},
|
||||
partial: {
|
||||
badge: 'border-sky-500/40 bg-sky-500/15 text-sky-300',
|
||||
panel: 'border-sky-500/35 bg-sky-500/8',
|
||||
title: 'text-sky-300',
|
||||
badge: 'feature-badge-partial border-sky-500/40 bg-sky-500/15 text-sky-300',
|
||||
panel: 'feature-panel-partial border-sky-500/35 bg-sky-500/8',
|
||||
title: 'feature-title-partial text-sky-300',
|
||||
label: 'Parcial',
|
||||
},
|
||||
mock: {
|
||||
badge: 'border-amber-500/40 bg-amber-500/15 text-amber-300',
|
||||
panel: 'border-amber-500/35 bg-amber-500/8',
|
||||
title: 'text-amber-300',
|
||||
badge: 'feature-badge-mock border-amber-500/40 bg-amber-500/15 text-amber-300',
|
||||
panel: 'feature-panel-mock border-amber-500/35 bg-amber-500/8',
|
||||
title: 'feature-title-mock text-amber-300',
|
||||
label: 'Mockado',
|
||||
},
|
||||
wip: {
|
||||
badge: 'border-rose-500/40 bg-rose-500/15 text-rose-300',
|
||||
panel: 'border-rose-500/35 bg-rose-500/8',
|
||||
title: 'text-rose-300',
|
||||
badge: 'feature-badge-wip border-rose-500/40 bg-rose-500/15 text-rose-300',
|
||||
panel: 'feature-panel-wip border-rose-500/35 bg-rose-500/8',
|
||||
title: 'feature-title-wip text-rose-300',
|
||||
label: 'WIP',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { isSameDay } from 'date-fns'
|
||||
|
||||
import { appointmentRepository } from '../repositories/appointmentRepository.js'
|
||||
@@ -8,6 +8,16 @@ import { professionalRepository } from '../repositories/professionalRepository.j
|
||||
import { profileRepository } from '../repositories/profileRepository.js'
|
||||
import { formatLocalDateInput, parseLocalDate, sortAppointmentsByTime } from '../utils/agendaDate.js'
|
||||
|
||||
const initialForm = {
|
||||
patientId: '',
|
||||
professionalId: '',
|
||||
type: 'Retorno',
|
||||
time: '15:30',
|
||||
mode: 'Teleconsulta',
|
||||
status: 'Aguardando',
|
||||
notes: '',
|
||||
}
|
||||
|
||||
export function useAgenda() {
|
||||
const [patients, setPatients] = useState([])
|
||||
const [professionals, setProfessionals] = useState([])
|
||||
@@ -27,14 +37,9 @@ export function useAgenda() {
|
||||
const [doctorSearch, setDoctorSearch] = useState('')
|
||||
const [unitFilter, setUnitFilter] = useState('')
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editingAppointment, setEditingAppointment] = useState(null)
|
||||
const [form, setForm] = useState(initialForm)
|
||||
|
||||
const [form, setForm] = useState({
|
||||
patientId: '',
|
||||
professionalId: '',
|
||||
type: 'Retorno',
|
||||
time: '15:30',
|
||||
mode: 'Teleconsulta',
|
||||
})
|
||||
const agendaScope = viewerProfile?.isDoctor ? 'doctor' : 'global'
|
||||
const canCreateAppointment = agendaScope === 'doctor'
|
||||
? Boolean(currentProfessional?.id)
|
||||
@@ -55,10 +60,10 @@ export function useAgenda() {
|
||||
|
||||
if (!active) return
|
||||
|
||||
const agendaScope = currentProfile?.isDoctor ? 'doctor' : 'global'
|
||||
const currentScope = currentProfile?.isDoctor ? 'doctor' : 'global'
|
||||
const resolvedProfessional = professionalRepository.resolveCurrentProfessional(currentProfile, professionalsData)
|
||||
const initialProfessionalId =
|
||||
agendaScope === 'doctor'
|
||||
currentScope === 'doctor'
|
||||
? resolvedProfessional?.id || ''
|
||||
: professionalsData?.[0]?.id || ''
|
||||
|
||||
@@ -72,20 +77,20 @@ export function useAgenda() {
|
||||
professionalId: initialProfessionalId,
|
||||
}))
|
||||
|
||||
if (agendaScope === 'doctor' && !resolvedProfessional) {
|
||||
if (currentScope === 'doctor' && !resolvedProfessional) {
|
||||
setLocalAppointments([])
|
||||
setError('Não foi possível vincular o médico logado a um profissional da base.')
|
||||
return
|
||||
}
|
||||
|
||||
const appointmentsData = await appointmentRepository.getAll({
|
||||
doctorId: agendaScope === 'doctor' ? resolvedProfessional?.id : undefined,
|
||||
doctorId: currentScope === 'doctor' ? resolvedProfessional?.id : undefined,
|
||||
})
|
||||
|
||||
if (!active) return
|
||||
|
||||
setLocalAppointments(
|
||||
agendaScope === 'doctor' && resolvedProfessional
|
||||
currentScope === 'doctor' && resolvedProfessional
|
||||
? filterAppointmentsByProfessional(appointmentsData || [], resolvedProfessional.id)
|
||||
: sortAppointmentsByTime(appointmentsData || []),
|
||||
)
|
||||
@@ -95,9 +100,7 @@ export function useAgenda() {
|
||||
console.error(loadError)
|
||||
setError(loadError.message || 'Erro ao carregar agenda.')
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false)
|
||||
}
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +112,7 @@ export function useAgenda() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalOpen) return
|
||||
if (!modalOpen || editingAppointment) return
|
||||
|
||||
const targetProfessionalId = agendaScope === 'doctor'
|
||||
? currentProfessional?.id
|
||||
@@ -160,7 +163,7 @@ export function useAgenda() {
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [agendaScope, baseDate, currentProfessional?.id, form.mode, form.professionalId, modalOpen])
|
||||
}, [agendaScope, baseDate, currentProfessional?.id, editingAppointment, form.mode, form.professionalId, modalOpen])
|
||||
|
||||
const visibleAppointments = useMemo(() => {
|
||||
let filtered = localAppointments
|
||||
@@ -205,46 +208,154 @@ export function useAgenda() {
|
||||
}
|
||||
|
||||
return sortAppointmentsByTime(filtered)
|
||||
}, [localAppointments, status, agendaScope, doctorFilter, doctorSearch, unitFilter, professionals, activeView, baseDate])
|
||||
}, [activeView, agendaScope, baseDate, doctorFilter, doctorSearch, localAppointments, professionals, status, unitFilter])
|
||||
|
||||
function updateForm(field, value) {
|
||||
setForm((current) => ({ ...current, [field]: value }))
|
||||
}
|
||||
|
||||
async function handleCreate(event) {
|
||||
function openCreateModal({ date, time } = {}) {
|
||||
if (date) {
|
||||
const parsedDate = parseLocalDate(date)
|
||||
if (parsedDate) setBaseDate(parsedDate)
|
||||
}
|
||||
|
||||
setEditingAppointment(null)
|
||||
setAvailableSlots([])
|
||||
setSlotsError('')
|
||||
setForm((current) => ({
|
||||
...initialForm,
|
||||
patientId: current.patientId || patients[0]?.id || '',
|
||||
professionalId:
|
||||
agendaScope === 'doctor'
|
||||
? currentProfessional?.id || ''
|
||||
: current.professionalId || professionals[0]?.id || '',
|
||||
time: time || current.time || initialForm.time,
|
||||
}))
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
function openAppointmentModal(appointment) {
|
||||
const parsedDate = parseLocalDate(appointment.date)
|
||||
if (parsedDate) setBaseDate(parsedDate)
|
||||
|
||||
setEditingAppointment(appointment)
|
||||
setAvailableSlots([])
|
||||
setSlotsError('')
|
||||
setForm({
|
||||
patientId: appointment.patientId || '',
|
||||
professionalId: appointment.professionalId || '',
|
||||
type: appointment.type || initialForm.type,
|
||||
time: appointment.time || initialForm.time,
|
||||
mode: appointment.mode || initialForm.mode,
|
||||
status: appointment.status || initialForm.status,
|
||||
notes: appointment.notes || '',
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
function closeAppointmentModal() {
|
||||
setModalOpen(false)
|
||||
setEditingAppointment(null)
|
||||
}
|
||||
|
||||
async function handleSubmitAppointment(event) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!form.patientId) {
|
||||
alert('Selecione um paciente para criar o agendamento.')
|
||||
if (editingAppointment) {
|
||||
await updateAppointment()
|
||||
return
|
||||
}
|
||||
|
||||
await createAppointment()
|
||||
}
|
||||
|
||||
async function createAppointment() {
|
||||
const payload = buildPayload()
|
||||
if (!payload) return
|
||||
|
||||
try {
|
||||
const created = await appointmentRepository.create(payload)
|
||||
setLocalAppointments((current) => sortAppointmentsByTime([...current, enrichAppointment(created, payload, patients, professionals)]))
|
||||
closeAppointmentModal()
|
||||
} catch (createError) {
|
||||
alert(createError.message || 'Erro ao criar agendamento.')
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAppointment() {
|
||||
if (!editingAppointment) return
|
||||
|
||||
const payload = buildPayload()
|
||||
if (!payload) return
|
||||
|
||||
try {
|
||||
const updated = await appointmentRepository.update(editingAppointment.id, payload)
|
||||
setLocalAppointments((current) =>
|
||||
sortAppointmentsByTime(
|
||||
current.map((appointment) =>
|
||||
appointment.id === editingAppointment.id
|
||||
? enrichAppointment(updated, payload, patients, professionals)
|
||||
: appointment,
|
||||
),
|
||||
),
|
||||
)
|
||||
closeAppointmentModal()
|
||||
} catch (updateError) {
|
||||
alert(updateError.message || 'Erro ao atualizar agendamento.')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelAppointment() {
|
||||
if (!editingAppointment) return
|
||||
if (!window.confirm('Tem certeza que deseja cancelar este agendamento?')) return
|
||||
|
||||
const payload = buildPayload({ status: 'Cancelada' })
|
||||
if (!payload) return
|
||||
|
||||
try {
|
||||
const cancelled = await appointmentRepository.cancel(editingAppointment.id, payload)
|
||||
setLocalAppointments((current) =>
|
||||
sortAppointmentsByTime(
|
||||
current.map((appointment) =>
|
||||
appointment.id === editingAppointment.id
|
||||
? enrichAppointment(cancelled, payload, patients, professionals)
|
||||
: appointment,
|
||||
),
|
||||
),
|
||||
)
|
||||
closeAppointmentModal()
|
||||
} catch (cancelError) {
|
||||
alert(cancelError.message || 'Erro ao cancelar agendamento.')
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(overrides = {}) {
|
||||
if (!form.patientId) {
|
||||
alert('Selecione um paciente para salvar o agendamento.')
|
||||
return null
|
||||
}
|
||||
|
||||
const targetProfessionalId = agendaScope === 'doctor'
|
||||
? currentProfessional?.id
|
||||
: form.professionalId
|
||||
|
||||
if (!targetProfessionalId) {
|
||||
alert('Não foi possível identificar o profissional da consulta.')
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
const dateStr = formatLocalDateInput(baseDate)
|
||||
|
||||
try {
|
||||
const created = await appointmentRepository.create({
|
||||
patientId: form.patientId,
|
||||
date: dateStr,
|
||||
time: form.time,
|
||||
type: form.type,
|
||||
mode: form.mode,
|
||||
room: form.mode === 'Teleconsulta' ? 'Virtual' : 'Consultório 1',
|
||||
professionalId: targetProfessionalId,
|
||||
})
|
||||
|
||||
setLocalAppointments((current) => sortAppointmentsByTime([...current, created]))
|
||||
setModalOpen(false)
|
||||
} catch (createError) {
|
||||
alert(createError.message || 'Erro ao criar agendamento.')
|
||||
return {
|
||||
patientId: form.patientId,
|
||||
date: formatLocalDateInput(baseDate),
|
||||
time: form.time,
|
||||
type: form.type,
|
||||
mode: form.mode,
|
||||
status: form.status,
|
||||
notes: form.notes,
|
||||
room: form.mode === 'Teleconsulta' ? 'Virtual' : 'Consultório 1',
|
||||
professionalId: targetProfessionalId,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,10 +381,14 @@ export function useAgenda() {
|
||||
unitFilter,
|
||||
setUnitFilter,
|
||||
modalOpen,
|
||||
setModalOpen,
|
||||
editingAppointment,
|
||||
form,
|
||||
updateForm,
|
||||
handleCreate,
|
||||
openCreateModal,
|
||||
openAppointmentModal,
|
||||
closeAppointmentModal,
|
||||
handleSubmitAppointment,
|
||||
handleCancelAppointment,
|
||||
visibleAppointments,
|
||||
availableSlots,
|
||||
slotsLoading,
|
||||
@@ -289,6 +404,26 @@ function filterAppointmentsByProfessional(appointments, professionalId) {
|
||||
)
|
||||
}
|
||||
|
||||
function enrichAppointment(appointment, payload, patients, professionals) {
|
||||
const patient = patients.find((item) => String(item.id) === String(payload.patientId))
|
||||
const professional = professionals.find((item) => String(item.id) === String(payload.professionalId))
|
||||
|
||||
return {
|
||||
...appointment,
|
||||
patientId: payload.patientId,
|
||||
professionalId: payload.professionalId,
|
||||
patient: patient?.name || patient?.full_name || patient?.nome || appointment.patient,
|
||||
professional: professional?.name || professional?.full_name || professional?.nome || appointment.professional,
|
||||
date: payload.date,
|
||||
time: payload.time,
|
||||
type: payload.type,
|
||||
mode: payload.mode,
|
||||
status: payload.status,
|
||||
notes: payload.notes,
|
||||
room: payload.room,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeValue(value) {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
421
src/index.css
421
src/index.css
@@ -46,12 +46,12 @@ button:disabled {
|
||||
|
||||
:root[data-theme='light'] {
|
||||
color: #333333;
|
||||
background: #eef2f7;
|
||||
background: #d9e4f0;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
[data-theme='light'] body {
|
||||
background: #eef2f7;
|
||||
background: #d9e4f0;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ button:disabled {
|
||||
|
||||
[data-theme='light'] .bg-\[\#0a0a0a\],
|
||||
[data-theme='light'] .bg-\[\#171717\] {
|
||||
background-color: #eef2f7;
|
||||
background-color: #d9e4f0;
|
||||
}
|
||||
|
||||
[data-theme='light'] .bg-\[\#1a1a1a\] {
|
||||
@@ -106,7 +106,7 @@ button:disabled {
|
||||
}
|
||||
|
||||
[data-theme='light'] .disabled\:bg-\[\#303030\]:disabled {
|
||||
background-color: #eef2f7;
|
||||
background-color: #d9e4f0;
|
||||
}
|
||||
|
||||
[data-theme='light'] .border-\[\#404040\],
|
||||
@@ -173,3 +173,416 @@ button:disabled {
|
||||
[data-theme='light'] svg [fill='#171717'] {
|
||||
fill: #f9fafb;
|
||||
}
|
||||
|
||||
.auth-dark {
|
||||
background: #0a0a0a;
|
||||
color: #ffffff;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.auth-dark .auth-input {
|
||||
border-color: #404040;
|
||||
background: #171717;
|
||||
color: #e5e5e5;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.auth-dark .auth-input::placeholder {
|
||||
color: #737373;
|
||||
}
|
||||
|
||||
.auth-dark .auth-menu {
|
||||
border-color: #404040;
|
||||
background: #171717;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.auth-dark .auth-menu:hover {
|
||||
color: #e5e5e5;
|
||||
}
|
||||
|
||||
[data-theme='light'] .auth-dark {
|
||||
background: #0a0a0a;
|
||||
color: #ffffff;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
[data-theme='light'] .auth-dark .auth-input {
|
||||
border-color: #404040;
|
||||
background: #171717;
|
||||
color: #e5e5e5;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
[data-theme='light'] .auth-dark .auth-input::placeholder {
|
||||
color: #737373;
|
||||
}
|
||||
|
||||
[data-theme='light'] .auth-dark .auth-menu {
|
||||
border-color: #404040;
|
||||
background: #171717;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
[data-theme='light'] .auth-dark .auth-menu:hover {
|
||||
color: #e5e5e5;
|
||||
}
|
||||
|
||||
[data-theme='light'] .settings-theme-preview-dark {
|
||||
border-color: #525252;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
[data-theme='light'] .settings-theme-preview-dark .settings-theme-preview-bar {
|
||||
background: #262626;
|
||||
}
|
||||
|
||||
[data-theme='light'] .settings-theme-preview-dark .settings-theme-preview-side {
|
||||
background: #171717;
|
||||
}
|
||||
|
||||
[data-theme='light'] .settings-theme-preview-dark .settings-theme-preview-line {
|
||||
background: #525252;
|
||||
}
|
||||
|
||||
[data-theme='light'] .settings-theme-preview-light {
|
||||
border-color: #d6dee8;
|
||||
background: #f4f7fb;
|
||||
}
|
||||
|
||||
[data-theme='light'] button:has(.settings-theme-preview-dark) .bg-\[\#3b82f6\] {
|
||||
background-color: #404040;
|
||||
}
|
||||
|
||||
[data-theme='light'] .feature-badge-partial {
|
||||
border-color: #0284c7;
|
||||
background: #dff3ff;
|
||||
color: #075985;
|
||||
box-shadow: inset 0 0 0 1px rgba(2, 132, 199, 0.12);
|
||||
}
|
||||
|
||||
[data-theme='light'] .feature-panel-partial {
|
||||
border-color: #38bdf8;
|
||||
background: #eef9ff;
|
||||
}
|
||||
|
||||
[data-theme='light'] .feature-title-partial {
|
||||
color: #075985;
|
||||
}
|
||||
|
||||
[data-theme='light'] .feature-badge-mock {
|
||||
border-color: #d97706;
|
||||
background: #fff2c2;
|
||||
color: #92400e;
|
||||
box-shadow: inset 0 0 0 1px rgba(217, 119, 6, 0.14);
|
||||
}
|
||||
|
||||
[data-theme='light'] .feature-panel-mock {
|
||||
border-color: #f59e0b;
|
||||
background: #fff8db;
|
||||
}
|
||||
|
||||
[data-theme='light'] .feature-title-mock {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
[data-theme='light'] .feature-badge-wip {
|
||||
border-color: #e11d48;
|
||||
background: #ffe4e8;
|
||||
color: #9f1239;
|
||||
box-shadow: inset 0 0 0 1px rgba(225, 29, 72, 0.12);
|
||||
}
|
||||
|
||||
[data-theme='light'] .feature-panel-wip {
|
||||
border-color: #fb7185;
|
||||
background: #fff1f3;
|
||||
}
|
||||
|
||||
[data-theme='light'] .feature-title-wip {
|
||||
color: #9f1239;
|
||||
}
|
||||
|
||||
.agenda-calendar-shell {
|
||||
border-color: #3b3b3b;
|
||||
background: #202020;
|
||||
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
.agenda-calendar-header {
|
||||
border-color: #3b3b3b;
|
||||
}
|
||||
|
||||
.agenda-legend-pill {
|
||||
border-color: #404040;
|
||||
background: #171717;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.agenda-legend-free {
|
||||
border-color: #166534;
|
||||
background: #052e1a;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.agenda-legend-booked {
|
||||
border-color: #a16207;
|
||||
background: #422006;
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.agenda-day-grid {
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #3b3b3b;
|
||||
border-radius: 14px;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
#1f2933 0,
|
||||
#1f2933 39px,
|
||||
#334155 40px
|
||||
);
|
||||
}
|
||||
|
||||
.agenda-slot {
|
||||
margin: 0;
|
||||
border-width: 1px;
|
||||
border-radius: 0;
|
||||
color: #e5e5e5;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 4px 14px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.agenda-slot + .agenda-slot {
|
||||
border-top-width: 0;
|
||||
}
|
||||
|
||||
.agenda-slot-free {
|
||||
border-color: #15803d;
|
||||
background: linear-gradient(180deg, #083d22 0%, #052e1a 100%);
|
||||
color: #bbf7d0;
|
||||
}
|
||||
|
||||
.agenda-slot-waiting,
|
||||
.agenda-event-waiting {
|
||||
border-color: #b7791f;
|
||||
background: linear-gradient(180deg, #53350a 0%, #3f2a09 100%);
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.agenda-slot-confirmed,
|
||||
.agenda-event-confirmed {
|
||||
border-color: #0891b2;
|
||||
background: linear-gradient(180deg, #083344 0%, #0c2636 100%);
|
||||
color: #a5f3fc;
|
||||
}
|
||||
|
||||
.agenda-slot-triage,
|
||||
.agenda-event-triage {
|
||||
border-color: #9333ea;
|
||||
background: linear-gradient(180deg, #3b0764 0%, #2e0a4f 100%);
|
||||
color: #e9d5ff;
|
||||
}
|
||||
|
||||
.agenda-event-finished {
|
||||
border-color: #2563eb;
|
||||
background: linear-gradient(180deg, #172554 0%, #111c3d 100%);
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
.agenda-slot-cancelled,
|
||||
.agenda-event-cancelled {
|
||||
border-color: #b91c1c;
|
||||
background: linear-gradient(180deg, #4c0519 0%, #3b0713 100%);
|
||||
color: #fecdd3;
|
||||
}
|
||||
|
||||
.agenda-slot-blocked,
|
||||
.agenda-event-blocked {
|
||||
border-color: #525252;
|
||||
background: linear-gradient(180deg, #262626 0%, #1f1f1f 100%);
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.agenda-slot-chip,
|
||||
.agenda-slot-status {
|
||||
border-color: rgba(229, 229, 229, 0.12);
|
||||
background: rgba(0, 0, 0, 0.26);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.agenda-slot-add {
|
||||
border-color: rgba(229, 229, 229, 0.18);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.agenda-slot-add:hover {
|
||||
background: rgba(0, 0, 0, 0.46);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.agenda-week-day,
|
||||
.agenda-month-day {
|
||||
border-color: #3b3b3b;
|
||||
background: #1f1f1f;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agenda-month-day:nth-child(7n + 1),
|
||||
.agenda-month-day:nth-child(7n) {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.agenda-event,
|
||||
.agenda-month-event {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.agenda-event span,
|
||||
.agenda-month-event span {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-calendar-shell {
|
||||
border-color: #d7e2ec;
|
||||
background: #f8fbfd;
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-calendar-header {
|
||||
border-color: #dbe7f1;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-legend-pill {
|
||||
border-color: #d7e2ec;
|
||||
background: #ffffff;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-legend-free {
|
||||
border-color: #86c98a;
|
||||
background: #eaf9ea;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-legend-booked {
|
||||
border-color: #f0b23d;
|
||||
background: #fff5cf;
|
||||
color: #7a4a05;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-day-grid {
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #d7e2ec;
|
||||
border-radius: 14px;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
#eef4f8 0,
|
||||
#eef4f8 39px,
|
||||
#dbe7f1 40px
|
||||
);
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot {
|
||||
margin: 0;
|
||||
border-width: 1px;
|
||||
border-radius: 0;
|
||||
color: #334155;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8), 0 2px 8px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot + .agenda-slot {
|
||||
border-top-width: 0;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot-free {
|
||||
border-color: #97d39b;
|
||||
background: linear-gradient(180deg, #f2fff2 0%, #e6f7e7 100%);
|
||||
color: #14532d;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot-waiting,
|
||||
[data-theme='light'] .agenda-event-waiting {
|
||||
border-color: #f0b23d;
|
||||
background: linear-gradient(180deg, #fff8d7 0%, #fff2b7 100%);
|
||||
color: #6f4700;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot-confirmed,
|
||||
[data-theme='light'] .agenda-event-confirmed {
|
||||
border-color: #26b8ec;
|
||||
background: linear-gradient(180deg, #e5faff 0%, #cef3ff 100%);
|
||||
color: #075985;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot-triage,
|
||||
[data-theme='light'] .agenda-event-triage {
|
||||
border-color: #b35cff;
|
||||
background: linear-gradient(180deg, #f8ddff 0%, #edc4ff 100%);
|
||||
color: #5b217f;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-event-finished {
|
||||
border-color: #60a5fa;
|
||||
background: linear-gradient(180deg, #dbeafe 0%, #bfdbfe 100%);
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot-cancelled,
|
||||
[data-theme='light'] .agenda-event-cancelled {
|
||||
border-color: #fb7185;
|
||||
background: linear-gradient(180deg, #ffe4e6 0%, #fecdd3 100%);
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot-blocked,
|
||||
[data-theme='light'] .agenda-event-blocked {
|
||||
border-color: #cbd5e1;
|
||||
background: linear-gradient(180deg, #f1f5f9 0%, #e2e8f0 100%);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot-chip,
|
||||
[data-theme='light'] .agenda-slot-status {
|
||||
border-color: rgba(51, 65, 85, 0.18);
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot-add {
|
||||
border-color: rgba(30, 64, 175, 0.28);
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot-add:hover {
|
||||
background: #ffffff;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-week-day,
|
||||
[data-theme='light'] .agenda-month-day {
|
||||
border-color: #d7e2ec;
|
||||
background: #eef4f8;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-month-day:nth-child(7n + 1),
|
||||
[data-theme='light'] .agenda-month-day:nth-child(7n) {
|
||||
background: #e8f0f6;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-event,
|
||||
[data-theme='light'] .agenda-month-event {
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.78), 0 1px 4px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-event span,
|
||||
[data-theme='light'] .agenda-month-event span {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export const appointmentMapper = {
|
||||
cancelled: 'Cancelada',
|
||||
}
|
||||
|
||||
const rawStatus = (apiData.status || '').toLowerCase()
|
||||
const rawStatus = String(apiData.status || '').toLowerCase()
|
||||
const mappedStatus = statusMap[rawStatus] || apiData.situacao || 'Aguardando'
|
||||
|
||||
// Modalidade
|
||||
@@ -66,6 +66,7 @@ export const appointmentMapper = {
|
||||
type: apiData.type || apiData.tipo || apiData.tipo_consulta || 'Consulta',
|
||||
mode: mode,
|
||||
status: mappedStatus,
|
||||
notes: apiData.notes || apiData.observations || apiData.observacoes || apiData.observacao || apiData.description || '',
|
||||
room: apiData.room || apiData.sala || apiData.local || 'Consultório 1',
|
||||
}
|
||||
},
|
||||
@@ -80,7 +81,9 @@ export const appointmentMapper = {
|
||||
doctor_id: uiData.professionalId || null,
|
||||
scheduled_at: scheduledAt,
|
||||
appointment_type: uiData.mode === 'Teleconsulta' ? 'telemedicina' : 'presencial',
|
||||
status: uiData.status === 'Confirmada' ? 'confirmed' : 'requested',
|
||||
status: toApiStatus(uiData.status),
|
||||
notes: emptyToUndefined(uiData.notes),
|
||||
observations: emptyToUndefined(uiData.notes),
|
||||
duration_minutes: 30, // Padrao
|
||||
}
|
||||
}
|
||||
@@ -94,6 +97,37 @@ export const appointmentMapper = {
|
||||
mode: uiData.mode,
|
||||
status: uiData.status || 'Confirmada',
|
||||
room: uiData.room,
|
||||
notes: uiData.notes,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
function emptyToUndefined(value) {
|
||||
return value === '' || value === null ? undefined : value
|
||||
}
|
||||
|
||||
function toApiStatus(status) {
|
||||
const normalized = String(status || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
const statusMap = {
|
||||
confirmada: 'confirmed',
|
||||
confirmado: 'confirmed',
|
||||
em_triagem: 'checked_in',
|
||||
triagem: 'checked_in',
|
||||
aguardando: 'requested',
|
||||
solicitada: 'requested',
|
||||
solicitacao: 'requested',
|
||||
cancelada: 'cancelled',
|
||||
cancelado: 'cancelled',
|
||||
concluida: 'completed',
|
||||
concluido: 'completed',
|
||||
finalizada: 'completed',
|
||||
finalizado: 'completed',
|
||||
}
|
||||
|
||||
return statusMap[normalized.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')] || 'requested'
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@ export const reportMapper = {
|
||||
conclusion: emptyToUndefined(uiData.conclusion),
|
||||
content_html: emptyToUndefined(uiData.contentHtml),
|
||||
content_json: uiData.contentJson === undefined ? undefined : uiData.contentJson,
|
||||
hide_date: Boolean(uiData.hideDate),
|
||||
hide_signature: Boolean(uiData.hideSignature),
|
||||
due_at: emptyToUndefined(uiData.dueAt),
|
||||
created_by: emptyToUndefined(uiData.createdBy),
|
||||
updated_by: emptyToUndefined(uiData.updatedBy),
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import {
|
||||
addDays,
|
||||
subDays,
|
||||
addWeeks,
|
||||
subWeeks,
|
||||
addMonths,
|
||||
subMonths,
|
||||
addWeeks,
|
||||
endOfWeek,
|
||||
format,
|
||||
startOfWeek,
|
||||
subDays,
|
||||
subMonths,
|
||||
subWeeks,
|
||||
} from 'date-fns'
|
||||
import { ptBR } from 'date-fns/locale'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { AgendaDailyView } from '../components/calendar/AgendaDailyView.jsx'
|
||||
import { AgendaWeeklyView } from '../components/calendar/AgendaWeeklyView.jsx'
|
||||
import { AgendaMonthlyView } from '../components/calendar/AgendaMonthlyView.jsx'
|
||||
import { AgendaWeeklyView } from '../components/calendar/AgendaWeeklyView.jsx'
|
||||
import { useAgenda } from '../hooks/useAgenda.js'
|
||||
import { formatLocalDateInput, parseLocalDate } from '../utils/agendaDate.js'
|
||||
|
||||
@@ -23,6 +23,7 @@ const statusFilters = [
|
||||
{ label: 'Confirmadas', value: 'Confirmada' },
|
||||
{ label: 'Em triagem', value: 'Em triagem' },
|
||||
{ label: 'Aguardando', value: 'Aguardando' },
|
||||
{ label: 'Canceladas', value: 'Cancelada' },
|
||||
]
|
||||
|
||||
const viewFilters = [
|
||||
@@ -32,8 +33,9 @@ const viewFilters = [
|
||||
]
|
||||
|
||||
const appointmentTypeOptions = ['Retorno', 'Primeira consulta', 'Exame', 'Avaliação pre-op']
|
||||
const appointmentStatusOptions = ['Confirmada', 'Em triagem', 'Aguardando']
|
||||
|
||||
export function AgendaPage({ navigate }) {
|
||||
export function AgendaPage() {
|
||||
const [modalPatientSearch, setModalPatientSearch] = useState('')
|
||||
const [modalDoctorSearch, setModalDoctorSearch] = useState('')
|
||||
const {
|
||||
@@ -57,10 +59,14 @@ export function AgendaPage({ navigate }) {
|
||||
unitFilter,
|
||||
setUnitFilter,
|
||||
modalOpen,
|
||||
setModalOpen,
|
||||
editingAppointment,
|
||||
form,
|
||||
updateForm,
|
||||
handleCreate,
|
||||
openCreateModal,
|
||||
openAppointmentModal,
|
||||
closeAppointmentModal,
|
||||
handleSubmitAppointment,
|
||||
handleCancelAppointment,
|
||||
visibleAppointments,
|
||||
availableSlots,
|
||||
slotsLoading,
|
||||
@@ -79,42 +85,41 @@ export function AgendaPage({ navigate }) {
|
||||
const weekEnd = endOfWeek(baseDate, { weekStartsOn: 0 })
|
||||
const isDoctorScope = agendaScope === 'doctor'
|
||||
const unitOptions = [
|
||||
...new Set(
|
||||
professionals
|
||||
.map((professional) => professional.unit)
|
||||
.filter(Boolean),
|
||||
),
|
||||
...new Set(professionals.map((professional) => professional.unit).filter(Boolean)),
|
||||
].sort((a, b) => a.localeCompare(b, 'pt-BR'))
|
||||
const filteredPatients = (() => {
|
||||
const query = normalizeSearch(modalPatientSearch)
|
||||
if (!query) return patients
|
||||
|
||||
return patients.filter((patient) =>
|
||||
[patient.name, patient.full_name, patient.nome, patient.cpf, patient.email]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
)
|
||||
})()
|
||||
const filteredProfessionals = (() => {
|
||||
const query = normalizeSearch(modalDoctorSearch)
|
||||
if (!query) return professionals
|
||||
|
||||
return professionals.filter((professional) =>
|
||||
[professional.name, professional.email, professional.unit]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
)
|
||||
})()
|
||||
const filteredPatients = filterBySearch(patients, modalPatientSearch, (patient) => [
|
||||
patient.name,
|
||||
patient.full_name,
|
||||
patient.nome,
|
||||
patient.cpf,
|
||||
patient.email,
|
||||
])
|
||||
const filteredProfessionals = filterBySearch(professionals, modalDoctorSearch, (professional) => [
|
||||
professional.name,
|
||||
professional.email,
|
||||
professional.unit,
|
||||
])
|
||||
const selectedPatient = patients.find((patient) => String(patient.id) === String(form.patientId))
|
||||
const selectedProfessional = professionals.find((professional) => String(professional.id) === String(form.professionalId))
|
||||
const timeOptions = getTimeOptions(form.time, availableSlots)
|
||||
|
||||
function openCreate(options = {}) {
|
||||
setModalPatientSearch('')
|
||||
setModalDoctorSearch('')
|
||||
openCreateModal(options)
|
||||
}
|
||||
|
||||
function openManage(appointment) {
|
||||
setModalPatientSearch('')
|
||||
setModalDoctorSearch('')
|
||||
openAppointmentModal(appointment)
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setModalPatientSearch('')
|
||||
setModalDoctorSearch('')
|
||||
closeAppointmentModal()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-[1180px] flex-col gap-8 text-[#e5e5e5]">
|
||||
@@ -169,7 +174,7 @@ export function AgendaPage({ navigate }) {
|
||||
<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] disabled:cursor-not-allowed disabled:border-[#404040] disabled:bg-[#303030] disabled:text-[#737373] disabled:shadow-none"
|
||||
disabled={!canCreateAppointment}
|
||||
onClick={() => setModalOpen(true)}
|
||||
onClick={() => openCreate()}
|
||||
type="button"
|
||||
>
|
||||
+ Novo agendamento
|
||||
@@ -283,7 +288,7 @@ export function AgendaPage({ navigate }) {
|
||||
<AgendaWeeklyView
|
||||
baseDate={baseDate}
|
||||
appointments={visibleAppointments}
|
||||
onAppointmentClick={(appointment) => navigate(`/pacientes/${appointment.patientId}`)}
|
||||
onAppointmentClick={openManage}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -300,9 +305,11 @@ export function AgendaPage({ navigate }) {
|
||||
|
||||
{activeView === 'Dia' && (
|
||||
<AgendaDailyView
|
||||
baseDate={baseDate}
|
||||
appointments={visibleAppointments}
|
||||
onAppointmentClick={(appointment) => navigate(`/pacientes/${appointment.patientId}`)}
|
||||
baseDate={baseDate}
|
||||
canCreateAppointment={canCreateAppointment}
|
||||
onAppointmentClick={openManage}
|
||||
onSlotCreate={(time) => openCreate({ time })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -310,54 +317,93 @@ export function AgendaPage({ navigate }) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<DarkModal onClose={() => setModalOpen(false)} open={modalOpen} title="Novo agendamento">
|
||||
<form className="grid gap-4" onSubmit={handleCreate}>
|
||||
<DarkField label="Dia do agendamento">
|
||||
<input
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none [color-scheme:dark] focus:border-[#3b82f6]"
|
||||
onChange={(event) => {
|
||||
const parsedDate = parseLocalDate(event.target.value)
|
||||
if (parsedDate) setBaseDate(parsedDate)
|
||||
}}
|
||||
type="date"
|
||||
value={formatLocalDateInput(baseDate)}
|
||||
/>
|
||||
</DarkField>
|
||||
<DarkModal onClose={closeModal} open={modalOpen} title={editingAppointment ? 'Gerenciar agendamento' : 'Novo agendamento'}>
|
||||
<form className="grid gap-4" onSubmit={handleSubmitAppointment}>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="grid content-start gap-4">
|
||||
<DarkField label="Paciente">
|
||||
<input
|
||||
className="h-10 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
|
||||
onChange={(event) => {
|
||||
setModalPatientSearch(event.target.value)
|
||||
updateForm('patientId', '')
|
||||
}}
|
||||
placeholder="Pesquisar paciente"
|
||||
type="search"
|
||||
value={modalPatientSearch || getPatientLabel(selectedPatient)}
|
||||
/>
|
||||
<SearchResults
|
||||
emptyText="Nenhum paciente encontrado."
|
||||
getLabel={getPatientLabel}
|
||||
items={filteredPatients.slice(0, 5)}
|
||||
onSelect={(patient) => {
|
||||
updateForm('patientId', patient.id)
|
||||
setModalPatientSearch(getPatientLabel(patient))
|
||||
}}
|
||||
selectedId={form.patientId}
|
||||
/>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Paciente">
|
||||
<input
|
||||
className="h-10 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
|
||||
onChange={(event) => {
|
||||
setModalPatientSearch(event.target.value)
|
||||
updateForm('patientId', '')
|
||||
}}
|
||||
placeholder="Pesquisar paciente"
|
||||
type="search"
|
||||
value={modalPatientSearch || getPatientLabel(selectedPatient)}
|
||||
/>
|
||||
<SearchResults
|
||||
emptyText="Nenhum paciente encontrado."
|
||||
getLabel={getPatientLabel}
|
||||
items={filteredPatients.slice(0, 6)}
|
||||
onSelect={(patient) => {
|
||||
updateForm('patientId', patient.id)
|
||||
setModalPatientSearch(getPatientLabel(patient))
|
||||
}}
|
||||
selectedId={form.patientId}
|
||||
/>
|
||||
</DarkField>
|
||||
<DarkField label="Profissional">
|
||||
{isDoctorScope ? (
|
||||
<input
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#262626] px-3 text-sm text-[#a3a3a3] outline-none"
|
||||
disabled
|
||||
readOnly
|
||||
value={currentProfessional?.name || 'Médico não vinculado'}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className="h-10 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
|
||||
onChange={(event) => {
|
||||
setModalDoctorSearch(event.target.value)
|
||||
updateForm('professionalId', '')
|
||||
}}
|
||||
placeholder="Pesquisar médico"
|
||||
type="search"
|
||||
value={modalDoctorSearch || selectedProfessional?.name || ''}
|
||||
/>
|
||||
<SearchResults
|
||||
emptyText="Nenhum médico encontrado."
|
||||
getDescription={(professional) => professional.unit || professional.email}
|
||||
getLabel={(professional) => professional.name}
|
||||
items={filteredProfessionals.slice(0, 5)}
|
||||
onSelect={(professional) => {
|
||||
updateForm('professionalId', professional.id)
|
||||
setModalDoctorSearch(professional.name)
|
||||
}}
|
||||
selectedId={form.professionalId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DarkField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<DarkField label="Horário">
|
||||
{availableSlots.length ? (
|
||||
<div className="grid content-start gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<DarkField label="Dia">
|
||||
<input
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none [color-scheme:dark] focus:border-[#3b82f6]"
|
||||
onChange={(event) => {
|
||||
const parsedDate = parseLocalDate(event.target.value)
|
||||
if (parsedDate) setBaseDate(parsedDate)
|
||||
}}
|
||||
type="date"
|
||||
value={formatLocalDateInput(baseDate)}
|
||||
/>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Horário">
|
||||
{timeOptions.length ? (
|
||||
<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('time', event.target.value)}
|
||||
value={form.time}
|
||||
>
|
||||
{availableSlots.map((slot) => (
|
||||
<option key={slot.time} value={slot.time}>
|
||||
{slot.time}
|
||||
{timeOptions.map((time) => (
|
||||
<option key={time} value={time}>
|
||||
{time}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -369,88 +415,99 @@ export function AgendaPage({ navigate }) {
|
||||
value={form.time}
|
||||
/>
|
||||
)}
|
||||
{slotsLoading ? (
|
||||
<span className="text-xs font-normal text-[#a3a3a3]">Calculando horários...</span>
|
||||
) : null}
|
||||
{slotsError ? (
|
||||
<span className="text-xs font-normal text-amber-400">{slotsError}</span>
|
||||
) : null}
|
||||
</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>
|
||||
{slotsLoading ? <span className="text-xs font-normal text-[#a3a3a3]">Calculando horários...</span> : null}
|
||||
{slotsError ? <span className="text-xs font-normal text-amber-400">{slotsError}</span> : null}
|
||||
</DarkField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<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>
|
||||
|
||||
<DarkField label="Status">
|
||||
<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('status', event.target.value)}
|
||||
value={form.status}
|
||||
>
|
||||
{!appointmentStatusOptions.includes(form.status) && form.status ? (
|
||||
<option value={form.status}>{form.status}</option>
|
||||
) : null}
|
||||
{appointmentStatusOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</DarkField>
|
||||
</div>
|
||||
|
||||
<DarkField label="Tipo de consulta">
|
||||
<select
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||
onChange={(event) => updateForm('type', event.target.value)}
|
||||
value={form.type}
|
||||
>
|
||||
{appointmentTypeOptions.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Observações">
|
||||
<textarea
|
||||
className="min-h-24 resize-y rounded-md border border-[#404040] bg-[#303030] px-3 py-2 text-sm leading-5 text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
|
||||
onChange={(event) => updateForm('notes', event.target.value)}
|
||||
placeholder="Observações sobre o agendamento"
|
||||
value={form.notes}
|
||||
/>
|
||||
</DarkField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DarkField label="Profissional">
|
||||
{isDoctorScope ? (
|
||||
<input
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#262626] px-3 text-sm text-[#a3a3a3] outline-none"
|
||||
disabled
|
||||
readOnly
|
||||
value={currentProfessional?.name || 'Médico não vinculado'}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className="h-10 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
|
||||
onChange={(event) => {
|
||||
setModalDoctorSearch(event.target.value)
|
||||
updateForm('professionalId', '')
|
||||
}}
|
||||
placeholder="Pesquisar médico"
|
||||
type="search"
|
||||
value={modalDoctorSearch || selectedProfessional?.name || ''}
|
||||
/>
|
||||
<SearchResults
|
||||
emptyText="Nenhum médico encontrado."
|
||||
getDescription={(professional) => professional.unit || professional.email}
|
||||
getLabel={(professional) => professional.name}
|
||||
items={filteredProfessionals.slice(0, 6)}
|
||||
onSelect={(professional) => {
|
||||
updateForm('professionalId', professional.id)
|
||||
setModalDoctorSearch(professional.name)
|
||||
}}
|
||||
selectedId={form.professionalId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Tipo de consulta">
|
||||
<select
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||
onChange={(event) => updateForm('type', event.target.value)}
|
||||
value={form.type}
|
||||
>
|
||||
{appointmentTypeOptions.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</DarkField>
|
||||
{editingAppointment ? (
|
||||
<div className="rounded-xl border border-[#404040] bg-[#1f1f1f] px-4 py-3 text-sm text-[#a3a3a3]">
|
||||
<p>
|
||||
Agendamento de {selectedPatient ? getPatientLabel(selectedPatient) : 'paciente não informado'} às {form.time}.
|
||||
</p>
|
||||
<p className="mt-1">Status atual: {form.status}</p>
|
||||
{form.notes ? <p className="mt-1">Observações: {form.notes}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-3 pt-2">
|
||||
{editingAppointment ? (
|
||||
<button
|
||||
className="mr-auto h-10 rounded-sm border border-red-500/40 bg-red-950/20 px-4 text-sm font-semibold text-red-200 transition hover:bg-red-950/35"
|
||||
onClick={handleCancelAppointment}
|
||||
type="button"
|
||||
>
|
||||
Cancelar agendamento
|
||||
</button>
|
||||
) : null}
|
||||
<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)}
|
||||
onClick={closeModal}
|
||||
type="button"
|
||||
>
|
||||
Cancelar
|
||||
Fechar
|
||||
</button>
|
||||
<button
|
||||
className="h-10 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#3478ed] disabled:cursor-not-allowed disabled:border-[#404040] disabled:bg-[#303030] disabled:text-[#737373]"
|
||||
disabled={!canCreateAppointment}
|
||||
type="submit"
|
||||
>
|
||||
Salvar
|
||||
{editingAppointment ? 'Salvar alterações' : 'Salvar'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -469,13 +526,11 @@ function DarkField({ children, label }) {
|
||||
}
|
||||
|
||||
function DarkModal({ children, onClose, open, title }) {
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
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="w-full max-w-4xl 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
|
||||
@@ -526,6 +581,30 @@ function getPatientLabel(patient) {
|
||||
return patient?.name || patient?.full_name || patient?.nome || ''
|
||||
}
|
||||
|
||||
function filterBySearch(items, search, getValues) {
|
||||
const query = normalizeSearch(search)
|
||||
if (!query) return items
|
||||
|
||||
return items.filter((item) =>
|
||||
getValues(item)
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
)
|
||||
}
|
||||
|
||||
function getTimeOptions(selectedTime, slots) {
|
||||
return [
|
||||
...new Set([
|
||||
selectedTime,
|
||||
...slots.map((slot) => slot.time),
|
||||
].filter(Boolean)),
|
||||
].sort()
|
||||
}
|
||||
|
||||
function normalizeSearch(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFD')
|
||||
|
||||
@@ -43,7 +43,7 @@ export function LoginPage({ navigate }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[#0a1628] text-white">
|
||||
<main className="auth-dark min-h-screen text-white">
|
||||
<div className="grid min-h-screen lg:grid-cols-2">
|
||||
<section className="relative hidden min-h-screen overflow-hidden lg:block">
|
||||
<img
|
||||
@@ -56,7 +56,7 @@ export function LoginPage({ navigate }) {
|
||||
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%)',
|
||||
'linear-gradient(126.72deg, rgba(10, 10, 10, 0.92) 0%, rgba(23, 23, 23, 0.72) 52%, rgba(59, 130, 246, 0.28) 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -107,7 +107,7 @@ export function LoginPage({ navigate }) {
|
||||
<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"
|
||||
className={authInputClass}
|
||||
id="login-email"
|
||||
onChange={(event) => updateField('email', event.target.value)}
|
||||
placeholder="seu@email.com"
|
||||
@@ -132,7 +132,7 @@ export function LoginPage({ navigate }) {
|
||||
<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"
|
||||
className={authPasswordInputClass}
|
||||
id="login-password"
|
||||
onChange={(event) => updateField('password', event.target.value)}
|
||||
placeholder="••••••••"
|
||||
@@ -162,7 +162,7 @@ export function LoginPage({ navigate }) {
|
||||
|
||||
<div className="absolute bottom-4 right-4">
|
||||
{credentialsOpen ? (
|
||||
<div className="mb-2 w-[292px] rounded-md border border-white/10 bg-[#0f1b2d] p-2 shadow-2xl">
|
||||
<div className="auth-menu mb-2 w-[292px] rounded-md border p-2 shadow-2xl">
|
||||
<p className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-white/40">
|
||||
Credenciais de acesso
|
||||
</p>
|
||||
@@ -188,7 +188,7 @@ export function LoginPage({ navigate }) {
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
className="flex h-[29px] items-center gap-1.5 rounded-sm border border-white/10 bg-white/[0.05] px-3 font-mono text-[10px] font-medium leading-[15px] text-white/30 transition hover:text-white/50"
|
||||
className="auth-menu flex h-[29px] items-center gap-1.5 rounded-sm border px-3 font-mono text-[10px] font-medium leading-[15px] transition"
|
||||
onClick={() => setCredentialsOpen((current) => !current)}
|
||||
title="Preencher credenciais de acesso"
|
||||
type="button"
|
||||
@@ -321,7 +321,7 @@ export function ForgotPasswordPage({ navigate }) {
|
||||
|
||||
function AuthLayout({ children, description, title }) {
|
||||
return (
|
||||
<main className="min-h-screen bg-[#0a1628] text-white">
|
||||
<main className="auth-dark min-h-screen 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} />
|
||||
@@ -330,7 +330,7 @@ function AuthLayout({ children, description, title }) {
|
||||
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%)',
|
||||
'linear-gradient(126.72deg, rgba(10, 10, 10, 0.92) 0%, rgba(23, 23, 23, 0.72) 52%, rgba(59, 130, 246, 0.28) 100%)',
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex min-h-screen flex-col justify-between px-[43px] py-[43px] xl:px-12 xl:py-12">
|
||||
@@ -351,7 +351,7 @@ function AuthLayout({ children, description, title }) {
|
||||
</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="w-full max-w-[448px] lg:translate-y-3">
|
||||
<div className="mb-12 lg:hidden">
|
||||
<LoginLogo />
|
||||
</div>
|
||||
@@ -366,11 +366,13 @@ function AuthLayout({ children, description, title }) {
|
||||
}
|
||||
|
||||
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'
|
||||
'auth-input h-11 w-full rounded-[6px] border px-4 text-sm outline-none transition focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
|
||||
const authPasswordInputClass =
|
||||
'auth-input h-11 w-full rounded-[6px] border py-2 pl-4 pr-11 text-sm outline-none transition 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">
|
||||
<label className="grid gap-1.5 text-xs font-medium leading-4 text-[#a3a3a3]">
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
@@ -380,7 +382,7 @@ function AuthField({ 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">
|
||||
<span className="flex min-h-4 items-center justify-between gap-4 text-xs font-medium leading-4 text-[#a3a3a3]">
|
||||
<label htmlFor={htmlFor}>{label}</label>
|
||||
{action}
|
||||
</span>
|
||||
|
||||
@@ -23,16 +23,6 @@ export function HomePage({ navigate }) {
|
||||
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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-3">
|
||||
|
||||
@@ -542,14 +542,16 @@ function TemplateCard({ onEdit, onUse, template }) {
|
||||
}
|
||||
|
||||
function MessageComposer({ allowedChannelKeys, draft, onChange, onClose, onSubmit, patients, templates }) {
|
||||
const [patientSearch, setPatientSearch] = useState('')
|
||||
const [patientSearch, setPatientSearch] = useState(draft.patient || '')
|
||||
const filteredPatients = useMemo(() => {
|
||||
const query = patientSearch.trim().toLowerCase()
|
||||
const query = normalizeSearch(patientSearch)
|
||||
if (!query) return patients
|
||||
|
||||
return patients.filter((patient) =>
|
||||
[patient.name, patient.phone, patient.document]
|
||||
.join(' ')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
)
|
||||
@@ -559,15 +561,14 @@ function MessageComposer({ allowedChannelKeys, draft, onChange, onClose, onSubmi
|
||||
onChange((current) => ({ ...current, [field]: value }))
|
||||
}
|
||||
|
||||
function selectPatient(patientId) {
|
||||
const patient = patients.find((item) => item.id === patientId)
|
||||
|
||||
function selectPatient(patient) {
|
||||
onChange((current) => ({
|
||||
...current,
|
||||
patientId,
|
||||
patientId: patient?.id || '',
|
||||
patient: patient?.name || '',
|
||||
phone: patient?.phone || current.phone,
|
||||
}))
|
||||
setPatientSearch(patient?.name || '')
|
||||
}
|
||||
|
||||
function applyTemplate(templateName) {
|
||||
@@ -589,31 +590,44 @@ function MessageComposer({ allowedChannelKeys, draft, onChange, onClose, onSubmi
|
||||
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">
|
||||
<DarkField label="Paciente">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
className={inputClass}
|
||||
onChange={(event) => setPatientSearch(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setPatientSearch(event.target.value)
|
||||
onChange((current) => ({ ...current, patientId: '', patient: '' }))
|
||||
}}
|
||||
placeholder="Digite nome, CPF ou telefone"
|
||||
type="search"
|
||||
value={patientSearch}
|
||||
/>
|
||||
</DarkField>
|
||||
<DarkField label="Selecionar paciente">
|
||||
<select
|
||||
className={inputClass}
|
||||
onChange={(event) => selectPatient(event.target.value)}
|
||||
value={draft.patientId}
|
||||
>
|
||||
<option value="">Selecione um paciente</option>
|
||||
{filteredPatients.map((patient) => (
|
||||
<option key={patient.id} value={patient.id}>
|
||||
{patient.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</DarkField>
|
||||
</div>
|
||||
<div className="max-h-44 overflow-y-auto rounded-md border border-[#404040] bg-[#1f1f1f]">
|
||||
{filteredPatients.length ? (
|
||||
filteredPatients.slice(0, 8).map((patient) => {
|
||||
const isSelected = String(patient.id) === String(draft.patientId)
|
||||
return (
|
||||
<button
|
||||
className={`block w-full px-3 py-2 text-left text-sm transition ${
|
||||
isSelected ? 'bg-[#3b82f6]/20 text-[#e5e5e5]' : 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
|
||||
}`}
|
||||
key={patient.id}
|
||||
onClick={() => selectPatient(patient)}
|
||||
type="button"
|
||||
>
|
||||
<span className="block font-semibold">{patient.name}</span>
|
||||
<span className="mt-0.5 block text-xs text-[#737373]">
|
||||
{[patient.document, patient.phone].filter(Boolean).join(' | ') || 'Sem documento informado'}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<p className="px-3 py-2 text-xs text-[#737373]">Nenhum paciente encontrado.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DarkField>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DarkField label="Paciente selecionado">
|
||||
@@ -749,6 +763,14 @@ function DarkField({ children, label }) {
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeSearch(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function CommIcon({ className = 'size-4', name }) {
|
||||
const common = {
|
||||
className,
|
||||
|
||||
@@ -376,7 +376,7 @@ export function PatientsPage({ navigate, role }) {
|
||||
<td className="px-6 py-4 align-top text-[#a3a3a3]">{patient.state || missingValue('Estado')}</td>
|
||||
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.lastVisit || 'Ainda não houve atendimento'}</td>
|
||||
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.nextVisit || 'Nenhum atendimento agendado'}</td>
|
||||
<td className="relative sticky right-0 bg-[#262626] px-6 py-4 text-right shadow-[-10px_0_12px_-12px_rgba(0,0,0,0.75)]">
|
||||
<td className="sticky right-0 bg-[#262626] px-4 py-4 text-right shadow-[-10px_0_12px_-12px_rgba(0,0,0,0.75)]">
|
||||
<button
|
||||
aria-label={`Ações de ${patient.name}`}
|
||||
className="rounded p-1 text-[#a3a3a3] transition hover:bg-[#333333] hover:text-[#e5e5e5]"
|
||||
@@ -396,7 +396,7 @@ export function PatientsPage({ navigate, role }) {
|
||||
onClick={() => setOpenMenuId(null)}
|
||||
type="button"
|
||||
/>
|
||||
<div className="absolute right-4 top-12 z-50 w-48 rounded-md border border-[#404040] bg-[#262626] p-1 text-left shadow-lg">
|
||||
<div className="fixed right-8 z-50 w-48 rounded-md border border-[#404040] bg-[#262626] p-1 text-left shadow-lg">
|
||||
<ActionItem icon="file" label="Ver detalhes" onClick={() => openDetail(patient)} />
|
||||
{canEditPatients ? <ActionItem icon="edit" label="Editar" onClick={() => openForm(patient.id)} /> : null}
|
||||
<ActionItem
|
||||
@@ -1504,7 +1504,9 @@ function PatientIcon({ className = 'size-4', name }) {
|
||||
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" />
|
||||
<circle cx="5" cy="12" fill="currentColor" r="1.5" stroke="none" />
|
||||
<circle cx="12" cy="12" fill="currentColor" r="1.5" stroke="none" />
|
||||
<circle cx="19" cy="12" fill="currentColor" r="1.5" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { FeatureCallout } from '../components/FeatureState.jsx'
|
||||
import { featurePanelClass } from '../components/featureStateStyles.js'
|
||||
import { profileRepository } from '../repositories/profileRepository.js'
|
||||
import { normalizeRole } from '../config/permissions.js'
|
||||
import { authRepository } from '../repositories/authRepository.js'
|
||||
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'
|
||||
const readOnlyInputClass =
|
||||
'h-10 rounded-sm border border-[#404040] bg-[#1f1f1f] px-3 text-sm text-[#a3a3a3] outline-none'
|
||||
|
||||
export function ProfilePage({ navigate }) {
|
||||
const [saved, setSaved] = useState(false)
|
||||
@@ -18,10 +21,13 @@ export function ProfilePage({ navigate }) {
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
profileRepository.getCurrentUserProfile().then(data => {
|
||||
setProfile(data)
|
||||
setLoading(false)
|
||||
}).catch(() => setLoading(false))
|
||||
profileRepository
|
||||
.getCurrentUserProfile()
|
||||
.then((data) => {
|
||||
setProfile(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
function update(field, value) {
|
||||
@@ -56,31 +62,33 @@ export function ProfilePage({ navigate }) {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center pt-20 text-[#a3a3a3]">Localizando dados do paciente...</div>
|
||||
return <div className="pt-20 text-center text-[#a3a3a3]">Localizando dados do perfil...</div>
|
||||
}
|
||||
|
||||
const normalizedRole = normalizeRole(profile.role)
|
||||
const canEditProfile = !['medico', 'secretaria'].includes(normalizedRole)
|
||||
const currentInputClass = canEditProfile ? inputClass : readOnlyInputClass
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<FeatureCallout
|
||||
description="Carregar perfil, avatar e logout usam integração. O botão de salvar preferências desta tela ainda grava só localmente."
|
||||
status="partial"
|
||||
title="Perfil com persistência parcial"
|
||||
/>
|
||||
{canEditProfile ? (
|
||||
<FeatureCallout
|
||||
description="Carregar perfil, avatar e logout usam integração. O botão de salvar preferências desta tela ainda grava só localmente."
|
||||
status="partial"
|
||||
title="Perfil com persistência parcial"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
<p className="mt-1 text-sm text-[#b8b8b8]">Dados 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} ${featurePanelClass('partial')} p-6`}>
|
||||
<section className={`${cardClass} ${featurePanelClass(canEditProfile ? 'partial' : 'live')} p-6`}>
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
{profile.avatarUrl ? (
|
||||
<img
|
||||
alt=""
|
||||
className="size-16 rounded-full border border-[#3b82f6]/30 object-cover"
|
||||
src={profile.avatarUrl}
|
||||
/>
|
||||
<img alt="" className="size-16 rounded-full border border-[#3b82f6]/30 object-cover" src={profile.avatarUrl} />
|
||||
) : (
|
||||
<div className="grid size-16 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 text-xl font-bold text-[#3b82f6]">
|
||||
{initials(profile.name)}
|
||||
@@ -89,21 +97,25 @@ export function ProfilePage({ navigate }) {
|
||||
<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] disabled:opacity-60"
|
||||
disabled={uploadingAvatar}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
{uploadingAvatar ? 'Enviando...' : 'Alterar foto'}
|
||||
</button>
|
||||
<input
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleAvatarChange}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
{canEditProfile ? (
|
||||
<>
|
||||
<button
|
||||
className="mt-1 text-xs font-semibold text-[#3b82f6] disabled:opacity-60"
|
||||
disabled={uploadingAvatar}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
{uploadingAvatar ? 'Enviando...' : 'Alterar foto'}
|
||||
</button>
|
||||
<input
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleAvatarChange}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{avatarError ? <p className="mt-1 text-xs font-semibold text-red-400">{avatarError}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,38 +124,44 @@ export function ProfilePage({ navigate }) {
|
||||
className="grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
setSaved(true)
|
||||
if (canEditProfile) 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} />
|
||||
<input className={currentInputClass} onChange={(event) => update('name', event.target.value)} readOnly={!canEditProfile} value={profile.name} />
|
||||
</Field>
|
||||
<Field label="Cargo">
|
||||
<input className={inputClass} onChange={(event) => update('role', event.target.value)} value={profile.role} />
|
||||
<input className={currentInputClass} onChange={(event) => update('role', event.target.value)} readOnly={!canEditProfile} 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} />
|
||||
<input className={currentInputClass} onChange={(event) => update('email', event.target.value)} readOnly={!canEditProfile} type="email" value={profile.email} />
|
||||
</Field>
|
||||
<Field label="Telefone">
|
||||
<input className={inputClass} onChange={(event) => update('phone', event.target.value)} value={profile.phone} />
|
||||
<input className={currentInputClass} onChange={(event) => update('phone', event.target.value)} readOnly={!canEditProfile} 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>
|
||||
{canEditProfile ? (
|
||||
<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>
|
||||
) : (
|
||||
<input className={readOnlyInputClass} readOnly value={profile.unit} />
|
||||
)}
|
||||
</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-amber-500/20 px-2.5 py-1 text-xs font-bold text-amber-300">Preferências salvas localmente</span> : null}
|
||||
</div>
|
||||
{canEditProfile ? (
|
||||
<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-amber-500/20 px-2.5 py-1 text-xs font-bold text-amber-300">Preferências salvas localmente</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -155,9 +173,10 @@ export function ProfilePage({ navigate }) {
|
||||
<Info label="Permissões" value="Agenda, pacientes, comunicação e configurações" />
|
||||
</dl>
|
||||
<div className="mt-8 border-t border-[#404040] pt-6">
|
||||
<button
|
||||
className="w-full h-10 rounded-sm border border-red-500/30 text-red-500 font-semibold text-sm transition hover:bg-red-500/10"
|
||||
<button
|
||||
className="h-10 w-full rounded-sm border border-red-500/30 text-sm font-semibold text-red-500 transition hover:bg-red-500/10"
|
||||
onClick={handleLogout}
|
||||
type="button"
|
||||
>
|
||||
Sair da conta
|
||||
</button>
|
||||
@@ -181,7 +200,7 @@ 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>
|
||||
<dd className="mt-1 text-[#e5e5e5]">{value || '-'}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { normalizeRole } from '../config/permissions.js'
|
||||
import { patientRepository } from '../repositories/patientRepository.js'
|
||||
@@ -35,6 +35,91 @@ const textareaClass =
|
||||
const labelClass = 'mb-1.5 block text-xs font-medium text-[#e5e5e5]'
|
||||
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||
|
||||
const reportTemplates = [
|
||||
{
|
||||
id: 'consulta-medica',
|
||||
category: 'Relatórios',
|
||||
title: 'Relatório de Consulta Médica',
|
||||
description: 'Resumo clínico com queixa, exame físico, hipótese diagnóstica e conduta.',
|
||||
popular: true,
|
||||
tags: ['consulta', 'clínico', 'conduta'],
|
||||
exam: 'Consulta médica',
|
||||
cidCode: 'Z00.0',
|
||||
diagnosis: 'Paciente avaliado(a) em consulta médica, com hipótese diagnóstica em investigação conforme quadro clínico.',
|
||||
conclusion: 'Paciente orientado(a) quanto à conduta proposta, sinais de alerta e necessidade de seguimento.',
|
||||
contentHtml:
|
||||
'<h2>Relatório de Consulta Médica</h2><p><strong>Queixa principal:</strong> </p><p><strong>História clínica:</strong> </p><p><strong>Exame físico:</strong> </p><p><strong>Hipóteses diagnósticas:</strong> </p><p><strong>Conduta:</strong> </p>',
|
||||
},
|
||||
{
|
||||
id: 'evolucao-clinica',
|
||||
category: 'Relatórios',
|
||||
title: 'Evolução Clínica',
|
||||
description: 'Registro de evolução diária para acompanhamento de internação.',
|
||||
tags: ['internação', 'evolução', 'diário'],
|
||||
exam: 'Evolução clínica',
|
||||
cidCode: 'Z51.9',
|
||||
diagnosis: 'Paciente em acompanhamento clínico durante internação, com evolução registrada em prontuário.',
|
||||
conclusion: 'Manter acompanhamento multiprofissional e reavaliar conduta conforme evolução.',
|
||||
contentHtml:
|
||||
'<h2>Evolução Clínica</h2><p><strong>Data e hora:</strong> </p><p><strong>Estado geral:</strong> </p><p><strong>Sinais vitais:</strong> </p><p><strong>Evolução:</strong> </p><p><strong>Conduta do dia:</strong> </p><p><strong>Profissional:</strong> </p>',
|
||||
},
|
||||
{
|
||||
id: 'hemograma',
|
||||
category: 'Laudos',
|
||||
title: 'Laudo de Hemograma',
|
||||
description: 'Interpretação clínica de hemograma com correlação diagnóstica.',
|
||||
tags: ['laboratorial', 'sangue', 'hemograma'],
|
||||
exam: 'Hemograma completo',
|
||||
cidCode: 'Z01.7',
|
||||
diagnosis: 'Exame laboratorial avaliado em conjunto com quadro clínico e exames complementares.',
|
||||
conclusion: 'Resultado analisado e correlacionado com a hipótese diagnóstica descrita.',
|
||||
contentHtml:
|
||||
'<h2>Laudo de Hemograma</h2><p><strong>Material:</strong> Sangue periférico.</p><p><strong>Achados principais:</strong> </p><p><strong>Interpretação:</strong> </p><p><strong>Conclusão:</strong> </p>',
|
||||
},
|
||||
{
|
||||
id: 'imagem',
|
||||
category: 'Laudos',
|
||||
title: 'Laudo de Imagem',
|
||||
description: 'Modelo para exames de imagem com descrição técnica e impressão diagnóstica.',
|
||||
popular: true,
|
||||
tags: ['imagem', 'radiologia', 'exame'],
|
||||
exam: 'Exame de imagem',
|
||||
cidCode: 'Z01.6',
|
||||
diagnosis: 'Achados de imagem descritos conforme exame realizado e indicação clínica.',
|
||||
conclusion: 'Impressão diagnóstica registrada conforme achados do exame.',
|
||||
contentHtml:
|
||||
'<h2>Laudo de Imagem</h2><p><strong>Técnica:</strong> </p><p><strong>Achados:</strong> </p><p><strong>Impressão diagnóstica:</strong> </p><p><strong>Recomendação:</strong> </p>',
|
||||
},
|
||||
{
|
||||
id: 'pre-operatorio',
|
||||
category: 'Relatórios',
|
||||
title: 'Avaliação Pré-operatória',
|
||||
description: 'Avaliação clínica para estratificação de risco e liberação cirúrgica.',
|
||||
tags: ['pré-op', 'cirurgia', 'risco'],
|
||||
exam: 'Avaliação pré-operatória',
|
||||
cidCode: 'Z01.8',
|
||||
diagnosis: 'Paciente em avaliação pré-operatória, com risco definido conforme dados clínicos disponíveis.',
|
||||
conclusion: 'Conduta pré-operatória orientada conforme avaliação clínica e exames apresentados.',
|
||||
contentHtml:
|
||||
'<h2>Avaliação Pré-operatória</h2><p><strong>Procedimento proposto:</strong> </p><p><strong>Comorbidades:</strong> </p><p><strong>Medicamentos em uso:</strong> </p><p><strong>Estratificação de risco:</strong> </p><p><strong>Orientações:</strong> </p>',
|
||||
},
|
||||
{
|
||||
id: 'encaminhamento',
|
||||
category: 'Encaminhamentos',
|
||||
title: 'Encaminhamento Especializado',
|
||||
description: 'Encaminhamento com justificativa clínica e resumo do caso.',
|
||||
tags: ['encaminhamento', 'especialista', 'conduta'],
|
||||
exam: 'Encaminhamento médico',
|
||||
cidCode: 'Z75.8',
|
||||
diagnosis: 'Paciente encaminhado(a) para avaliação especializada por necessidade clínica descrita.',
|
||||
conclusion: 'Solicitada avaliação especializada e continuidade do cuidado compartilhado.',
|
||||
contentHtml:
|
||||
'<h2>Encaminhamento Especializado</h2><p><strong>Especialidade solicitada:</strong> </p><p><strong>Resumo clínico:</strong> </p><p><strong>Motivo do encaminhamento:</strong> </p><p><strong>Exames anexos:</strong> </p>',
|
||||
},
|
||||
]
|
||||
|
||||
const templateCategories = ['Todos', ...Array.from(new Set(reportTemplates.map((template) => template.category)))]
|
||||
|
||||
const emptyEditor = {
|
||||
id: null,
|
||||
orderNumber: '',
|
||||
@@ -47,8 +132,6 @@ const emptyEditor = {
|
||||
conclusion: '',
|
||||
contentHtml: '',
|
||||
contentJson: undefined,
|
||||
hideDate: false,
|
||||
hideSignature: false,
|
||||
dueAt: '',
|
||||
}
|
||||
|
||||
@@ -253,15 +336,16 @@ export function ReportsPage({ role }) {
|
||||
conclusion: report.conclusion,
|
||||
contentHtml: report.contentHtml,
|
||||
contentJson: report.contentJson,
|
||||
hideDate: report.hideDate,
|
||||
hideSignature: report.hideSignature,
|
||||
dueAt: toDateTimeLocal(report.dueAt),
|
||||
})
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!editor.patientId) return
|
||||
if (!isReportEditorValid(editor)) {
|
||||
alert('Preencha todos os campos obrigatórios antes de salvar o relatório.')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
@@ -276,8 +360,6 @@ export function ReportsPage({ role }) {
|
||||
conclusion: editor.conclusion,
|
||||
contentHtml: editor.contentHtml,
|
||||
contentJson: editor.contentJson,
|
||||
hideDate: editor.hideDate,
|
||||
hideSignature: editor.hideSignature,
|
||||
dueAt: editor.dueAt ? new Date(editor.dueAt).toISOString() : '',
|
||||
createdBy: editor.id ? undefined : viewerProfile?.id || currentProfessional?.userId || currentProfessional?.id || undefined,
|
||||
updatedBy: viewerProfile?.id || currentProfessional?.userId || currentProfessional?.id || undefined,
|
||||
@@ -475,7 +557,7 @@ export function ReportsPage({ role }) {
|
||||
</section>
|
||||
|
||||
{editorOpen ? (
|
||||
<ReportEditorModal
|
||||
<ReportEditorModalV2
|
||||
editor={editor}
|
||||
onChange={setEditor}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
@@ -523,9 +605,313 @@ function ReportRow({ onEdit, onView, report }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ReportEditorModalV2({ editor, onChange, onClose, onSave, patientOptions, professionalOptions, saving }) {
|
||||
const editorRef = useRef(null)
|
||||
const [requesterSearch, setRequesterSearch] = useState(editor.requestedBy || '')
|
||||
const [patientSearch, setPatientSearch] = useState('')
|
||||
const [templateSearch, setTemplateSearch] = useState('')
|
||||
const [templateCategory, setTemplateCategory] = useState('Todos')
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState('')
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
const isValid = isReportEditorValid(editor)
|
||||
const selectedPatient = patientOptions.find((patient) => patient.id === String(editor.patientId))
|
||||
const filteredPatients = patientOptions
|
||||
.filter((patient) => normalizeSearch(patient.name).includes(normalizeSearch(patientSearch)))
|
||||
.slice(0, 5)
|
||||
const filteredRequesterOptions = professionalOptions
|
||||
.filter((professional) => normalizeSearch(professional.name).includes(normalizeSearch(requesterSearch)))
|
||||
.slice(0, 5)
|
||||
const filteredTemplates = reportTemplates.filter((template) => {
|
||||
const matchesCategory = templateCategory === 'Todos' || template.category === templateCategory
|
||||
const query = normalizeSearch(templateSearch)
|
||||
const matchesSearch = !query || normalizeSearch([template.title, template.description, template.tags.join(' ')].join(' ')).includes(query)
|
||||
return matchesCategory && matchesSearch
|
||||
})
|
||||
const selectedTemplate = reportTemplates.find((template) => template.id === selectedTemplateId)
|
||||
|
||||
function updateField(field, value) {
|
||||
onChange((current) => ({ ...current, [field]: value }))
|
||||
}
|
||||
|
||||
function applyTemplate(template) {
|
||||
setSelectedTemplateId(template.id)
|
||||
setPreviewOpen(true)
|
||||
onChange((current) => ({
|
||||
...current,
|
||||
exam: template.exam,
|
||||
cidCode: template.cidCode,
|
||||
diagnosis: template.diagnosis,
|
||||
conclusion: template.conclusion,
|
||||
contentHtml: template.contentHtml,
|
||||
contentJson: {
|
||||
templateId: template.id,
|
||||
templateTitle: template.title,
|
||||
appliedAt: new Date().toISOString(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function runCommand(command, value = null) {
|
||||
editorRef.current?.focus()
|
||||
document.execCommand(command, false, value)
|
||||
updateField('contentHtml', editorRef.current?.innerHTML || '')
|
||||
}
|
||||
|
||||
function insertToken(token) {
|
||||
const values = {
|
||||
patient: selectedPatient?.name || '[Paciente]',
|
||||
date: new Date().toLocaleDateString('pt-BR'),
|
||||
doctor: editor.requestedBy || '[Médico]',
|
||||
}
|
||||
runCommand('insertText', values[token] || '')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-3" onClick={onClose}>
|
||||
<div
|
||||
className="flex max-h-[94vh] w-full max-w-6xl flex-col overflow-hidden rounded-xl border border-[#404040] bg-[#242424] shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[#404040] px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid size-9 place-items-center rounded-sm bg-[#0f2f66] text-[#3b82f6]">
|
||||
<ReportIcon className="size-5" name="bolt" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-[#f5f5f5]">{editor.id ? 'Editar relatório' : 'Novo relatório'}</h2>
|
||||
<p className="text-xs text-[#a3a3a3]">Selecione um template e finalize o conteúdo no editor rico.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="inline-flex h-9 items-center gap-2 rounded-sm border border-[#404040] bg-[#1a1a1a] px-3 text-sm font-semibold text-[#d4d4d4] transition hover:bg-[#303030]"
|
||||
onClick={() => setPreviewOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<ReportIcon className="size-4" name="eye" />
|
||||
Pré-visualizar
|
||||
</button>
|
||||
<button className="grid size-9 place-items-center rounded-sm text-[#a3a3a3] transition hover:bg-[#303030] hover:text-[#e5e5e5]" onClick={onClose} type="button">
|
||||
<ReportIcon className="size-4" name="x" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 lg:grid-cols-[230px_minmax(0,1fr)_300px]">
|
||||
<aside className="min-h-0 border-b border-[#404040] bg-[#202020] p-4 lg:border-b-0 lg:border-r">
|
||||
<p className="mb-3 text-xs font-bold uppercase tracking-[0.12em] text-[#a3a3a3]">Categorias</p>
|
||||
<div className="space-y-1">
|
||||
{templateCategories.map((category) => {
|
||||
const count = category === 'Todos' ? reportTemplates.length : reportTemplates.filter((template) => template.category === category).length
|
||||
return (
|
||||
<button
|
||||
className={`flex w-full items-center justify-between rounded-sm px-3 py-2 text-left text-sm font-semibold transition ${
|
||||
templateCategory === category
|
||||
? 'bg-[#3b82f6]/15 text-[#3b82f6]'
|
||||
: 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
|
||||
}`}
|
||||
key={category}
|
||||
onClick={() => setTemplateCategory(category)}
|
||||
type="button"
|
||||
>
|
||||
<span>{category}</span>
|
||||
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[10px]">{count}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="min-h-0 overflow-y-auto p-5">
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<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-sm border border-[#404040] bg-[#171717] pl-10 pr-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6]"
|
||||
onChange={(event) => setTemplateSearch(event.target.value)}
|
||||
placeholder="Buscar templates..."
|
||||
value={templateSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 grid gap-3 md:grid-cols-2">
|
||||
{filteredTemplates.map((template) => (
|
||||
<button
|
||||
className={`min-h-[132px] rounded-md border p-4 text-left transition hover:border-[#3b82f6] ${
|
||||
selectedTemplateId === template.id ? 'border-[#3b82f6] bg-[#2a2f3a]' : 'border-[#404040] bg-[#262626]'
|
||||
}`}
|
||||
key={template.id}
|
||||
onClick={() => applyTemplate(template)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex items-start justify-between gap-3">
|
||||
<span className="text-sm font-bold leading-5 text-[#f5f5f5]">{template.title}</span>
|
||||
{template.popular ? (
|
||||
<span className="rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-bold text-amber-300">Popular</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="mt-2 block text-xs leading-5 text-[#b8b8b8]">{template.description}</span>
|
||||
<span className="mt-3 flex flex-wrap gap-1.5">
|
||||
{template.tags.map((tag) => (
|
||||
<span className="rounded bg-[#1f1f1f] px-2 py-1 text-[10px] font-semibold text-[#a3a3a3]" key={tag}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 border-t border-[#404040] pt-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DarkField label="Tipo de relatório *">
|
||||
<input
|
||||
className={inputClass}
|
||||
onChange={(event) => updateField('exam', event.target.value)}
|
||||
placeholder="Ex: Relatório de consulta médica"
|
||||
value={editor.exam}
|
||||
/>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Paciente *">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
className={inputClass}
|
||||
onChange={(event) => setPatientSearch(event.target.value)}
|
||||
placeholder="Digite o nome do paciente..."
|
||||
value={patientSearch || selectedPatient?.name || ''}
|
||||
/>
|
||||
<SearchPickList
|
||||
emptyText="Nenhum paciente encontrado."
|
||||
items={filteredPatients}
|
||||
labelKey="name"
|
||||
onSelect={(patient) => {
|
||||
updateField('patientId', patient.id)
|
||||
setPatientSearch(patient.name)
|
||||
}}
|
||||
selectedValue={editor.patientId}
|
||||
valueKey="id"
|
||||
/>
|
||||
</div>
|
||||
</DarkField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_160px]">
|
||||
<DarkField label="Médico responsável *">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
className={inputClass}
|
||||
onChange={(event) => {
|
||||
setRequesterSearch(event.target.value)
|
||||
updateField('requestedBy', event.target.value)
|
||||
}}
|
||||
placeholder="Pesquisar médico"
|
||||
value={requesterSearch}
|
||||
/>
|
||||
<SearchPickList
|
||||
emptyText="Nenhum médico encontrado."
|
||||
items={filteredRequesterOptions}
|
||||
labelKey="name"
|
||||
onSelect={(professional) => {
|
||||
setRequesterSearch(professional.name)
|
||||
updateField('requestedBy', professional.name)
|
||||
}}
|
||||
selectedValue={editor.requestedBy}
|
||||
valueKey="name"
|
||||
/>
|
||||
</div>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Status *">
|
||||
<select className={inputClass} onChange={(event) => updateField('status', event.target.value)} value={editor.status}>
|
||||
<option value="draft">Rascunho</option>
|
||||
<option value="finalized">Finalizado</option>
|
||||
</select>
|
||||
</DarkField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DarkField label="CID-10 *">
|
||||
<input className={inputClass} onChange={(event) => updateField('cidCode', event.target.value)} placeholder="Ex: Z01.7" value={editor.cidCode} />
|
||||
</DarkField>
|
||||
<DarkField label="Prazo *">
|
||||
<input className={`${inputClass} [color-scheme:dark]`} onChange={(event) => updateField('dueAt', event.target.value)} type="datetime-local" value={editor.dueAt} />
|
||||
</DarkField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DarkField label="Diagnóstico *">
|
||||
<textarea className={textareaClass} onChange={(event) => updateField('diagnosis', event.target.value)} value={editor.diagnosis} />
|
||||
</DarkField>
|
||||
<DarkField label="Conclusão *">
|
||||
<textarea className={textareaClass} onChange={(event) => updateField('conclusion', event.target.value)} value={editor.conclusion} />
|
||||
</DarkField>
|
||||
</div>
|
||||
|
||||
<DarkField label="Conteúdo">
|
||||
<RichTextEditor
|
||||
editorRef={editorRef}
|
||||
onChange={(value) => updateField('contentHtml', value)}
|
||||
onCommand={runCommand}
|
||||
onInsertToken={insertToken}
|
||||
value={editor.contentHtml}
|
||||
/>
|
||||
</DarkField>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside className="hidden min-h-0 border-l border-[#404040] bg-[#202020] p-5 lg:block">
|
||||
{previewOpen || selectedTemplate ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.12em] text-[#a3a3a3]">Pré-visualização</p>
|
||||
<h3 className="mt-4 text-lg font-bold text-[#f5f5f5]">{editor.exam || selectedTemplate?.title || 'Relatório médico'}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-[#a3a3a3]">
|
||||
{selectedTemplate?.description || 'Use o editor para preencher o conteúdo do relatório.'}
|
||||
</p>
|
||||
<div className="mt-5 rounded-xl border border-[#404040] bg-[#171717] p-4 text-sm leading-6 text-[#d4d4d4]">
|
||||
<div dangerouslySetInnerHTML={{ __html: sanitizePreviewHtml(editor.contentHtml || selectedTemplate?.contentHtml || '') }} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<span className="grid size-16 place-items-center rounded-full bg-[#2a2a2a] text-[#a3a3a3]">
|
||||
<ReportIcon className="size-8" name="file" />
|
||||
</span>
|
||||
<h3 className="mt-4 text-base font-bold text-[#f5f5f5]">Selecione um template</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-[#a3a3a3]">Clique em qualquer modelo para preencher o editor automaticamente.</p>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-[#404040] px-6 py-4">
|
||||
<p className="text-xs font-semibold text-amber-300">
|
||||
{!isValid ? '* Preencha paciente, tipo, médico, CID, prazo, diagnóstico e conclusão para salvar.' : 'Relatório pronto para salvar.'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button className="rounded-sm border border-[#404040] bg-[#262626] px-4 py-2 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]" onClick={onClose} type="button">
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center gap-2 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#2563eb] disabled:cursor-not-allowed disabled:border-[#404040] disabled:bg-[#303030] disabled:text-[#737373]"
|
||||
disabled={!isValid || saving}
|
||||
onClick={onSave}
|
||||
type="button"
|
||||
>
|
||||
<ReportIcon className="size-3.5" name="save" />
|
||||
{saving ? 'Salvando...' : editor.status === 'finalized' ? 'Liberar relatório' : 'Salvar rascunho'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions, professionalOptions, saving }) {
|
||||
const [requesterSearch, setRequesterSearch] = useState(editor.requestedBy || '')
|
||||
const isValid = Boolean(editor.patientId)
|
||||
const isValid = isReportEditorValid(editor)
|
||||
const filteredRequesterOptions = professionalOptions
|
||||
.filter((professional) => normalizeSearch(professional.name).includes(normalizeSearch(requesterSearch)))
|
||||
.slice(0, 6)
|
||||
@@ -563,7 +949,7 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
||||
</select>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Status">
|
||||
<DarkField label="Status *">
|
||||
<select className={inputClass} onChange={(event) => updateField('status', event.target.value)} value={editor.status}>
|
||||
<option value="draft">Rascunho</option>
|
||||
<option value="finalized">Finalizado</option>
|
||||
@@ -572,7 +958,7 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DarkField label="Exame">
|
||||
<DarkField label="Exame *">
|
||||
<input
|
||||
className={inputClass}
|
||||
onChange={(event) => updateField('exam', event.target.value)}
|
||||
@@ -581,7 +967,7 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
||||
/>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Solicitante">
|
||||
<DarkField label="Solicitante *">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
className={inputClass}
|
||||
@@ -620,7 +1006,7 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DarkField label="CID-10">
|
||||
<DarkField label="CID-10 *">
|
||||
<input
|
||||
className={inputClass}
|
||||
onChange={(event) => updateField('cidCode', event.target.value)}
|
||||
@@ -629,7 +1015,7 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
||||
/>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Prazo">
|
||||
<DarkField label="Prazo *">
|
||||
<input
|
||||
className={`${inputClass} [color-scheme:dark]`}
|
||||
onChange={(event) => updateField('dueAt', event.target.value)}
|
||||
@@ -639,7 +1025,7 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
||||
</DarkField>
|
||||
</div>
|
||||
|
||||
<DarkField label="Diagnóstico">
|
||||
<DarkField label="Diagnóstico *">
|
||||
<textarea
|
||||
className={textareaClass}
|
||||
onChange={(event) => updateField('diagnosis', event.target.value)}
|
||||
@@ -648,7 +1034,7 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
||||
/>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Conclusão">
|
||||
<DarkField label="Conclusão *">
|
||||
<textarea
|
||||
className={textareaClass}
|
||||
onChange={(event) => updateField('conclusion', event.target.value)}
|
||||
@@ -664,28 +1050,6 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
||||
value={editor.contentHtml}
|
||||
/>
|
||||
</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.hideDate}
|
||||
className="size-4 accent-[#3b82f6]"
|
||||
onChange={(event) => updateField('hideDate', event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
Ocultar data
|
||||
</label>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-[#e5e5e5]">
|
||||
<input
|
||||
checked={editor.hideSignature}
|
||||
className="size-4 accent-[#3b82f6]"
|
||||
onChange={(event) => updateField('hideSignature', event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
Ocultar assinatura
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -712,6 +1076,103 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
||||
)
|
||||
}
|
||||
|
||||
function SearchPickList({ emptyText, items, labelKey, onSelect, selectedValue, valueKey }) {
|
||||
return (
|
||||
<div className="max-h-32 overflow-y-auto rounded-md border border-[#404040] bg-[#1a1a1a] p-1">
|
||||
{items.length ? (
|
||||
items.map((item) => {
|
||||
const value = String(item[valueKey] || '')
|
||||
const selected = String(selectedValue || '') === value
|
||||
return (
|
||||
<button
|
||||
className={`flex w-full items-center justify-between gap-3 rounded-sm px-3 py-2 text-left text-sm font-medium transition hover:bg-[#303030] ${
|
||||
selected ? 'text-[#51a2ff]' : 'text-[#e5e5e5]'
|
||||
}`}
|
||||
key={value || item[labelKey]}
|
||||
onClick={() => onSelect(item)}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{item[labelKey]}</span>
|
||||
{selected ? <ReportIcon className="size-3.5" name="check" /> : null}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<p className="px-3 py-2 text-sm text-[#a3a3a3]">{emptyText}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RichTextEditor({ editorRef, onChange, onCommand, onInsertToken, value }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-sm border border-[#404040] bg-[#171717]">
|
||||
<div className="flex flex-wrap items-center gap-1 border-b border-[#404040] bg-[#202020] px-3 py-2">
|
||||
<ToolbarButton label="Desfazer" name="undo" onClick={() => onCommand('undo')} />
|
||||
<ToolbarButton label="Refazer" name="redo" onClick={() => onCommand('redo')} />
|
||||
<span className="mx-1 h-5 w-px bg-[#404040]" />
|
||||
<select className="h-8 rounded-sm border border-[#404040] bg-[#171717] px-2 text-xs font-semibold text-[#d4d4d4]" onChange={(event) => onCommand('formatBlock', event.target.value)} defaultValue="p">
|
||||
<option value="p">Padrão</option>
|
||||
<option value="h2">Título</option>
|
||||
<option value="h3">Subtítulo</option>
|
||||
</select>
|
||||
<ToolbarButton active label="Negrito" name="bold" onClick={() => onCommand('bold')} />
|
||||
<ToolbarButton label="Itálico" name="italic" onClick={() => onCommand('italic')} />
|
||||
<ToolbarButton label="Sublinhado" name="underline" onClick={() => onCommand('underline')} />
|
||||
<ToolbarButton label="Tachado" name="strike" onClick={() => onCommand('strikeThrough')} />
|
||||
<span className="mx-1 h-5 w-px bg-[#404040]" />
|
||||
<ToolbarButton label="Alinhar à esquerda" name="align-left" onClick={() => onCommand('justifyLeft')} />
|
||||
<ToolbarButton label="Centralizar" name="align-center" onClick={() => onCommand('justifyCenter')} />
|
||||
<ToolbarButton label="Alinhar à direita" name="align-right" onClick={() => onCommand('justifyRight')} />
|
||||
<ToolbarButton label="Lista" name="list" onClick={() => onCommand('insertUnorderedList')} />
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<span className="mr-1 text-[11px] text-[#a3a3a3]">Inserir:</span>
|
||||
<button className="h-8 rounded-sm border border-[#3b82f6]/40 px-2 text-xs font-semibold text-[#3b82f6] hover:bg-[#3b82f6]/10" onClick={() => onInsertToken('patient')} type="button">
|
||||
+ Paciente
|
||||
</button>
|
||||
<button className="h-8 rounded-sm border border-[#3b82f6]/40 px-2 text-xs font-semibold text-[#3b82f6] hover:bg-[#3b82f6]/10" onClick={() => onInsertToken('date')} type="button">
|
||||
+ Data
|
||||
</button>
|
||||
<button className="h-8 rounded-sm border border-[#3b82f6]/40 px-2 text-xs font-semibold text-[#3b82f6] hover:bg-[#3b82f6]/10" onClick={() => onInsertToken('doctor')} type="button">
|
||||
+ Médico
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="min-h-[320px] px-4 py-3 text-sm leading-6 text-[#e5e5e5] outline-none empty:before:text-[#737373]"
|
||||
contentEditable
|
||||
dangerouslySetInnerHTML={{ __html: value || '' }}
|
||||
onInput={(event) => onChange(event.currentTarget.innerHTML)}
|
||||
ref={editorRef}
|
||||
role="textbox"
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolbarButton({ active = false, label, name, onClick }) {
|
||||
return (
|
||||
<button
|
||||
aria-label={label}
|
||||
className={`grid size-8 place-items-center rounded-sm transition ${
|
||||
active ? 'bg-[#3b82f6]/20 text-[#3b82f6]' : 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
<ReportIcon className="size-4" name={name} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function sanitizePreviewHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/\son\w+="[^"]*"/gi, '')
|
||||
}
|
||||
|
||||
function ReportViewModal({ onClose, report }) {
|
||||
const currentStatus = statusConfig[report.status] || statusConfig.draft
|
||||
|
||||
@@ -760,20 +1221,13 @@ function ReportViewModal({ onClose, report }) {
|
||||
<DetailBlock label="Diagnóstico" value={report.diagnosis || '-'} />
|
||||
<DetailBlock label="Conclusão" value={report.conclusion || '-'} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-3 text-xs text-[#a3a3a3]">
|
||||
<span className="rounded-full border border-[#404040] px-3 py-1">
|
||||
{report.hideDate ? 'Data oculta' : 'Data visivel'}
|
||||
</span>
|
||||
<span className="rounded-full border border-[#404040] px-3 py-1">
|
||||
{report.hideSignature ? 'Assinatura oculta' : 'Assinatura visivel'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-xl border border-[#404040] bg-[#1a1a1a] p-5">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-[#a3a3a3]">Complemento</p>
|
||||
{report.contentHtml ? (
|
||||
<p className="whitespace-pre-wrap text-sm leading-6 text-[#e5e5e5]">{report.contentHtml}</p>
|
||||
<div
|
||||
className="whitespace-pre-wrap text-sm leading-6 text-[#e5e5e5]"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizePreviewHtml(report.contentHtml) }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-[#a3a3a3]">Nenhum complemento informado.</p>
|
||||
)}
|
||||
@@ -884,6 +1338,19 @@ function uniqueValues(values) {
|
||||
return [...new Set(values.map((value) => String(value || '').trim()).filter(Boolean))]
|
||||
}
|
||||
|
||||
function isReportEditorValid(editor) {
|
||||
return [
|
||||
editor.patientId,
|
||||
editor.status,
|
||||
editor.exam,
|
||||
editor.requestedBy,
|
||||
editor.cidCode,
|
||||
editor.diagnosis,
|
||||
editor.conclusion,
|
||||
editor.dueAt,
|
||||
].every((value) => String(value || '').trim())
|
||||
}
|
||||
|
||||
function normalizeSearch(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFD')
|
||||
@@ -944,7 +1411,7 @@ function printReportAsPdf(report, status) {
|
||||
</div>
|
||||
<div class="section box">
|
||||
<p class="label">Complemento</p>
|
||||
<p class="value">${escapeHtml(report.contentHtml || 'Nenhum complemento informado.')}</p>
|
||||
<div class="value">${report.contentHtml ? sanitizePreviewHtml(report.contentHtml) : 'Nenhum complemento informado.'}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -991,6 +1458,105 @@ function ReportIcon({ className = 'size-4', name }) {
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'bolt') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="m13 2-8 12h6l-1 8 8-12h-6l1-8Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'search') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'undo') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M9 7 5 11l4 4" />
|
||||
<path d="M5 11h9a5 5 0 0 1 5 5v1" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'redo') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="m15 7 4 4-4 4" />
|
||||
<path d="M19 11h-9a5 5 0 0 0-5 5v1" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'bold') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M7 5h6a3 3 0 0 1 0 6H7zM7 11h7a3 3 0 0 1 0 6H7z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'italic') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M10 5h7M7 19h7M14 5l-4 14" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'underline') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M7 5v6a5 5 0 0 0 10 0V5M5 21h14" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'strike') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M5 12h14M8 17a5 5 0 0 0 4 2c2.8 0 5-1.4 5-3.5 0-4-9-2.5-9-7C8 6.6 9.8 5 12.5 5c1.6 0 3 .5 4 1.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'align-left') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M4 6h16M4 10h10M4 14h16M4 18h10" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'align-center') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M4 6h16M7 10h10M4 14h16M7 18h10" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'align-right') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M4 6h16M10 10h10M4 14h16M10 18h10" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'list') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'file') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
|
||||
@@ -54,10 +54,7 @@ export function SettingsPage() {
|
||||
|
||||
<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>
|
||||
@@ -76,29 +73,33 @@ function AppearanceSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionFrame description="Personalize a interface do MediConnect." title="Aparência">
|
||||
<SectionFrame description="Personalize a interface do MediConnect." title="Aparência e Acessibilidade">
|
||||
<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: 'dark', label: 'Escuro', preview: 'bg-[#0a0a0a]' },
|
||||
{ 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'
|
||||
theme === item.id
|
||||
? item.id === 'dark'
|
||||
? 'border-[#737373] bg-[#171717] shadow-md shadow-black/30'
|
||||
: 'border-[#3b82f6] bg-[#3b82f6]/5 shadow-md shadow-[#3b82f6]/20'
|
||||
: 'border-[#404040] bg-[#262626] hover:border-[#737373]'
|
||||
}`}
|
||||
key={item.id}
|
||||
onClick={() => handleThemeChange(item.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className={`mb-3 flex h-20 flex-col gap-1.5 overflow-hidden rounded-xl border border-[#404040] p-2 ${item.preview}`}>
|
||||
<span className={`h-2.5 rounded ${item.id === 'dark' ? 'bg-[#1a3050]' : 'bg-white'}`} />
|
||||
<span className={`settings-theme-preview ${item.id === 'dark' ? 'settings-theme-preview-dark' : 'settings-theme-preview-light'} mb-3 flex h-20 flex-col gap-1.5 overflow-hidden rounded-xl border border-[#404040] p-2 ${item.preview}`}>
|
||||
<span className={`settings-theme-preview-bar h-2.5 rounded ${item.id === 'dark' ? 'bg-[#262626]' : 'bg-white'}`} />
|
||||
<span className="flex flex-1 gap-1">
|
||||
<span className={`w-8 rounded ${item.id === 'dark' ? 'bg-[#0f1f36]' : 'bg-white'}`} />
|
||||
<span className={`settings-theme-preview-side w-8 rounded ${item.id === 'dark' ? 'bg-[#171717]' : '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 className={`settings-theme-preview-line h-1.5 w-3/4 rounded-full ${item.id === 'dark' ? 'bg-[#525252]' : 'bg-[#dde8f7]'}`} />
|
||||
<span className={`settings-theme-preview-line h-1.5 w-1/2 rounded-full ${item.id === 'dark' ? 'bg-[#404040]' : 'bg-[#dde8f7]'}`} />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
|
||||
import { appointmentMapper } from '../mappers/appointmentMapper.js'
|
||||
import { getResponseError, normalizeItem } from './repositoryUtils.js'
|
||||
|
||||
export const appointmentRepository = {
|
||||
async getAll({ doctorId } = {}) {
|
||||
@@ -9,7 +10,7 @@ export const appointmentRepository = {
|
||||
headers: getAuthenticatedHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Erro ao buscar agendamentos.')
|
||||
if (!response.ok) throw new Error(await getResponseError(response, 'Erro ao buscar agendamentos.'))
|
||||
|
||||
const data = await response.json()
|
||||
return (Array.isArray(data) ? data : []).map(appointmentMapper.toUi)
|
||||
@@ -22,10 +23,26 @@ export const appointmentRepository = {
|
||||
body: JSON.stringify(appointmentMapper.toApi(uiData, 'supabase')),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Falha ao criar o agendamento.')
|
||||
if (!response.ok) throw new Error(await getResponseError(response, 'Falha ao criar o agendamento.'))
|
||||
|
||||
const data = await response.json()
|
||||
const item = Array.isArray(data) ? data[0] : data
|
||||
return appointmentMapper.toUi(item)
|
||||
}
|
||||
return appointmentMapper.toUi(normalizeItem(data))
|
||||
},
|
||||
|
||||
async update(id, uiData) {
|
||||
const response = await fetch(`${apiConfig.restUrl}/appointments?id=eq.${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
|
||||
body: JSON.stringify(appointmentMapper.toApi(uiData, 'supabase')),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error(await getResponseError(response, 'Falha ao atualizar o agendamento.'))
|
||||
|
||||
const data = await response.json()
|
||||
return appointmentMapper.toUi(normalizeItem(data))
|
||||
},
|
||||
|
||||
async cancel(id, uiData) {
|
||||
return this.update(id, { ...uiData, status: 'Cancelada' })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,21 +2,18 @@ export const settingsRepository = {
|
||||
getIntegrations() {
|
||||
return [
|
||||
['WhatsApp Business', 'Envio automático de lembretes e confirmações', true, 'bg-[#3b82f6]'],
|
||||
['Google Calendar', 'Sincronizacao bidirecional de agenda', false, 'bg-blue-500'],
|
||||
['Stripe / PagSeguro', 'Pagamentos online e links de cobranca', true, 'bg-violet-500'],
|
||||
['CFM - Conselho Federal de Medicina', 'Validacao automatica de CRM', false, 'bg-amber-500'],
|
||||
['ANS - Planos de Saude', 'Integracao com tabela TUSS e convenios', false, 'bg-rose-500'],
|
||||
['Google Calendar', 'Sincronização bidirecional de agenda', false, 'bg-blue-500'],
|
||||
['Stripe / PagSeguro', 'Pagamentos online e links de cobrança', true, 'bg-violet-500'],
|
||||
['CFM - Conselho Federal de Medicina', 'Validação automática de CRM', false, 'bg-amber-500'],
|
||||
['ANS - Planos de Saúde', 'Integração com tabela TUSS e convênios', false, 'bg-rose-500'],
|
||||
['API de IA Preditiva', 'Score de absenteísmo e predição de faltas', true, 'bg-[#3b82f6]'],
|
||||
]
|
||||
},
|
||||
|
||||
getSections() {
|
||||
return [
|
||||
{ id: 'aparencia', label: 'Aparência', description: 'Tema, cores e exibição', icon: 'palette' },
|
||||
{ id: 'notificacoes', label: 'Notificações', description: 'Alertas e lembretes', icon: 'bell' },
|
||||
{ id: 'aparencia', label: 'Aparência e Acessibilidade', description: 'Tema, cores e exibição', icon: 'palette' },
|
||||
{ id: 'privacidade', label: 'Privacidade & LGPD', description: 'Dados e conformidade', icon: 'shield' },
|
||||
{ id: 'conta', label: 'Conta & Perfil', description: 'Informações pessoais', icon: 'user' },
|
||||
{ id: 'integracoes', label: 'Integrações', description: 'APIs e sistemas externos', icon: 'globe' },
|
||||
{ id: 'dados', label: 'Dados & Backup', description: 'Exportação e backup', icon: 'database' },
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user