forked from RiseUP/riseup_squad_03
new file: public/favicon.svg
deleted: src/assets/hero.png modified: src/components/AppShell.jsx modified: src/components/calendar/AgendaDailyView.jsx modified: src/components/calendar/AgendaMonthlyView.jsx modified: src/components/calendar/AgendaWeeklyView.jsx modified: src/hooks/useAgenda.js modified: src/index.css modified: src/mappers/appointmentMapper.js modified: src/mappers/reportMapper.js modified: src/pages/AgendaPage.jsx modified: src/pages/AuthPages.jsx modified: src/pages/HomePage.jsx modified: src/pages/MessagesPage.jsx modified: src/pages/PatientsPage.jsx modified: src/pages/ProfilePage.jsx modified: src/pages/ReportsPage.jsx modified: src/pages/SettingsPage.jsx modified: src/repositories/appointmentRepository.js modified: src/repositories/settingsRepository.js
This commit is contained in:
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user