From 86d13bee5d7449e85984beb5165b330f0c10ef42 Mon Sep 17 00:00:00 2001 From: Tiago Date: Sat, 29 Nov 2025 21:18:53 -0300 Subject: [PATCH] Initial commit --- .dockerignore | 5 + .gitignore | 8 + Dockerfile | 18 + README.md | 99 ++ client/index.html | 13 + client/package.json | 20 + client/src/App.css | 1268 ++++++++++++++++++++++++++ client/src/App.jsx | 1972 ++++++++++++++++++++++++++++++++++++++++ client/src/main.jsx | 10 + client/vite.config.mjs | 12 + data/.gitkeep | 0 docker-compose.yml | 34 + package.json | 18 + server/index.js | 739 +++++++++++++++ 14 files changed, 4216 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 client/index.html create mode 100644 client/package.json create mode 100644 client/src/App.css create mode 100644 client/src/App.jsx create mode 100644 client/src/main.jsx create mode 100644 client/vite.config.mjs create mode 100644 data/.gitkeep create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 server/index.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9a1111a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +client/node_modules +data/agenda.db +npm-debug.log +.DS_Store diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42ebf09 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules +client/node_modules +dist +client/dist +data/agenda.db +.env +npm-debug.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..354f3f1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine AS builder +WORKDIR /app + +COPY client/package.json client/ +RUN cd client && npm install +COPY client client +RUN cd client && npm run build + +FROM node:20-alpine +WORKDIR /app + +COPY package.json . +RUN npm install +COPY server server +COPY --from=builder /app/client/dist client/dist + +EXPOSE 4000 +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..574dafb --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# OdontoFlow Scheduler + +Dashboard de agendamento para clínicas odontológicas com front-end em React e back-end Node/PostgreSQL prontos para rodar em Docker. + +## Estrutura +- `client/`: app React (Vite) reproduzindo a experiência solicitada com agenda, filtros e modais. +- `server/`: API Express conectada a um banco PostgreSQL com CRUD completo de pacientes, agendamentos e prontuários. +- `docker-compose.yml`: sobe o app, o banco PostgreSQL e o Adminer para gerenciamento visual. + +## Comandos de desenvolvimento +1. Instale dependências do servidor: + ```bash + npm install + ``` +2. No diretório `client`, instale o front-end: + ```bash + cd client && npm install + ``` +3. Garanta que exista um PostgreSQL disponível (por exemplo `docker compose up postgres -d`). Configure a URL em `DATABASE_URL` ou use `postgresql://agenda:agenda@localhost:5432/agenda`. + +4. Em uma janela, rode o servidor: + ```bash + npm run start + ``` +5. Em outra, inicie o front-end em modo dev: + ```bash + cd client && npm run dev + ``` +O front-end irá consumir a API em `http://localhost:4000/api` automaticamente. + +## Construindo e executando via Docker +```bash +docker compose up --build +``` +A aplicação ficará disponível em `http://localhost:4000`. O PostgreSQL roda no serviço `postgres` (persistido em `postgres_data`) e o Adminer para inspeção está em `http://localhost:8080` (use `agenda` / `agenda` / banco `agenda`). + +## Notas +- Os seeds criam três médicos e alguns agendamentos iniciais na primeira execução do banco. +- O servidor serve os assets estáticos de `client/dist`; rode `npm run build` dentro de `client` antes de subir a imagem ou deixe o Docker cuidar disso. +- Utilize as rotas REST em `/api/appointments`, `/api/patients`, `/api/records` e `/api/doctors` para integrar outros sistemas. + +## Integração via N8N +Há um endpoint único pensado para automações low-code. Ele concentra todas as ações necessárias para criar, consultar, alterar ou remover agendamentos e cadastrar pacientes usando o N8N (ou qualquer outro orquestrador). + +### Endpoint +- **URL:** `POST /api/integrations/n8n` +- **Corpo:** JSON com as chaves: + - `action`: string informando qual operação executar (ver tabela abaixo). + - `data`: objeto com os campos necessários para a ação escolhida. + +### Ações suportadas +| Ação (`action`) | Dados mínimos em `data` | O que faz | +|-----------------|-------------------------|-----------| +| `create_appointment` | `patient`, `doctorId`, `date`, `time` (opcionais: `phone`, `type`, `status`, `notes`) | Cria um agendamento e retorna o registro completo | +| `list_appointments` | (opcional) `date`, `doctorId`, `status`, `patient` | Lista agendamentos aplicando filtros | +| `get_appointment` | `id` | Busca um agendamento específico com dados do médico | +| `update_appointment` | `id` + campos a atualizar (`patient`, `doctorId`, `date`, `time`, etc.) | Atualiza todas as informações do agendamento | +| `reschedule_appointment` | `id`, `date`, `time` (opcional `doctorId`) | Atualiza apenas data/horário e, opcionalmente, o médico | +| `delete_appointment` | `id` | Remove o agendamento informado | +| `create_patient` | `name`, `phone` (opcionais: `email`, `cpf`, `cep`, `address`, `birthdate`) | Cadastra um novo paciente | + +Caso `action` seja inválido, o servidor responde com HTTP 400 e mensagem listando as opções válidas. + +### Exemplo de uso (cURL ou HTTP Request node no N8N) +```bash +curl -X POST http://localhost:4000/api/integrations/n8n \ + -H "Content-Type: application/json" \ + -d '{ + "action": "create_appointment", + "data": { + "patient": "João Silva", + "phone": "+55 11 9999-9999", + "doctorId": "dr-ana", + "date": "2024-10-01", + "time": "09:00", + "type": "Avaliação", + "status": "pending" + } + }' +``` +Resposta típica: +```json +{ + "result": { + "id": 123, + "patient": "João Silva", + "phone": "+55 11 9999-9999", + "doctor_id": "dr-ana", + "date": "2024-10-01", + "time": "09:00", + "type": "Avaliação", + "status": "pending", + "notes": "", + "createdAt": "2024-09-10T12:34:56.000Z", + "doctorId": "dr-ana" + } +} +``` +No N8N, basta configurar um nó HTTP Request em `POST`, apontando para a URL acima e enviando o JSON conforme a ação desejada. diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..cee5199 --- /dev/null +++ b/client/index.html @@ -0,0 +1,13 @@ + + + + + + OdontoFlow + + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..7454753 --- /dev/null +++ b/client/package.json @@ -0,0 +1,20 @@ +{ + "name": "agenda-client", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^0.278.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.0", + "vite": "^5.1.2" + }, + "type": "module" +} diff --git a/client/src/App.css b/client/src/App.css new file mode 100644 index 0000000..116ee46 --- /dev/null +++ b/client/src/App.css @@ -0,0 +1,1268 @@ +:root { + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: #0f172a; + background-color: #e7ecf5; +} + +* { + box-sizing: border-box; +} + +body, +#root { + margin: 0; + min-height: 100vh; + background: radial-gradient(circle at top right, rgba(59, 130, 246, 0.12), transparent 40%), + #e7ecf5; +} + +button { + font: inherit; +} + +.app-shell { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: 280px; + padding: 24px; + background: #0f172a; + color: #f1f5f9; + display: flex; + flex-direction: column; + gap: 30px; +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: 14px; +} + +.logo-circle { + width: 40px; + height: 40px; + border-radius: 12px; + background: linear-gradient(135deg, #06b6d4, #0ea5e9); + display: flex; + align-items: center; + justify-content: center; +} + +.sidebar-brand h1 { + font-size: 1.25rem; + margin: 0; + font-weight: 600; +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 8px; +} + +.nav-item { + border: none; + background: transparent; + color: inherit; + padding: 12px 16px; + border-radius: 14px; + display: flex; + gap: 10px; + align-items: center; + font-weight: 500; + cursor: pointer; + transition: background 0.2s ease; +} + +.nav-item:hover, +.nav-item.active { + background: rgba(255, 255, 255, 0.12); +} + +.sidebar-footer { + margin-top: auto; +} + +.status-card { + background: #1e293b; + padding: 16px; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.08); + font-size: 0.9rem; + display: flex; + flex-direction: column; + gap: 6px; +} + +.status-card span { + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.7rem; + color: rgba(248, 250, 252, 0.64); +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #d1d5db; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: #34d399; +} + +.main-area { + flex: 1; + display: flex; + flex-direction: column; + background: #f8fafc; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 30px; + border-bottom: 1px solid #e2e8f0; + background: #ffffff; +} + +.topbar-info h2 { + margin: 0; + font-size: 1.25rem; +} + +.topbar-info p { + margin: 4px 0 0; + color: #64748b; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.search-box { + display: inline-flex; + align-items: center; + gap: 8px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 8px 12px; +} + +.search-box input { + border: none; + outline: none; + font-size: 0.95rem; +} + +.primary-btn, +.ghost-btn { + border: none; + border-radius: 999px; + padding: 10px 18px; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.primary-btn { + background: linear-gradient(135deg, #14b8a6, #06b6d4); + color: #fff; + box-shadow: 0 12px 18px rgba(6, 182, 212, 0.3); +} + +.avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: #e2e8f0; + display: grid; + place-items: center; + color: #475569; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + padding: 24px 30px 10px; +} + +.stat-card { + background: #fff; + border-radius: 18px; + padding: 18px; + box-shadow: 0 10px 20px rgba(15, 23, 42, 0.06); + display: flex; + flex-direction: column; + gap: 4px; +} + +.stat-hit { + border: none; + background: transparent; + text-align: left; + padding: 0; + cursor: pointer; + width: 100%; +} + +.stat-hit.active span, +.stat-hit.active strong { + color: #0ea5e9; +} + +.stat-card span { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: #94a3b8; +} + +.stat-card strong { + font-size: 1.7rem; +} + +.stat-card.confirmed span { + color: #22c55e; +} + +.stat-card.pending span { + color: #fbbf24; +} + +.stat-card.add-mobile { + display: none; +} + +.stats-grid.secondary { + margin-top: -6px; +} + +.ghost-btn { + border: 1px dashed #94a3b8; + color: #475569; +} + +.filter-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + padding: 0 30px 24px; + gap: 12px; +} + +.date-navigator { + display: flex; + align-items: center; + gap: 8px; + border: 1px solid #d1d5db; + border-radius: 12px; + padding: 6px; + background: #fff; +} + +.date-navigator button { + border: none; + background: transparent; + padding: 6px; + border-radius: 8px; + cursor: pointer; +} + +.date-navigator input { + border: none; + font-size: 0.9rem; + font-weight: 600; +} + +.date-navigator input:focus { + outline: none; +} +.doctor-filter { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.doctor-filter button { + border: 1px solid #cbd5f5; + border-radius: 12px; + padding: 6px 12px; + background: #fff; + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + color: #475569; +} + +.doctor-filter button.active { + background: #0ea5e9; + color: #fff; + border-color: transparent; +} + +.doctor-filter button.doctor-active { + box-shadow: 0 6px 12px rgba(14, 165, 233, 0.32); +} + +.doctor-filter .dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; +} + +.schedule-panel { + flex: 1; + padding: 0 30px 30px; + overflow: hidden; +} + +.panel-header { + display: flex; + justify-content: space-between; + margin-bottom: 16px; + align-items: center; +} + +.panel-header h3 { + margin: 0; +} + +.panel-header p { + margin: 2px 0 0; + color: #64748b; +} + +.panel-actions { + display: flex; + gap: 8px; +} + +.icon-btn { + width: 40px; + height: 38px; + border-radius: 12px; + border: none; + background: #fff; + box-shadow: 0 6px 10px rgba(15, 23, 42, 0.08); + display: grid; + place-items: center; + cursor: pointer; +} + +.schedule-grid { + background: #fff; + border-radius: 24px; + border: 1px solid #e2e8f0; + padding: 14px; + max-height: calc(100vh - 260px); + overflow-y: auto; +} + +.loader { + display: flex; + flex-direction: column; + align-items: center; + padding: 40px 0; + gap: 12px; + color: #94a3b8; +} + +.spinner { + width: 48px; + height: 48px; + border-radius: 50%; + border: 4px solid rgba(15, 23, 42, 0.12); + border-top-color: #14b8a6; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.slot-row { + display: flex; + border-bottom: 1px solid #f1f5f9; + padding: 12px 0; + gap: 12px; +} + +.slot-row:last-child { + border-bottom: none; +} + +.slot-time { + width: 80px; + font-size: 0.9rem; + font-weight: 600; + color: #94a3b8; + text-align: center; +} + +.slot-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; +} + +.slot-empty { + align-self: flex-end; + border: 1px dashed #cbd5f5; + border-radius: 12px; + padding: 6px 12px; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.75rem; + color: #475569; + cursor: pointer; +} + +.slot-cards { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.appointment-card { + flex: 1 1 250px; + border-radius: 16px; + border: 1px solid rgba(15, 23, 42, 0.1); + padding: 12px 14px; + background: #f8fafc; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 6px; + transition: transform 0.2s ease; +} + +.appointment-card:hover { + transform: translateY(-4px); +} + +.appointment-card.pending { + border-left: 4px solid #facc15; +} + +.appointment-card.confirmed { + border-left: 4px solid #22c55e; +} + +.appointment-card.cancelled { + border-left: 4px solid #ef4444; + opacity: 0.78; +} + +.card-head { + display: flex; + justify-content: space-between; + align-items: center; +} + +.card-head span { + font-weight: 600; +} + +.card-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 0.8rem; + color: #475569; +} + +.type-pill { + background: #e0f2fe; + padding: 2px 8px; + border-radius: 999px; +} + +.card-footer { + display: flex; + justify-content: space-between; + align-items: center; +} + +.tag { + border: 1px solid; + padding: 2px 6px; + border-radius: 999px; + font-size: 0.7rem; +} + +.confirm-status { + border: none; + background: transparent; + color: #15803d; + cursor: pointer; +} + +.status-badge { + padding: 2px 8px; + border-radius: 999px; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.status-badge.pending { + background: #fff7ed; + color: #c2410c; +} + +.status-badge.confirmed { + background: #ecfdf5; + color: #047857; +} + +.status-badge.cancelled { + background: #fef2f2; + color: #b91c1c; +} +.quick-overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.35); + display: grid; + place-items: center; + z-index: 9; + padding: 12px; +} + +.quick-panel { + width: min(760px, 96vw); + max-height: 80vh; + background: #ffffff; + border-radius: 24px; + box-shadow: 0 40px 80px rgba(15, 23, 42, 0.35); + border: 1px solid #e2e8f0; + overflow: hidden; +} + +.quick-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; +} + +.quick-header p { + margin: 4px 0 0; + color: #64748b; +} + +.quick-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.quick-body { + max-height: calc(80vh - 80px); + overflow-y: auto; +} + +.quick-actions-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 10px; +} + +.quick-btn { + border: 1px solid #e2e8f0; + background: #f8fafc; + color: #0f172a; + border-radius: 10px; + padding: 6px 10px; + font-size: 0.85rem; + cursor: pointer; +} + +.quick-btn.success { + border-color: #22c55e; + background: #ecfdf3; + color: #15803d; +} + +.quick-btn.neutral { + border-color: #fbbf24; + background: #fffbeb; + color: #92400e; +} + +.quick-btn.danger { + border-color: #ef4444; + background: #fef2f2; + color: #991b1b; +} + +.quick-reschedule { + margin-top: 10px; + padding: 12px; + border-radius: 12px; + border: 1px solid #e2e8f0; + background: #f8fafc; +} + +.reschedule-fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; +} + +.reschedule-fields label { + display: flex; + flex-direction: column; + gap: 6px; + color: #475569; + font-size: 0.9rem; +} + +.reschedule-fields input, +.reschedule-fields select { + border: 1px solid #cbd5f5; + border-radius: 10px; + padding: 8px 10px; + font-size: 0.95rem; +} + +.reschedule-actions { + margin-top: 10px; + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.quick-row { + padding: 14px 20px; + border-bottom: 1px solid #f1f5f9; + cursor: pointer; +} + +.quick-row:hover { + background: #f8fafc; +} + +.quick-main { + display: flex; + justify-content: space-between; + align-items: center; +} + +.quick-name { + font-weight: 600; +} + +.quick-time { + display: inline-flex; + align-items: center; + gap: 6px; + color: #475569; + font-size: 0.9rem; +} + +.quick-meta { + margin-top: 8px; + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + color: #475569; + font-size: 0.9rem; +} + +.quick-type { + background: #e0f2fe; + padding: 4px 10px; + border-radius: 12px; +} + +.quick-doctor { + font-weight: 600; +} + +.quick-body .empty { + padding: 20px; + color: #94a3b8; + text-align: center; +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.35); + display: grid; + place-items: center; + z-index: 10; +} + +.modal-overlay.patient-modal { + z-index: 12; +} + +.modal-box { + width: min(600px, 95vw); + background: #fff; + border-radius: 24px; + box-shadow: 0 40px 60px rgba(15, 23, 42, 0.25); + max-height: 90vh; + overflow-y: auto; +} + +.modal-box.large { + width: min(960px, 98vw); + max-height: 90vh; + overflow-y: auto; +} + +.modal-header { + padding: 20px 28px; + border-bottom: 1px solid #e2e8f0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-form { + padding: 20px 28px 28px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.modal-form label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.85rem; + color: #475569; +} + +.modal-form input, +.modal-form select, +.modal-form textarea { + border: 1px solid #cbd5f5; + border-radius: 12px; + padding: 10px 14px; + font-size: 0.95rem; + transition: border 0.2s ease; + resize: vertical; +} + +.record-form textarea { + min-height: 80px; +} + +.modal-form input:focus, +.modal-form select:focus, +.modal-form textarea:focus { + border-color: #0ea5e9; + outline: none; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.status-actions { + display: flex; + gap: 8px; +} + +.status-actions button { + flex: 1; + padding: 8px 10px; + border-radius: 12px; + border: 1px solid #cbd5f5; + background: transparent; + font-weight: 600; + cursor: pointer; +} + +.status-actions button.active { + background: #0ea5e9; + color: #fff; + border: none; +} + +.modal-actions { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 12px; +} + +.modal-actions .ghost { + border: 1px solid #e11d48; + color: #b91c1c; + background: transparent; + border-radius: 12px; + padding: 8px 14px; + cursor: pointer; +} + +.right-actions { + display: flex; + gap: 8px; +} + +.right-actions button { + border-radius: 12px; + padding: 8px 16px; + border: 1px solid transparent; + cursor: pointer; +} + +.right-actions button.primary { + background: #0ea5e9; + color: #fff; +} + +.toast { + position: fixed; + bottom: 30px; + right: 30px; + background: #0f172a; + color: #fff; + padding: 12px 20px; + border-radius: 16px; + box-shadow: 0 20px 40px rgba(15, 23, 42, 0.4); +} + +.patients-page { + display: flex; + flex-direction: column; + gap: 16px; +} + +.patients-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 16px; + padding: 0 30px 30px; +} + +.patient-form, +.patient-list { + background: #fff; + border-radius: 18px; + border: 1px solid #e2e8f0; + padding: 18px; + box-shadow: 0 10px 20px rgba(15, 23, 42, 0.06); +} + +.patient-form h3 { + margin-top: 0; +} + +.patient-form-body { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 12px; +} + +.patient-form-body label { + display: flex; + flex-direction: column; + gap: 6px; + color: #475569; + font-size: 0.9rem; +} + +.patient-form-body input { + border: 1px solid #cbd5f5; + border-radius: 10px; + padding: 10px 12px; +} + +.patient-actions { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + gap: 10px; + align-items: center; +} + +.patient-list .patients-heading { + margin-bottom: 12px; +} + +.patient-rows { + display: flex; + flex-direction: column; + gap: 10px; +} + +.patient-row { + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 12px; + background: #f8fafc; + display: flex; + flex-direction: column; + gap: 6px; +} + +.patient-main { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.patient-main strong { + font-size: 1rem; +} + +.patient-meta { + display: flex; + gap: 10px; + color: #475569; + flex-wrap: wrap; + font-size: 0.9rem; +} + +.patient-actions-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.settings-page { + display: flex; + flex-direction: column; + gap: 16px; +} + +.settings-grid { + display: flex; + flex-direction: column; + gap: 12px; + padding: 0 30px 30px; +} + +.settings-card { + background: #fff; + border-radius: 18px; + border: 1px solid #e2e8f0; + padding: 18px; + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08); + display: flex; + flex-direction: column; + gap: 12px; +} + +.settings-card h3 { + margin: 0; +} + +.settings-card-toggle { + border: none; + background: transparent; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0; + cursor: pointer; + font: inherit; + color: inherit; +} + +.settings-card-toggle svg { + transition: transform 0.2s ease; +} + +.settings-card-toggle .rotated { + transform: rotate(180deg); +} + +.settings-card-body { + border-top: 1px solid #e2e8f0; + padding-top: 14px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.settings-form label { + display: flex; + flex-direction: column; + gap: 6px; + color: #475569; + font-size: 0.9rem; +} + +.settings-form input, +.settings-form textarea { + border: 1px solid #cbd5f5; + border-radius: 10px; + padding: 10px 12px; + font-size: 0.95rem; +} + +.settings-form textarea { + resize: vertical; +} + +.settings-actions { + display: flex; + justify-content: flex-end; +} + +.inline-form { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.inline-form input[type='text'], +.inline-form input[type='color'] { + border: 1px solid #cbd5f5; + border-radius: 10px; + padding: 8px 10px; + font-size: 0.9rem; + flex: 1; + min-width: 120px; +} + +.inline-form input[type='color'] { + max-width: 70px; + padding: 0; + height: 38px; +} + +.settings-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} +.settings-list li { + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 10px 12px; + display: flex; + justify-content: space-between; + align-items: center; + background: #f8fafc; + gap: 12px; +} + +.settings-list li .info { + display: flex; + flex-direction: column; +} + +.settings-list li.empty { + justify-content: center; + color: #94a3b8; +} + +.patient-selected { + margin-top: 8px; + padding: 10px 12px; + border-radius: 12px; + background: #f1f5f9; + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.records-page { + display: flex; + flex-direction: column; + gap: 16px; +} + +.records-grid { + padding: 0 30px 30px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.record-card { + background: #fff; + border-radius: 18px; + border: 1px solid #e2e8f0; + padding: 18px; + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08); +} + +.record-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.record-card-header h3 { + margin: 0; +} + +.record-entries { + display: flex; + flex-direction: column; + gap: 12px; +} + +.record-entry { + border: 1px solid #e2e8f0; + border-radius: 14px; + padding: 14px; + background: #f8fafc; +} + +.record-entry-head { + display: flex; + gap: 12px; + font-size: 0.9rem; + color: #475569; + flex-wrap: wrap; +} + +.record-section { + margin-top: 10px; +} + +.record-section h4 { + margin: 0 0 4px; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #94a3b8; +} + +.record-section p { + margin: 0; + color: #1e293b; + font-size: 0.95rem; +} + +.patient-picker { + display: flex; + gap: 8px; + align-items: flex-end; + flex-wrap: wrap; +} + +.patient-picker .ghost-btn { + height: 40px; +} + +.patient-suggestions { + margin-top: 6px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #fff; + box-shadow: 0 10px 20px rgba(15, 23, 42, 0.12); + overflow: hidden; +} + +.patient-suggestions button { + width: 100%; + padding: 10px 12px; + border: none; + background: #fff; + display: flex; + gap: 8px; + align-items: center; + cursor: pointer; + text-align: left; + border-bottom: 1px solid #f1f5f9; +} + +.patient-suggestions button:last-child { + border-bottom: none; +} + +.patient-suggestions button:hover { + background: #f8fafc; +} + +.patient-name { + font-weight: 600; +} + +.patient-meta { + color: #475569; + font-size: 0.9rem; +} + +@media (max-width: 1100px) { + .sidebar { + display: none; + } + .app-shell { + flex-direction: column; + } + .main-area { + width: 100%; + } + .stat-card.add-mobile { + display: flex; + justify-content: center; + align-items: center; + } +} + +@media (max-width: 768px) { + .topbar { + flex-direction: column; + align-items: flex-start; + gap: 16px; + } + + .filter-bar { + flex-direction: column; + align-items: stretch; + } + + .form-grid { + grid-template-columns: 1fr; + } + + .slot-row { + flex-direction: column; + } + + .slot-time { + width: 100%; + text-align: left; + } +} diff --git a/client/src/App.jsx b/client/src/App.jsx new file mode 100644 index 0000000..ee74141 --- /dev/null +++ b/client/src/App.jsx @@ -0,0 +1,1972 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Activity, + CalendarDays, + CheckCircle, + Clock, + ChevronDown, + ChevronLeft, + ChevronRight, + FileText, + MoreVertical, + Phone, + Plus, + Search, + Settings, + User, + Users, + XCircle, +} from 'lucide-react'; + +const TIME_SLOTS = [ + '08:00', + '08:30', + '09:00', + '09:30', + '10:00', + '10:30', + '11:00', + '11:30', + '13:30', + '14:00', + '14:30', + '15:00', + '15:30', + '16:00', + '16:30', + '17:00', + '17:30', + '18:00', +]; + +const DEFAULT_APPOINTMENT_TYPES = [ + 'Avaliação', + 'Limpeza', + 'Restauração', + 'Extração', + 'Canal', + 'Manutenção Ortodôntica', + 'Cirurgia', +]; + +const statusLabels = { + pending: 'Pendente', + confirmed: 'Confirmado', + cancelled: 'Cancelado', +}; + +const today = new Date().toISOString().split('T')[0]; + +const StatusBadge = ({ status }) => ( + {statusLabels[status] ?? status} +); + +export default function App() { + const [doctors, setDoctors] = useState([]); + const [appointments, setAppointments] = useState([]); + const [selectedDate, setSelectedDate] = useState(today); + const [selectedDoctor, setSelectedDoctor] = useState('all'); + const [loading, setLoading] = useState(true); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); + const [currentAppointment, setCurrentAppointment] = useState(null); + const [toast, setToast] = useState(null); + const [statusFilter, setStatusFilter] = useState('all'); + const [quickListOpen, setQuickListOpen] = useState(false); + const [rescheduleTarget, setRescheduleTarget] = useState(null); + const [activePage, setActivePage] = useState('agenda'); + const [patients, setPatients] = useState([]); + const [patientModalOpen, setPatientModalOpen] = useState(false); + const [patientSearch, setPatientSearch] = useState(''); + const [appointmentPatientQuery, setAppointmentPatientQuery] = useState(''); + const [selectedPatient, setSelectedPatient] = useState(null); + const [records, setRecords] = useState([]); + const [recordModalOpen, setRecordModalOpen] = useState(false); + const [recordPatientQuery, setRecordPatientQuery] = useState(''); + const [recordSelectedPatient, setRecordSelectedPatient] = useState(null); + const [recordSearch, setRecordSearch] = useState(''); + const [patientForm, setPatientForm] = useState({ + id: null, + name: '', + phone: '', + email: '', + cpf: '', + cep: '', + address: '', + birthdate: '', + }); + const emptyRecordForm = { + patientId: '', + date: today, + professionalName: '', + professionalRegistration: '', + clinicInfo: '', + chiefComplaint: '', + medicalHistory: '', + dentalHistory: '', + clinicalExam: '', + diagnosis: '', + treatmentPlan: '', + consentNotes: '', + evolution: '', + prescriptions: '', + communications: '', + attachments: '', + }; + const [recordForm, setRecordForm] = useState(emptyRecordForm); + const [serviceTypes, setServiceTypes] = useState([]); + const [newServiceType, setNewServiceType] = useState(''); + const [clinicSettings, setClinicSettings] = useState(null); + const [clinicForm, setClinicForm] = useState({ + clinicName: '', + phone: '', + email: '', + address: '', + workingHours: '', + notes: '', + }); + const [doctorForm, setDoctorForm] = useState({ + name: '', + specialty: '', + color: '#0ea5e9', + }); + const [openSettingsSection, setOpenSettingsSection] = useState(null); + const [formData, setFormData] = useState({ + patient: '', + phone: '', + doctorId: 'dr-ana', + date: today, + time: '09:00', + type: DEFAULT_APPOINTMENT_TYPES[0], + notes: '', + status: 'pending', + }); + + useEffect(() => { + const loadDoctors = async () => { + try { + const res = await fetch('/api/doctors'); + const data = await res.json(); + setDoctors(data); + setFormData((prev) => ({ + ...prev, + doctorId: + data.find((doc) => doc.id === prev.doctorId)?.id ?? data[0]?.id ?? 'dr-ana', + })); + } catch (err) { + console.error('Erro ao carregar médicos', err); + } + }; + loadDoctors(); + }, []); + + useEffect(() => { + loadAppointments(); + }, [selectedDate]); + + useEffect(() => { + if (activePage === 'patients') { + loadPatients(); + } + if (activePage === 'records') { + loadRecords(); + loadPatients(); + } + if (activePage === 'settings') { + loadPatients(); + loadServiceTypes(); + loadClinicSettings(); + } + }, [activePage]); + + useEffect(() => { + loadServiceTypes(); + loadClinicSettings(); + }, []); + + useEffect(() => { + if (!toast) return undefined; + const timer = setTimeout(() => setToast(null), 3200); + return () => clearTimeout(timer); + }, [toast]); + + const loadAppointments = async () => { + setLoading(true); + try { + const res = await fetch(`/api/appointments?date=${selectedDate}`); + const data = await res.json(); + setAppointments(data); + } catch (err) { + console.error('Erro ao carregar agendamentos', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (serviceTypes.length) { + setFormData((prev) => { + if (serviceTypes.some((type) => type.name === prev.type)) { + return prev; + } + return { ...prev, type: serviceTypes[0].name }; + }); + } + }, [serviceTypes]); + + const loadRecords = async () => { + try { + const res = await fetch('/api/records'); + const data = await res.json(); + setRecords(data); + } catch (err) { + console.error('Erro ao carregar prontuários', err); + } + }; + + const loadServiceTypes = async () => { + try { + const res = await fetch('/api/service-types'); + const data = await res.json(); + setServiceTypes(data); + } catch (err) { + console.error('Erro ao carregar tipos de atendimento', err); + } + }; + + const loadClinicSettings = async () => { + try { + const res = await fetch('/api/settings/clinic'); + const data = await res.json(); + if (data) { + setClinicSettings(data); + setClinicForm({ + clinicName: data.clinic_name || '', + phone: data.phone || '', + email: data.email || '', + address: data.address || '', + workingHours: data.working_hours || '', + notes: data.notes || '', + }); + } + } catch (err) { + console.error('Erro ao carregar configurações da clínica', err); + } + }; + + const loadPatients = async () => { + try { + const res = await fetch('/api/patients'); + const data = await res.json(); + setPatients(data); + } catch (err) { + console.error('Erro ao carregar pacientes', err); + } + }; + + const doctorFiltered = useMemo(() => { + if (selectedDoctor === 'all') return appointments; + return appointments.filter((apt) => apt.doctorId === selectedDoctor); + }, [appointments, selectedDoctor]); + + const filteredAppointments = useMemo(() => { + if (statusFilter === 'all') return doctorFiltered; + return doctorFiltered.filter((apt) => apt.status === statusFilter); + }, [doctorFiltered, statusFilter]); + + const stats = useMemo(() => { + const confirmed = doctorFiltered.filter((apt) => apt.status === 'confirmed').length; + const pending = doctorFiltered.filter((apt) => apt.status === 'pending').length; + return { + total: doctorFiltered.length, + confirmed, + pending, + }; + }, [doctorFiltered]); + + const appointmentsByTime = useMemo(() => { + const slots = {}; + TIME_SLOTS.forEach((time) => (slots[time] = [])); + filteredAppointments.forEach((app) => { + if (slots[app.time]) { + slots[app.time].push(app); + } else { + slots[app.time] = [app]; + } + }); + return slots; + }, [filteredAppointments]); + + const filteredPatients = useMemo(() => { + if (!patientSearch.trim()) return patients; + const term = patientSearch.toLowerCase(); + return patients.filter( + (p) => + p.name?.toLowerCase().includes(term) || + p.phone?.toLowerCase().includes(term) || + (p.email && p.email.toLowerCase().includes(term)), + ); + }, [patients, patientSearch]); + + const recordPatientMatches = useMemo(() => { + if (!recordPatientQuery.trim()) return patients; + const term = recordPatientQuery.toLowerCase(); + return patients.filter( + (p) => + p.name?.toLowerCase().includes(term) || + p.cpf?.toLowerCase().includes(term) || + (p.phone && p.phone.toLowerCase().includes(term)), + ); + }, [patients, recordPatientQuery]); + + const filteredRecords = useMemo(() => { + if (!recordSearch.trim()) return records; + const term = recordSearch.toLowerCase(); + return records.filter( + (rec) => + rec.patientName?.toLowerCase().includes(term) || + rec.cpf?.toLowerCase().includes(term) || + (rec.professionalName && rec.professionalName.toLowerCase().includes(term)), + ); + }, [records, recordSearch]); + + const recordsByPatient = useMemo(() => { + const groups = {}; + filteredRecords.forEach((rec) => { + if (!groups[rec.patientId]) { + groups[rec.patientId] = { + patientName: rec.patientName, + cpf: rec.cpf, + phone: rec.phone, + items: [], + }; + } + groups[rec.patientId].items.push(rec); + }); + Object.values(groups).forEach((group) => + group.items.sort((a, b) => new Date(b.date) - new Date(a.date)), + ); + return groups; + }, [filteredRecords]); + + const appointmentTypeOptions = serviceTypes.length ? serviceTypes.map((type) => type.name) : DEFAULT_APPOINTMENT_TYPES; + + const appointmentPatients = useMemo(() => { + if (!appointmentPatientQuery.trim()) return patients; + const term = appointmentPatientQuery.toLowerCase(); + return patients.filter( + (p) => + p.name?.toLowerCase().includes(term) || + p.phone?.toLowerCase().includes(term) || + (p.email && p.email.toLowerCase().includes(term)), + ); + }, [patients, appointmentPatientQuery]); + + const sortedDoctors = useMemo(() => { + return [...doctors].sort((a, b) => a.name.localeCompare(b.name, 'pt-BR')); + }, [doctors]); + + const sortedServiceTypes = useMemo(() => { + return [...serviceTypes].sort((a, b) => a.name.localeCompare(b.name, 'pt-BR')); + }, [serviceTypes]); + + const toggleSettingsSection = (section) => { + setOpenSettingsSection((prev) => (prev === section ? null : section)); + }; + + const changeDate = (days) => { + const base = new Date(selectedDate); + base.setDate(base.getDate() + days); + setSelectedDate(base.toISOString().split('T')[0]); + }; + + const openModal = (appointment = null, slotTime = null) => { + if (!patients.length) { + loadPatients(); + } + setAppointmentPatientQuery(''); + setSelectedPatient(null); + if (appointment) { + setIsEditMode(true); + setCurrentAppointment(appointment); + setFormData({ + patient: appointment.patient, + phone: appointment.phone || '', + doctorId: appointment.doctorId, + date: appointment.date, + time: appointment.time, + type: appointment.type || serviceTypes[0]?.name || DEFAULT_APPOINTMENT_TYPES[0], + notes: appointment.notes || '', + status: appointment.status || 'pending', + }); + setAppointmentPatientQuery(''); + setSelectedPatient({ name: appointment.patient, phone: appointment.phone || '' }); + } else { + setIsEditMode(false); + setCurrentAppointment(null); + setFormData({ + patient: '', + phone: '', + doctorId: selectedDoctor === 'all' ? doctors[0]?.id ?? 'dr-ana' : selectedDoctor, + date: selectedDate, + time: slotTime ?? '09:00', + type: serviceTypes[0]?.name || DEFAULT_APPOINTMENT_TYPES[0], + notes: '', + status: 'pending', + }); + } + setIsModalOpen(true); + }; + + const openRecordModal = (patient = null) => { + if (!patients.length) { + loadPatients(); + } + if (patient) { + setRecordSelectedPatient(patient); + setRecordPatientQuery(''); + setRecordForm({ ...emptyRecordForm, patientId: patient.id, date: today }); + } else { + setRecordSelectedPatient(null); + setRecordPatientQuery(''); + setRecordForm({ ...emptyRecordForm }); + } + setRecordModalOpen(true); + }; + + const showToast = (message) => { + setToast(message); + }; + + const handleSubmit = async (event) => { + event.preventDefault(); + const payload = { ...formData }; + const method = isEditMode ? 'PUT' : 'POST'; + const url = isEditMode + ? `/api/appointments/${currentAppointment?.id}` + : '/api/appointments'; + try { + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + throw new Error('Falha ao salvar'); + } + showToast(isEditMode ? 'Alterações salvas' : 'Agendamento criado'); + setIsModalOpen(false); + setSelectedPatient(null); + loadAppointments(); + } catch (error) { + console.error(error); + alert('Não foi possível salvar o agendamento.'); + } + }; + + const handleStatusChange = async (id, status) => { + try { + const res = await fetch(`/api/appointments/${id}/status`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }), + }); + if (!res.ok) { + throw new Error('Erro ao atualizar status'); + } + showToast('Status atualizado'); + loadAppointments(); + } catch (err) { + console.error(err); + alert('Não foi possível atualizar o status.'); + } + }; + + const handleDelete = async () => { + if (!currentAppointment) return; + if (!confirm('Deseja mesmo excluir este agendamento?')) return; + try { + const res = await fetch(`/api/appointments/${currentAppointment.id}`, { + method: 'DELETE', + }); + if (!res.ok) { + throw new Error('Não foi possível excluir'); + } + showToast('Agendamento removido'); + setIsModalOpen(false); + setSelectedPatient(null); + loadAppointments(); + } catch (err) { + console.error(err); + alert('Não foi possível excluir o agendamento.'); + } + }; + + const handleRecordSubmit = async (event) => { + event.preventDefault(); + if (!recordForm.patientId) { + alert('Selecione um paciente para o prontuário.'); + return; + } + try { + const res = await fetch('/api/records', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(recordForm), + }); + if (!res.ok) { + throw new Error('Erro ao salvar prontuário'); + } + showToast('Prontuário registrado'); + setRecordModalOpen(false); + setRecordForm({ ...emptyRecordForm }); + setRecordSelectedPatient(null); + loadRecords(); + } catch (err) { + console.error(err); + alert('Não foi possível salvar o prontuário.'); + } + }; + + const handleClinicSave = async (event) => { + event.preventDefault(); + try { + const res = await fetch('/api/settings/clinic', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(clinicForm), + }); + if (!res.ok) throw new Error('Falha ao salvar dados da clínica'); + const data = await res.json(); + setClinicSettings(data); + showToast('Dados da clínica atualizados'); + } catch (err) { + console.error(err); + alert('Não foi possível salvar as configurações da clínica.'); + } + }; + + const handleAddDoctor = async (event) => { + event.preventDefault(); + if (!doctorForm.name) { + alert('Informe o nome do profissional.'); + return; + } + try { + const res = await fetch('/api/doctors', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(doctorForm), + }); + if (!res.ok) throw new Error('Falha ao cadastrar doutor'); + await res.json(); + setDoctorForm({ name: '', specialty: '', color: '#0ea5e9' }); + showToast('Profissional adicionado'); + const updated = await fetch('/api/doctors'); + const doctorsData = await updated.json(); + setDoctors(doctorsData); + } catch (err) { + console.error(err); + alert('Erro ao cadastrar profissional.'); + } + }; + + const handleDeleteDoctor = async (id) => { + if (!confirm('Deseja remover este profissional?')) return; + try { + const res = await fetch(`/api/doctors/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Falha ao remover doutor'); + showToast('Profissional removido'); + const updated = await fetch('/api/doctors'); + const doctorsData = await updated.json(); + setDoctors(doctorsData); + } catch (err) { + console.error(err); + alert('Não foi possível remover o profissional.'); + } + }; + + const handleAddServiceType = async (event) => { + event.preventDefault(); + if (!newServiceType.trim()) return; + try { + const res = await fetch('/api/service-types', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newServiceType }), + }); + if (!res.ok) throw new Error('Erro ao cadastrar tipo'); + setNewServiceType(''); + showToast('Tipo de atendimento adicionado'); + loadServiceTypes(); + } catch (err) { + console.error(err); + alert('Não foi possível adicionar o tipo de atendimento.'); + } + }; + + const handleDeleteServiceType = async (id) => { + if (!confirm('Remover este tipo de atendimento?')) return; + try { + const res = await fetch(`/api/service-types/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Erro ao remover tipo'); + showToast('Tipo de atendimento removido'); + loadServiceTypes(); + } catch (err) { + console.error(err); + alert('Não foi possível remover o tipo de atendimento.'); + } + }; + + const handlePatientSubmit = async (event) => { + event.preventDefault(); + const method = patientForm.id ? 'PUT' : 'POST'; + const url = patientForm.id ? `/api/patients/${patientForm.id}` : '/api/patients'; + try { + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patientForm), + }); + if (!res.ok) throw new Error('Falha ao salvar paciente'); + setPatientForm({ id: null, name: '', phone: '', email: '', cpf: '', cep: '', address: '', birthdate: '' }); + loadPatients(); + showToast(patientForm.id ? 'Paciente atualizado' : 'Paciente cadastrado'); + setPatientModalOpen(false); + } catch (err) { + console.error(err); + alert('Erro ao salvar paciente.'); + } + }; + + const handlePatientEdit = (patient) => { + setPatientForm({ + id: patient.id, + name: patient.name, + phone: patient.phone, + email: patient.email || '', + cpf: patient.cpf || '', + cep: patient.cep || '', + address: patient.address || '', + birthdate: patient.birthdate || '', + }); + setPatientModalOpen(true); + }; + + const handlePatientDelete = async (id) => { + if (!confirm('Deseja mesmo excluir este paciente?')) return; + try { + const res = await fetch(`/api/patients/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Erro ao excluir'); + loadPatients(); + showToast('Paciente removido'); + } catch (err) { + console.error(err); + alert('Não foi possível excluir o paciente.'); + } + }; + + const handleQuickStatus = async (id, status) => { + await handleStatusChange(id, status); + setQuickListOpen(true); + }; + + const handleRescheduleSubmit = async () => { + if (!rescheduleTarget) return; + const apt = appointments.find((item) => item.id === rescheduleTarget.id); + if (!apt) return; + const payload = { + patient: apt.patient, + phone: apt.phone || '', + doctorId: apt.doctorId, + date: rescheduleTarget.date, + time: rescheduleTarget.time, + type: apt.type || '', + notes: apt.notes || '', + status: 'pending', + }; + try { + const res = await fetch(`/api/appointments/${apt.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error('Erro ao reagendar'); + showToast('Reagendado para novo horário'); + setRescheduleTarget(null); + loadAppointments(); + setQuickListOpen(true); + } catch (err) { + console.error(err); + alert('Não foi possível reagendar.'); + } + }; + + return ( +
+ + +
+ {activePage === 'agenda' && ( + <> +
+
+

Dashboard de agendamentos

+

Visão moderna para clínicas odontológicas

+
+
+ +
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + setSelectedDate(e.target.value)} + /> + +
+
+ + {doctors.map((doc) => ( + + ))} +
+
+ +
+
+
+

Agenda detalhada

+

Toque para editar ou confirme os horários diretamente

+
+
+ + +
+
+ +
+ {loading ? ( +
+
+ Carregando agenda... +
+ ) : ( + TIME_SLOTS.map((time) => ( +
+
{time}
+
+
openModal(null, time)} + > + adicionar +
+
+ {appointmentsByTime[time]?.map((app) => ( +
openModal(app)} + > +
+ {app.patient} + +
+
+ + {app.time} + + {app.type} +
+
+
+ {app.doctorName} +
+ {app.status === 'pending' && ( + + )} +
+
+ ))} +
+
+
+ )) + )} +
+
+ + )} + + {activePage === 'records' && ( +
+
+
+

Prontuários

+

Histórico clínico completo por paciente

+
+
+
+ + setRecordSearch(e.target.value)} + /> +
+ +
+
+
+ {Object.keys(recordsByPatient).length === 0 ? ( +

Nenhum prontuário registrado ainda.

+ ) : ( + Object.entries(recordsByPatient).map(([patientId, group]) => ( +
+
+
+

{group.patientName || 'Paciente sem nome'}

+

+ {group.cpf && CPF: {group.cpf} · } + {group.phone} +

+
+ +
+
+ {group.items.map((rec) => ( +
+
+ {new Date(rec.date).toLocaleDateString('pt-BR')} + {rec.professionalName} + {rec.professionalRegistration && {rec.professionalRegistration}} +
+
+

Dados da clínica

+

{rec.clinicInfo || 'Não informado'}

+
+
+

Queixa principal

+

{rec.chiefComplaint || '—'}

+
+
+

Histórico médico/odontológico

+

{rec.medicalHistory || '—'}

+

{rec.dentalHistory || ''}

+
+
+

Exame clínico

+

{rec.clinicalExam || '—'}

+
+
+

Diagnóstico

+

{rec.diagnosis || '—'}

+
+
+

Plano de tratamento

+

{rec.treatmentPlan || '—'}

+
+
+

Evolução

+

{rec.evolution || '—'}

+
+
+

Prescrições e orientações

+

{rec.prescriptions || '—'}

+
+
+

Consentimentos / comunicações

+

{rec.consentNotes || '—'}

+

{rec.communications || ''}

+
+ {rec.attachments && ( +
+

Observações adicionais

+

{rec.attachments}

+
+ )} +
+ ))} +
+
+ )) + )} +
+
+ )} + + {activePage === 'settings' && ( +
+
+
+

Configurações

+

Personalize os dados principais da clínica

+
+
+
+
+ + {openSettingsSection === 'clinic' && ( +
+
+ + + + + +