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,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,8 +47,6 @@ const emptyEditor = {
|
||||
conclusion: '',
|
||||
contentHtml: '',
|
||||
contentJson: undefined,
|
||||
hideDate: false,
|
||||
hideSignature: false,
|
||||
dueAt: '',
|
||||
}
|
||||
|
||||
@@ -253,15 +251,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 +275,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,
|
||||
@@ -525,7 +522,7 @@ function ReportRow({ onEdit, onView, report }) {
|
||||
|
||||
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 +560,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 +569,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 +578,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 +617,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 +626,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 +636,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 +645,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 +661,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>
|
||||
|
||||
@@ -760,16 +735,6 @@ 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 ? (
|
||||
@@ -884,6 +849,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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user