Compare commits

...

16 Commits

Author SHA1 Message Date
e77077f5fc Merge pull request 'módulo-prontuário' (#7) from módulo-prontuário into main
Reviewed-on: #7
2026-05-11 18:33:49 +00:00
8f0e616d2b modified: src/App.jsx
modified:   src/components/AppShell.jsx
modified:   src/components/Brand.jsx
modified:   src/index.css
modified:   src/pages/MedicalRecordsPage.jsx
modified:   src/pages/PatientsPage.jsx
modified:   src/pages/ReportsPage.jsx
modified:   src/pages/SettingsPage.jsx
modified:   src/repositories/authRepository.js
modified:   src/repositories/professionalRepository.js
modified:   src/repositories/repositoryUtils.js
modified:   src/repositories/settingsRepository.js
modified:   src/utils/theme.js
2026-05-11 15:26:55 -03:00
04a13c24d3 modified: docs/repository-api-audit.md
modified:   src/repositories/authRepository.js
modified:   src/repositories/patientRepository.js
modified:   src/repositories/professionalRepository.js
modified:   src/repositories/profileRepository.js
modified:   src/repositories/userRepository.js
2026-05-11 13:24:29 -03:00
307ad9431b Implementa integrações de repositórios 2026-05-11 12:45:46 -03:00
fba021e048 modified: package-lock.json
modified:   package.json
modified:   src/App.jsx
modified:   src/index.css
modified:   src/pages/MedicalRecordsPage.jsx
modified:   src/pages/ReportsPage.jsx
modified:   src/repositories/medicalRecordRepository.js
2026-05-09 23:32:04 -03:00
bcee06b908 modified: src/components/featureStateStyles.js
modified:   src/index.css
modified:   src/pages/ReportsPage.jsx
2026-05-09 19:10:20 -03:00
94dab58d85 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
2026-05-08 01:32:46 -03:00
bc900fbdd4 Merge pull request 'modo-claro' (#4) from modo-claro into main
Reviewed-on: #4
2026-05-07 16:03:45 +00:00
db7a2fe8f5 modo-claro
modified:   src/hooks/useAgenda.js
modified:   src/index.css
modified:   src/main.jsx
modified:   src/mappers/reportMapper.js
modified:   src/pages/AgendaPage.jsx
modified:   src/pages/AuthPages.jsx
modified:   src/pages/MedicalRecordsPage.jsx
modified:   src/pages/PatientsPage.jsx
modified:   src/pages/ReportsPage.jsx
modified:   src/pages/SettingsPage.jsx
modified:   src/repositories/analyticsRepository.js
modified:   src/repositories/authRepository.js
modified:   src/repositories/patientRepository.js
modified:   src/repositories/reportRepository.js
modified:   src/repositories/repositoryUtils.js
new file:   src/utils/theme.js
new file:   vercel.json
2026-05-07 05:51:07 -03:00
64d9527318 Merge pull request 'modified: index.html' (#3) from user-profiles into main
Reviewed-on: #3
2026-05-07 04:15:41 +00:00
efb942d5aa modified: index.html
modified:   src/App.jsx
modified:   src/components/AppShell.jsx
modified:   src/components/featureStateStyles.js
modified:   src/config/permissions.js
modified:   src/hooks/useAgenda.js
modified:   src/mappers/reportMapper.js
modified:   src/pages/AgendaPage.jsx
modified:   src/pages/AnalyticsPage.jsx
modified:   src/pages/AuthPages.jsx
modified:   src/pages/HomePage.jsx
modified:   src/pages/MedicalRecordsPage.jsx
modified:   src/pages/MessagesPage.jsx
modified:   src/pages/PatientsPage.jsx
modified:   src/pages/ReportsPage.jsx
modified:   src/pages/SettingsPage.jsx
deleted:    src/pages/TeamPage.jsx
modified:   src/pages/UsersPage.jsx
modified:   src/repositories/availabilityRepository.js
modified:   src/repositories/patientRepository.js
modified:   src/repositories/professionalRepository.js
modified:   src/repositories/reportRepository.js
modified:   src/repositories/settingsRepository.js
2026-05-07 01:11:10 -03:00
151aa4b76d Merge pull request 'user-profiles' (#2) from user-profiles into main
Reviewed-on: #2
2026-05-06 21:10:33 +00:00
9335e974eb modified: src/App.jsx
modified:   src/components/AppShell.jsx
modified:   src/config/permissions.js
modified:   src/pages/PatientsPage.jsx
2026-05-06 17:49:52 -03:00
666b3b5c0e modified: src/App.jsx
modified:   src/components/AppShell.jsx
modified:   src/config/api.js
modified:   src/config/permissions.js
modified:   src/data/mockData.js
modified:   src/hooks/useAgenda.js
modified:   src/hooks/useAuth.js
modified:   src/mappers/appointmentMapper.js
modified:   src/pages/AgendaPage.jsx
modified:   src/pages/AuthPages.jsx
modified:   src/pages/HomePage.jsx
modified:   src/pages/MedicalRecordsPage.jsx
modified:   src/pages/MessagesPage.jsx
modified:   src/pages/NotFoundPage.jsx
modified:   src/pages/PatientsPage.jsx
modified:   src/pages/ReportsPage.jsx
modified:   src/pages/TeamPage.jsx
modified:   src/pages/UsersPage.jsx
modified:   src/pages/VisitsPage.jsx
modified:   src/repositories/authRepository.js
new file:   src/repositories/availabilityRepository.js
modified:   src/repositories/communicationRepository.js
modified:   src/repositories/patientRepository.js
modified:   src/repositories/professionalRepository.js
modified:   src/repositories/profileRepository.js
modified:   src/repositories/reportRepository.js
modified:   src/repositories/repositoryUtils.js
modified:   src/repositories/settingsRepository.js
modified:   src/repositories/userRepository.js
modified:   src/repositories/visitRepository.js
2026-05-06 01:09:36 -03:00
bb5200664a modified: src/App.jsx
modified:   src/components/AppShell.jsx
new file:   src/config/permissions.js
new file:   src/hooks/useAuth.js
new file:   src/pages/UsersPage.jsx
new file:   src/repositories/userRepository.js
2026-05-05 22:49:20 -03:00
06acf8cc61 Merge pull request 'faeture/integracao_api' (#1) from faeture/integracao_api into main
Reviewed-on: #1
2026-04-28 17:41:12 +00:00
52 changed files with 7066 additions and 1589 deletions

View File

@@ -1,49 +1,73 @@
# Auditoria de Implementacao e Mapeamento da API # Auditoria de Implementacao e Mapeamento da API
Este documento resume o estado atual da integracao entre o front-end e os endpoints da API. Este documento resume as APIs do Apidog conectadas no projeto `riseup_squad_03`.
## Integrado no front ## Autenticacao
- **Autenticacao** - `POST /auth/v1/token?grant_type=password` em `authRepository.login`
- Login com email e senha via Supabase Auth (`/auth/v1/token`). - `POST /auth/v1/otp` em `authRepository.sendMagicLink`
- Solicitar reset de senha: tenta `/solicitar-reset-de-senha` e usa `/auth/v1/recover` como fallback. - `POST /functions/v1/request-password-reset` em `authRepository.requestPasswordReset`
- Dados do usuario autenticado: tenta `/informacoes-do-usuario-autenticado` e usa `/auth/v1/user` como fallback. - `GET /auth/v1/user` em `authRepository.getUser` como fallback
- Logout: tenta `/logout`, usa `/auth/v1/logout` como fallback e sempre limpa a sessao local. - `POST /functions/v1/user-info` em `authRepository.getUser`
- `POST /auth/v1/logout` em `authRepository.logout`
- **Pacientes** ## Usuarios
- Listar, criar, atualizar e deletar pacientes via Supabase REST.
- Criar paciente com validacao via Edge Function quando disponivel.
- **Agendamentos** - `POST /functions/v1/create-user` em `userRepository.create`
- Listar agendamentos: tenta `GET /agendamentos` e usa Supabase REST `appointments` como fallback. - `POST /functions/v1/create-user-with-password` em `userRepository.createWithPassword`
- Criar agendamento: tenta `POST /agendamentos` e usa Supabase REST `appointments` como fallback. - `POST /functions/v1/user-info-by-id/:id` em `userRepository.getById`
- `POST /functions/v1/delete-user` em `userRepository.remove`
- Listagem de perfis via REST em `profiles` / `user_profiles` em `userRepository.getAll`
- **Laudos Medicos** ## Pacientes
- Listar relatorios: tenta `GET /reports` e usa Supabase REST `reports` como fallback.
- Criar relatorio: tenta `POST /reports` e usa Supabase REST `reports` como fallback.
- Atualizar relatorio: tenta `PATCH /reports/{id}`, depois `PATCH /reports`, e usa Supabase REST `reports` como fallback.
- **Medicos / Profissionais** - `GET /rest/v1/patients` em `patientRepository.getAll`
- Listar medicos: tenta `GET /listar-medicos` e usa Supabase REST `doctors` como fallback. - `POST /rest/v1/patients` em `patientRepository.create`
- `PATCH /rest/v1/patients?id=eq.ID` em `patientRepository.update`
- `DELETE /rest/v1/patients?id=eq.ID` em `patientRepository.remove`
- `POST /functions/v1/create-patient` em `patientRepository.createWithValidation`
- `POST /functions/v1/register-patient` em `patientRepository.registerPublic`
- **Mensageria** ## Medicos
- Enviar SMS: tenta `POST /enviar-sms-via-twilio` e usa Edge Function `send-sms` como fallback.
- O formulario agora coleta telefone quando o canal selecionado e SMS.
- **Storage** - `GET /rest/v1/doctors` em `professionalRepository.getAll`
- Upload de avatar: tenta `/upload-avatar` e usa Supabase Storage no bucket `avatars` como fallback. - `POST /functions/v1/create-doctor` em `professionalRepository.create`
- A tela de perfil atualiza a imagem exibida apos upload bem-sucedido.
## Ainda sem endpoint consolidado documentado ## Agendamentos
- Dashboard / Inicio (`HomePage` / `homeRepository.js`). - `GET /rest/v1/appointments` em `appointmentRepository.getAll`
- Estatisticas e BI (`AnalyticsPage` / `analyticsRepository.js`). - `POST /rest/v1/appointments` em `appointmentRepository.create`
- Prontuarios especificos separados de laudos (`MedicalRecordsPage` / `medicalRecordRepository.js`). - `PATCH /rest/v1/appointments?id=eq.ID` em `appointmentRepository.update`
- Consultas isoladas fora de agendamento (`VisitsPage` / `visitRepository.js`). - Cancelamento via `PATCH /rest/v1/appointments?id=eq.ID` em `appointmentRepository.cancel`
- Configuracoes gerais do tenant (`SettingsPage` / `settingsRepository.js`).
## Disponibilidade e Slots
- `GET /rest/v1/doctor_availability` em `availabilityRepository.getAll`
- `POST /rest/v1/doctor_availability` em `availabilityRepository.create`
- `PATCH /rest/v1/doctor_availability?id=eq.ID` em `availabilityRepository.update`
- `DELETE /rest/v1/doctor_availability?id=eq.ID` em `availabilityRepository.remove`
- `GET /rest/v1/doctor_exceptions` em `availabilityRepository.getExceptions`
- `POST /rest/v1/doctor_exceptions` em `availabilityRepository.createException`
- `POST /functions/v1/get-available-slots` em `availabilityRepository.getAvailableSlots`
## Reports / Laudos Medicos
- `GET /rest/v1/reports` em `reportRepository.getInitialReports`
- `POST /rest/v1/reports` em `reportRepository.create`
- `PATCH /rest/v1/reports?id=eq.ID` em `reportRepository.update`
## SMS / Comunicacao
- `POST /functions/v1/send-sms` em `communicationRepository.sendSms`
## Storage
- `POST /storage/v1/object/avatars/{path}` em `profileRepository.updateAvatar`
- `GET /storage/v1/object/avatars/{path}` em `profileRepository.downloadAvatar`
## Observacoes ## Observacoes
- `VITE_API_BASE_URL` define a base dos endpoints nomeados da API. Quando nao informado, o front usa `VITE_SUPABASE_FUNCTIONS_URL`. - O Supabase real responde as rotas REST em `/rest/v1/...`.
- Os reposititorios aceitam formatos de resposta comuns como arrays diretos ou objetos com chaves `data`, `reports`, `agendamentos`, `medicos` etc. - As Edge Functions reais respondem em `/functions/v1/...`.
- Os fallbacks existem para manter o front funcional em ambientes onde parte das Edge Functions ainda nao foi publicada. - Algumas rotas curtas do Apidog retornam `404` no ambiente real; o codigo usa o caminho que respondeu em producao.
- `Schemas` no Apidog nao sao endpoints executaveis, apenas contratos de dados.

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>projeto-residencia</title> <title>MediConnect</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

654
package-lock.json generated
View File

@@ -8,6 +8,11 @@
"name": "projeto-residencia", "name": "projeto-residencia",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tiptap/extension-text-align": "^3.23.1",
"@tiptap/extension-underline": "^3.23.1",
"@tiptap/pm": "^3.23.1",
"@tiptap/react": "^3.23.1",
"@tiptap/starter-kit": "^3.23.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4"
@@ -459,6 +464,34 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT",
"optional": true
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1156,6 +1189,447 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8" "vite": "^5.2.0 || ^6 || ^7 || ^8"
} }
}, },
"node_modules/@tiptap/core": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.1.tgz",
"integrity": "sha512-8YvSGiJTeU5wPuGiYIIYgyiyaaT1CAx+kJL0bju0w871OvbJJj0T/ywhcmxGXW6pOal2T8X2xt9ZqE+vib0VJw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.23.1"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.23.1.tgz",
"integrity": "sha512-FdVZLZOkL06j3WLXOC2UeX7++Cj3qI2vfohruMJiz4vk1Q5UUH7G4+AykFzjzBJHrdEpkiRUkRpU1KZIWdbluw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.23.1.tgz",
"integrity": "sha512-EAYdNzyOjlQh2VBY1EhdxtiTjVMaOAD6P0ezms60dKRjd4oj/8grfXfUqwgo4NVdFb11Ks85vXoHuXJSylfR4A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.23.1.tgz",
"integrity": "sha512-1advMCpPkHD/3ucZhYmNau8B4tF0L6iRAFhUOglp5bBZDuq13+rYujh3cm4vFmjH9KqThzpcUDn+ZU2c+mTMyw==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1",
"@tiptap/pm": "3.23.1"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.23.1.tgz",
"integrity": "sha512-owWnBBI4t+jqVDY0naDjhsAmrNGldh4czouef2K+mEf032B7uGsDVCwKp1qaX1JZesyYDfvXOaIwT22hNID2mw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.1"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.23.1.tgz",
"integrity": "sha512-nGuhb4YghgTfkejwWHrD9GSpwcC5kkVmm2sN/UY4yceDw+PkyysYKJWZehRLTOC8GNgSAhq/EeQeq14Xwk6dyg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.23.1.tgz",
"integrity": "sha512-BdJGqM57CsKgYrQUZz78vIG8Yn7EpsE2pA7iKn5tYoSXpYtt0IaU4qB1heH7lwWD/vVCAm0YQVD7/0F+0++yhA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1",
"@tiptap/pm": "3.23.1"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.23.1.tgz",
"integrity": "sha512-NA5Rx59HRwG6Hb6LwLpC5lE7z6vCj6f90S7RNNsnE+CyiXNR/OhY2BcjuxiGnascHvsnsAbvxGU3ymKMDgvDVg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.23.1.tgz",
"integrity": "sha512-WRN7e/h9m3uI5j9/+L6jcPhHbTL6aKxfFfQWZHNf5M8TqSL1P+/2h034td0XMj3n48i4fWyzjVUV9+sz6t2fDw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.23.1"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.23.1.tgz",
"integrity": "sha512-XrYHpLn1DpLFSGTko9F9xgbNamL6fGpWkK4wqgwPVbg/SJwQCDO/9p5D3DtJTwD+xgw4sQ9as4O6rt6jx8JT+Q==",
"license": "MIT",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.23.1",
"@tiptap/pm": "3.23.1"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.23.1.tgz",
"integrity": "sha512-E4hB0xquUpEXy7kboLBazrFyRCsN0j0fsTFR8udgQf5xetAVPhOexSTKuzOcU/n0kxsKJin7laYYEag/Fd2KNw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.23.1"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.23.1.tgz",
"integrity": "sha512-XYkCKC5RVqMmmBk+nd22/6IDDx1OC54sdStH5VEHtfOrarriO0JztK8Mr0TijPPk9N4rKXsmndYZM2xyWZZytQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.23.1.tgz",
"integrity": "sha512-1z9yCSp8fevgX3r/4kWXO3of0WFCQWfYjWfHANvoJ4JQTYBkARjXlj1tbk5rrAJBFDDfKRkUpZOurXKgGo+h+g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.23.1.tgz",
"integrity": "sha512-30XUHXdEZxcz1FCWjz9HW2EEq06NQcAye6rXGnvHo6Y60iJ6MRsrX5byvceFNF9DTVtOIcUFBQ/psIiRcoi0KA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1",
"@tiptap/pm": "3.23.1"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.23.1.tgz",
"integrity": "sha512-lZB9YCjoVNDoPMguya66nBvaS/2YpGN5iAcjAGx/JQkCAZeOAtl9+ALMzbWPKH6tQP6m98YtkY1T7RXr++T0bA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.23.1.tgz",
"integrity": "sha512-uOeyLqYQI0WG62agpFG24kVHSn3Z48gD8Y0uLLJbtzh/nDFC3d9So2sQGWlSVyMzsgkJ4k/9jNnxxsVO8qgJOg==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1",
"@tiptap/pm": "3.23.1"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.23.1.tgz",
"integrity": "sha512-v1AeXPpagslgRZdOp7WdjCoO4TjjNP8RM2R6Gqx0/inGaNXnM8zCMshOxZlAb03Ad7kq/4RGJmkpM/Jjsi6dEQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1",
"@tiptap/pm": "3.23.1"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.23.1.tgz",
"integrity": "sha512-Fk/884un5OSLCFxe2TbOmfp3sLMB5b76CnMjaSrvgfiaZnsV2WlJZGPXxCAPbxNIATTykNlSBsVuMBO7we64Vg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.1"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.23.1.tgz",
"integrity": "sha512-sHbE5sxiJzhgGn94GUAzD4qKM9SyImBrOlAGS/EIe+pausjqQE7xi+YW0gRo2jG+gXhSYl4/oAGXQXzmSInSUQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.1"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.23.1.tgz",
"integrity": "sha512-3GG7YFhVJWw/HWmRxvMMUC296x7TPBQRLsH4ryEC1SMAmVJnbTIvetyvIcLqLEXGW7Rj41S7SO8qjOXVceSOTA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.1"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.23.1.tgz",
"integrity": "sha512-GC7b6yAjASl1q9sNkPmukZmVYMfxx03EEhpMMrLYJY9GBz82Ald927yYQsOqf2aKA/Rjo/aZMYCGtjXkGk6aBA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.23.1.tgz",
"integrity": "sha512-+R5LG0ZW9SDZc4weA79uq6uUduVsCEph9tRcoQCRA82IVIiPYSTxTLew9odalmk/Mc7vdZvOK5jjtO5jUVw/rg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.23.1.tgz",
"integrity": "sha512-k1Ki9bBV6mLz1mFP+Laqh1YHJ2MY0P8XzaMqpkgMndEBIJQ3XcpWQc5bfAlRnYcOI9ZXDbAgQ8CwgArxHmQWCQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-text-align": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.23.1.tgz",
"integrity": "sha512-ap4ZN31v57mVX2P+0OoW5iO+ehsUNe0C5MgF/Ta2F/HRmTCc1M1mFqYUCk8zJYX1TFRV18vqK2j6STRBk0R8ng==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.23.1.tgz",
"integrity": "sha512-+PvHyVozHyxJ9oWCIQx5JHBZ7LAa/sFJUOFaKyfmel4gL9AbP52MmvrciXARlZHd1WCULJtdbLan0+x5/D/9hQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.23.1.tgz",
"integrity": "sha512-7UIn+idaVTVhdlP0KmgzBh8Csmwck357Dq4te5DuAxhSkN1gsXHlq39mpx907UYKJdSOgd+GMFeyOziPwSmbOQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1",
"@tiptap/pm": "3.23.1"
}
},
"node_modules/@tiptap/pm": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.23.1.tgz",
"integrity": "sha512-8G+TkNsUHHAAJYREpA6fw+Dw/m2Y3Go4/QMQM8RYepid+wTeE1wSv7sBA/CBrphhYmJSWeTyCPtgQIxnTJXMCA==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-keymap": "^1.2.2",
"prosemirror-model": "^1.24.1",
"prosemirror-schema-list": "^1.5.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.38.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.23.1.tgz",
"integrity": "sha512-43zUwKOcsxRIcgiDbcEUagojhPIez2OIryaNG/uiDcRzkrUteiTu2wSJndkQqwouwh3wJEm+KOw8xybNYvU+qA==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.23.1",
"@tiptap/extension-floating-menu": "^3.23.1"
},
"peerDependencies": {
"@tiptap/core": "3.23.1",
"@tiptap/pm": "3.23.1",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.23.1.tgz",
"integrity": "sha512-CURePHQagBaZIDJrHH3of4Nmi0VYGpZ6yBlkdFxFHBxY9aeG2/h5kn+oHo8GbzkSFsRV+9olzRgDTOULVgs8pQ==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^3.23.1",
"@tiptap/extension-blockquote": "^3.23.1",
"@tiptap/extension-bold": "^3.23.1",
"@tiptap/extension-bullet-list": "^3.23.1",
"@tiptap/extension-code": "^3.23.1",
"@tiptap/extension-code-block": "^3.23.1",
"@tiptap/extension-document": "^3.23.1",
"@tiptap/extension-dropcursor": "^3.23.1",
"@tiptap/extension-gapcursor": "^3.23.1",
"@tiptap/extension-hard-break": "^3.23.1",
"@tiptap/extension-heading": "^3.23.1",
"@tiptap/extension-horizontal-rule": "^3.23.1",
"@tiptap/extension-italic": "^3.23.1",
"@tiptap/extension-link": "^3.23.1",
"@tiptap/extension-list": "^3.23.1",
"@tiptap/extension-list-item": "^3.23.1",
"@tiptap/extension-list-keymap": "^3.23.1",
"@tiptap/extension-ordered-list": "^3.23.1",
"@tiptap/extension-paragraph": "^3.23.1",
"@tiptap/extension-strike": "^3.23.1",
"@tiptap/extension-text": "^3.23.1",
"@tiptap/extension-underline": "^3.23.1",
"@tiptap/extensions": "^3.23.1",
"@tiptap/pm": "^3.23.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.1", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -1185,7 +1659,6 @@
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -1195,12 +1668,17 @@
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
@@ -1493,7 +1971,6 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/date-fns": { "node_modules/date-fns": {
@@ -1776,6 +2253,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-json-stable-stringify": { "node_modules/fast-json-stable-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -2399,6 +2885,12 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/linkifyjs": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT"
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -2513,6 +3005,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/p-limit": { "node_modules/p-limit": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -2644,6 +3142,135 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prosemirror-changeset": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.4",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.8",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -2726,6 +3353,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -2901,6 +3534,15 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.10", "version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
@@ -2979,6 +3621,12 @@
} }
} }
}, },
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -10,6 +10,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tiptap/extension-text-align": "^3.23.1",
"@tiptap/extension-underline": "^3.23.1",
"@tiptap/pm": "^3.23.1",
"@tiptap/react": "^3.23.1",
"@tiptap/starter-kit": "^3.23.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4"

10
public/favicon.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<rect width="48" height="48" rx="8" fill="#3b82f6"/>
<g fill="none" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4">
<path d="M22 7v5"/>
<path d="M12 7v5"/>
<path d="M12 9h-2a4 4 0 0 0-4 4v8a12 12 0 0 0 24 0v-8a4 4 0 0 0-4-4h-2"/>
<path d="M18 34a12 12 0 0 0 24 0v-6"/>
<circle cx="42" cy="24" r="4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 431 B

View File

@@ -1,26 +1,39 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'
import { authRepository } from './repositories/authRepository.js'
import './App.css' import './App.css'
import { AppShell } from './components/AppShell.jsx' import { AppShell } from './components/AppShell.jsx'
import { AgendaPage } from './pages/AgendaPage.jsx' import { canAccess } from './config/permissions.js'
import { AnalyticsPage } from './pages/AnalyticsPage.jsx' import { useAuth } from './hooks/useAuth.js'
import { ForgotPasswordPage, LoginPage, RegisterPage } from './pages/AuthPages.jsx' import { ForgotPasswordPage, LoginPage, RegisterPage } from './pages/AuthPages.jsx'
import { HomePage } from './pages/HomePage.jsx'
import { MedicalRecordsPage } from './pages/MedicalRecordsPage.jsx'
import { MessagesPage } from './pages/MessagesPage.jsx'
import { NotFoundPage } from './pages/NotFoundPage.jsx' import { NotFoundPage } from './pages/NotFoundPage.jsx'
import { PatientDetailPage, PatientsPage } from './pages/PatientsPage.jsx'
import { ProfilePage } from './pages/ProfilePage.jsx'
import { ReportsPage } from './pages/ReportsPage.jsx'
import { SettingsPage } from './pages/SettingsPage.jsx'
import { TeamPage } from './pages/TeamPage.jsx'
import { VisitsPage } from './pages/VisitsPage.jsx'
import { patientRepository } from './repositories/patientRepository.js' import { patientRepository } from './repositories/patientRepository.js'
const AgendaPage = lazyPage(() => import('./pages/AgendaPage.jsx'), 'AgendaPage')
const AnalyticsPage = lazyPage(() => import('./pages/AnalyticsPage.jsx'), 'AnalyticsPage')
const HomePage = lazyPage(() => import('./pages/HomePage.jsx'), 'HomePage')
const MedicalRecordsPage = lazyPage(() => import('./pages/MedicalRecordsPage.jsx'), 'MedicalRecordsPage')
const MessagesPage = lazyPage(() => import('./pages/MessagesPage.jsx'), 'MessagesPage')
const PatientDetailPage = lazyPage(() => import('./pages/PatientsPage.jsx'), 'PatientDetailPage')
const PatientsPage = lazyPage(() => import('./pages/PatientsPage.jsx'), 'PatientsPage')
const ProfilePage = lazyPage(() => import('./pages/ProfilePage.jsx'), 'ProfilePage')
const ReportsPage = lazyPage(() => import('./pages/ReportsPage.jsx'), 'ReportsPage')
const SettingsPage = lazyPage(() => import('./pages/SettingsPage.jsx'), 'SettingsPage')
const UsersPage = lazyPage(() => import('./pages/UsersPage.jsx'), 'UsersPage')
const VisitsPage = lazyPage(() => import('./pages/VisitsPage.jsx'), 'VisitsPage')
const PANEL_PATHS = ['/inicio', '/home', '/dashboard']
const ROLE_HOME_PATHS = {
medico: '/agenda',
secretaria: '/agenda',
}
function lazyPage(loader, exportName) {
return lazy(() => loader().then((module) => ({ default: module[exportName] })))
}
function App() { function App() {
const [location, setLocation] = useState(() => readLocation()) const [location, setLocation] = useState(() => readLocation())
const { isAuthenticated, role, loading: authLoading } = useAuth()
const navigate = useCallback((to, options = {}) => { const navigate = useCallback((to, options = {}) => {
if (options.replace) { if (options.replace) {
@@ -49,25 +62,69 @@ function App() {
return () => window.removeEventListener('popstate', handlePopState) return () => window.removeEventListener('popstate', handlePopState)
}, []) }, [])
const route = useMemo(() => resolveRoute(location.pathname, navigate), [location.pathname, navigate]) const route = useMemo(
const isAuthenticated = authRepository.isAuthenticated() () => resolveRoute(location.pathname, navigate, role),
[location.pathname, navigate, role],
)
if (!route.withShell) { // Tela de carregamento enquanto busca o role do usuário
return route.element if (authLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#0a0a0a]">
<p className="text-sm text-[#a3a3a3]">Carregando...</p>
</div>
)
} }
// Rotas públicas (sem shell)
if (!route.withShell) {
return <RouteSuspense>{route.element}</RouteSuspense>
}
// Usuário não autenticado
if (!isAuthenticated) { if (!isAuthenticated) {
return <LoginPage navigate={navigate} /> return <LoginPage navigate={navigate} />
} }
// Usuário autenticado mas sem permissão para a rota
if (!role || !canAccess(role, location.pathname)) {
const roleHomePath = ROLE_HOME_PATHS[role]
if (roleHomePath && PANEL_PATHS.includes(location.pathname)) {
navigate(roleHomePath, { replace: true })
return null
}
return (
<AppShell currentPath={location.pathname} navigate={navigate} role={role} routeTitle="Sem acesso">
<UnauthorizedPage navigate={navigate} />
</AppShell>
)
}
return ( return (
<AppShell currentPath={location.pathname} navigate={navigate} routeTitle={route.title}> <AppShell currentPath={location.pathname} navigate={navigate} role={role} routeTitle={route.title}>
{route.element} <RouteSuspense>{route.element}</RouteSuspense>
</AppShell> </AppShell>
) )
} }
function resolveRoute(pathname, navigate) { function RouteSuspense({ children }) {
return (
<Suspense fallback={<RouteFallback />}>
{children}
</Suspense>
)
}
function RouteFallback() {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-sm text-[#a3a3a3]">Carregando...</p>
</div>
)
}
function resolveRoute(pathname, navigate, role) {
if (pathname === '/' || pathname === '/login') { if (pathname === '/' || pathname === '/login') {
return { return {
element: <LoginPage navigate={navigate} />, element: <LoginPage navigate={navigate} />,
@@ -102,7 +159,7 @@ function resolveRoute(pathname, navigate) {
if (pathname === '/agenda') { if (pathname === '/agenda') {
return { return {
element: <AgendaPage navigate={navigate} />, element: <AgendaPage navigate={navigate} role={role} />,
title: 'Agenda', title: 'Agenda',
withShell: true, withShell: true,
} }
@@ -110,7 +167,7 @@ function resolveRoute(pathname, navigate) {
if (pathname === '/pacientes') { if (pathname === '/pacientes') {
return { return {
element: <PatientsPage navigate={navigate} />, element: <PatientsPage navigate={navigate} role={role} />,
title: 'Pacientes', title: 'Pacientes',
withShell: true, withShell: true,
} }
@@ -124,11 +181,27 @@ function resolveRoute(pathname, navigate) {
} }
} }
if (pathname === '/prontuario/novo') {
return {
element: <MedicalRecordsPage mode="new" navigate={navigate} />,
title: 'Novo prontuário',
withShell: true,
}
}
if (pathname.startsWith('/prontuario/')) {
const [, , recordId, action] = pathname.split('/')
return {
element: <MedicalRecordsPage mode={action === 'editar' ? 'edit' : 'detail'} navigate={navigate} recordId={recordId} />,
title: action === 'editar' ? 'Editar prontuário' : 'Prontuário',
withShell: true,
}
}
if (pathname.startsWith('/pacientes/')) { if (pathname.startsWith('/pacientes/')) {
const patientId = pathname.split('/')[2] const patientId = pathname.split('/')[2]
return { return {
element: <PatientDetailRoute navigate={navigate} patientId={patientId} />, element: <PatientDetailRoute navigate={navigate} patientId={patientId} role={role} />,
title: 'Paciente', title: 'Paciente',
withShell: true, withShell: true,
} }
@@ -144,8 +217,8 @@ function resolveRoute(pathname, navigate) {
if (pathname === '/laudos') { if (pathname === '/laudos') {
return { return {
element: <ReportsPage navigate={navigate} />, element: <ReportsPage navigate={navigate} role={role} />,
title: 'Relatorios medicos', title: 'Relatórios',
withShell: true, withShell: true,
} }
} }
@@ -153,23 +226,32 @@ function resolveRoute(pathname, navigate) {
if (pathname === '/relatorios') { if (pathname === '/relatorios') {
return { return {
element: <AnalyticsPage />, element: <AnalyticsPage />,
title: 'Relatórios', title: 'Analytics',
withShell: true, withShell: true,
} }
} }
if (pathname === '/camunicacao' || pathname === '/comunicacao' || pathname === '/mensagens') { if (pathname === '/camunicacao') {
navigate('/comunicacao', { replace: true })
return { return {
element: <MessagesPage navigate={navigate} />, element: <MessagesPage navigate={navigate} role={role} />,
title: 'Comunicação', title: 'Comunicação',
withShell: true, withShell: true,
} }
} }
if (pathname === '/profissionais') { if (pathname === '/comunicacao' || pathname === '/mensagens') {
return { return {
element: <TeamPage navigate={navigate} />, element: <MessagesPage navigate={navigate} role={role} />,
title: 'Profissionais', title: 'Comunicação',
withShell: true,
}
}
if (pathname === '/usuarios') {
return {
element: <UsersPage role={role} />,
title: 'Usuários',
withShell: true, withShell: true,
} }
} }
@@ -192,19 +274,20 @@ function resolveRoute(pathname, navigate) {
return { return {
element: <NotFoundPage navigate={navigate} />, element: <NotFoundPage navigate={navigate} />,
title: 'Tela nao encontrada', title: 'Página não encontrada',
withShell: true, withShell: true,
} }
} }
function PatientDetailRoute({ navigate, patientId }) { function PatientDetailRoute({ navigate, patientId, role }) {
const [patient, setPatient] = useState(null) const [patient, setPatient] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
let active = true let active = true
patientRepository.getById(patientId) patientRepository
.getById(patientId)
.then((data) => { .then((data) => {
if (active) setPatient(data) if (active) setPatient(data)
}) })
@@ -221,7 +304,30 @@ function PatientDetailRoute({ navigate, patientId }) {
return <div className="pt-10 text-sm text-[#a3a3a3]">Carregando paciente...</div> return <div className="pt-10 text-sm text-[#a3a3a3]">Carregando paciente...</div>
} }
return patient ? <PatientDetailPage navigate={navigate} patient={patient} /> : <NotFoundPage navigate={navigate} /> return patient ? (
<PatientDetailPage navigate={navigate} patient={patient} role={role} />
) : (
<NotFoundPage navigate={navigate} />
)
}
function UnauthorizedPage({ navigate }) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<p className="text-5xl">🔒</p>
<h1 className="mt-4 text-2xl font-bold text-[#e5e5e5]">Acesso não permitido</h1>
<p className="mt-2 text-sm text-[#a3a3a3]">
Você não tem permissão para acessar esta página.
</p>
<button
className="mt-6 rounded-lg bg-[#3b82f6] px-5 py-2.5 text-sm font-medium text-white transition hover:bg-[#2563eb]"
onClick={() => navigate('/inicio')}
type="button"
>
Voltar ao painel
</button>
</div>
)
} }
function readLocation() { function readLocation() {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -1,22 +1,26 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { ROLE_LABELS, ROLE_NAV_ITEMS } from '../config/permissions.js'
import { authRepository } from '../repositories/authRepository.js'
import { profileRepository } from '../repositories/profileRepository.js' import { profileRepository } from '../repositories/profileRepository.js'
import { BrandLogo } from './Brand.jsx' import { BrandLogo } from './Brand.jsx'
const navItems = [ // Todos os itens de navegação com seus ícones e metadados
const ALL_NAV_ITEMS = [
{ href: '/inicio', label: 'Painel', icon: 'pulse', activePaths: ['/inicio', '/home', '/dashboard'] }, { href: '/inicio', label: 'Painel', icon: 'pulse', activePaths: ['/inicio', '/home', '/dashboard'] },
{ href: '/agenda', label: 'Agenda', icon: 'calendar' }, { href: '/agenda', label: 'Agenda', icon: 'calendar' },
{ href: '/pacientes', label: 'Pacientes', icon: 'users', exact: true }, { href: '/pacientes', label: 'Pacientes', icon: 'users', exact: true },
{ href: '/prontuario', label: 'Prontuario', icon: 'file' }, { href: '/prontuario', label: 'Prontuário', icon: 'file' },
{ href: '/laudos', label: 'Relatorios medicos', icon: 'clipboard' }, { href: '/laudos', label: 'Relatórios', icon: 'clipboard' },
{ {
href: '/camunicacao', href: '/comunicacao',
label: 'Comunicacao', label: 'Comunicação',
icon: 'message', icon: 'message',
activePaths: ['/camunicacao', '/comunicacao', '/mensagens'], activePaths: ['/comunicacao', '/mensagens'],
}, },
{ href: '/relatorios', label: 'Relatorios', icon: 'chart' }, { href: '/relatorios', label: 'Analytics', icon: 'chart' },
{ href: '/configuracoes', label: 'Configuracoes', icon: 'settings', activePaths: ['/configuracoes', '/config'] }, { href: '/usuarios', label: 'Usuários', icon: 'shield' },
{ href: '/configuracoes', label: 'Configurações', icon: 'settings', activePaths: ['/configuracoes', '/config'] },
] ]
const titles = { const titles = {
@@ -25,23 +29,24 @@ const titles = {
'/dashboard': 'Painel', '/dashboard': 'Painel',
'/agenda': 'Agenda', '/agenda': 'Agenda',
'/consultas': 'Consultas', '/consultas': 'Consultas',
'/laudos': 'Relatorios medicos', '/laudos': 'Relatórios',
'/pacientes': 'Pacientes', '/pacientes': 'Pacientes',
'/prontuario': 'Prontuario', '/prontuario': 'Prontuário',
'/camunicacao': 'Comunicacao', '/comunicacao': 'Comunicação',
'/comunicacao': 'Comunicacao', '/mensagens': 'Comunicação',
'/mensagens': 'Comunicacao', '/relatorios': 'Analytics',
'/relatorios': 'Relatorios',
'/profissionais': 'Profissionais',
'/perfil': 'Perfil', '/perfil': 'Perfil',
'/configuracoes': 'Configuracoes', '/configuracoes': 'Configurações',
'/config': 'Configuracoes', '/config': 'Configurações',
'/usuarios': 'Usuários',
} }
export function AppShell({ children, currentPath, navigate, routeTitle }) { export function AppShell({ children, currentPath, navigate, role, routeTitle }) {
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [quickSearch, setQuickSearch] = useState('') const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [viewerProfile, setViewerProfile] = useState({ name: 'Usuario', role: 'Usuario do Sistema' }) const [profileMenuOpen, setProfileMenuOpen] = useState(false)
const [notificationsOpen, setNotificationsOpen] = useState(false)
const [viewerProfile, setViewerProfile] = useState({ name: 'Usuário', role: 'Usuário do Sistema' })
const pageTitle = useMemo(() => { const pageTitle = useMemo(() => {
if (currentPath.startsWith('/pacientes/') && routeTitle) { if (currentPath.startsWith('/pacientes/') && routeTitle) {
@@ -51,49 +56,121 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
return routeTitle || titles[currentPath] || 'MediConnect' return routeTitle || titles[currentPath] || 'MediConnect'
}, [currentPath, routeTitle]) }, [currentPath, routeTitle])
// Filtra os itens de navegação com base no role do usuário
const navItems = useMemo(() => {
if (!role) return []
const allowedPaths = ROLE_NAV_ITEMS[role]?.map((item) => item.path) ?? []
return ALL_NAV_ITEMS.filter((item) =>
allowedPaths.some(
(allowed) => item.href === allowed || item.activePaths?.includes(allowed),
),
)
}, [role])
const canOpenSettings = useMemo(
() =>
(ROLE_NAV_ITEMS[role] ?? []).some(
(item) => item.path === '/configuracoes' || item.path === '/config',
),
[role],
)
const mockNotifications = useMemo(
() => [
{ id: 'mock-1', title: 'Retorno agendado', detail: 'Paciente Ana Souza às 14:30', time: 'Agora' },
{ id: 'mock-2', title: 'Laudo pendente', detail: 'Hemograma aguardando revisão', time: '12 min' },
{ id: 'mock-3', title: 'Mensagem recebida', detail: 'Resposta via WhatsApp registrada', time: '35 min' },
],
[],
)
useEffect(() => { useEffect(() => {
let active = true let active = true
profileRepository.getCurrentUserProfile() profileRepository
.getCurrentUserProfile()
.then((profile) => { .then((profile) => {
if (!active || !profile) return if (!active || !profile) return
setViewerProfile({ setViewerProfile({
name: profile.name || 'Usuario', name: profile.name || 'Usuário',
role: profile.role || 'Usuario do Sistema', role: ROLE_LABELS[role] || profile.role || 'Usuário do Sistema',
}) })
}) })
.catch(() => {}) .catch(() => {
// Fallback: usa o label do role diretamente
if (active && role) {
setViewerProfile((prev) => ({
...prev,
role: ROLE_LABELS[role] || 'Usuário do Sistema',
}))
}
})
return () => { return () => {
active = false active = false
} }
}, []) }, [role])
useEffect(() => {
if (!profileMenuOpen && !notificationsOpen) return undefined
function closeOnEscape(event) {
if (event.key === 'Escape') {
setProfileMenuOpen(false)
setNotificationsOpen(false)
}
}
window.addEventListener('keydown', closeOnEscape)
return () => window.removeEventListener('keydown', closeOnEscape)
}, [notificationsOpen, profileMenuOpen])
function goTo(path) { function goTo(path) {
setMenuOpen(false) setMenuOpen(false)
setProfileMenuOpen(false)
setNotificationsOpen(false)
navigate(path) navigate(path)
} }
async function handleLogout() {
setProfileMenuOpen(false)
await authRepository.logout()
navigate('/login', { replace: true })
}
function toggleSidebarCollapsed() {
if (typeof window !== 'undefined' && !window.matchMedia('(min-width: 1024px)').matches) {
return
}
setSidebarCollapsed((current) => !current)
}
return ( return (
<div className="min-h-screen bg-[#171717] text-[#e5e5e5]"> <div className="min-h-screen bg-[#171717] text-[#e5e5e5]">
<a <a
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-[#262626] focus:px-4 focus:py-2 focus:text-sm focus:font-semibold focus:text-[#3b82f6]" className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-[#262626] focus:px-4 focus:py-2 focus:text-sm focus:font-semibold focus:text-[#3b82f6]"
href="#app-content" href="#app-content"
> >
Pular para conteudo Pular para conteúdo
</a> </a>
<aside <aside
className={`fixed inset-y-0 left-0 z-40 flex w-64 -translate-x-full flex-col border-r border-[#404040] bg-[#262626] transition-transform duration-200 lg:translate-x-0 ${ className={`fixed inset-y-0 left-0 z-40 flex w-56 -translate-x-full flex-col border-r border-[#404040] bg-[#262626] transition-all duration-200 lg:translate-x-0 ${
sidebarCollapsed ? 'lg:w-16' : 'lg:w-56'
} ${
menuOpen ? 'translate-x-0' : '' menuOpen ? 'translate-x-0' : ''
}`} }`}
> >
<div className="flex h-16 items-center border-b border-[#404040] px-3"> <div className={`flex h-16 items-center border-b border-[#404040] px-3 ${sidebarCollapsed ? 'lg:justify-center' : ''}`}>
<BrandLogo <BrandLogo
iconClassName="size-8 rounded-sm" iconClassName="size-8 rounded-sm"
iconButtonLabel={sidebarCollapsed ? 'Expandir sidebar' : 'Recolher sidebar'}
markClassName="size-5" markClassName="size-5"
textClassName="text-xl font-bold leading-7 tracking-[-0.025em] text-[#e5e5e5]" onIconClick={toggleSidebarCollapsed}
textClassName={`text-xl font-bold leading-7 tracking-[-0.025em] text-[#e5e5e5] ${sidebarCollapsed ? 'lg:hidden' : ''}`}
/> />
</div> </div>
@@ -105,6 +182,7 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
item={item} item={item}
key={`${item.label}-${item.href}`} key={`${item.label}-${item.href}`}
onNavigate={goTo} onNavigate={goTo}
sidebarCollapsed={sidebarCollapsed}
/> />
))} ))}
</div> </div>
@@ -112,12 +190,21 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
<div className="p-3"> <div className="p-3">
<button <button
className="w-full rounded-md border border-[#404040] bg-[#303030] px-3 py-2.5 text-left transition hover:border-[#525252] hover:bg-[#333333]" className={`w-full rounded-md border border-[#404040] bg-[#303030] text-left transition hover:border-[#525252] hover:bg-[#333333] ${
sidebarCollapsed ? 'grid h-10 place-items-center px-0 py-0 lg:rounded-full' : 'px-3 py-2.5'
}`}
onClick={() => goTo('/perfil')} onClick={() => goTo('/perfil')}
title={sidebarCollapsed ? `${viewerProfile.name} - ${viewerProfile.role}` : undefined}
type="button" type="button"
> >
<p className="truncate text-xs font-semibold text-[#e5e5e5]">{viewerProfile.name}</p> {sidebarCollapsed ? (
<p className="mt-0.5 truncate text-[11px] leading-4 text-[#a3a3a3]">{viewerProfile.role}</p> <span className="text-xs font-bold text-[#3b82f6]">{getInitials(viewerProfile.name)}</span>
) : (
<>
<p className="truncate text-xs font-semibold text-[#e5e5e5]">{viewerProfile.name}</p>
<p className="mt-0.5 truncate text-[11px] leading-4 text-[#a3a3a3]">{viewerProfile.role}</p>
</>
)}
</button> </button>
</div> </div>
</aside> </aside>
@@ -131,7 +218,7 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
/> />
) : null} ) : null}
<div className="lg:pl-64"> <div className={`transition-[padding] duration-200 ${sidebarCollapsed ? 'lg:pl-16' : 'lg:pl-56'}`}>
<header className="sticky top-0 z-20 h-auto border-b border-[#404040] bg-[#262626] px-4 py-3 md:px-8 lg:h-16 lg:py-0"> <header className="sticky top-0 z-20 h-auto border-b border-[#404040] bg-[#262626] px-4 py-3 md:px-8 lg:h-16 lg:py-0">
<div className="flex h-full flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex h-full flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@@ -143,57 +230,132 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
> >
Menu Menu
</button> </button>
<div className="relative w-full max-w-sm lg:w-96">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#a3a3a3]" />
<input
aria-label="Busca rapida"
className="h-[38px] w-full rounded-sm border border-[#404040] bg-[#303030] py-2 pl-10 pr-4 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20"
onChange={(event) => setQuickSearch(event.target.value)}
placeholder="Buscar paciente, prontuario..."
value={quickSearch}
/>
</div>
</div> </div>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<button <div className="relative z-30">
aria-label="Notificacoes" <button
className="relative grid size-8 place-items-center text-[#a3a3a3] transition hover:text-[#e5e5e5]" aria-expanded={notificationsOpen}
type="button" aria-haspopup="menu"
> aria-label="Notificações"
<BellIcon className="size-5" /> className="relative grid size-8 place-items-center text-[#a3a3a3] transition hover:text-[#e5e5e5]"
<span className="absolute right-0 top-0 grid size-4 place-items-center rounded-full bg-[#ef4444] text-[10px] font-bold leading-none text-white"> onClick={() => {
3 setNotificationsOpen((open) => !open)
</span> setProfileMenuOpen(false)
</button> }}
type="button"
>
<BellIcon className="size-5" />
<span className="absolute right-0 top-0 grid size-4 place-items-center rounded-full bg-[#ef4444] text-[10px] font-bold leading-none text-white">
{mockNotifications.length}
</span>
</button>
{notificationsOpen ? (
<div
aria-label="Notificações mock"
className="absolute right-0 top-12 z-30 w-80 rounded-md border border-[#404040] bg-[#262626] p-2 shadow-2xl shadow-black/30"
role="menu"
>
<div className="flex items-center justify-between px-2 py-2">
<p className="text-sm font-semibold text-[#e5e5e5]">Notificações</p>
<span className="feature-badge-mock rounded border border-amber-500/40 bg-amber-500/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.08em] text-amber-300">
Mock
</span>
</div>
<div className="space-y-1">
{mockNotifications.map((notification) => (
<button
className="w-full rounded-sm border border-transparent px-2 py-2 text-left transition hover:border-[#404040] hover:bg-[#303030]"
key={notification.id}
role="menuitem"
type="button"
>
<span className="flex items-start justify-between gap-3">
<span className="min-w-0">
<span className="block text-sm font-semibold text-[#e5e5e5]">{notification.title}</span>
<span className="mt-0.5 block text-xs leading-5 text-[#a3a3a3]">{notification.detail}</span>
</span>
<span className="shrink-0 text-[10px] font-semibold text-[#51a2ff]">{notification.time}</span>
</span>
</button>
))}
</div>
</div>
) : null}
</div>
<span className="hidden h-6 w-px bg-[#404040] sm:block" aria-hidden="true" /> <span className="hidden h-6 w-px bg-[#404040] sm:block" aria-hidden="true" />
<button <div className="relative z-30">
className="flex min-w-0 items-center gap-3 text-left" <button
onClick={() => goTo('/perfil')} aria-expanded={profileMenuOpen}
type="button" aria-haspopup="menu"
> className="flex min-w-0 items-center gap-3 rounded-sm px-1.5 py-1 text-left transition hover:bg-[#303030] focus:outline-none focus:ring-2 focus:ring-[#3b82f6]/40"
<span className="grid size-8 shrink-0 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/15 text-xs font-bold text-[#3b82f6]"> onClick={() => {
{getInitials(viewerProfile.name)} setProfileMenuOpen((open) => !open)
</span> setNotificationsOpen(false)
<span className="hidden min-w-0 sm:block"> }}
<span className="block truncate text-sm font-semibold leading-4 text-[#e5e5e5]"> type="button"
{viewerProfile.name} >
<span className="grid size-8 shrink-0 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/15 text-xs font-bold text-[#3b82f6]">
{getInitials(viewerProfile.name)}
</span> </span>
<span className="mt-0.5 block truncate text-[11px] font-medium leading-4 text-[#51a2ff]"> <span className="hidden min-w-0 sm:block">
{viewerProfile.role} <span className="block max-w-40 truncate text-sm font-semibold leading-4 text-[#e5e5e5]">
{viewerProfile.name}
</span>
<span className="mt-0.5 block max-w-40 truncate text-[11px] font-medium leading-4 text-[#51a2ff]">
{viewerProfile.role}
</span>
</span> </span>
</span> <ChevronDownIcon className="hidden size-4 text-[#a3a3a3] sm:block" />
<ChevronDownIcon className="hidden size-4 text-[#a3a3a3] sm:block" /> </button>
</button>
{profileMenuOpen ? (
<div
aria-label="Menu do usuário"
className="absolute right-0 top-12 z-30 w-56 rounded-md border border-[#404040] bg-[#262626] p-1 shadow-2xl shadow-black/30"
role="menu"
>
<button
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm font-medium text-[#e5e5e5] transition hover:bg-[#303030]"
onClick={() => goTo('/perfil')}
role="menuitem"
type="button"
>
<UserIcon className="size-4 text-[#a3a3a3]" />
Ver perfil
</button>
{canOpenSettings ? (
<button
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm font-medium text-[#e5e5e5] transition hover:bg-[#303030]"
onClick={() => goTo('/configuracoes')}
role="menuitem"
type="button"
>
<AppIcon className="size-4 text-[#a3a3a3]" name="settings" />
Configurações
</button>
) : null}
<div className="my-1 h-px bg-[#404040]" />
<button
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm font-medium text-[#f87171] transition hover:bg-[#303030]"
onClick={handleLogout}
role="menuitem"
type="button"
>
<LogoutIcon className="size-4" />
Sair
</button>
</div>
) : null}
</div>
</div> </div>
</div> </div>
{quickSearch ? (
<div className="mt-3 rounded-md border border-[#404040] bg-[#303030] px-4 py-3 text-sm text-[#a3a3a3] lg:absolute lg:left-8 lg:top-[52px] lg:w-96">
Busca local ativa por <strong className="text-[#e5e5e5]">{quickSearch}</strong>.
</div>
) : null}
</header> </header>
<main className="w-full px-4 py-6 md:px-8 md:py-8" id="app-content"> <main className="w-full px-4 py-6 md:px-8 md:py-8" id="app-content">
@@ -207,10 +369,11 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
) )
} }
function NavItem({ active, item, onNavigate }) { function NavItem({ active, item, onNavigate, sidebarCollapsed = false }) {
return ( return (
<a <a
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
aria-label={sidebarCollapsed ? item.label : undefined}
className={`flex h-9 items-center gap-3 rounded-sm px-2 text-sm font-medium transition ${ className={`flex h-9 items-center gap-3 rounded-sm px-2 text-sm font-medium transition ${
active ? 'bg-[#3b82f6]/10 text-[#3b82f6]' : 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]' active ? 'bg-[#3b82f6]/10 text-[#3b82f6]' : 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
}`} }`}
@@ -219,9 +382,10 @@ function NavItem({ active, item, onNavigate }) {
event.preventDefault() event.preventDefault()
onNavigate(item.href) onNavigate(item.href)
}} }}
title={sidebarCollapsed ? item.label : undefined}
> >
<AppIcon className="size-5 shrink-0" name={item.icon} /> <AppIcon className={`size-5 shrink-0 ${sidebarCollapsed ? 'lg:mx-auto' : ''}`} name={item.icon} />
<span>{item.label}</span> <span className={sidebarCollapsed ? 'lg:hidden' : ''}>{item.label}</span>
</a> </a>
) )
} }
@@ -303,10 +467,10 @@ function AppIcon({ className = 'size-5', name }) {
) )
} }
if (name === 'dollar') { if (name === 'shield') {
return ( return (
<svg {...common}> <svg {...common}>
<path d="M12 2v20M17 6.5C15.8 5.4 14.2 5 12.5 5 9.9 5 8 6.2 8 8s1.6 2.7 4.2 3.3C15 12 17 13 17 15.5S14.8 19 12 19c-2 0-3.8-.6-5-1.8" /> <path d="M12 3 5 6v5c0 4.5 3 8.5 7 10 4-1.5 7-5.5 7-10V6l-7-3Z" />
</svg> </svg>
) )
} }
@@ -336,19 +500,29 @@ function BellIcon({ className = 'size-5' }) {
) )
} }
function ChevronDownIcon({ className = 'size-4' }) { function UserIcon({ className = 'size-4' }) {
return ( return (
<svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24"> <svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24">
<path d="m6 9 6 6 6-6" /> <path d="M20 21a8 8 0 0 0-16 0" />
<circle cx="12" cy="7" r="4" />
</svg> </svg>
) )
} }
function SearchIcon({ className = 'size-4' }) { function LogoutIcon({ className = 'size-4' }) {
return ( return (
<svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24"> <svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24">
<path d="m21 21-4.3-4.3" /> <path d="M10 17 15 12l-5-5" />
<circle cx="11" cy="11" r="7" /> <path d="M15 12H3" />
<path d="M21 3v18" />
</svg>
)
}
function ChevronDownIcon({ className = 'size-4' }) {
return (
<svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24">
<path d="m6 9 6 6 6-6" />
</svg> </svg>
) )
} }

View File

@@ -1,14 +1,32 @@
export function BrandLogo({ export function BrandLogo({
className = '', className = '',
iconClassName = 'size-10 rounded-[6px]', iconClassName = 'size-10 rounded-[6px]',
iconButtonLabel = 'MediConnect',
markClassName = 'size-6', markClassName = 'size-6',
onIconClick,
textClassName = 'text-2xl font-bold leading-8 tracking-[-0.025em] text-white', textClassName = 'text-2xl font-bold leading-8 tracking-[-0.025em] text-white',
}) { }) {
const icon = (
<div className={`grid place-items-center bg-[#3b82f6] text-white ${iconClassName}`}>
<StethoscopeIcon className={markClassName} />
</div>
)
return ( return (
<div className={`flex items-center gap-3 ${className}`}> <div className={`flex items-center gap-3 ${className}`}>
<div className={`grid place-items-center bg-[#3b82f6] text-white ${iconClassName}`}> {onIconClick ? (
<StethoscopeIcon className={markClassName} /> <button
</div> aria-label={iconButtonLabel}
className="shrink-0 rounded-sm transition hover:brightness-110 focus:outline-none focus:ring-2 focus:ring-[#3b82f6]/50"
onClick={onIconClick}
title={iconButtonLabel}
type="button"
>
{icon}
</button>
) : (
icon
)}
<p className={textClassName}>MediConnect</p> <p className={textClassName}>MediConnect</p>
</div> </div>
) )

View File

@@ -1,18 +1,23 @@
import React from 'react'
import { format, isToday } from 'date-fns' import { format, isToday } from 'date-fns'
import { ptBR } from 'date-fns/locale' import { ptBR } from 'date-fns/locale'
import { sortAppointmentsByTime } from '../../utils/agendaDate.js' import { sortAppointmentsByTime } from '../../utils/agendaDate.js'
export function AgendaDailyView({ baseDate, appointments, onAppointmentClick }) { const DAY_START = '07:00'
const DAY_END = '19:00'
const SLOT_MINUTES = 30
export function AgendaDailyView({ baseDate, appointments, canCreateAppointment = true, onAppointmentClick, onSlotCreate }) {
const dailyAppointments = sortAppointmentsByTime(appointments) const dailyAppointments = sortAppointmentsByTime(appointments)
const appointmentsByTime = groupAppointmentsByTime(dailyAppointments)
const slots = mergeSlotsWithAppointmentTimes(generateSlots(DAY_START, DAY_END, SLOT_MINUTES), dailyAppointments)
return ( return (
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5"> <div className="agenda-calendar-shell rounded-2xl border border-[#404040] bg-[#262626] p-5">
<div className="flex flex-col gap-3 border-b border-[#404040] pb-4 md:flex-row md:items-end md:justify-between"> <div className="agenda-calendar-header flex flex-col gap-3 border-b border-[#404040] pb-4 md:flex-row md:items-end md:justify-between">
<div> <div>
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-[#737373]"> <span className="text-xs font-semibold uppercase tracking-[0.16em] text-[#737373]">
Vista ampliada do dia Grade de horários do dia
</span> </span>
<h3 className="mt-2 text-xl font-bold text-[#e5e5e5]"> <h3 className="mt-2 text-xl font-bold text-[#e5e5e5]">
{format(baseDate, "EEEE, dd 'de' MMMM", { locale: ptBR })} {format(baseDate, "EEEE, dd 'de' MMMM", { locale: ptBR })}
@@ -20,9 +25,15 @@ export function AgendaDailyView({ baseDate, appointments, onAppointmentClick })
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<span className="rounded-full border border-[#404040] bg-[#1f1f1f] px-3 py-1 text-xs font-semibold text-[#a3a3a3]"> <span className="agenda-legend-pill rounded-full border border-[#404040] bg-[#1f1f1f] px-3 py-1 text-xs font-semibold text-[#a3a3a3]">
{dailyAppointments.length} {dailyAppointments.length === 1 ? 'agendamento' : 'agendamentos'} {dailyAppointments.length} {dailyAppointments.length === 1 ? 'agendamento' : 'agendamentos'}
</span> </span>
<span className="agenda-legend-pill agenda-legend-free rounded-full border border-emerald-700/40 bg-emerald-950/30 px-3 py-1 text-xs font-semibold text-emerald-200 shadow-sm">
Livre
</span>
<span className="agenda-legend-pill agenda-legend-booked rounded-full border border-red-700/40 bg-red-950/30 px-3 py-1 text-xs font-semibold text-red-200 shadow-sm">
Agendado
</span>
{isToday(baseDate) && ( {isToday(baseDate) && (
<span className="rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 px-3 py-1 text-xs font-semibold text-[#93c5fd]"> <span className="rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 px-3 py-1 text-xs font-semibold text-[#93c5fd]">
Hoje Hoje
@@ -31,70 +42,134 @@ export function AgendaDailyView({ baseDate, appointments, onAppointmentClick })
</div> </div>
</div> </div>
{dailyAppointments.length === 0 ? ( <div className="agenda-day-grid mt-4 grid gap-2">
<div className="mt-4 rounded-xl border border-dashed border-[#404040] bg-[#1f1f1f] p-8 text-center"> {slots.map((time) => {
<h3 className="text-base font-bold text-[#e5e5e5]">Nenhum horário encontrado</h3> const slotAppointments = appointmentsByTime.get(time) || []
<p className="mt-2 text-sm leading-6 text-[#a3a3a3]"> const primaryAppointment = slotAppointments[0]
Ajuste o filtro ou altere o período no calendário. const isBooked = Boolean(primaryAppointment)
</p>
</div> return (
) : (
<div className="mt-4 grid gap-3">
{dailyAppointments.map((appointment) => (
<article <article
key={appointment.id} className={`agenda-slot ${isBooked ? getDailyToneClass(primaryAppointment.status) : 'agenda-slot-free'} grid gap-3 rounded-xl border px-4 py-3 shadow-[0_8px_18px_rgba(0,0,0,0.16)] md:grid-cols-[84px_1fr_auto] ${
className={`grid gap-4 rounded-xl border p-4 md:grid-cols-[96px_1fr_auto] ${getStatusColors(appointment.status)}`} isBooked
? 'border-red-700/50 bg-red-950/35 text-red-50'
: 'border-emerald-700/50 bg-emerald-950/35 text-emerald-50'
}`}
key={time}
> >
<div> <div>
<p className="text-2xl font-bold leading-none">{appointment.time || '--:--'}</p> <p className="text-xl font-bold leading-none">{time}</p>
<p className="mt-2 text-[11px] font-semibold uppercase tracking-[0.14em] opacity-80"> <p className="mt-1 text-[11px] font-semibold uppercase tracking-[0.12em] opacity-80">
{appointment.mode} {isBooked ? 'Agendado' : 'Disponível'}
</p> </p>
</div> </div>
<div> {isBooked ? (
<button <div>
className="text-left text-base font-bold transition hover:opacity-85" <button
onClick={() => onAppointmentClick && onAppointmentClick(appointment)} className="text-left text-sm font-bold transition hover:opacity-85"
type="button" onClick={() => onAppointmentClick?.(primaryAppointment)}
> type="button"
{appointment.patient} >
</button> {primaryAppointment.patient}
<p className="mt-1 text-sm opacity-90"> </button>
{appointment.type} com {appointment.professional} <p className="mt-1 text-sm opacity-90">
</p> {primaryAppointment.type} com {primaryAppointment.professional}
<div className="mt-3 flex flex-wrap gap-2 text-xs font-medium opacity-80"> </p>
<span className="rounded-full bg-black/15 px-2.5 py-1">{appointment.room}</span> <div className="mt-2 flex flex-wrap gap-2 text-xs font-medium opacity-80">
<span className="rounded-full bg-black/15 px-2.5 py-1">{appointment.type}</span> {primaryAppointment.room ? <span className="agenda-slot-chip rounded-full bg-black/25 px-2.5 py-1 shadow-sm">{primaryAppointment.room}</span> : null}
{primaryAppointment.mode ? <span className="agenda-slot-chip rounded-full bg-black/25 px-2.5 py-1 shadow-sm">{primaryAppointment.mode}</span> : null}
{slotAppointments.length > 1 ? <span className="agenda-slot-chip rounded-full bg-black/25 px-2.5 py-1 shadow-sm">+{slotAppointments.length - 1}</span> : null}
</div>
</div> </div>
</div> ) : (
<div className="flex items-center text-sm font-medium opacity-90">
Horário disponível para novo agendamento.
</div>
)}
<div className="flex items-start justify-start md:justify-end"> <div className="flex flex-wrap items-start justify-start gap-2 md:justify-end">
<span className="rounded-full border border-current/20 bg-black/10 px-3 py-1 text-xs font-bold"> <span className="agenda-slot-status rounded-full border border-current/30 bg-black/25 px-3 py-1 text-xs font-bold shadow-sm">
{appointment.status} {isBooked ? primaryAppointment.status : 'Livre'}
</span> </span>
{canCreateAppointment ? (
<button
aria-label={`Criar agendamento às ${time}`}
className="agenda-slot-add grid size-8 place-items-center rounded-full border border-current/30 bg-black/30 text-base font-bold leading-none shadow-sm transition hover:bg-black/45"
onClick={() => onSlotCreate?.(time)}
title={`Novo agendamento às ${time}`}
type="button"
>
+
</button>
) : null}
</div> </div>
</article> </article>
))} )
</div> })}
)} </div>
</div> </div>
) )
} }
function getStatusColors(status) { function getDailyToneClass(status) {
switch (status) { switch (status) {
case 'Confirmada': case 'Confirmada':
return 'border-[#14532d] bg-[#052e1a] text-[#a7f3d0]' return 'agenda-slot-confirmed'
case 'Em triagem': case 'Em triagem':
return 'border-[#78350f] bg-[#2d1e05] text-[#fde68a]' return 'agenda-slot-triage'
case 'Concluida':
case 'Concluída':
return 'border-[#1e3a8a] bg-[#172554] text-[#bfdbfe]'
case 'Cancelada': case 'Cancelada':
return 'border-[#7f1d1d] bg-[#450a0a] text-[#fecaca]' return 'agenda-slot-cancelled'
case 'Bloqueado':
return 'agenda-slot-blocked'
case 'Aguardando': case 'Aguardando':
default: default:
return 'border-[#404040] bg-[#1f1f1f] text-[#e5e5e5]' return 'agenda-slot-waiting'
} }
} }
function generateSlots(start, end, intervalMinutes) {
const [startHour, startMinute] = start.split(':').map(Number)
const [endHour, endMinute] = end.split(':').map(Number)
const slots = []
let cursor = startHour * 60 + startMinute
const last = endHour * 60 + endMinute
while (cursor < last) {
slots.push(formatMinutes(cursor))
cursor += intervalMinutes
}
return slots
}
function groupAppointmentsByTime(appointments) {
return appointments.reduce((map, appointment) => {
const time = normalizeTime(appointment.time)
if (!time) return map
map.set(time, [...(map.get(time) || []), appointment])
return map
}, new Map())
}
function mergeSlotsWithAppointmentTimes(slots, appointments) {
return [...new Set([...slots, ...appointments.map((appointment) => normalizeTime(appointment.time)).filter(Boolean)])]
.sort((first, second) => minutesFromTime(first) - minutesFromTime(second))
}
function normalizeTime(value) {
const match = String(value || '').match(/^(\d{1,2}):(\d{2})/)
if (!match) return ''
return `${match[1].padStart(2, '0')}:${match[2]}`
}
function minutesFromTime(value) {
const [hours, minutes] = normalizeTime(value).split(':').map(Number)
return hours * 60 + minutes
}
function formatMinutes(totalMinutes) {
const hours = String(Math.floor(totalMinutes / 60)).padStart(2, '0')
const minutes = String(totalMinutes % 60).padStart(2, '0')
return `${hours}:${minutes}`
}

View File

@@ -23,8 +23,8 @@ export function AgendaMonthlyView({ baseDate, appointments, onDayClick }) {
const weekDays = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'] const weekDays = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb']
return ( return (
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5"> <div className="agenda-calendar-shell rounded-2xl border border-[#404040] bg-[#262626] p-5">
<div className="grid grid-cols-7 gap-px border-b border-[#404040] pb-4"> <div className="agenda-calendar-header grid grid-cols-7 gap-px border-b border-[#404040] pb-4">
{weekDays.map((day) => ( {weekDays.map((day) => (
<div key={day} className="text-center text-xs font-semibold uppercase tracking-widest text-[#a3a3a3]"> <div key={day} className="text-center text-xs font-semibold uppercase tracking-widest text-[#a3a3a3]">
{day} {day}
@@ -49,7 +49,7 @@ export function AgendaMonthlyView({ baseDate, appointments, onDayClick }) {
<button <button
key={day.toISOString()} key={day.toISOString()}
onClick={() => onDayClick && onDayClick(day)} onClick={() => onDayClick && onDayClick(day)}
className={`flex min-h-[100px] flex-col rounded-xl border p-2 text-left transition hover:border-[#525252] ${ className={`agenda-month-day flex min-h-[100px] flex-col rounded-xl border p-2 text-left transition hover:border-[#525252] ${
isCurrentMonth isCurrentMonth
? 'border-[#404040] bg-[#1f1f1f]' ? 'border-[#404040] bg-[#1f1f1f]'
: 'border-transparent bg-transparent opacity-40 hover:opacity-80' : 'border-transparent bg-transparent opacity-40 hover:opacity-80'
@@ -69,7 +69,7 @@ export function AgendaMonthlyView({ baseDate, appointments, onDayClick }) {
{dayAppointments.slice(0, 3).map((appointment) => ( {dayAppointments.slice(0, 3).map((appointment) => (
<div <div
key={appointment.id} key={appointment.id}
className="flex items-center gap-1.5 truncate rounded bg-[#303030] px-1.5 py-1 text-[10px] font-semibold text-[#a3a3a3]" className={`agenda-month-event ${getStatusToneClass(appointment.status)} flex items-center gap-1.5 truncate rounded bg-[#303030] px-1.5 py-1 text-[10px] font-semibold text-[#a3a3a3]`}
> >
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${getDotColor(appointment.status)}`} /> <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${getDotColor(appointment.status)}`} />
<span className="truncate"> <span className="truncate">
@@ -91,6 +91,22 @@ export function AgendaMonthlyView({ baseDate, appointments, onDayClick }) {
) )
} }
function getStatusToneClass(status) {
switch (status) {
case 'Confirmada':
return 'agenda-event-confirmed'
case 'Em triagem':
return 'agenda-event-triage'
case 'Cancelada':
return 'agenda-event-cancelled'
case 'Bloqueado':
return 'agenda-event-blocked'
case 'Aguardando':
default:
return 'agenda-event-waiting'
}
}
function getDotColor(status) { function getDotColor(status) {
switch (status) { switch (status) {
case 'Confirmada': case 'Confirmada':

View File

@@ -26,8 +26,8 @@ export function AgendaWeeklyView({ baseDate, appointments, onAppointmentClick })
) )
return ( return (
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5"> <div className="agenda-calendar-shell rounded-2xl border border-[#404040] bg-[#262626] p-5">
<div className="grid grid-cols-7 gap-4 border-b border-[#404040] pb-4"> <div className="agenda-calendar-header grid grid-cols-7 gap-4 border-b border-[#404040] pb-4">
{days.map((day) => { {days.map((day) => {
const isWeekend = day.getDay() === 0 const isWeekend = day.getDay() === 0
@@ -60,7 +60,7 @@ export function AgendaWeeklyView({ baseDate, appointments, onAppointmentClick })
return ( return (
<div <div
key={day.toISOString()} key={day.toISOString()}
className="flex h-full flex-col gap-2 rounded-lg border border-[#404040]/50 bg-[#1f1f1f] p-2" className="agenda-week-day flex h-full min-w-0 flex-col gap-2 rounded-lg border border-[#404040]/50 bg-[#1f1f1f] p-2"
> >
{dayAppointments.length === 0 ? ( {dayAppointments.length === 0 ? (
<div className="flex h-full items-center justify-center p-4"> <div className="flex h-full items-center justify-center p-4">
@@ -71,21 +71,21 @@ export function AgendaWeeklyView({ baseDate, appointments, onAppointmentClick })
<button <button
key={appointment.id} key={appointment.id}
onClick={() => onAppointmentClick && onAppointmentClick(appointment)} onClick={() => onAppointmentClick && onAppointmentClick(appointment)}
className={`flex w-full flex-col items-start rounded-md border p-2 text-left shadow-sm transition hover:brightness-110 ${getStatusColors(appointment.status)}`} className={`agenda-event ${getStatusToneClass(appointment.status)} flex w-full min-w-0 flex-col items-start overflow-hidden rounded-md border p-2 text-left shadow-sm transition hover:brightness-110 ${getStatusColors(appointment.status)}`}
> >
<div className="mb-1 flex items-center gap-2"> <div className="mb-1 flex w-full min-w-0 items-center gap-1.5 overflow-hidden">
<span className="rounded bg-black/20 px-1.5 py-0.5 text-xs font-bold leading-none"> <span className="shrink-0 rounded bg-black/20 px-1.5 py-0.5 text-[10px] font-bold leading-none">
{appointment.time} {appointment.time}
</span> </span>
<span className="truncate text-[10px] font-semibold uppercase tracking-wider opacity-80"> <span className="min-w-0 flex-1 truncate text-[9px] font-semibold uppercase tracking-normal opacity-80">
{appointment.mode} {appointment.mode}
</span> </span>
</div> </div>
<span className="w-full truncate text-xs font-bold leading-tight" title={appointment.patient}> <span className="block w-full min-w-0 truncate text-xs font-bold leading-tight" title={appointment.patient}>
{appointment.patient} {appointment.patient}
</span> </span>
<span <span
className="mt-0.5 w-full truncate text-[10px] font-medium opacity-80" className="mt-0.5 block w-full min-w-0 truncate text-[10px] font-medium opacity-80"
title={appointment.professional} title={appointment.professional}
> >
Dr(a). {appointment.professional?.split(' ')[0]} Dr(a). {appointment.professional?.split(' ')[0]}
@@ -101,6 +101,25 @@ export function AgendaWeeklyView({ baseDate, appointments, onAppointmentClick })
) )
} }
function getStatusToneClass(status) {
switch (status) {
case 'Confirmada':
return 'agenda-event-confirmed'
case 'Em triagem':
return 'agenda-event-triage'
case 'Concluida':
case 'Concluída':
return 'agenda-event-finished'
case 'Cancelada':
return 'agenda-event-cancelled'
case 'Bloqueado':
return 'agenda-event-blocked'
case 'Aguardando':
default:
return 'agenda-event-waiting'
}
}
function getStatusColors(status) { function getStatusColors(status) {
switch (status) { switch (status) {
case 'Confirmada': case 'Confirmada':

View File

@@ -1,26 +1,26 @@
export const featureStateStyles = { export const featureStateStyles = {
live: { live: {
badge: 'border-emerald-500/40 bg-emerald-500/15 text-emerald-300', badge: 'hidden',
panel: 'border-emerald-500/35 bg-emerald-500/8', panel: 'border-[#404040] bg-[#262626]',
title: 'text-emerald-300', title: 'text-[#e5e5e5]',
label: 'Integrado', label: '',
}, },
partial: { partial: {
badge: 'border-sky-500/40 bg-sky-500/15 text-sky-300', badge: 'feature-badge-partial border-sky-500/40 bg-sky-500/15 text-sky-300',
panel: 'border-sky-500/35 bg-sky-500/8', panel: 'feature-panel-partial border-sky-500/35 bg-sky-500/8',
title: 'text-sky-300', title: 'feature-title-partial text-sky-300',
label: 'Parcial', label: 'Parcial',
}, },
mock: { mock: {
badge: 'border-amber-500/40 bg-amber-500/15 text-amber-300', badge: 'feature-badge-mock border-amber-500/40 bg-amber-500/15 text-amber-300',
panel: 'border-amber-500/35 bg-amber-500/8', panel: 'feature-panel-mock border-amber-500/35 bg-amber-500/8',
title: 'text-amber-300', title: 'feature-title-mock text-amber-300',
label: 'Mockado', label: 'Mockado',
}, },
wip: { wip: {
badge: 'border-rose-500/40 bg-rose-500/15 text-rose-300', badge: 'feature-badge-wip border-rose-500/40 bg-rose-500/15 text-rose-300',
panel: 'border-rose-500/35 bg-rose-500/8', panel: 'feature-panel-wip border-rose-500/35 bg-rose-500/8',
title: 'text-rose-300', title: 'feature-title-wip text-rose-300',
label: 'WIP', label: 'WIP',
}, },
} }

View File

@@ -2,6 +2,7 @@ const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || 'https://yuanqfswhberk
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ' const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ'
const AUTH_SESSION_KEY = 'mediconnect.auth.session' const AUTH_SESSION_KEY = 'mediconnect.auth.session'
export const AUTH_SESSION_CHANGED_EVENT = 'mediconnect:auth-session-changed'
export const apiConfig = { export const apiConfig = {
apiUrl: import.meta.env.VITE_API_BASE_URL || import.meta.env.VITE_SUPABASE_FUNCTIONS_URL || `${SUPABASE_URL}/functions/v1`, apiUrl: import.meta.env.VITE_API_BASE_URL || import.meta.env.VITE_SUPABASE_FUNCTIONS_URL || `${SUPABASE_URL}/functions/v1`,
@@ -34,12 +35,14 @@ export function getAuthSession() {
export function saveAuthSession(session) { export function saveAuthSession(session) {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session)) window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
notifyAuthSessionChanged()
} }
} }
export function clearAuthSession() { export function clearAuthSession() {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.sessionStorage.removeItem(AUTH_SESSION_KEY) window.sessionStorage.removeItem(AUTH_SESSION_KEY)
notifyAuthSessionChanged()
} }
} }
@@ -85,3 +88,7 @@ function cleanHeaders(headers) {
Object.entries(headers).filter(([, value]) => value !== undefined && value !== null), Object.entries(headers).filter(([, value]) => value !== undefined && value !== null),
) )
} }
function notifyAuthSessionChanged() {
window.dispatchEvent(new Event(AUTH_SESSION_CHANGED_EVENT))
}

208
src/config/permissions.js Normal file
View File

@@ -0,0 +1,208 @@
// Roles disponíveis no sistema
export const ROLES = {
ADMIN: 'admin',
GESTOR: 'gestor',
MEDICO: 'medico',
SECRETARIA: 'secretaria',
PACIENTE: 'paciente',
}
const ROLE_ALIASES = {
admin: ROLES.ADMIN,
administrador: ROLES.ADMIN,
administrator: ROLES.ADMIN,
gestor: ROLES.GESTOR,
gestao: ROLES.GESTOR,
gestao_coordenacao: ROLES.GESTOR,
coordenacao: ROLES.GESTOR,
coordenador: ROLES.GESTOR,
manager: ROLES.GESTOR,
medico: ROLES.MEDICO,
medica: ROLES.MEDICO,
doctor: ROLES.MEDICO,
physician: ROLES.MEDICO,
secretaria: ROLES.SECRETARIA,
secretario: ROLES.SECRETARIA,
secretary: ROLES.SECRETARIA,
receptionist: ROLES.SECRETARIA,
paciente: ROLES.PACIENTE,
patient: ROLES.PACIENTE,
}
// Rotas permitidas por role ('*' = todas)
const ROLE_ROUTES = {
admin: '*',
gestor: [
'/inicio', '/home', '/dashboard',
'/agenda',
'/pacientes',
'/prontuario',
'/laudos',
'/relatorios',
'/comunicacao', '/mensagens',
'/configuracoes', '/config',
'/consultas',
'/usuarios',
'/perfil',
],
medico: [
'/agenda',
'/pacientes',
'/prontuario',
'/laudos',
'/comunicacao', '/mensagens',
'/configuracoes', '/config',
'/perfil',
],
secretaria: [
'/agenda',
'/pacientes',
'/comunicacao', '/mensagens',
'/configuracoes', '/config',
'/perfil',
],
paciente: [
'/inicio', '/home', '/dashboard',
'/configuracoes', '/config',
'/perfil',
],
}
// Capacidades especiais por role
export const ROLE_CAPABILITIES = {
admin: {
manageUsers: true,
hardDeletePatients: true,
accessSettings: true,
ownAppointmentsOnly: false,
canEditPatients: true,
canViewReports: true,
canViewMedicalRecords: true,
},
gestor: {
manageUsers: true,
hardDeletePatients: true,
accessSettings: true,
ownAppointmentsOnly: false,
canEditPatients: true,
canViewReports: true,
canViewMedicalRecords: true,
},
medico: {
manageUsers: false,
hardDeletePatients: false,
accessSettings: true,
ownAppointmentsOnly: true,
canEditPatients: false,
canViewReports: true,
canViewMedicalRecords: true,
},
secretaria: {
manageUsers: false,
hardDeletePatients: false,
accessSettings: true,
ownAppointmentsOnly: false,
canEditPatients: true,
canViewReports: false,
canViewMedicalRecords: false,
},
paciente: {
manageUsers: false,
hardDeletePatients: false,
accessSettings: true,
ownAppointmentsOnly: false,
canEditPatients: false,
canViewReports: false,
canViewMedicalRecords: false,
},
}
// Itens do menu por role (para o AppShell)
export const ROLE_NAV_ITEMS = {
admin: [
{ path: '/inicio', label: 'Painel' },
{ path: '/agenda', label: 'Agenda' },
{ path: '/pacientes', label: 'Pacientes' },
{ path: '/prontuario', label: 'Prontuário' },
{ path: '/laudos', label: 'Relatórios' },
{ path: '/relatorios', label: 'Analytics' },
{ path: '/comunicacao', label: 'Comunicação' },
{ path: '/usuarios', label: 'Usuários' },
{ path: '/configuracoes', label: 'Configurações' },
],
gestor: [
{ path: '/inicio', label: 'Painel' },
{ path: '/agenda', label: 'Agenda' },
{ path: '/pacientes', label: 'Pacientes' },
{ path: '/prontuario', label: 'Prontuário' },
{ path: '/laudos', label: 'Relatórios' },
{ path: '/relatorios', label: 'Analytics' },
{ path: '/comunicacao', label: 'Comunicação' },
{ path: '/usuarios', label: 'Usuários' },
{ path: '/configuracoes', label: 'Configurações' },
],
medico: [
{ path: '/agenda', label: 'Agenda' },
{ path: '/pacientes', label: 'Pacientes' },
{ path: '/prontuario', label: 'Prontuário' },
{ path: '/laudos', label: 'Relatórios' },
{ path: '/comunicacao', label: 'Comunicação' },
{ path: '/configuracoes', label: 'Configurações' },
],
secretaria: [
{ path: '/agenda', label: 'Agenda' },
{ path: '/pacientes', label: 'Pacientes' },
{ path: '/comunicacao', label: 'Comunicação' },
{ path: '/configuracoes', label: 'Configurações' },
],
paciente: [
{ path: '/inicio', label: 'Painel' },
{ path: '/configuracoes', label: 'Configurações' },
],
}
// Verifica se um role pode acessar uma rota
export function canAccess(role, pathname) {
const normalizedRole = normalizeRole(role)
if (!normalizedRole) return false
const allowed = ROLE_ROUTES[normalizedRole]
if (allowed === '*') return true
if (!Array.isArray(allowed)) return false
return allowed.some((route) => pathname === route || pathname.startsWith(route + '/'))
}
// Verifica se um role tem uma capacidade específica
export function hasCapability(role, capability) {
const normalizedRole = normalizeRole(role)
return ROLE_CAPABILITIES[normalizedRole]?.[capability] ?? false
}
export function normalizeRole(role) {
const normalized = normalizeRoleKey(role)
return ROLE_ALIASES[normalized] ?? null
}
function normalizeRoleKey(role) {
return String(role ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
}
// Rótulos amigáveis para cada role
export const ROLE_LABELS = {
admin: 'Administrador',
gestor: 'Gestão / Coordenação',
medico: 'Médico',
secretaria: 'Secretária',
paciente: 'Paciente',
}
// Roles que um gestor pode criar
export const GESTOR_CREATABLE_ROLES = ['medico', 'secretaria', 'paciente']
// Roles que um admin pode criar
export const ADMIN_CREATABLE_ROLES = ['admin', 'gestor', 'medico', 'secretaria', 'paciente']

View File

@@ -65,8 +65,8 @@ export const patients = [
nextVisit: '07 abr 2026, 14:30', nextVisit: '07 abr 2026, 14:30',
team: ['Dr. Rafael Nunes', 'Nutri. Clara Meireles'], team: ['Dr. Rafael Nunes', 'Nutri. Clara Meireles'],
notes: [ notes: [
'Pressao ainda oscilando no periodo da tarde.', 'Pressão ainda oscilando no período da tarde.',
'Conferir adesao ao medicamento e orientar diario de pressao.', 'Conferir adesão ao medicamento e orientar diário de pressão.',
], ],
exams: ['MAPA 24h', 'Eletrocardiograma', 'Creatinina'], exams: ['MAPA 24h', 'Eletrocardiograma', 'Creatinina'],
}, },
@@ -179,10 +179,10 @@ export const careQueue = [
id: 'queue-002', id: 'queue-002',
patient: 'Bruno Lima', patient: 'Bruno Lima',
patientId: 'bruno-lima', patientId: 'bruno-lima',
status: 'Aguardando medico', status: 'Aguardando médico',
priority: 'Alta', priority: 'Alta',
wait: '25 min', wait: '25 min',
reason: 'Pressao elevada', reason: 'Pressão elevada',
}, },
{ {
id: 'queue-003', id: 'queue-003',
@@ -226,7 +226,7 @@ export const conversations = [
id: 'conv-bruno', id: 'conv-bruno',
patient: 'Bruno Lima', patient: 'Bruno Lima',
patientId: 'bruno-lima', patientId: 'bruno-lima',
subject: 'Pressao no fim do dia', subject: 'Pressão no fim do dia',
unread: 1, unread: 1,
lastMessage: 'Hoje marcou 15 por 9 novamente.', lastMessage: 'Hoje marcou 15 por 9 novamente.',
status: 'Prioridade alta', status: 'Prioridade alta',
@@ -247,7 +247,7 @@ export const conversations = [
id: 'conv-carla', id: 'conv-carla',
patient: 'Carla Mendes', patient: 'Carla Mendes',
patientId: 'carla-mendes', patientId: 'carla-mendes',
subject: 'Confirmacao de horario', subject: 'Confirmação de horario',
unread: 0, unread: 0,
lastMessage: 'Confirmado para quinta as 08:30.', lastMessage: 'Confirmado para quinta as 08:30.',
status: 'Respondida', status: 'Respondida',
@@ -265,7 +265,7 @@ export const professionals = [
{ {
id: 'marina-lopes', id: 'marina-lopes',
name: 'Dra. Marina Lopes', name: 'Dra. Marina Lopes',
role: 'Clinica geral', role: 'Clínica geral',
schedule: 'Seg a sex, 08:00-16:00', schedule: 'Seg a sex, 08:00-16:00',
status: 'Disponivel', status: 'Disponivel',
nextSlot: 'Hoje, 15:30', nextSlot: 'Hoje, 15:30',

View File

@@ -1,12 +1,23 @@
import { useState, useEffect, useMemo } from 'react' import { useEffect, useMemo, useState } from 'react'
import { isSameDay } from 'date-fns' import { isSameDay } from 'date-fns'
import { appointmentRepository } from '../repositories/appointmentRepository.js' import { appointmentRepository } from '../repositories/appointmentRepository.js'
import { availabilityRepository } from '../repositories/availabilityRepository.js'
import { patientRepository } from '../repositories/patientRepository.js' import { patientRepository } from '../repositories/patientRepository.js'
import { professionalRepository } from '../repositories/professionalRepository.js' import { professionalRepository } from '../repositories/professionalRepository.js'
import { profileRepository } from '../repositories/profileRepository.js' import { profileRepository } from '../repositories/profileRepository.js'
import { formatLocalDateInput, parseLocalDate, sortAppointmentsByTime } from '../utils/agendaDate.js' import { formatLocalDateInput, parseLocalDate, sortAppointmentsByTime } from '../utils/agendaDate.js'
const initialForm = {
patientId: '',
professionalId: '',
type: 'Retorno',
time: '15:30',
mode: 'Teleconsulta',
status: 'Aguardando',
notes: '',
}
export function useAgenda() { export function useAgenda() {
const [patients, setPatients] = useState([]) const [patients, setPatients] = useState([])
const [professionals, setProfessionals] = useState([]) const [professionals, setProfessionals] = useState([])
@@ -15,19 +26,24 @@ export function useAgenda() {
const [localAppointments, setLocalAppointments] = useState([]) const [localAppointments, setLocalAppointments] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [availableSlots, setAvailableSlots] = useState([])
const [slotsLoading, setSlotsLoading] = useState(false)
const [slotsError, setSlotsError] = useState('')
const [activeView, setActiveView] = useState('Dia') const [activeView, setActiveView] = useState('Dia')
const [baseDate, setBaseDate] = useState(new Date()) const [baseDate, setBaseDate] = useState(new Date())
const [status, setStatus] = useState('Todos') const [status, setStatus] = useState('Todos')
const [doctorFilter, setDoctorFilter] = useState('Todos')
const [doctorSearch, setDoctorSearch] = useState('')
const [unitFilter, setUnitFilter] = useState('')
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
const [editingAppointment, setEditingAppointment] = useState(null)
const [form, setForm] = useState(initialForm)
const [form, setForm] = useState({ const agendaScope = viewerProfile?.isDoctor ? 'doctor' : 'global'
patientId: '', const canCreateAppointment = agendaScope === 'doctor'
professionalId: '', ? Boolean(currentProfessional?.id)
type: 'Retorno', : professionals.length > 0
time: '15:30',
mode: 'Teleconsulta',
})
useEffect(() => { useEffect(() => {
let active = true let active = true
@@ -44,10 +60,10 @@ export function useAgenda() {
if (!active) return if (!active) return
const agendaScope = currentProfile?.isDoctor ? 'doctor' : 'global' const currentScope = currentProfile?.isDoctor ? 'doctor' : 'global'
const resolvedProfessional = resolveCurrentProfessional(currentProfile, professionalsData) const resolvedProfessional = professionalRepository.resolveCurrentProfessional(currentProfile, professionalsData)
const initialProfessionalId = const initialProfessionalId =
agendaScope === 'doctor' currentScope === 'doctor'
? resolvedProfessional?.id || '' ? resolvedProfessional?.id || ''
: professionalsData?.[0]?.id || '' : professionalsData?.[0]?.id || ''
@@ -61,20 +77,20 @@ export function useAgenda() {
professionalId: initialProfessionalId, professionalId: initialProfessionalId,
})) }))
if (agendaScope === 'doctor' && !resolvedProfessional) { if (currentScope === 'doctor' && !resolvedProfessional) {
setLocalAppointments([]) setLocalAppointments([])
setError('Nao foi possivel vincular o medico logado a um profissional da base.') setError('Não foi possível vincular o médico logado a um profissional da base.')
return return
} }
const appointmentsData = await appointmentRepository.getAll({ const appointmentsData = await appointmentRepository.getAll({
doctorId: agendaScope === 'doctor' ? resolvedProfessional?.id : undefined, doctorId: currentScope === 'doctor' ? resolvedProfessional?.id : undefined,
}) })
if (!active) return if (!active) return
setLocalAppointments( setLocalAppointments(
agendaScope === 'doctor' && resolvedProfessional currentScope === 'doctor' && resolvedProfessional
? filterAppointmentsByProfessional(appointmentsData || [], resolvedProfessional.id) ? filterAppointmentsByProfessional(appointmentsData || [], resolvedProfessional.id)
: sortAppointmentsByTime(appointmentsData || []), : sortAppointmentsByTime(appointmentsData || []),
) )
@@ -84,9 +100,7 @@ export function useAgenda() {
console.error(loadError) console.error(loadError)
setError(loadError.message || 'Erro ao carregar agenda.') setError(loadError.message || 'Erro ao carregar agenda.')
} finally { } finally {
if (active) { if (active) setLoading(false)
setLoading(false)
}
} }
} }
@@ -97,6 +111,60 @@ export function useAgenda() {
} }
}, []) }, [])
useEffect(() => {
if (!modalOpen || editingAppointment) return
const targetProfessionalId = agendaScope === 'doctor'
? currentProfessional?.id
: form.professionalId
let active = true
async function loadAvailableSlots() {
if (!targetProfessionalId) {
setAvailableSlots([])
setSlotsError('')
return
}
setSlotsLoading(true)
setSlotsError('')
try {
const slots = await availabilityRepository.getAvailableSlots({
doctorId: targetProfessionalId,
date: formatLocalDateInput(baseDate),
appointmentType: form.mode,
})
if (!active) return
const activeSlots = slots.filter((slot) => slot.available)
setAvailableSlots(activeSlots)
if (activeSlots.length) {
setForm((current) =>
activeSlots.some((slot) => slot.time === current.time)
? current
: { ...current, time: activeSlots[0].time },
)
}
} catch (loadError) {
if (!active) return
setAvailableSlots([])
setSlotsError(loadError.message || 'Não foi possível calcular horários disponíveis.')
} finally {
if (active) setSlotsLoading(false)
}
}
loadAvailableSlots()
return () => {
active = false
}
}, [agendaScope, baseDate, currentProfessional?.id, editingAppointment, form.mode, form.professionalId, modalOpen])
const visibleAppointments = useMemo(() => { const visibleAppointments = useMemo(() => {
let filtered = localAppointments let filtered = localAppointments
@@ -104,6 +172,30 @@ export function useAgenda() {
filtered = filtered.filter((appointment) => appointment.status === status) filtered = filtered.filter((appointment) => appointment.status === status)
} }
if (agendaScope !== 'doctor' && doctorFilter !== 'Todos') {
filtered = filterAppointmentsByProfessional(filtered, doctorFilter)
}
if (agendaScope !== 'doctor') {
const normalizedDoctorSearch = normalizeValue(doctorSearch)
const normalizedUnit = normalizeValue(unitFilter)
if (normalizedDoctorSearch || normalizedUnit) {
filtered = filtered.filter((appointment) => {
const professional = professionals.find(
(item) => normalizeValue(item.id) === normalizeValue(appointment.professionalId),
)
const professionalName = normalizeValue(professional?.name || appointment.professional)
const professionalUnit = normalizeValue(professional?.unit || appointment.unit)
return (
(!normalizedDoctorSearch || professionalName.includes(normalizedDoctorSearch)) &&
(!normalizedUnit || professionalUnit === normalizedUnit)
)
})
}
}
if (activeView === 'Dia') { if (activeView === 'Dia') {
filtered = filtered.filter((appointment) => { filtered = filtered.filter((appointment) => {
if (!appointment.date) return false if (!appointment.date) return false
@@ -116,46 +208,154 @@ export function useAgenda() {
} }
return sortAppointmentsByTime(filtered) return sortAppointmentsByTime(filtered)
}, [localAppointments, status, activeView, baseDate]) }, [activeView, agendaScope, baseDate, doctorFilter, doctorSearch, localAppointments, professionals, status, unitFilter])
const agendaScope = viewerProfile?.isDoctor ? 'doctor' : 'global'
const canCreateAppointment = agendaScope === 'doctor'
? Boolean(currentProfessional?.id)
: professionals.length > 0
function updateForm(field, value) { function updateForm(field, value) {
setForm((current) => ({ ...current, [field]: value })) setForm((current) => ({ ...current, [field]: value }))
} }
async function handleCreate(event) { function openCreateModal({ date, time } = {}) {
if (date) {
const parsedDate = parseLocalDate(date)
if (parsedDate) setBaseDate(parsedDate)
}
setEditingAppointment(null)
setAvailableSlots([])
setSlotsError('')
setForm((current) => ({
...initialForm,
patientId: current.patientId || patients[0]?.id || '',
professionalId:
agendaScope === 'doctor'
? currentProfessional?.id || ''
: current.professionalId || professionals[0]?.id || '',
time: time || current.time || initialForm.time,
}))
setModalOpen(true)
}
function openAppointmentModal(appointment) {
const parsedDate = parseLocalDate(appointment.date)
if (parsedDate) setBaseDate(parsedDate)
setEditingAppointment(appointment)
setAvailableSlots([])
setSlotsError('')
setForm({
patientId: appointment.patientId || '',
professionalId: appointment.professionalId || '',
type: appointment.type || initialForm.type,
time: appointment.time || initialForm.time,
mode: appointment.mode || initialForm.mode,
status: appointment.status || initialForm.status,
notes: appointment.notes || '',
})
setModalOpen(true)
}
function closeAppointmentModal() {
setModalOpen(false)
setEditingAppointment(null)
}
async function handleSubmitAppointment(event) {
event.preventDefault() event.preventDefault()
if (editingAppointment) {
await updateAppointment()
return
}
await createAppointment()
}
async function createAppointment() {
const payload = buildPayload()
if (!payload) return
try {
const created = await appointmentRepository.create(payload)
setLocalAppointments((current) => sortAppointmentsByTime([...current, enrichAppointment(created, payload, patients, professionals)]))
closeAppointmentModal()
} catch (createError) {
alert(createError.message || 'Erro ao criar agendamento.')
}
}
async function updateAppointment() {
if (!editingAppointment) return
const payload = buildPayload()
if (!payload) return
try {
const updated = await appointmentRepository.update(editingAppointment.id, payload)
setLocalAppointments((current) =>
sortAppointmentsByTime(
current.map((appointment) =>
appointment.id === editingAppointment.id
? enrichAppointment(updated, payload, patients, professionals)
: appointment,
),
),
)
closeAppointmentModal()
} catch (updateError) {
alert(updateError.message || 'Erro ao atualizar agendamento.')
}
}
async function handleCancelAppointment() {
if (!editingAppointment) return
if (!window.confirm('Tem certeza que deseja cancelar este agendamento?')) return
const payload = buildPayload({ status: 'Cancelada' })
if (!payload) return
try {
const cancelled = await appointmentRepository.cancel(editingAppointment.id, payload)
setLocalAppointments((current) =>
sortAppointmentsByTime(
current.map((appointment) =>
appointment.id === editingAppointment.id
? enrichAppointment(cancelled, payload, patients, professionals)
: appointment,
),
),
)
closeAppointmentModal()
} catch (cancelError) {
alert(cancelError.message || 'Erro ao cancelar agendamento.')
}
}
function buildPayload(overrides = {}) {
if (!form.patientId) {
alert('Selecione um paciente para salvar o agendamento.')
return null
}
const targetProfessionalId = agendaScope === 'doctor' const targetProfessionalId = agendaScope === 'doctor'
? currentProfessional?.id ? currentProfessional?.id
: form.professionalId : form.professionalId
if (!targetProfessionalId) { if (!targetProfessionalId) {
alert('Nao foi possivel identificar o profissional da consulta.') alert('Não foi possível identificar o profissional da consulta.')
return return null
} }
const dateStr = formatLocalDateInput(baseDate) return {
patientId: form.patientId,
try { date: formatLocalDateInput(baseDate),
const created = await appointmentRepository.create({ time: form.time,
patientId: form.patientId, type: form.type,
date: dateStr, mode: form.mode,
time: form.time, status: form.status,
type: form.type, notes: form.notes,
mode: form.mode, room: form.mode === 'Teleconsulta' ? 'Virtual' : 'Consultório 1',
room: form.mode === 'Teleconsulta' ? 'Virtual' : 'Consultório 1', professionalId: targetProfessionalId,
professionalId: targetProfessionalId, ...overrides,
})
setLocalAppointments((current) => sortAppointmentsByTime([...current, created]))
setModalOpen(false)
} catch (createError) {
alert(createError.message || 'Erro ao criar agendamento.')
} }
} }
@@ -174,29 +374,28 @@ export function useAgenda() {
setBaseDate, setBaseDate,
status, status,
setStatus, setStatus,
doctorFilter,
setDoctorFilter,
doctorSearch,
setDoctorSearch,
unitFilter,
setUnitFilter,
modalOpen, modalOpen,
setModalOpen, editingAppointment,
form, form,
updateForm, updateForm,
handleCreate, openCreateModal,
openAppointmentModal,
closeAppointmentModal,
handleSubmitAppointment,
handleCancelAppointment,
visibleAppointments, visibleAppointments,
availableSlots,
slotsLoading,
slotsError,
} }
} }
function resolveCurrentProfessional(profile, professionals) {
const doctorId = normalizeValue(profile?.doctorId)
const userId = normalizeValue(profile?.id)
const email = normalizeValue(profile?.email)
return (
professionals.find((professional) => normalizeValue(professional.id) === doctorId) ||
professionals.find((professional) => normalizeValue(professional.userId) === userId) ||
professionals.find((professional) => normalizeValue(professional.id) === userId) ||
professionals.find((professional) => normalizeValue(professional.email) === email) ||
null
)
}
function filterAppointmentsByProfessional(appointments, professionalId) { function filterAppointmentsByProfessional(appointments, professionalId) {
const normalizedProfessionalId = normalizeValue(professionalId) const normalizedProfessionalId = normalizeValue(professionalId)
@@ -205,6 +404,26 @@ function filterAppointmentsByProfessional(appointments, professionalId) {
) )
} }
function enrichAppointment(appointment, payload, patients, professionals) {
const patient = patients.find((item) => String(item.id) === String(payload.patientId))
const professional = professionals.find((item) => String(item.id) === String(payload.professionalId))
return {
...appointment,
patientId: payload.patientId,
professionalId: payload.professionalId,
patient: patient?.name || patient?.full_name || patient?.nome || appointment.patient,
professional: professional?.name || professional?.full_name || professional?.nome || appointment.professional,
date: payload.date,
time: payload.time,
type: payload.type,
mode: payload.mode,
status: payload.status,
notes: payload.notes,
room: payload.room,
}
}
function normalizeValue(value) { function normalizeValue(value) {
return String(value || '').trim().toLowerCase() return String(value || '').trim().toLowerCase()
} }

101
src/hooks/useAuth.js Normal file
View File

@@ -0,0 +1,101 @@
import { useEffect, useState } from 'react'
import { AUTH_SESSION_CHANGED_EVENT, getAuthSession, saveAuthSession } from '../config/api.js'
import { normalizeRole } from '../config/permissions.js'
import { authRepository } from '../repositories/authRepository.js'
export function useAuth() {
const [state, setState] = useState(() => getStateFromSession(getAuthSession()))
useEffect(() => {
function syncSession() {
setState(getStateFromSession(getAuthSession()))
}
window.addEventListener(AUTH_SESSION_CHANGED_EVENT, syncSession)
return () => window.removeEventListener(AUTH_SESSION_CHANGED_EVENT, syncSession)
}, [])
useEffect(() => {
if (!state.isAuthenticated || state.role) {
return
}
let cancelled = false
authRepository
.getUser()
.then((data) => {
if (cancelled || !data) return
const profile = data.profile ?? data.perfil ?? null
const user = data.user ?? data.usuario ?? data ?? null
const role = resolveRole(data)
const session = getAuthSession()
saveAuthSession({ ...session, role, profile, user: user || session?.user })
setState((current) => ({ ...current, role, profile, user: user || current.user, loading: false }))
})
.catch(() => {
if (!cancelled) setState((current) => ({ ...current, loading: false }))
})
return () => {
cancelled = true
}
}, [state.isAuthenticated, state.role])
return state
}
function getStateFromSession(session) {
const role = normalizeRole(session?.role)
return {
user: session?.user ?? null,
role,
profile: session?.profile ?? null,
isAuthenticated: !!session?.access_token,
loading: !!session?.access_token && !role,
}
}
function resolveRole(data) {
const user = data?.user ?? data?.usuario ?? {}
const profile = data?.profile ?? data?.perfil ?? {}
const metadata = {
...user?.user_metadata,
...user?.app_metadata,
...user?.metadata,
...data?.user_metadata,
...data?.app_metadata,
...data?.metadata,
}
const candidates = [
...(Array.isArray(data?.roles) ? data.roles : []),
...(Array.isArray(user?.roles) ? user.roles : []),
data?.role,
data?.cargo,
profile?.role,
profile?.cargo,
user?.role,
user?.cargo,
metadata.role,
metadata.cargo,
]
for (const candidate of candidates) {
const role = normalizeRole(candidate)
if (role) return role
}
const permissions = data?.permissions ?? data?.permissoes ?? {}
if (permissions.isAdmin) return 'admin'
if (permissions.isManager) return 'gestor'
if (permissions.isDoctor) return 'medico'
if (permissions.isSecretary) return 'secretaria'
if (permissions.isPatient) return 'paciente'
return null
}

View File

@@ -43,3 +43,667 @@ button:disabled {
#root { #root {
min-height: 100vh; min-height: 100vh;
} }
:root[data-theme='light'] {
color: #333333;
background: #d9e4f0;
color-scheme: light;
}
[data-theme='light'] body {
background: #d9e4f0;
color: #333333;
}
[data-theme='light'] input,
[data-theme='light'] select,
[data-theme='light'] textarea {
color-scheme: light;
}
[data-theme='light'] :where(input, textarea, [contenteditable='true'], .ProseMirror) {
caret-color: #000000;
}
[data-theme='light'] .auth-dark :where(input, textarea) {
caret-color: #e5e5e5;
}
[data-theme='light'] aside.bg-\[\#262626\] {
background-color: #f3f4f6;
}
[data-theme='light'] .bg-\[\#0a0a0a\],
[data-theme='light'] .bg-\[\#171717\] {
background-color: #d9e4f0;
}
[data-theme='light'] .bg-\[\#1a1a1a\] {
background-color: #f9fafb;
}
[data-theme='light'] .bg-\[\#262626\] {
background-color: #ffffff;
}
[data-theme='light'] .bg-\[\#1f1f1f\],
[data-theme='light'] .bg-\[\#202020\] {
background-color: #f3f4f6;
}
[data-theme='light'] .bg-\[\#2a2a2a\],
[data-theme='light'] .bg-\[\#303030\],
[data-theme='light'] .bg-\[\#333333\] {
background-color: #e8edf4;
}
[data-theme='light'] .bg-\[\#3b82f6\] {
background-color: #3b82f6;
}
[data-theme='light'] .bg-\[\#2563eb\],
[data-theme='light'] .hover\:bg-\[\#2563eb\]:hover,
[data-theme='light'] .hover\:bg-\[\#3478ed\]:hover {
background-color: #2563eb;
}
[data-theme='light'] .hover\:bg-\[\#2a2a2a\]:hover,
[data-theme='light'] .hover\:bg-\[\#303030\]:hover,
[data-theme='light'] .hover\:bg-\[\#333333\]:hover {
background-color: #e8edf4;
}
[data-theme='light'] .disabled\:bg-\[\#303030\]:disabled {
background-color: #d9e4f0;
}
[data-theme='light'] .border-\[\#404040\],
[data-theme='light'] .divide-\[\#404040\] > :not([hidden]) ~ :not([hidden]) {
border-color: #d6dee8;
}
[data-theme='light'] .divide-y.divide-\[\#404040\] > :not(:last-child),
[data-theme='light'] .divide-\[\#404040\] > :not(:last-child),
[data-theme='light'] table .divide-\[\#404040\] > tr:not(:last-child) {
border-color: #d6dee8;
}
[data-theme='light'] .border-\[\#525252\],
[data-theme='light'] .hover\:border-\[\#525252\]:hover {
border-color: #d1d5db;
}
[data-theme='light'] .border-\[\#5b4b75\] {
border-color: #d6dee8;
}
[data-theme='light'] .hover\:border-\[\#404040\]:hover,
[data-theme='light'] .disabled\:border-\[\#404040\]:disabled {
border-color: #d6dee8;
}
[data-theme='light'] .text-\[\#f5f5f5\],
[data-theme='light'] .text-\[\#e5e5e5\],
[data-theme='light'] .hover\:text-\[\#e5e5e5\]:hover {
color: #333333;
}
[data-theme='light'] .text-\[\#d4d4d4\],
[data-theme='light'] .text-\[\#b8b8b8\] {
color: #4b5563;
}
[data-theme='light'] .text-\[\#a3a3a3\],
[data-theme='light'] .text-\[\#737373\],
[data-theme='light'] .disabled\:text-\[\#737373\]:disabled {
color: #6b7280;
}
[data-theme='light'] .text-\[\#51a2ff\] {
color: #1d4ed8;
}
[data-theme='light'] .placeholder\:text-\[\#737373\]::placeholder,
[data-theme='light'] .placeholder\:text-\[\#a3a3a3\]::placeholder {
color: #6b7280;
}
[data-theme='light'] [class*='[color-scheme:dark]'] {
color-scheme: light;
}
[data-theme='light'] svg [stroke='#303030'] {
stroke: #e5e7eb;
}
[data-theme='light'] svg [stroke='#1d4ed8'] {
stroke: #3b82f6;
}
[data-theme='light'] svg [fill='#262626'] {
fill: #ffffff;
}
[data-theme='light'] svg [fill='#a3a3a3'] {
fill: #6b7280;
}
[data-theme='light'] svg [fill='#171717'] {
fill: #f9fafb;
}
.auth-dark {
background: #0a0a0a;
color: #ffffff;
color-scheme: dark;
}
.auth-dark .auth-input {
border-color: #404040;
background: #171717;
color: #e5e5e5;
color-scheme: dark;
}
.auth-dark .auth-input::placeholder {
color: #737373;
}
.auth-dark .auth-menu {
border-color: #404040;
background: #171717;
color: #a3a3a3;
}
.auth-dark .auth-menu:hover {
color: #e5e5e5;
}
[data-theme='light'] .auth-dark {
background: #0a0a0a;
color: #ffffff;
color-scheme: dark;
}
[data-theme='light'] .auth-dark .auth-input {
border-color: #404040;
background: #171717;
color: #e5e5e5;
color-scheme: dark;
}
[data-theme='light'] .auth-dark .auth-input::placeholder {
color: #737373;
}
[data-theme='light'] .auth-dark .auth-menu {
border-color: #404040;
background: #171717;
color: #a3a3a3;
}
[data-theme='light'] .auth-dark .auth-menu:hover {
color: #e5e5e5;
}
[data-theme='light'] .settings-theme-preview-dark {
border-color: #525252;
background: #0a0a0a;
}
[data-theme='light'] .settings-theme-preview-dark .settings-theme-preview-bar {
background: #262626;
}
[data-theme='light'] .settings-theme-preview-dark .settings-theme-preview-side {
background: #171717;
}
[data-theme='light'] .settings-theme-preview-dark .settings-theme-preview-line {
background: #525252;
}
[data-theme='light'] .settings-theme-preview-light {
border-color: #d6dee8;
background: #f4f7fb;
}
[data-theme='light'] button:has(.settings-theme-preview-dark) .bg-\[\#3b82f6\] {
background-color: #404040;
}
[data-theme='light'] .feature-badge-partial {
border-color: #0284c7;
background: #dff3ff;
color: #075985;
box-shadow: inset 0 0 0 1px rgba(2, 132, 199, 0.12);
}
[data-theme='light'] .feature-panel-partial {
border-color: #38bdf8;
background: #eef9ff;
}
[data-theme='light'] .feature-title-partial {
color: #075985;
}
[data-theme='light'] .feature-badge-mock {
border-color: #d97706;
background: #fff2c2;
color: #92400e;
box-shadow: inset 0 0 0 1px rgba(217, 119, 6, 0.14);
}
[data-theme='light'] .feature-panel-mock {
border-color: #f59e0b;
background: #fff8db;
}
[data-theme='light'] .feature-title-mock {
color: #92400e;
}
[data-theme='light'] .feature-badge-wip {
border-color: #e11d48;
background: #ffe4e8;
color: #9f1239;
box-shadow: inset 0 0 0 1px rgba(225, 29, 72, 0.12);
}
[data-theme='light'] .feature-panel-wip {
border-color: #fb7185;
background: #fff1f3;
}
[data-theme='light'] .feature-title-wip {
color: #9f1239;
}
[data-theme='light'] .report-editor-backdrop {
background: rgba(15, 23, 42, 0.35);
}
[data-theme='light'] .report-editor-shell {
border-color: #c8d4e2;
background: #f8fbff;
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.18);
}
[data-theme='light'] .report-editor-header,
[data-theme='light'] .report-editor-footer {
border-color: #c8d4e2;
background: #ffffff;
}
[data-theme='light'] .report-editor-body {
background: #f8fbff;
}
[data-theme='light'] .report-template-trigger,
[data-theme='light'] .report-template-menu,
[data-theme='light'] .report-rich-editor,
[data-theme='light'] .report-rich-toolbar {
border-color: #c8d4e2;
background: #ffffff;
color: #333333;
}
[data-theme='light'] .report-rich-surface {
background: #ffffff;
caret-color: #000000;
color: #333333;
}
[data-theme='light'] .report-rich-toolbar select {
border-color: #c8d4e2;
background: #ffffff;
color: #333333;
}
[data-theme='light'] .report-rich-toolbar button {
color: #4b5563;
}
[data-theme='light'] .report-rich-toolbar button[aria-pressed='true'],
[data-theme='light'] .report-rich-toolbar button:hover {
color: #2563eb;
}
.report-rich-surface {
caret-color: #e5e5e5;
cursor: text;
min-height: 560px;
}
.report-rich-surface * {
cursor: text;
}
.report-rich-surface.ProseMirror-focused {
caret-color: #e5e5e5;
}
[data-theme='light'] .report-rich-surface.ProseMirror-focused {
caret-color: #000000;
}
.report-rich-surface p {
margin: 0 0 0.75rem;
}
.report-rich-surface h2 {
margin: 0 0 0.85rem;
font-size: 1.1rem;
font-weight: 700;
}
.report-rich-surface h3 {
margin: 0 0 0.75rem;
font-size: 1rem;
font-weight: 700;
}
.report-rich-surface ul,
.report-rich-surface ol {
margin: 0.5rem 0 0.75rem;
padding-left: 1.4rem;
}
.report-rich-surface .is-empty::before {
color: #737373;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
[data-theme='light'] .report-rich-toolbar button:hover,
[data-theme='light'] .report-template-menu button:hover {
background: #e8edf4;
}
.agenda-calendar-shell {
border-color: #3b3b3b;
background: #202020;
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.32);
}
.agenda-calendar-header {
border-color: #3b3b3b;
}
.agenda-legend-pill {
border-color: #404040;
background: #171717;
color: #a3a3a3;
}
.agenda-legend-free {
border-color: #166534;
background: #052e1a;
color: #86efac;
}
.agenda-legend-booked {
border-color: #a16207;
background: #422006;
color: #fde68a;
}
.agenda-day-grid {
gap: 0;
overflow: hidden;
border: 1px solid #3b3b3b;
border-radius: 14px;
background:
repeating-linear-gradient(
to bottom,
#1f2933 0,
#1f2933 39px,
#334155 40px
);
}
.agenda-slot {
margin: 0;
border-width: 1px;
border-radius: 0;
color: #e5e5e5;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 4px 14px rgba(0, 0, 0, 0.28);
}
.agenda-slot + .agenda-slot {
border-top-width: 0;
}
.agenda-slot-free {
border-color: #15803d;
background: linear-gradient(180deg, #083d22 0%, #052e1a 100%);
color: #bbf7d0;
}
.agenda-slot-waiting,
.agenda-event-waiting {
border-color: #b7791f;
background: linear-gradient(180deg, #53350a 0%, #3f2a09 100%);
color: #fde68a;
}
.agenda-slot-confirmed,
.agenda-event-confirmed {
border-color: #0891b2;
background: linear-gradient(180deg, #083344 0%, #0c2636 100%);
color: #a5f3fc;
}
.agenda-slot-triage,
.agenda-event-triage {
border-color: #9333ea;
background: linear-gradient(180deg, #3b0764 0%, #2e0a4f 100%);
color: #e9d5ff;
}
.agenda-event-finished {
border-color: #2563eb;
background: linear-gradient(180deg, #172554 0%, #111c3d 100%);
color: #bfdbfe;
}
.agenda-slot-cancelled,
.agenda-event-cancelled {
border-color: #b91c1c;
background: linear-gradient(180deg, #4c0519 0%, #3b0713 100%);
color: #fecdd3;
}
.agenda-slot-blocked,
.agenda-event-blocked {
border-color: #525252;
background: linear-gradient(180deg, #262626 0%, #1f1f1f 100%);
color: #a3a3a3;
}
.agenda-slot-chip,
.agenda-slot-status {
border-color: rgba(229, 229, 229, 0.12);
background: rgba(0, 0, 0, 0.26);
color: inherit;
}
.agenda-slot-add {
border-color: rgba(229, 229, 229, 0.18);
background: rgba(0, 0, 0, 0.3);
color: inherit;
}
.agenda-slot-add:hover {
background: rgba(0, 0, 0, 0.46);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
}
.agenda-week-day,
.agenda-month-day {
border-color: #3b3b3b;
background: #1f1f1f;
min-width: 0;
overflow: hidden;
}
.agenda-month-day:nth-child(7n + 1),
.agenda-month-day:nth-child(7n) {
background: #1a1a1a;
}
.agenda-event,
.agenda-month-event {
min-width: 0;
max-width: 100%;
overflow: hidden;
box-shadow: none;
}
.agenda-event span,
.agenda-month-event span {
color: inherit;
}
[data-theme='light'] .agenda-calendar-shell {
border-color: #d7e2ec;
background: #f8fbfd;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
}
[data-theme='light'] .agenda-calendar-header {
border-color: #dbe7f1;
}
[data-theme='light'] .agenda-legend-pill {
border-color: #d7e2ec;
background: #ffffff;
color: #475569;
}
[data-theme='light'] .agenda-legend-free {
border-color: #86c98a;
background: #eaf9ea;
color: #166534;
}
[data-theme='light'] .agenda-legend-booked {
border-color: #f0b23d;
background: #fff5cf;
color: #7a4a05;
}
[data-theme='light'] .agenda-day-grid {
gap: 0;
overflow: hidden;
border: 1px solid #d7e2ec;
border-radius: 14px;
background:
repeating-linear-gradient(
to bottom,
#eef4f8 0,
#eef4f8 39px,
#dbe7f1 40px
);
}
[data-theme='light'] .agenda-slot {
margin: 0;
border-width: 1px;
border-radius: 0;
color: #334155;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8), 0 2px 8px rgba(15, 23, 42, 0.08);
}
[data-theme='light'] .agenda-slot + .agenda-slot {
border-top-width: 0;
}
[data-theme='light'] .agenda-slot-free {
border-color: #97d39b;
background: linear-gradient(180deg, #f2fff2 0%, #e6f7e7 100%);
color: #14532d;
}
[data-theme='light'] .agenda-slot-waiting,
[data-theme='light'] .agenda-event-waiting {
border-color: #f0b23d;
background: linear-gradient(180deg, #fff8d7 0%, #fff2b7 100%);
color: #6f4700;
}
[data-theme='light'] .agenda-slot-confirmed,
[data-theme='light'] .agenda-event-confirmed {
border-color: #26b8ec;
background: linear-gradient(180deg, #e5faff 0%, #cef3ff 100%);
color: #075985;
}
[data-theme='light'] .agenda-slot-triage,
[data-theme='light'] .agenda-event-triage {
border-color: #b35cff;
background: linear-gradient(180deg, #f8ddff 0%, #edc4ff 100%);
color: #5b217f;
}
[data-theme='light'] .agenda-event-finished {
border-color: #60a5fa;
background: linear-gradient(180deg, #dbeafe 0%, #bfdbfe 100%);
color: #1e3a8a;
}
[data-theme='light'] .agenda-slot-cancelled,
[data-theme='light'] .agenda-event-cancelled {
border-color: #fb7185;
background: linear-gradient(180deg, #ffe4e6 0%, #fecdd3 100%);
color: #991b1b;
}
[data-theme='light'] .agenda-slot-blocked,
[data-theme='light'] .agenda-event-blocked {
border-color: #cbd5e1;
background: linear-gradient(180deg, #f1f5f9 0%, #e2e8f0 100%);
color: #475569;
}
[data-theme='light'] .agenda-slot-chip,
[data-theme='light'] .agenda-slot-status {
border-color: rgba(51, 65, 85, 0.18);
background: rgba(255, 255, 255, 0.58);
color: inherit;
}
[data-theme='light'] .agenda-slot-add {
border-color: rgba(30, 64, 175, 0.28);
background: rgba(255, 255, 255, 0.76);
color: #1d4ed8;
}
[data-theme='light'] .agenda-slot-add:hover {
background: #ffffff;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.18);
}
[data-theme='light'] .agenda-week-day,
[data-theme='light'] .agenda-month-day {
border-color: #d7e2ec;
background: #eef4f8;
}
[data-theme='light'] .agenda-month-day:nth-child(7n + 1),
[data-theme='light'] .agenda-month-day:nth-child(7n) {
background: #e8f0f6;
}
[data-theme='light'] .agenda-event,
[data-theme='light'] .agenda-month-event {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.78), 0 1px 4px rgba(15, 23, 42, 0.08);
}
[data-theme='light'] .agenda-event span,
[data-theme='light'] .agenda-month-event span {
color: inherit;
}

View File

@@ -2,6 +2,9 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.jsx' import App from './App.jsx'
import { applyTheme, getStoredTheme } from './utils/theme.js'
applyTheme(getStoredTheme())
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>

View File

@@ -32,7 +32,7 @@ export const appointmentMapper = {
cancelled: 'Cancelada', cancelled: 'Cancelada',
} }
const rawStatus = (apiData.status || '').toLowerCase() const rawStatus = String(apiData.status || '').toLowerCase()
const mappedStatus = statusMap[rawStatus] || apiData.situacao || 'Aguardando' const mappedStatus = statusMap[rawStatus] || apiData.situacao || 'Aguardando'
// Modalidade // Modalidade
@@ -60,12 +60,13 @@ export const appointmentMapper = {
professional.full_name || professional.full_name ||
professional.name || professional.name ||
professional.nome || professional.nome ||
'Medico(a)', 'Médico(a)',
date: dateStr, date: dateStr,
time: timeStr, time: timeStr,
type: apiData.type || apiData.tipo || apiData.tipo_consulta || 'Consulta', type: apiData.type || apiData.tipo || apiData.tipo_consulta || 'Consulta',
mode: mode, mode: mode,
status: mappedStatus, status: mappedStatus,
notes: apiData.notes || apiData.observations || apiData.observacoes || apiData.observacao || apiData.description || '',
room: apiData.room || apiData.sala || apiData.local || 'Consultório 1', room: apiData.room || apiData.sala || apiData.local || 'Consultório 1',
} }
}, },
@@ -80,7 +81,9 @@ export const appointmentMapper = {
doctor_id: uiData.professionalId || null, doctor_id: uiData.professionalId || null,
scheduled_at: scheduledAt, scheduled_at: scheduledAt,
appointment_type: uiData.mode === 'Teleconsulta' ? 'telemedicina' : 'presencial', appointment_type: uiData.mode === 'Teleconsulta' ? 'telemedicina' : 'presencial',
status: uiData.status === 'Confirmada' ? 'confirmed' : 'requested', status: toApiStatus(uiData.status),
notes: emptyToUndefined(uiData.notes),
observations: emptyToUndefined(uiData.notes),
duration_minutes: 30, // Padrao duration_minutes: 30, // Padrao
} }
} }
@@ -94,6 +97,37 @@ export const appointmentMapper = {
mode: uiData.mode, mode: uiData.mode,
status: uiData.status || 'Confirmada', status: uiData.status || 'Confirmada',
room: uiData.room, room: uiData.room,
notes: uiData.notes,
} }
}, },
} }
function emptyToUndefined(value) {
return value === '' || value === null ? undefined : value
}
function toApiStatus(status) {
const normalized = String(status || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
const statusMap = {
confirmada: 'confirmed',
confirmado: 'confirmed',
em_triagem: 'checked_in',
triagem: 'checked_in',
aguardando: 'requested',
solicitada: 'requested',
solicitacao: 'requested',
cancelada: 'cancelled',
cancelado: 'cancelled',
concluida: 'completed',
concluido: 'completed',
finalizada: 'completed',
finalizado: 'completed',
}
return statusMap[normalized.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')] || 'requested'
}

View File

@@ -27,6 +27,7 @@ export const reportMapper = {
toApi(uiData) { toApi(uiData) {
return cleanPayload({ return cleanPayload({
patient_id: uiData.patientId, patient_id: uiData.patientId,
order_number: emptyToUndefined(uiData.orderNumber),
status: normalizeApiStatus(uiData.status), status: normalizeApiStatus(uiData.status),
exam: emptyToUndefined(uiData.exam), exam: emptyToUndefined(uiData.exam),
requested_by: emptyToUndefined(uiData.requestedBy), requested_by: emptyToUndefined(uiData.requestedBy),
@@ -35,19 +36,24 @@ export const reportMapper = {
conclusion: emptyToUndefined(uiData.conclusion), conclusion: emptyToUndefined(uiData.conclusion),
content_html: emptyToUndefined(uiData.contentHtml), content_html: emptyToUndefined(uiData.contentHtml),
content_json: uiData.contentJson === undefined ? undefined : uiData.contentJson, content_json: uiData.contentJson === undefined ? undefined : uiData.contentJson,
hide_date: Boolean(uiData.hideDate),
hide_signature: Boolean(uiData.hideSignature),
due_at: emptyToUndefined(uiData.dueAt), due_at: emptyToUndefined(uiData.dueAt),
created_by: emptyToUndefined(uiData.createdBy),
updated_by: emptyToUndefined(uiData.updatedBy),
}) })
}, },
} }
function normalizeStatus(status) { function normalizeStatus(status) {
return status === 'draft' ? 'draft' : 'draft' const normalized = String(status || '').toLowerCase()
if (['finalized', 'finalizado', 'finished', 'completed', 'done'].includes(normalized)) {
return 'finalized'
}
return 'draft'
} }
function normalizeApiStatus(status) { function normalizeApiStatus(status) {
return status === 'draft' ? 'draft' : 'draft' return status === 'finalized' ? 'finalized' : 'draft'
} }
function emptyToUndefined(value) { function emptyToUndefined(value) {

View File

@@ -1,26 +1,29 @@
import { import {
addDays, addDays,
subDays,
addWeeks,
subWeeks,
addMonths, addMonths,
subMonths, addWeeks,
endOfWeek, endOfWeek,
format, format,
startOfWeek, startOfWeek,
subDays,
subMonths,
subWeeks,
} from 'date-fns' } from 'date-fns'
import { ptBR } from 'date-fns/locale' import { ptBR } from 'date-fns/locale'
import { useState } from 'react'
import { AgendaDailyView } from '../components/calendar/AgendaDailyView.jsx' import { AgendaDailyView } from '../components/calendar/AgendaDailyView.jsx'
import { AgendaWeeklyView } from '../components/calendar/AgendaWeeklyView.jsx'
import { AgendaMonthlyView } from '../components/calendar/AgendaMonthlyView.jsx' import { AgendaMonthlyView } from '../components/calendar/AgendaMonthlyView.jsx'
import { AgendaWeeklyView } from '../components/calendar/AgendaWeeklyView.jsx'
import { useAgenda } from '../hooks/useAgenda.js' import { useAgenda } from '../hooks/useAgenda.js'
import { formatLocalDateInput, parseLocalDate } from '../utils/agendaDate.js'
const statusFilters = [ const statusFilters = [
{ label: 'Todos', value: 'Todos' }, { label: 'Todos', value: 'Todos' },
{ label: 'Confirmadas', value: 'Confirmada' }, { label: 'Confirmadas', value: 'Confirmada' },
{ label: 'Em triagem', value: 'Em triagem' }, { label: 'Em triagem', value: 'Em triagem' },
{ label: 'Aguardando', value: 'Aguardando' }, { label: 'Aguardando', value: 'Aguardando' },
{ label: 'Canceladas', value: 'Cancelada' },
] ]
const viewFilters = [ const viewFilters = [
@@ -29,7 +32,12 @@ const viewFilters = [
{ label: 'Mês', value: 'Mes' }, { label: 'Mês', value: 'Mes' },
] ]
export function AgendaPage({ navigate }) { const appointmentTypeOptions = ['Retorno', 'Primeira consulta', 'Exame', 'Avaliação pre-op']
const appointmentStatusOptions = ['Confirmada', 'Em triagem', 'Aguardando']
export function AgendaPage() {
const [modalPatientSearch, setModalPatientSearch] = useState('')
const [modalDoctorSearch, setModalDoctorSearch] = useState('')
const { const {
patients, patients,
professionals, professionals,
@@ -45,12 +53,24 @@ export function AgendaPage({ navigate }) {
setBaseDate, setBaseDate,
status, status,
setStatus, setStatus,
setDoctorFilter,
doctorSearch,
setDoctorSearch,
unitFilter,
setUnitFilter,
modalOpen, modalOpen,
setModalOpen, editingAppointment,
form, form,
updateForm, updateForm,
handleCreate, openCreateModal,
openAppointmentModal,
closeAppointmentModal,
handleSubmitAppointment,
handleCancelAppointment,
visibleAppointments, visibleAppointments,
availableSlots,
slotsLoading,
slotsError,
} = useAgenda() } = useAgenda()
if (loading) { if (loading) {
@@ -64,6 +84,42 @@ export function AgendaPage({ navigate }) {
const weekStart = startOfWeek(baseDate, { weekStartsOn: 0 }) const weekStart = startOfWeek(baseDate, { weekStartsOn: 0 })
const weekEnd = endOfWeek(baseDate, { weekStartsOn: 0 }) const weekEnd = endOfWeek(baseDate, { weekStartsOn: 0 })
const isDoctorScope = agendaScope === 'doctor' const isDoctorScope = agendaScope === 'doctor'
const unitOptions = [
...new Set(professionals.map((professional) => professional.unit).filter(Boolean)),
].sort((a, b) => a.localeCompare(b, 'pt-BR'))
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 ( return (
<div className="mx-auto flex max-w-[1180px] flex-col gap-8 text-[#e5e5e5]"> <div className="mx-auto flex max-w-[1180px] flex-col gap-8 text-[#e5e5e5]">
@@ -73,9 +129,7 @@ export function AgendaPage({ navigate }) {
Agenda Agenda
</h1> </h1>
<p className="mt-2 text-sm leading-5 text-[#a3a3a3]"> <p className="mt-2 text-sm leading-5 text-[#a3a3a3]">
{isDoctorScope Perfil atual: {viewerProfile?.role || (isDoctorScope ? 'Médico' : 'Usuário')}
? `Agenda restrita ao médico logado: ${currentProfessional?.name || viewerProfile?.name || 'Médico atual'}.`
: 'Visualização completa da agenda com todos os médicos.'}
</p> </p>
</div> </div>
@@ -120,10 +174,10 @@ export function AgendaPage({ navigate }) {
<button <button
className="h-9 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.16)] transition hover:bg-[#3478ed] disabled:cursor-not-allowed disabled:border-[#404040] disabled:bg-[#303030] disabled:text-[#737373] disabled:shadow-none" 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} disabled={!canCreateAppointment}
onClick={() => setModalOpen(true)} onClick={() => openCreate()}
type="button" type="button"
> >
+ Nova consulta + Novo agendamento
</button> </button>
</div> </div>
</section> </section>
@@ -131,10 +185,10 @@ export function AgendaPage({ navigate }) {
{error ? ( {error ? (
<section className="rounded-2xl border border-[#404040] bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)]"> <section className="rounded-2xl border border-[#404040] bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)]">
<div className="rounded-xl border border-dashed border-[#7f1d1d] bg-[#2a1111] p-6"> <div className="rounded-xl border border-dashed border-[#7f1d1d] bg-[#2a1111] p-6">
<h2 className="text-base font-bold text-[#fecaca]">Nao foi possivel liberar a agenda</h2> <h2 className="text-base font-bold text-[#fecaca]">Não foi possível liberar a agenda</h2>
<p className="mt-2 text-sm leading-6 text-[#fca5a5]">{error}</p> <p className="mt-2 text-sm leading-6 text-[#fca5a5]">{error}</p>
<p className="mt-3 text-sm leading-6 text-[#a3a3a3]"> <p className="mt-3 text-sm leading-6 text-[#a3a3a3]">
Enquanto esse vinculo nao existir na API, a tela fica bloqueada para evitar exibir consultas de outro medico. Enquanto esse vínculo não existir na API, a tela fica bloqueada para evitar exibir consultas de outro médico.
</p> </p>
</div> </div>
</section> </section>
@@ -171,26 +225,61 @@ export function AgendaPage({ navigate }) {
</div> </div>
</div> </div>
<div className="mt-5 flex flex-wrap gap-2"> <div className="mt-5 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
{statusFilters.map((filter) => ( <div className="flex flex-wrap gap-2">
<button {statusFilters.map((filter) => (
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${ <button
status === filter.value className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
? 'border-[#3b82f6] bg-[#3b82f6]/10 text-[#3b82f6]' status === filter.value
: 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]' ? 'border-[#3b82f6] bg-[#3b82f6]/10 text-[#3b82f6]'
}`} : 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]'
key={filter.value} }`}
onClick={() => setStatus(filter.value)} key={filter.value}
type="button" onClick={() => setStatus(filter.value)}
> type="button"
{filter.label} >
</button> {filter.label}
))} </button>
))}
</div>
{!isDoctorScope ? (
<div className="grid gap-3 sm:min-w-[32rem] sm:grid-cols-2">
<label className="grid gap-1.5 text-xs font-semibold text-[#a3a3a3]">
<span>Médico</span>
<input
className="h-9 rounded-sm border border-[#404040] bg-[#303030] px-3 text-sm font-medium text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
onChange={(event) => {
setDoctorFilter('Todos')
setDoctorSearch(event.target.value)
}}
placeholder="Pesquisar médico pelo nome"
type="search"
value={doctorSearch}
/>
</label>
<label className="grid gap-1.5 text-xs font-semibold text-[#a3a3a3]">
<span>Unidade</span>
<select
className="h-9 rounded-sm border border-[#404040] bg-[#303030] px-3 text-sm font-medium text-[#e5e5e5] outline-none transition focus:border-[#3b82f6]"
onChange={(event) => setUnitFilter(event.target.value)}
value={unitFilter}
>
<option value="">Todas as unidades</option>
{unitOptions.map((unit) => (
<option key={unit} value={unit}>
{unit}
</option>
))}
</select>
</label>
</div>
) : null}
</div> </div>
{!isDoctorScope && ( {!isDoctorScope && (
<div className="mt-4 rounded-xl border border-[#404040] bg-[#1f1f1f] px-4 py-3 text-sm text-[#a3a3a3]"> <div className="mt-4 rounded-xl border border-[#404040] bg-[#1f1f1f] px-4 py-3 text-sm text-[#a3a3a3]">
Perfil atual: {viewerProfile?.role || 'Administrador'} | agendamentos exibidos para todos os profissionais. Perfil atual: {viewerProfile?.role || 'Administrador'}
</div> </div>
)} )}
@@ -199,7 +288,7 @@ export function AgendaPage({ navigate }) {
<AgendaWeeklyView <AgendaWeeklyView
baseDate={baseDate} baseDate={baseDate}
appointments={visibleAppointments} appointments={visibleAppointments}
onAppointmentClick={(appointment) => navigate(`/pacientes/${appointment.patientId}`)} onAppointmentClick={openManage}
/> />
)} )}
@@ -216,9 +305,11 @@ export function AgendaPage({ navigate }) {
{activeView === 'Dia' && ( {activeView === 'Dia' && (
<AgendaDailyView <AgendaDailyView
baseDate={baseDate}
appointments={visibleAppointments} appointments={visibleAppointments}
onAppointmentClick={(appointment) => navigate(`/pacientes/${appointment.patientId}`)} baseDate={baseDate}
canCreateAppointment={canCreateAppointment}
onAppointmentClick={openManage}
onSlotCreate={(time) => openCreate({ time })}
/> />
)} )}
</div> </div>
@@ -226,88 +317,197 @@ export function AgendaPage({ navigate }) {
</section> </section>
)} )}
<DarkModal onClose={() => setModalOpen(false)} open={modalOpen} title="Nova consulta"> <DarkModal onClose={closeModal} open={modalOpen} title={editingAppointment ? 'Gerenciar agendamento' : 'Novo agendamento'}>
<form className="grid gap-4" onSubmit={handleCreate}> <form className="grid gap-4" onSubmit={handleSubmitAppointment}>
<DarkField label="Paciente"> <div className="grid gap-4 lg:grid-cols-2">
<select <div className="grid content-start gap-4">
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]" <DarkField label="Paciente">
onChange={(event) => updateForm('patientId', event.target.value)} <input
value={form.patientId} 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) => {
{patients.map((patient) => ( setModalPatientSearch(event.target.value)
<option key={patient.id} value={patient.id}> updateForm('patientId', '')
{patient.name || patient.full_name || patient.nome} }}
</option> placeholder="Pesquisar paciente"
))} type="search"
</select> value={modalPatientSearch || getPatientLabel(selectedPatient)}
</DarkField> />
<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>
<div className="grid gap-4 sm:grid-cols-2"> <DarkField label="Profissional">
<DarkField label="Horário"> {isDoctorScope ? (
<input <input
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]" className="h-11 rounded-md border border-[#404040] bg-[#262626] px-3 text-sm text-[#a3a3a3] outline-none"
onChange={(event) => updateForm('time', event.target.value)} disabled
type="time" readOnly
value={form.time} value={currentProfessional?.name || 'Médico não vinculado'}
/> />
</DarkField> ) : (
<DarkField label="Formato"> <>
<select <input
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]" 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) => updateForm('mode', event.target.value)} onChange={(event) => {
value={form.mode} setModalDoctorSearch(event.target.value)
> updateForm('professionalId', '')
<option>Teleconsulta</option> }}
<option>Presencial</option> placeholder="Pesquisar médico"
</select> type="search"
</DarkField> 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 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}
>
{timeOptions.map((time) => (
<option key={time} value={time}>
{time}
</option>
))}
</select>
) : (
<input
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
onChange={(event) => updateForm('time', event.target.value)}
type="time"
value={form.time}
/>
)}
{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> </div>
<DarkField label="Profissional"> {editingAppointment ? (
{isDoctorScope ? ( <div className="rounded-xl border border-[#404040] bg-[#1f1f1f] px-4 py-3 text-sm text-[#a3a3a3]">
<input <p>
className="h-11 rounded-md border border-[#404040] bg-[#262626] px-3 text-sm text-[#a3a3a3] outline-none" Agendamento de {selectedPatient ? getPatientLabel(selectedPatient) : 'paciente não informado'} às {form.time}.
disabled </p>
readOnly <p className="mt-1">Status atual: {form.status}</p>
value={currentProfessional?.name || 'Médico não vinculado'} {form.notes ? <p className="mt-1">Observações: {form.notes}</p> : null}
/> </div>
) : ( ) : null}
<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('professionalId', event.target.value)}
value={form.professionalId}
>
{professionals.map((professional) => (
<option key={professional.id} value={professional.id}>
{professional.name}
</option>
))}
</select>
)}
</DarkField>
<DarkField label="Tipo de consulta">
<input
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
onChange={(event) => updateForm('type', event.target.value)}
value={form.type}
/>
</DarkField>
<div className="flex flex-wrap justify-end gap-3 pt-2"> <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 <button
className="h-10 rounded-sm border border-[#404040] bg-[#303030] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#333333]" 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" type="button"
> >
Cancelar Fechar
</button> </button>
<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]" 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} disabled={!canCreateAppointment}
type="submit" type="submit"
> >
Salvar consulta {editingAppointment ? 'Salvar alterações' : 'Salvar'}
</button> </button>
</div> </div>
</form> </form>
@@ -326,13 +526,11 @@ function DarkField({ children, label }) {
} }
function DarkModal({ children, onClose, open, title }) { function DarkModal({ children, onClose, open, title }) {
if (!open) { if (!open) return null
return null
}
return ( return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-4 sm:items-center"> <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"> <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> <h2 className="text-lg font-bold text-[#e5e5e5]">{title}</h2>
<button <button
@@ -349,3 +547,68 @@ function DarkModal({ children, onClose, open, title }) {
</div> </div>
) )
} }
function SearchResults({ emptyText, getDescription, getLabel, items, onSelect, selectedId }) {
return (
<div className="max-h-44 overflow-y-auto rounded-md border border-[#404040] bg-[#1f1f1f]">
{items.length ? (
items.map((item) => {
const isSelected = String(item.id) === String(selectedId)
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={item.id}
onClick={() => onSelect(item)}
type="button"
>
<span className="block font-semibold">{getLabel(item)}</span>
{getDescription?.(item) ? (
<span className="mt-0.5 block text-xs text-[#737373]">{getDescription(item)}</span>
) : null}
</button>
)
})
) : (
<p className="px-3 py-2 text-xs text-[#737373]">{emptyText}</p>
)}
</div>
)
}
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')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
}

View File

@@ -34,7 +34,7 @@ export function AnalyticsPage() {
<section className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center"> <section className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Relatórios & Analytics</h1> <h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Analytics</h1>
<p className="mt-1 text-sm text-[#b8b8b8]">Dashboard executivo com métricas de desempenho</p> <p className="mt-1 text-sm text-[#b8b8b8]">Dashboard executivo com métricas de desempenho</p>
</div> </div>
@@ -70,7 +70,7 @@ export function AnalyticsPage() {
</ChartCard> </ChartCard>
</section> </section>
<section className="grid gap-6 lg:grid-cols-3" aria-label="Relatórios complementares"> <section className="grid gap-6 lg:grid-cols-3" aria-label="Analytics complementares">
<ChartCard description="Evolução de receita" title="Faturamento Mensal"> <ChartCard description="Evolução de receita" title="Faturamento Mensal">
<RevenueChart data={revenueData} /> <RevenueChart data={revenueData} />
</ChartCard> </ChartCard>

View File

@@ -3,15 +3,23 @@ import { useState } from 'react'
import { authRepository } from '../repositories/authRepository.js' import { authRepository } from '../repositories/authRepository.js'
import { BrandLogo } from '../components/Brand.jsx' import { BrandLogo } from '../components/Brand.jsx'
import { FeatureBadge, FeatureCallout } from '../components/FeatureState.jsx' import { FeatureCallout } from '../components/FeatureState.jsx'
import loginClinicImage from '../assets/figma/login-clinic.png' import loginClinicImage from '../assets/figma/login-clinic.png'
const mockCredentials = [
{ label: 'Admin', email: 'hugo@popcode.com.br', password: 'hdoria' },
{ label: 'Médico', email: 'leticia.lacerda@souunit.com.br', password: 'Senha@123' },
{ label: 'Secretária', email: 'recepcao@mediconnect.com', password: 'demo12345' },
{ label: 'Gestor', email: 'gestao@mediconnect.com', password: '12345678' },
]
export function LoginPage({ navigate }) { export function LoginPage({ navigate }) {
const [form, setForm] = useState({ const [form, setForm] = useState({
email: '', email: '',
password: '', password: '',
}) })
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [credentialsOpen, setCredentialsOpen] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -35,7 +43,7 @@ export function LoginPage({ navigate }) {
} }
return ( 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"> <div className="grid min-h-screen lg:grid-cols-2">
<section className="relative hidden min-h-screen overflow-hidden lg:block"> <section className="relative hidden min-h-screen overflow-hidden lg:block">
<img <img
@@ -48,7 +56,7 @@ export function LoginPage({ navigate }) {
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
background: 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%)',
}} }}
/> />
@@ -99,7 +107,7 @@ export function LoginPage({ navigate }) {
<LoginField htmlFor="login-email" label="E-mail"> <LoginField htmlFor="login-email" label="E-mail">
<input <input
autoComplete="email" 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" id="login-email"
onChange={(event) => updateField('email', event.target.value)} onChange={(event) => updateField('email', event.target.value)}
placeholder="seu@email.com" placeholder="seu@email.com"
@@ -124,7 +132,7 @@ export function LoginPage({ navigate }) {
<div className="relative"> <div className="relative">
<input <input
autoComplete="current-password" 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" id="login-password"
onChange={(event) => updateField('password', event.target.value)} onChange={(event) => updateField('password', event.target.value)}
placeholder="••••••••" placeholder="••••••••"
@@ -152,23 +160,45 @@ export function LoginPage({ navigate }) {
</form> </form>
</div> </div>
<button <div className="absolute bottom-4 right-4">
className="absolute bottom-4 right-4 flex h-[29px] items-center gap-1.5 rounded-sm border border-white/10 bg-white/[0.05] px-3 font-mono text-[10px] font-medium leading-[15px] text-white/30 transition hover:text-white/50" {credentialsOpen ? (
onClick={() => { <div className="auth-menu mb-2 w-[292px] rounded-md border p-2 shadow-2xl">
setForm({ <p className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-white/40">
email: 'recepcao@mediconnect.com', Credenciais de acesso
password: 'demo123', </p>
}) <div className="grid gap-1">
}} {mockCredentials.map((credential) => (
title="Preencher credenciais mockadas" <button
type="button" className="rounded px-2 py-2 text-left text-xs text-white/70 transition hover:bg-white/10 hover:text-white"
> key={credential.email}
dev · credenciais onClick={() => {
<FeatureBadge className="border-white/20 bg-white/10 text-white/70" status="mock" text="mock" /> setForm({
<span aria-hidden="true" className="text-[9px]"> email: credential.email,
^ password: credential.password,
</span> })
</button> setCredentialsOpen(false)
}}
type="button"
>
<span className="block font-semibold">{credential.label}</span>
<span className="block font-mono text-[11px] text-white/40">{credential.email}</span>
</button>
))}
</div>
</div>
) : null}
<button
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"
>
dev · credenciais
<span aria-hidden="true" className="text-[9px]">
{credentialsOpen ? 'v' : '^'}
</span>
</button>
</div>
</section> </section>
</div> </div>
</main> </main>
@@ -176,7 +206,7 @@ export function LoginPage({ navigate }) {
} }
export function RegisterPage({ navigate }) { export function RegisterPage({ navigate }) {
const [role, setRole] = useState('Clinica') const [role, setRole] = useState('Clínica')
return ( return (
<AuthLayout <AuthLayout
@@ -204,7 +234,7 @@ export function RegisterPage({ navigate }) {
</AuthField> </AuthField>
<AuthField label="Tipo de conta"> <AuthField label="Tipo de conta">
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{['Clinica', 'Profissional'].map((option) => ( {['Clínica', 'Profissional'].map((option) => (
<button <button
className={`h-11 rounded-[6px] border px-3 text-sm font-semibold transition ${ className={`h-11 rounded-[6px] border px-3 text-sm font-semibold transition ${
role === option role === option
@@ -291,7 +321,7 @@ export function ForgotPasswordPage({ navigate }) {
function AuthLayout({ children, description, title }) { function AuthLayout({ children, description, title }) {
return ( 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"> <div className="grid min-h-screen lg:grid-cols-2">
<section className="relative hidden min-h-screen overflow-hidden lg:block"> <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} /> <img alt="" className="absolute inset-0 h-full w-full object-cover" src={loginClinicImage} />
@@ -300,7 +330,7 @@ function AuthLayout({ children, description, title }) {
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
background: 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"> <div className="relative flex min-h-screen flex-col justify-between px-[43px] py-[43px] xl:px-12 xl:py-12">
@@ -314,14 +344,14 @@ function AuthLayout({ children, description, title }) {
<span className="text-[#3b82f6]">saúde.</span> <span className="text-[#3b82f6]">saúde.</span>
</h1> </h1>
<p className="mt-5 max-w-[360px] text-sm leading-[23px] text-white/60 xl:text-base xl:leading-[26px]"> <p className="mt-5 max-w-[360px] text-sm leading-[23px] text-white/60 xl:text-base xl:leading-[26px]">
Fluxos de acesso simulados para manter a navegação ponta a ponta sem backend real. Segurança e continuidade para equipes de saúde.
</p> </p>
</div> </div>
</div> </div>
</section> </section>
<section className="flex min-h-screen items-center justify-center px-6 py-12 sm:px-10 lg:px-[60px] xl:px-[68px]"> <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"> <div className="mb-12 lg:hidden">
<LoginLogo /> <LoginLogo />
</div> </div>
@@ -336,11 +366,13 @@ function AuthLayout({ children, description, title }) {
} }
const authInputClass = 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 }) { function AuthField({ children, label }) {
return ( 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> <span>{label}</span>
{children} {children}
</label> </label>
@@ -350,7 +382,7 @@ function AuthField({ children, label }) {
function LoginField({ action, children, htmlFor, label }) { function LoginField({ action, children, htmlFor, label }) {
return ( return (
<div className="grid gap-1.5"> <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> <label htmlFor={htmlFor}>{label}</label>
{action} {action}
</span> </span>

View File

@@ -23,23 +23,6 @@ export function HomePage({ navigate }) {
Bem-vindo, Dr. Henrique. Aqui está o resumo da sua clínica hoje. Bem-vindo, Dr. Henrique. Aqui está o resumo da sua clínica hoje.
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-3">
<button
className="h-9 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#303030]"
onClick={() => navigate('/relatorios')}
type="button"
>
Exportar
</button>
<button
className="h-9 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.16)] transition hover:bg-[#3478ed]"
onClick={() => navigate('/agenda')}
type="button"
>
+ Novo
</button>
</div>
</section> </section>
<section className="grid gap-6 lg:grid-cols-3"> <section className="grid gap-6 lg:grid-cols-3">
@@ -120,7 +103,7 @@ export function HomePage({ navigate }) {
<section className="grid gap-4" id="relatorios"> <section className="grid gap-4" id="relatorios">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-bold text-[#e5e5e5]">Relatórios e Análises</h2> <h2 className="text-base font-bold text-[#e5e5e5]">Analytics</h2>
<FeatureBadge status="mock" /> <FeatureBadge status="mock" />
</div> </div>
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
@@ -209,7 +192,7 @@ function ReportAction({ card, navigate }) {
function LineChart() { function LineChart() {
return ( return (
<svg aria-label="Grafico mockado de absenteismo" className="h-full w-full" role="img" viewBox="0 0 732 260"> <svg aria-label="Gráfico mockado de absenteísmo" className="h-full w-full" role="img" viewBox="0 0 732 260">
<defs> <defs>
<linearGradient id="home-chart-fill" x1="0" x2="0" y1="0" y2="1"> <linearGradient id="home-chart-fill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.24" /> <stop offset="0%" stopColor="#3b82f6" stopOpacity="0.24" />

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,10 @@
import { useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { normalizeRole } from '../config/permissions.js'
import { FeatureCallout } from '../components/FeatureState.jsx' import { FeatureCallout } from '../components/FeatureState.jsx'
import { featurePanelClass } from '../components/featureStateStyles.js' import { featurePanelClass } from '../components/featureStateStyles.js'
import { communicationRepository } from '../repositories/communicationRepository.js' import { communicationRepository } from '../repositories/communicationRepository.js'
import { patientRepository } from '../repositories/patientRepository.js'
const channels = { const channels = {
whatsapp: { label: 'WhatsApp', className: 'bg-emerald-500/20 text-emerald-400', icon: 'message' }, whatsapp: { label: 'WhatsApp', className: 'bg-emerald-500/20 text-emerald-400', icon: 'message' },
@@ -19,6 +21,7 @@ const statusConfig = {
const emptyMessage = { const emptyMessage = {
patientId: '',
patient: '', patient: '',
phone: '', phone: '',
channel: 'whatsapp', channel: 'whatsapp',
@@ -40,21 +43,62 @@ const textareaClass =
'min-h-28 w-full resize-y rounded-sm border border-[#404040] bg-[#171717] px-3 py-2 text-sm leading-6 text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20' 'min-h-28 w-full resize-y rounded-sm border border-[#404040] bg-[#171717] px-3 py-2 text-sm leading-6 text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
const labelClass = 'text-xs font-semibold uppercase tracking-[0.08em] text-[#a3a3a3]' const labelClass = 'text-xs font-semibold uppercase tracking-[0.08em] text-[#a3a3a3]'
export function MessagesPage() { export function MessagesPage({ role }) {
const normalizedRole = normalizeRole(role)
const isSecretary = normalizedRole === 'secretaria'
const allowedChannelKeys = useMemo(
() => (isSecretary ? ['whatsapp', 'sms'] : Object.keys(channels)),
[isSecretary],
)
const campaigns = communicationRepository.getCampaigns() const campaigns = communicationRepository.getCampaigns()
const [messages, setMessages] = useState(() => communicationRepository.getInitialMessages()) const [messages, setMessages] = useState(() => communicationRepository.getInitialMessages())
const [templates, setTemplates] = useState(() => communicationRepository.getInitialTemplates()) const [templates, setTemplates] = useState(() => communicationRepository.getInitialTemplates())
const [patients, setPatients] = useState([])
const [activeTab, setActiveTab] = useState('historico') const [activeTab, setActiveTab] = useState('historico')
const [channelFilter, setChannelFilter] = useState('todos') const [channelFilter, setChannelFilter] = useState('todos')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [composerOpen, setComposerOpen] = useState(false) const [composerOpen, setComposerOpen] = useState(false)
const [templateEditorOpen, setTemplateEditorOpen] = useState(false) const [templateEditorOpen, setTemplateEditorOpen] = useState(false)
const [editingTemplateId, setEditingTemplateId] = useState(null)
const [composer, setComposer] = useState(emptyMessage) const [composer, setComposer] = useState(emptyMessage)
const [templateDraft, setTemplateDraft] = useState(emptyTemplate) const [templateDraft, setTemplateDraft] = useState(emptyTemplate)
const availableTemplates = useMemo(
() => templates.filter((template) => allowedChannelKeys.includes(template.channel)),
[allowedChannelKeys, templates],
)
const patientOptions = useMemo(
() =>
patients.map((patient) => ({
id: String(patient.detailId || patient.id || ''),
name: patient.name || patient.full_name || patient.nome || 'Paciente',
phone: patient.phone || patient.phone_mobile || patient.telefone || '',
document: patient.cpf || patient.document || '',
})),
[patients],
)
useEffect(() => {
let active = true
patientRepository
.getDirectoryRows()
.then((data) => {
if (active) setPatients(data || [])
})
.catch((loadError) => {
console.error(loadError)
if (active) setPatients([])
})
return () => {
active = false
}
}, [])
const filteredMessages = useMemo( const filteredMessages = useMemo(
() => () =>
messages.filter((message) => { messages.filter((message) => {
const isAllowedChannel = allowedChannelKeys.includes(message.channel)
const matchesChannel = channelFilter === 'todos' || message.channel === channelFilter const matchesChannel = channelFilter === 'todos' || message.channel === channelFilter
const query = search.trim().toLowerCase() const query = search.trim().toLowerCase()
const matchesSearch = const matchesSearch =
@@ -64,23 +108,26 @@ export function MessagesPage() {
.toLowerCase() .toLowerCase()
.includes(query) .includes(query)
return matchesChannel && matchesSearch return isAllowedChannel && matchesChannel && matchesSearch
}), }),
[channelFilter, messages, search], [allowedChannelKeys, channelFilter, messages, search],
) )
const stats = useMemo( const stats = useMemo(
() => ({ () => ({
total: messages.length, total: messages.filter((message) => allowedChannelKeys.includes(message.channel)).length,
delivered: messages.filter((message) => message.status === 'entregue' || message.status === 'lida').length, delivered: messages.filter((message) => allowedChannelKeys.includes(message.channel) && (message.status === 'entregue' || message.status === 'lida')).length,
read: messages.filter((message) => message.status === 'lida').length, read: messages.filter((message) => allowedChannelKeys.includes(message.channel) && message.status === 'lida').length,
failed: messages.filter((message) => message.status === 'falha').length, failed: messages.filter((message) => allowedChannelKeys.includes(message.channel) && message.status === 'falha').length,
}), }),
[messages], [allowedChannelKeys, messages],
) )
function openTemplate(template) { function openTemplate(template) {
if (!allowedChannelKeys.includes(template.channel)) return
setComposer({ setComposer({
patientId: '',
patient: '', patient: '',
phone: '', phone: '',
channel: template.channel, channel: template.channel,
@@ -90,6 +137,26 @@ export function MessagesPage() {
setComposerOpen(true) setComposerOpen(true)
} }
function openTemplateEditor(template = null) {
if (template && !allowedChannelKeys.includes(template.channel)) return
setEditingTemplateId(template?.id || null)
setTemplateDraft(
template
? {
name: template.name || '',
channel: template.channel || allowedChannelKeys[0],
category: template.category || 'Personalizado',
content: template.content || '',
}
: {
...emptyTemplate,
channel: allowedChannelKeys[0] || 'whatsapp',
},
)
setTemplateEditorOpen(true)
}
async function submitMessage(event) { async function submitMessage(event) {
event.preventDefault() event.preventDefault()
@@ -97,6 +164,11 @@ export function MessagesPage() {
return return
} }
if (!allowedChannelKeys.includes(composer.channel)) {
alert('Canal indisponivel para o seu perfil.')
return
}
let smsSent = false let smsSent = false
if (composer.channel === 'sms') { if (composer.channel === 'sms') {
@@ -141,16 +213,20 @@ export function MessagesPage() {
return return
} }
setTemplates((current) => [ const nextTemplate = {
{ id: editingTemplateId || `template-${Date.now()}`,
id: `template-${Date.now()}`, name: templateDraft.name.trim(),
name: templateDraft.name.trim(), channel: templateDraft.channel,
channel: templateDraft.channel, content: templateDraft.content.trim(),
content: templateDraft.content.trim(), category: templateDraft.category.trim() || 'Personalizado',
category: templateDraft.category.trim() || 'Personalizado', }
},
...current, setTemplates((current) =>
]) editingTemplateId
? current.map((template) => (template.id === editingTemplateId ? nextTemplate : template))
: [nextTemplate, ...current],
)
setEditingTemplateId(null)
setTemplateDraft(emptyTemplate) setTemplateDraft(emptyTemplate)
setTemplateEditorOpen(false) setTemplateEditorOpen(false)
} }
@@ -158,26 +234,28 @@ export function MessagesPage() {
return ( return (
<div className="mx-auto max-w-7xl space-y-6"> <div className="mx-auto max-w-7xl space-y-6">
<FeatureCallout <FeatureCallout
description="Envio de SMS usa API. Histórico, templates e campanhas ainda são dados locais de demonstração." description={isSecretary ? 'Perfil Secretária limitado a comunicação básica por WhatsApp e SMS.' : 'Envio de SMS usa API. Histórico, templates e campanhas ainda são dados locais de demonstração.'}
status="partial" status="partial"
title="Mensageria híbrida" title={isSecretary ? 'Comunicação basica' : 'Mensageria hibrida'}
/> />
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center"> <div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Comunicação</h1> <h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Comunicação</h1>
<p className="mt-1 text-sm text-[#b8b8b8]">WhatsApp, E-mail e SMS - histórico e campanhas</p> <p className="mt-1 text-sm text-[#b8b8b8]">{isSecretary ? 'WhatsApp e SMS para contato operacional com pacientes' : 'WhatsApp, E-mail e SMS - historico e campanhas'}</p>
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<button {!isSecretary ? (
className="inline-flex h-12 items-center gap-2 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]" <button
onClick={() => setActiveTab('campanha')} className="inline-flex h-12 items-center gap-2 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
type="button" onClick={() => setActiveTab('campanha')}
> type="button"
<CommIcon className="size-4" name="send" /> >
Envio em Massa <CommIcon className="size-4" name="send" />
</button> Envio em Massa
</button>
) : null}
<button <button
className="inline-flex h-12 items-center gap-2 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]" className="inline-flex h-12 items-center gap-2 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
onClick={() => setComposerOpen(true)} onClick={() => setComposerOpen(true)}
@@ -199,8 +277,7 @@ export function MessagesPage() {
<div className="flex gap-4 border-b border-[#404040]"> <div className="flex gap-4 border-b border-[#404040]">
{[ {[
['historico', 'Histórico'], ['historico', 'Histórico'],
['templates', 'Templates'], ...(!isSecretary ? [['templates', 'Templates'], ['campanha', 'Campanhas']] : []),
['campanha', 'Campanhas'],
].map(([key, label]) => ( ].map(([key, label]) => (
<button <button
className={`border-b-2 px-2 pb-3 text-sm font-semibold transition ${ className={`border-b-2 px-2 pb-3 text-sm font-semibold transition ${
@@ -238,9 +315,7 @@ export function MessagesPage() {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{[ {[
['todos', 'Todos'], ['todos', 'Todos'],
['whatsapp', 'Whatsapp'], ...allowedChannelKeys.map((key) => [key, channels[key].label]),
['email', 'E-mail'],
['sms', 'Sms'],
].map(([key, label]) => ( ].map(([key, label]) => (
<button <button
className={`h-12 rounded-sm border px-4 text-xs font-semibold transition ${ className={`h-12 rounded-sm border px-4 text-xs font-semibold transition ${
@@ -291,7 +366,7 @@ export function MessagesPage() {
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
className="inline-flex h-10 items-center gap-2 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]" className="inline-flex h-10 items-center gap-2 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
onClick={() => setTemplateEditorOpen(true)} onClick={() => openTemplateEditor()}
type="button" type="button"
> >
<CommIcon className="size-4" name="plus" /> <CommIcon className="size-4" name="plus" />
@@ -300,8 +375,8 @@ export function MessagesPage() {
</div> </div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{templates.map((template) => ( {availableTemplates.map((template) => (
<TemplateCard key={template.id} onUse={openTemplate} template={template} /> <TemplateCard key={template.id} onEdit={openTemplateEditor} onUse={openTemplate} template={template} />
))} ))}
</div> </div>
</section> </section>
@@ -329,6 +404,7 @@ export function MessagesPage() {
className="mt-3 h-8 w-full rounded-sm bg-[#3b82f6] text-xs font-semibold text-white transition hover:bg-[#2563eb]" className="mt-3 h-8 w-full rounded-sm bg-[#3b82f6] text-xs font-semibold text-white transition hover:bg-[#2563eb]"
onClick={() => { onClick={() => {
setComposer({ setComposer({
patientId: '',
patient: campaign.count, patient: campaign.count,
phone: '', phone: '',
channel: 'whatsapp', channel: 'whatsapp',
@@ -363,6 +439,7 @@ export function MessagesPage() {
{composerOpen ? ( {composerOpen ? (
<MessageComposer <MessageComposer
allowedChannelKeys={allowedChannelKeys}
draft={composer} draft={composer}
onChange={setComposer} onChange={setComposer}
onClose={() => { onClose={() => {
@@ -370,19 +447,23 @@ export function MessagesPage() {
setComposer(emptyMessage) setComposer(emptyMessage)
}} }}
onSubmit={submitMessage} onSubmit={submitMessage}
templates={templates} patients={patientOptions}
templates={availableTemplates}
/> />
) : null} ) : null}
{templateEditorOpen ? ( {templateEditorOpen ? (
<TemplateEditor <TemplateEditor
allowedChannelKeys={allowedChannelKeys}
draft={templateDraft} draft={templateDraft}
onChange={setTemplateDraft} onChange={setTemplateDraft}
onClose={() => { onClose={() => {
setTemplateEditorOpen(false) setTemplateEditorOpen(false)
setTemplateDraft(emptyTemplate) setTemplateDraft(emptyTemplate)
setEditingTemplateId(null)
}} }}
onSubmit={submitTemplate} onSubmit={submitTemplate}
title={editingTemplateId ? 'Editar Template' : 'Novo Template'}
/> />
) : null} ) : null}
</div> </div>
@@ -424,7 +505,7 @@ function MessageRow({ message }) {
) )
} }
function TemplateCard({ onUse, template }) { function TemplateCard({ onEdit, onUse, template }) {
const channel = channels[template.channel] const channel = channels[template.channel]
return ( return (
@@ -443,6 +524,7 @@ function TemplateCard({ onUse, template }) {
<div className="mt-4 flex gap-2"> <div className="mt-4 flex gap-2">
<button <button
className="h-9 flex-1 rounded-sm border border-[#404040] bg-[#171717] text-xs font-semibold text-[#e5e5e5] transition hover:bg-[#303030]" className="h-9 flex-1 rounded-sm border border-[#404040] bg-[#171717] text-xs font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
onClick={() => onEdit(template)}
type="button" type="button"
> >
Editar Editar
@@ -459,11 +541,36 @@ function TemplateCard({ onUse, template }) {
) )
} }
function MessageComposer({ draft, onChange, onClose, onSubmit, templates }) { function MessageComposer({ allowedChannelKeys, draft, onChange, onClose, onSubmit, patients, templates }) {
const [patientSearch, setPatientSearch] = useState(draft.patient || '')
const filteredPatients = useMemo(() => {
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),
)
}, [patientSearch, patients])
function update(field, value) { function update(field, value) {
onChange((current) => ({ ...current, [field]: value })) onChange((current) => ({ ...current, [field]: value }))
} }
function selectPatient(patient) {
onChange((current) => ({
...current,
patientId: patient?.id || '',
patient: patient?.name || '',
phone: patient?.phone || current.phone,
}))
setPatientSearch(patient?.name || '')
}
function applyTemplate(templateName) { function applyTemplate(templateName) {
const template = templates.find((item) => item.name === templateName) const template = templates.find((item) => item.name === templateName)
@@ -483,20 +590,59 @@ function MessageComposer({ draft, onChange, onClose, onSubmit, templates }) {
return ( return (
<ModalFrame onClose={onClose} title="Nova Mensagem"> <ModalFrame onClose={onClose} title="Nova Mensagem">
<form className="space-y-4" onSubmit={onSubmit}> <form className="space-y-4" onSubmit={onSubmit}>
<DarkField label="Paciente">
<div className="space-y-2">
<input
className={inputClass}
onChange={(event) => {
setPatientSearch(event.target.value)
onChange((current) => ({ ...current, patientId: '', patient: '' }))
}}
placeholder="Digite nome, CPF ou telefone"
type="search"
value={patientSearch}
/>
<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"> <div className="grid gap-4 md:grid-cols-2">
<DarkField label="Paciente"> <DarkField label="Paciente selecionado">
<input <input
className={inputClass} className={inputClass}
onChange={(event) => update('patient', event.target.value)} onChange={(event) => update('patient', event.target.value)}
placeholder="Nome do paciente" readOnly
value={draft.patient} value={draft.patient}
/> />
</DarkField> </DarkField>
<DarkField label="Canal"> <DarkField label="Canal">
<select className={inputClass} onChange={(event) => update('channel', event.target.value)} value={draft.channel}> <select className={inputClass} onChange={(event) => update('channel', event.target.value)} value={draft.channel}>
<option value="whatsapp">WhatsApp</option> {allowedChannelKeys.map((key) => (
<option value="email">E-mail</option> <option key={key} value={key}>{channels[key].label}</option>
<option value="sms">SMS</option> ))}
</select> </select>
</DarkField> </DarkField>
</div> </div>
@@ -527,7 +673,7 @@ function MessageComposer({ draft, onChange, onClose, onSubmit, templates }) {
<textarea <textarea
className={textareaClass} className={textareaClass}
onChange={(event) => update('content', event.target.value)} onChange={(event) => update('content', event.target.value)}
placeholder="Escreva a mensagem mockada..." placeholder="Escreva a mensagem"
value={draft.content} value={draft.content}
/> />
</DarkField> </DarkField>
@@ -549,13 +695,13 @@ function MessageComposer({ draft, onChange, onClose, onSubmit, templates }) {
) )
} }
function TemplateEditor({ draft, onChange, onClose, onSubmit }) { function TemplateEditor({ allowedChannelKeys, draft, onChange, onClose, onSubmit, title }) {
function update(field, value) { function update(field, value) {
onChange((current) => ({ ...current, [field]: value })) onChange((current) => ({ ...current, [field]: value }))
} }
return ( return (
<ModalFrame onClose={onClose} title="Novo Template"> <ModalFrame onClose={onClose} title={title}>
<form className="space-y-4" onSubmit={onSubmit}> <form className="space-y-4" onSubmit={onSubmit}>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<DarkField label="Nome"> <DarkField label="Nome">
@@ -563,9 +709,9 @@ function TemplateEditor({ draft, onChange, onClose, onSubmit }) {
</DarkField> </DarkField>
<DarkField label="Canal"> <DarkField label="Canal">
<select className={inputClass} onChange={(event) => update('channel', event.target.value)} value={draft.channel}> <select className={inputClass} onChange={(event) => update('channel', event.target.value)} value={draft.channel}>
<option value="whatsapp">WhatsApp</option> {allowedChannelKeys.map((key) => (
<option value="email">E-mail</option> <option key={key} value={key}>{channels[key].label}</option>
<option value="sms">SMS</option> ))}
</select> </select>
</DarkField> </DarkField>
</div> </div>
@@ -617,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 }) { function CommIcon({ className = 'size-4', name }) {
const common = { const common = {
className, className,

View File

@@ -4,9 +4,9 @@ export function NotFoundPage({ navigate }) {
return ( return (
<div className="grid gap-6"> <div className="grid gap-6">
<PageHeader <PageHeader
description="A rota acessada nao faz parte do shell navegavel deste prototipo." description="A rota acessada não faz parte do shell navegável deste protótipo."
eyebrow="404" eyebrow="404"
title="Tela nao encontrada" title="Tela não encontrada"
/> />
<Card className="p-6"> <Card className="p-6">
<p className="max-w-2xl text-sm leading-6 text-slate-600"> <p className="max-w-2xl text-sm leading-6 text-slate-600">

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,16 @@
import { useRef, useState, useEffect } from 'react' import { useEffect, useRef, useState } from 'react'
import { FeatureCallout } from '../components/FeatureState.jsx' import { FeatureCallout } from '../components/FeatureState.jsx'
import { featurePanelClass } from '../components/featureStateStyles.js' 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 { authRepository } from '../repositories/authRepository.js'
import { profileRepository } from '../repositories/profileRepository.js'
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm' const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
const inputClass = 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' '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 }) { export function ProfilePage({ navigate }) {
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
@@ -18,10 +21,13 @@ export function ProfilePage({ navigate }) {
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
useEffect(() => { useEffect(() => {
profileRepository.getCurrentUserProfile().then(data => { profileRepository
setProfile(data) .getCurrentUserProfile()
setLoading(false) .then((data) => {
}).catch(() => setLoading(false)) setProfile(data)
setLoading(false)
})
.catch(() => setLoading(false))
}, []) }, [])
function update(field, value) { function update(field, value) {
@@ -56,31 +62,33 @@ export function ProfilePage({ navigate }) {
} }
if (loading) { 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 ( return (
<div className="mx-auto max-w-6xl space-y-6"> <div className="mx-auto max-w-6xl space-y-6">
<FeatureCallout {canEditProfile ? (
description="Carregar perfil, avatar e logout usam integração. O botão de salvar preferências desta tela ainda grava só localmente." <FeatureCallout
status="partial" description="Carregar perfil, avatar e logout usam integração. O botão de salvar preferências desta tela ainda grava só localmente."
title="Perfil com persistência parcial" status="partial"
/> title="Perfil com persistência parcial"
/>
) : null}
<header> <header>
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Perfil</h1> <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> </header>
<div className="grid gap-6 lg:grid-cols-[1fr_360px]"> <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"> <div className="mb-6 flex items-center gap-4">
{profile.avatarUrl ? ( {profile.avatarUrl ? (
<img <img alt="" className="size-16 rounded-full border border-[#3b82f6]/30 object-cover" src={profile.avatarUrl} />
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]"> <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)} {initials(profile.name)}
@@ -89,21 +97,25 @@ export function ProfilePage({ navigate }) {
<div> <div>
<h2 className="text-lg font-bold text-[#f5f5f5]">{profile.name}</h2> <h2 className="text-lg font-bold text-[#f5f5f5]">{profile.name}</h2>
<p className="mt-1 text-sm text-[#a3a3a3]">{profile.role}</p> <p className="mt-1 text-sm text-[#a3a3a3]">{profile.role}</p>
<button {canEditProfile ? (
className="mt-1 text-xs font-semibold text-[#3b82f6] disabled:opacity-60" <>
disabled={uploadingAvatar} <button
onClick={() => fileInputRef.current?.click()} className="mt-1 text-xs font-semibold text-[#3b82f6] disabled:opacity-60"
type="button" disabled={uploadingAvatar}
> onClick={() => fileInputRef.current?.click()}
{uploadingAvatar ? 'Enviando...' : 'Alterar foto'} type="button"
</button> >
<input {uploadingAvatar ? 'Enviando...' : 'Alterar foto'}
accept="image/*" </button>
className="hidden" <input
onChange={handleAvatarChange} accept="image/*"
ref={fileInputRef} className="hidden"
type="file" onChange={handleAvatarChange}
/> ref={fileInputRef}
type="file"
/>
</>
) : null}
{avatarError ? <p className="mt-1 text-xs font-semibold text-red-400">{avatarError}</p> : null} {avatarError ? <p className="mt-1 text-xs font-semibold text-red-400">{avatarError}</p> : null}
</div> </div>
</div> </div>
@@ -112,38 +124,44 @@ export function ProfilePage({ navigate }) {
className="grid gap-4" className="grid gap-4"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault() event.preventDefault()
setSaved(true) if (canEditProfile) setSaved(true)
}} }}
> >
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<Field label="Nome"> <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>
<Field label="Cargo"> <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> </Field>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<Field label="E-mail"> <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>
<Field label="Telefone"> <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> </Field>
</div> </div>
<Field label="Unidade padrão"> <Field label="Unidade padrão">
<select className={inputClass} onChange={(event) => update('unit', event.target.value)} value={profile.unit}> {canEditProfile ? (
<option>Clínica Boa Vista</option> <select className={inputClass} onChange={(event) => update('unit', event.target.value)} value={profile.unit}>
<option>Unidade Centro</option> <option>Clínica Boa Vista</option>
<option>Unidade Sul</option> <option>Unidade Centro</option>
</select> <option>Unidade Sul</option>
</select>
) : (
<input className={readOnlyInputClass} readOnly value={profile.unit} />
)}
</Field> </Field>
<div className="flex flex-wrap items-center gap-3"> {canEditProfile ? (
<button className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white" type="submit"> <div className="flex flex-wrap items-center gap-3">
Salvar alterações <button className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white" type="submit">
</button> Salvar alterações
{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} </button>
</div> {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> </form>
</section> </section>
@@ -156,8 +174,9 @@ export function ProfilePage({ navigate }) {
</dl> </dl>
<div className="mt-8 border-t border-[#404040] pt-6"> <div className="mt-8 border-t border-[#404040] pt-6">
<button <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" 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} onClick={handleLogout}
type="button"
> >
Sair da conta Sair da conta
</button> </button>
@@ -181,7 +200,7 @@ function Info({ label, value }) {
return ( return (
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4"> <div className="rounded-xl border border-[#404040] bg-[#171717] p-4">
<dt className="font-semibold text-[#a3a3a3]">{label}</dt> <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> </div>
) )
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { FeatureCallout } from '../components/FeatureState.jsx'
import { settingsRepository } from '../repositories/settingsRepository.js' import { settingsRepository } from '../repositories/settingsRepository.js'
import { getStoredTheme, setStoredTheme } from '../utils/theme.js'
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm' const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
@@ -15,13 +15,6 @@ export function SettingsPage() {
return ( return (
<div className="mx-auto max-w-5xl"> <div className="mx-auto max-w-5xl">
<FeatureCallout
className="mb-6"
description="Preferências, integrações e backup ainda são protótipos locais, sem persistência real."
status="mock"
title="Configurações ainda estão em modo protótipo"
/>
<header className="mb-8"> <header className="mb-8">
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Configurações</h1> <h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Configurações</h1>
<p className="mt-1 text-sm text-[#b8b8b8]">Gerencie preferências, segurança e integrações do MediConnect</p> <p className="mt-1 text-sm text-[#b8b8b8]">Gerencie preferências, segurança e integrações do MediConnect</p>
@@ -53,10 +46,7 @@ export function SettingsPage() {
<section className={`${cardClass} min-w-0 flex-1 p-6 lg:p-8`}> <section className={`${cardClass} min-w-0 flex-1 p-6 lg:p-8`}>
{activeSection === 'aparencia' ? <AppearanceSection /> : null} {activeSection === 'aparencia' ? <AppearanceSection /> : null}
{activeSection === 'notificacoes' ? <NotificationsSection /> : null}
{activeSection === 'privacidade' ? <PrivacySection /> : null} {activeSection === 'privacidade' ? <PrivacySection /> : null}
{activeSection === 'conta' ? <AccountSection /> : null}
{activeSection === 'integracoes' ? <IntegrationsSection /> : null}
{activeSection === 'dados' ? <DataSection /> : null} {activeSection === 'dados' ? <DataSection /> : null}
</section> </section>
</div> </div>
@@ -65,35 +55,43 @@ export function SettingsPage() {
} }
function AppearanceSection() { function AppearanceSection() {
const [theme, setTheme] = useState('dark') const [theme, setTheme] = useState(() => getStoredTheme())
const [compact, setCompact] = useState(false) const [compact, setCompact] = useState(false)
const [contrast, setContrast] = useState(false) const [contrast, setContrast] = useState(false)
const [animations, setAnimations] = useState(true) const [animations, setAnimations] = useState(true)
function handleThemeChange(nextTheme) {
setTheme(setStoredTheme(nextTheme))
}
return ( 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"> <div className="mb-8">
<p className="mb-4 text-sm font-semibold text-[#e5e5e5]">Tema da Interface</p> <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"> <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]' }, { id: 'light', label: 'Claro', preview: 'bg-[#f4f7fb]' },
].map((item) => ( ].map((item) => (
<button <button
className={`rounded-2xl border-2 p-4 text-left transition ${ 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} key={item.id}
onClick={() => setTheme(item.id)} onClick={() => handleThemeChange(item.id)}
type="button" 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={`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={`h-2.5 rounded ${item.id === 'dark' ? 'bg-[#1a3050]' : 'bg-white'}`} /> <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="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="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={`settings-theme-preview-line h-1.5 w-3/4 rounded-full ${item.id === 'dark' ? 'bg-[#525252]' : '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-1/2 rounded-full ${item.id === 'dark' ? 'bg-[#404040]' : 'bg-[#dde8f7]'}`} />
</span> </span>
</span> </span>
</span> </span>
@@ -129,50 +127,6 @@ function AppearanceSection() {
) )
} }
function NotificationsSection() {
const [settings, setSettings] = useState({
email: true,
sms: true,
whatsapp: true,
push: false,
ai: true,
appointment: true,
report: true,
noShow: true,
})
return (
<SectionFrame description="Configure como e quando deseja receber alertas." title="Notificações">
<Subsection title="Canais de Comunicação">
<ToggleRow checked={settings.email} description="Receba resumos e alertas via e-mail" label="Notificações por E-mail" onChange={(value) => setSettings((current) => ({ ...current, email: value }))} />
<ToggleRow checked={settings.sms} description="Alertas urgentes via mensagem de texto" label="SMS" onChange={(value) => setSettings((current) => ({ ...current, sms: value }))} />
<ToggleRow checked={settings.whatsapp} description="Integração com WhatsApp Business para lembretes" label="WhatsApp" onChange={(value) => setSettings((current) => ({ ...current, whatsapp: value }))} />
<ToggleRow checked={settings.push} description="Notificações no navegador em tempo real" label="Push (navegador)" onChange={(value) => setSettings((current) => ({ ...current, push: value }))} />
</Subsection>
<Subsection title="Tipos de Alerta">
<ToggleRow checked={settings.ai} description="Alerta preditivo quando paciente tem alto risco de faltar" label="Risco de No-Show (IA)" onChange={(value) => setSettings((current) => ({ ...current, ai: value }))} />
<ToggleRow checked={settings.appointment} description="Lembre pacientes 24h e 1h antes da consulta" label="Lembrete de Consulta" onChange={(value) => setSettings((current) => ({ ...current, appointment: value }))} />
<ToggleRow checked={settings.report} description="Notificar quando relatórios mensais estiverem prontos" label="Relatório Disponível" onChange={(value) => setSettings((current) => ({ ...current, report: value }))} />
<ToggleRow checked={settings.noShow} description="Confirmar quando uma falta é registrada no sistema" label="No-Show registrado" onChange={(value) => setSettings((current) => ({ ...current, noShow: value }))} />
</Subsection>
<Subsection title="Horário Silencioso">
<SettingRow description="Sem notificações push entre 22h e 7h" label="Ativar horário silencioso">
<ToggleSwitch checked onChange={() => {}} />
</SettingRow>
<SettingRow label="Horário de início / fim">
<div className="flex items-center gap-2">
<input className={`${inputClass} w-28`} defaultValue="22:00" type="time" />
<span className="text-sm text-[#a3a3a3]">até</span>
<input className={`${inputClass} w-28`} defaultValue="07:00" type="time" />
</div>
</SettingRow>
</Subsection>
</SectionFrame>
)
}
function PrivacySection() { function PrivacySection() {
const [twoFactor, setTwoFactor] = useState(false) const [twoFactor, setTwoFactor] = useState(false)
const [audit, setAudit] = useState(true) const [audit, setAudit] = useState(true)
@@ -222,81 +176,6 @@ function PrivacySection() {
) )
} }
function AccountSection() {
const [profile, setProfile] = useState({
name: 'Dra. Ana Silva',
email: 'ana.silva@mediconnect.com.br',
role: 'Coordenação Médica',
crm: 'CRM/SE 12345',
})
function update(field, value) {
setProfile((current) => ({ ...current, [field]: value }))
}
return (
<SectionFrame description="Gerencie suas informações pessoais e credenciais." title="Conta & Perfil">
<div className="mb-6 flex items-center gap-4 rounded-xl border border-[#404040] bg-[#171717] p-5">
<div className="grid size-16 place-items-center rounded-full border-2 border-[#3b82f6]/20 bg-[#3b82f6]/10 text-xl font-bold text-[#3b82f6]">
AS
</div>
<div>
<p className="text-sm font-bold text-[#f5f5f5]">{profile.name}</p>
<p className="text-xs text-[#a3a3a3]">{profile.role}</p>
<button className="mt-1 text-xs font-semibold text-[#3b82f6]" type="button">
Alterar foto
</button>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<TextField label="Nome completo" onChange={(value) => update('name', value)} value={profile.name} />
<TextField label="E-mail" onChange={(value) => update('email', value)} value={profile.email} />
<TextField label="Cargo / Função" onChange={(value) => update('role', value)} value={profile.role} />
<TextField label="CRM / Registro" onChange={(value) => update('crm', value)} value={profile.crm} />
</div>
<Subsection title="Segurança">
<SettingRow description="Última alteração há 45 dias" label="Alterar senha">
<button className="h-9 rounded-sm border border-[#404040] bg-[#303030] px-3 text-sm font-semibold text-[#e5e5e5]" type="button">
Alterar
</button>
</SettingRow>
<SettingRow description="Gerenciar dispositivos conectados" label="Sessões ativas">
<button className="text-sm font-semibold text-[#3b82f6]" type="button">
Ver sessões
</button>
</SettingRow>
</Subsection>
</SectionFrame>
)
}
function IntegrationsSection() {
const integrations = settingsRepository.getIntegrations()
return (
<SectionFrame description="Conecte o MediConnect com sistemas e serviços externos." title="Integrações">
<div className="space-y-3">
{integrations.map(([name, desc, connected, color]) => (
<div className="flex items-center gap-4 rounded-xl border border-[#404040] bg-[#171717] p-4" key={name}>
<div className={`grid size-10 shrink-0 place-items-center rounded-lg ${color}`}>
<SettingsIcon className="size-5 text-white" name="globe" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-[#f5f5f5]">{name}</p>
<p className="text-xs text-[#a3a3a3]">{desc}</p>
</div>
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${connected ? 'bg-emerald-500/10 text-emerald-400' : 'bg-[#303030] text-[#a3a3a3]'}`}>
{connected ? 'Conectado' : 'Desconectado'}
</span>
</div>
))}
</div>
</SectionFrame>
)
}
function DataSection() { function DataSection() {
return ( return (
<SectionFrame description="Exporte, importe e gerencie backups do sistema." title="Dados & Backup"> <SectionFrame description="Exporte, importe e gerencie backups do sistema." title="Dados & Backup">
@@ -386,15 +265,6 @@ function SettingRow({ children, description, label }) {
) )
} }
function TextField({ label, onChange, value }) {
return (
<label className="grid gap-2">
<span className="text-xs font-semibold text-[#a3a3a3]">{label}</span>
<input className={`${inputClass} w-full`} onChange={(event) => onChange(event.target.value)} value={value} />
</label>
)
}
function ToggleSwitch({ checked, onChange }) { function ToggleSwitch({ checked, onChange }) {
return ( return (
<button <button
@@ -420,15 +290,6 @@ function SettingsIcon({ className = 'size-4', name }) {
viewBox: '0 0 24 24', viewBox: '0 0 24 24',
} }
if (name === 'bell') {
return (
<svg {...common}>
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9" />
<path d="M10 21h4" />
</svg>
)
}
if (name === 'shield') { if (name === 'shield') {
return ( return (
<svg {...common}> <svg {...common}>
@@ -437,23 +298,6 @@ function SettingsIcon({ className = 'size-4', name }) {
) )
} }
if (name === 'user') {
return (
<svg {...common}>
<path d="M16 19a4 4 0 0 0-8 0M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" />
</svg>
)
}
if (name === 'globe') {
return (
<svg {...common}>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18" />
</svg>
)
}
if (name === 'database') { if (name === 'database') {
return ( return (
<svg {...common}> <svg {...common}>

View File

@@ -1,141 +0,0 @@
import { useState, useEffect } from 'react'
import { FeatureBadge, FeatureCallout } from '../components/FeatureState.jsx'
import { featurePanelClass } from '../components/featureStateStyles.js'
import { professionalRepository } from '../repositories/professionalRepository.js'
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
export function TeamPage({ navigate }) {
const [professionals, setProfessionals] = useState([])
const { slots, weekdays } = professionalRepository.getCoverageMap()
useEffect(() => {
professionalRepository.getAll().then(setProfessionals).catch(console.error)
}, [])
return (
<div className="mx-auto max-w-7xl space-y-6">
<FeatureCallout
description="A listagem de profissionais usa API, mas o mapa de cobertura e parte da disponibilidade ainda são simulados."
status="partial"
title="Tela híbrida: parte real, parte mockada"
/>
<header className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div>
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Profissionais</h1>
<p className="mt-1 text-sm text-[#b8b8b8]">Equipe, agenda e cobertura operacional da clínica.</p>
</div>
<button
className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
onClick={() => navigate('/agenda')}
type="button"
>
Ver disponibilidade
</button>
</header>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4" aria-label="Equipe médica">
{professionals.map((professional) => (
<article className={`${cardClass} ${featurePanelClass('live')} p-5`} key={professional.id}>
<div className="flex items-start justify-between gap-3">
<div>
<div className="grid size-11 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 text-sm font-bold text-[#3b82f6]">
{initials(professional.name)}
</div>
<h2 className="mt-4 text-lg font-bold text-[#f5f5f5]">{professional.name}</h2>
<p className="mt-1 text-sm text-[#a3a3a3]">{professional.role}</p>
</div>
<StatusPill status={professional.status} />
</div>
<dl className="mt-5 grid gap-3 text-sm">
<Info label="Agenda" value={professional.schedule} />
<Info label="Próximo horário" value={professional.nextSlot} />
<Info label="Pacientes ativos" value={professional.patients} />
</dl>
</article>
))}
</section>
<section className={`${cardClass} ${featurePanelClass('mock')} p-5`}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-xl font-bold text-[#f5f5f5]">Mapa de cobertura</h2>
<FeatureBadge status="mock" />
</div>
<p className="mt-1 text-sm text-[#a3a3a3]">
Matriz simples para preparar o fluxo de agenda, plantão e disponibilidade.
</p>
</div>
<button
className="h-10 rounded-sm border border-[#404040] bg-[#303030] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:border-[#3b82f6]"
onClick={() => navigate('/configuracoes')}
type="button"
>
Configurar regras
</button>
</div>
<div className="mt-5 overflow-x-auto rounded-sm border border-[#404040]">
<div className="grid min-w-[720px] grid-cols-[1.2fr_repeat(5,1fr)] bg-[#171717] text-xs font-bold uppercase tracking-[0.16em] text-[#a3a3a3]">
{['Profissional', ...weekdays].map((label) => (
<div className="border-b border-[#404040] px-4 py-3" key={label}>
{label}
</div>
))}
</div>
{professionals.map((professional, rowIndex) => (
<div className="grid min-w-[720px] grid-cols-[1.2fr_repeat(5,1fr)] text-sm" key={professional.id}>
<div className="border-b border-[#404040] px-4 py-3 font-semibold text-[#f5f5f5]">{professional.name}</div>
{slots.map((slot, index) => (
<div className="border-b border-[#404040] px-4 py-3 text-[#b8b8b8]" key={`${professional.id}-${slot}`}>
{shiftSlot(slot, rowIndex + index)}
</div>
))}
</div>
))}
</div>
</section>
</div>
)
}
function Info({ label, value }) {
return (
<div>
<dt className="text-xs font-semibold text-[#737373]">{label}</dt>
<dd className="mt-1 text-[#e5e5e5]">{value}</dd>
</div>
)
}
function StatusPill({ status }) {
const className =
status === 'Disponivel'
? 'bg-emerald-500/20 text-emerald-400'
: status === 'Em atendimento'
? 'bg-amber-500/20 text-amber-400'
: 'bg-blue-500/20 text-blue-400'
return <span className={`rounded px-2 py-1 text-[10px] font-bold ${className}`}>{status}</span>
}
function initials(name) {
return name
.replace(/^(Dr\.|Dra\.|Nutri\.|Enf\.)\s+/i, '')
.split(' ')
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase()
}
function shiftSlot(slot, index) {
if (index % 4 === 0) {
return 'Bloqueado'
}
return slot
}

474
src/pages/UsersPage.jsx Normal file
View File

@@ -0,0 +1,474 @@
import { useEffect, useState } from 'react'
import { ADMIN_CREATABLE_ROLES, GESTOR_CREATABLE_ROLES, hasCapability, normalizeRole, ROLE_LABELS } from '../config/permissions.js'
import { userRepository } from '../repositories/userRepository.js'
const darkInput =
'h-10 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]'
const darkLabel = 'mb-1.5 block text-xs font-medium text-[#e5e5e5]'
const authMethodOptions = [
{
value: 'magic_link',
label: 'Magic Link',
description: 'Enviar link de acesso por email',
},
{
value: 'password',
label: 'Email e senha',
description: 'Definir senha inicial agora',
},
]
const initialUserForm = {
email: '',
full_name: '',
phone: '',
cpf: '',
role: '',
auth_method: 'magic_link',
password: '',
confirm_password: '',
create_patient_record: false,
}
export function UsersPage({ role: currentRole }) {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [modalOpen, setModalOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [deletingId, setDeletingId] = useState(null)
const [form, setForm] = useState(initialUserForm)
const [roleFilter, setRoleFilter] = useState('Todos')
const normalizedRole = normalizeRole(currentRole)
const canManageUsers = hasCapability(normalizedRole, 'manageUsers')
const creatableRoles = normalizedRole === 'admin' ? ADMIN_CREATABLE_ROLES : GESTOR_CREATABLE_ROLES
const isPasswordCreation = form.auth_method === 'password'
const filterableRoles = normalizedRole === 'admin' ? ADMIN_CREATABLE_ROLES : GESTOR_CREATABLE_ROLES
const filteredUsers = users.filter((user) => {
if (roleFilter === 'Todos') return true
return normalizeRole(getUserRole(user)) === roleFilter
})
useEffect(() => {
loadUsers()
}, [])
async function loadUsers() {
setLoading(true)
setError(null)
try {
const data = await userRepository.getAll()
setUsers(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
function handleFormChange(event) {
const { checked, name, type, value } = event.target
setForm((current) => ({ ...current, [name]: type === 'checkbox' ? checked : value }))
}
async function handleCreate(event) {
event.preventDefault()
if (!canManageUsers) {
window.alert('Você não tem permissão para criar usuários.')
return
}
if (!form.email || !form.full_name || !form.phone || !form.cpf || !form.role) {
window.alert('Preencha email, nome completo, celular, CPF e perfil.')
return
}
if (isPasswordCreation) {
if (!form.password || !form.confirm_password) {
window.alert('Preencha a senha e a confirmação de senha.')
return
}
if (form.password.length < 8) {
window.alert('A senha deve ter pelo menos 8 caracteres.')
return
}
if (form.password !== form.confirm_password) {
window.alert('A confirmação de senha não confere.')
return
}
}
setSaving(true)
try {
if (isPasswordCreation) {
await userRepository.createWithPassword(form)
window.alert(`Usuário criado com email e senha para ${form.email}.`)
} else {
await userRepository.create(form)
window.alert(`Usuário criado! Magic Link enviado para ${form.email}.`)
}
setModalOpen(false)
setForm(initialUserForm)
loadUsers()
} catch (err) {
window.alert(`Erro ao criar usuário: ${err.message}`)
} finally {
setSaving(false)
}
}
async function handleDelete(user) {
if (!canManageUsers) {
window.alert('Você não tem permissão para deletar usuários.')
return
}
const confirmed = window.confirm(
`⚠️ ATENÇÃO: Esta operação é IRREVERSÍVEL!\n\nO usuário "${user.full_name || user.email}" e TODOS os dados relacionados (perfil, agendamentos, registros) serão deletados permanentemente.\n\nDeseja continuar?`
)
if (!confirmed) return
setDeletingId(user.id)
try {
await userRepository.remove(user.id)
setUsers((current) => current.filter((u) => u.id !== user.id))
} catch (err) {
window.alert(`Erro ao deletar usuário: ${err.message}`)
} finally {
setDeletingId(null)
}
}
if (!canManageUsers) {
return (
<div className="mx-auto max-w-3xl rounded-2xl border border-[#404040] bg-[#262626] p-8 text-center text-[#e5e5e5]">
<h1 className="text-xl font-bold">Acesso não permitido</h1>
<p className="mt-2 text-sm text-[#a3a3a3]">Somente Administrador e Gestão/Coordenação podem gerenciar usuários.</p>
</div>
)
}
return (
<div className="mx-auto max-w-7xl space-y-6 text-[#e5e5e5]">
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div>
<h1 className="text-2xl font-bold tracking-tight text-[#e5e5e5]">Usuários do Sistema</h1>
<p className="mt-1 text-sm text-[#a3a3a3]">Gerencie os usuários e seus perfis de acesso</p>
</div>
<button
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-[#3b82f6] px-4 text-sm font-medium text-white shadow-sm transition hover:bg-[#2563eb] md:w-auto"
onClick={() => setModalOpen(true)}
type="button"
>
+ Novo usuário
</button>
</div>
{loading ? (
<p className="py-10 text-center text-sm text-[#a3a3a3]">Carregando usuários...</p>
) : error ? (
<p className="py-10 text-center text-sm text-red-400">Erro ao carregar usuários: {error}</p>
) : (
<div className="rounded-2xl border border-[#404040] bg-[#262626] shadow-sm">
<div className="flex flex-col gap-3 border-b border-[#404040] px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-semibold text-[#e5e5e5]">Filtros</p>
<p className="mt-1 text-xs text-[#a3a3a3]">
{filteredUsers.length} de {users.length} usuários exibidos
</p>
</div>
<label className="grid gap-1.5 text-xs font-semibold text-[#a3a3a3] sm:min-w-56">
<span>Perfil</span>
<select
className={darkInput}
onChange={(event) => setRoleFilter(event.target.value)}
value={roleFilter}
>
<option value="Todos">Todos os perfis</option>
{filterableRoles.map((role) => (
<option key={`filter-role-${role}`} value={role}>
{ROLE_LABELS[role]}
</option>
))}
</select>
</label>
</div>
<div className="overflow-x-auto">
<table className="w-full whitespace-nowrap text-left text-sm">
<thead className="bg-[#171717] text-xs font-semibold uppercase text-[#a3a3a3]">
<tr>
<th className="px-6 py-4">Nome</th>
<th className="px-6 py-4">Email</th>
<th className="px-6 py-4">Perfil</th>
<th className="px-6 py-4">Status</th>
<th className="px-6 py-4 text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-[#404040]">
{filteredUsers.length ? (
filteredUsers.map((user) => {
const userRole = getUserRole(user)
return (
<tr className="transition hover:bg-[#303030]" key={user.id}>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<span className="grid size-8 place-items-center rounded-full bg-[#333333] text-xs font-bold text-[#3b82f6]">
{(user.full_name || user.email || '?').charAt(0).toUpperCase()}
</span>
<span className="font-medium text-[#e5e5e5]">{user.full_name || '—'}</span>
</div>
</td>
<td className="px-6 py-4 text-[#a3a3a3]">{user.email}</td>
<td className="px-6 py-4">
<RoleBadge role={userRole} />
</td>
<td className="px-6 py-4">
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${
user.email_confirmed_at
? 'bg-emerald-500/20 text-emerald-400'
: 'bg-amber-500/20 text-amber-400'
}`}>
{user.email_confirmed_at ? 'Ativo' : 'Pendente'}
</span>
</td>
<td className="px-6 py-4 text-right">
<button
className="rounded-lg border border-[#ef4444]/30 bg-[#ef4444]/10 px-3 py-1.5 text-xs font-semibold text-[#ef4444] transition hover:bg-[#ef4444]/20 disabled:opacity-50"
disabled={deletingId === user.id}
onClick={() => handleDelete(user)}
type="button"
>
{deletingId === user.id ? 'Deletando...' : 'Deletar'}
</button>
</td>
</tr>
)
})
) : (
<tr>
<td className="px-6 py-10 text-center text-[#a3a3a3]" colSpan={5}>
{users.length ? 'Nenhum usuário encontrado para o perfil selecionado.' : 'Nenhum usuário encontrado.'}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
{modalOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setModalOpen(false)}>
<div
className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-[#404040] bg-[#262626] p-6 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-[#e5e5e5]">Novo Usuário</h2>
<p className="mt-1 text-xs text-[#a3a3a3]">
{isPasswordCreation ? 'Crie o acesso inicial com email e senha.' : 'Um Magic Link sera enviado para o email cadastrado.'}
</p>
</div>
<button
className="rounded p-1 text-[#a3a3a3] transition hover:bg-[#333333]"
onClick={() => setModalOpen(false)}
type="button"
>
</button>
</div>
<form className="space-y-4" onSubmit={handleCreate}>
<div>
<span className={darkLabel}>Criar usuário usando *</span>
<div className="grid gap-3 sm:grid-cols-2">
{authMethodOptions.map((option) => {
const selected = form.auth_method === option.value
return (
<label
className={`cursor-pointer rounded-lg border p-3 transition ${
selected
? 'border-[#3b82f6] bg-[#3b82f6]/15 text-[#e5e5e5]'
: 'border-[#404040] bg-[#1a1a1a] text-[#a3a3a3] hover:border-[#525252] hover:text-[#e5e5e5]'
}`}
key={option.value}
>
<span className="flex items-start gap-3">
<input
checked={selected}
className="mt-1 size-4 accent-[#3b82f6]"
name="auth_method"
onChange={handleFormChange}
type="radio"
value={option.value}
/>
<span>
<span className="block text-sm font-semibold">{option.label}</span>
<span className="mt-1 block text-xs text-[#a3a3a3]">{option.description}</span>
</span>
</span>
</label>
)
})}
</div>
</div>
<div>
<label className={darkLabel}>Nome completo *</label>
<input
className={darkInput}
name="full_name"
onChange={handleFormChange}
placeholder="Ex: João da Silva"
required
value={form.full_name}
/>
</div>
<div>
<label className={darkLabel}>Email *</label>
<input
className={darkInput}
name="email"
onChange={handleFormChange}
placeholder="email@exemplo.com"
required
type="email"
value={form.email}
/>
</div>
<div>
<label className={darkLabel}>Celular *</label>
<input
className={darkInput}
maxLength={15}
name="phone"
onChange={handleFormChange}
placeholder="(00) 00000-0000"
required
value={form.phone}
/>
</div>
<div>
<label className={darkLabel}>CPF *</label>
<input
className={darkInput}
maxLength={14}
name="cpf"
onChange={handleFormChange}
placeholder="000.000.000-00"
required
value={form.cpf}
/>
</div>
{isPasswordCreation ? (
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className={darkLabel}>Senha *</label>
<input
autoComplete="new-password"
className={darkInput}
minLength={8}
name="password"
onChange={handleFormChange}
placeholder="Mínimo 8 caracteres"
required={isPasswordCreation}
type="password"
value={form.password}
/>
</div>
<div>
<label className={darkLabel}>Confirmar senha *</label>
<input
autoComplete="new-password"
className={darkInput}
minLength={8}
name="confirm_password"
onChange={handleFormChange}
placeholder="Repita a senha"
required={isPasswordCreation}
type="password"
value={form.confirm_password}
/>
</div>
</div>
) : null}
<div>
<label className={darkLabel}>Perfil de acesso *</label>
<select
className={darkInput}
name="role"
onChange={handleFormChange}
required
value={form.role}
>
<option value="">Selecione um perfil</option>
{creatableRoles.map((r) => (
<option key={`role-option-${r}`} value={r}>{ROLE_LABELS[r]}</option>
))}
</select>
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm text-[#e5e5e5]">
<input
checked={form.create_patient_record}
className="size-4 accent-[#3b82f6]"
name="create_patient_record"
onChange={handleFormChange}
type="checkbox"
/>
Criar também um registro de paciente
</label>
<div className="flex justify-end gap-3 pt-2">
<button
className="rounded-lg border border-[#404040] bg-[#262626] px-4 py-2 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#333333]"
disabled={saving}
onClick={() => setModalOpen(false)}
type="button"
>
Cancelar
</button>
<button
className="rounded-lg bg-[#3b82f6] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#2563eb] disabled:opacity-60"
disabled={saving}
type="submit"
>
{saving ? 'Criando...' : isPasswordCreation ? 'Criar com senha' : 'Criar e enviar Magic Link'}
</button>
</div>
</form>
</div>
</div>
) : null}
</div>
)
}
function RoleBadge({ role }) {
const styles = {
admin: 'bg-purple-500/20 text-purple-400',
gestor: 'bg-blue-500/20 text-blue-400',
medico: 'bg-emerald-500/20 text-emerald-400',
secretaria: 'bg-amber-500/20 text-amber-400',
paciente: 'bg-[#303030] text-[#a3a3a3]',
}
return (
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${styles[role] || styles.paciente}`}>
{ROLE_LABELS[role] || role}
</span>
)
}
function getUserRole(user) {
return Array.isArray(user.roles) ? user.roles[0] : (user.role ?? '—')
}

View File

@@ -22,7 +22,7 @@ export function VisitsPage({ navigate }) {
} }
if (activeTab === 'atendimento') { if (activeTab === 'atendimento') {
return careQueue.filter((item) => item.status !== 'Finalizada' && item.status !== 'Aguardando medico') return careQueue.filter((item) => item.status !== 'Finalizada' && item.status !== 'Aguardando médico')
} }
return careQueue.filter((item) => item.status !== 'Finalizada') return careQueue.filter((item) => item.status !== 'Finalizada')

View File

@@ -12,12 +12,12 @@ export const analyticsRepository = {
getDashboardData() { getDashboardData() {
return { return {
absenteeismData: [ absenteeismData: [
{ month: 'Out', taxa: 18, meta: 15 }, { month: 'Out/2025', taxa: 18, meta: 15 },
{ month: 'Nov', taxa: 16, meta: 15 }, { month: 'Nov/2025', taxa: 16, meta: 15 },
{ month: 'Dez', taxa: 22, meta: 15 }, { month: 'Dez/2025', taxa: 22, meta: 15 },
{ month: 'Jan', taxa: 14, meta: 15 }, { month: 'Jan/2026', taxa: 14, meta: 15 },
{ month: 'Fev', taxa: 12, meta: 15 }, { month: 'Fev/2026', taxa: 12, meta: 15 },
{ month: 'Mar', taxa: 14.2, meta: 15 }, { month: 'Mar/2026', taxa: 14.2, meta: 15 },
], ],
consultationsData: [ consultationsData: [
{ month: 'Out', total: 380, realizadas: 312 }, { month: 'Out', total: 380, realizadas: 312 },

View File

@@ -1,5 +1,6 @@
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js' import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
import { appointmentMapper } from '../mappers/appointmentMapper.js' import { appointmentMapper } from '../mappers/appointmentMapper.js'
import { getResponseError, normalizeItem } from './repositoryUtils.js'
export const appointmentRepository = { export const appointmentRepository = {
async getAll({ doctorId } = {}) { async getAll({ doctorId } = {}) {
@@ -9,7 +10,7 @@ export const appointmentRepository = {
headers: getAuthenticatedHeaders() headers: getAuthenticatedHeaders()
}) })
if (!response.ok) throw new Error('Erro ao buscar agendamentos.') if (!response.ok) throw new Error(await getResponseError(response, 'Erro ao buscar agendamentos.'))
const data = await response.json() const data = await response.json()
return (Array.isArray(data) ? data : []).map(appointmentMapper.toUi) return (Array.isArray(data) ? data : []).map(appointmentMapper.toUi)
@@ -22,10 +23,26 @@ export const appointmentRepository = {
body: JSON.stringify(appointmentMapper.toApi(uiData, 'supabase')), body: JSON.stringify(appointmentMapper.toApi(uiData, 'supabase')),
}) })
if (!response.ok) throw new Error('Falha ao criar o agendamento.') if (!response.ok) throw new Error(await getResponseError(response, 'Falha ao criar o agendamento.'))
const data = await response.json() const data = await response.json()
const item = Array.isArray(data) ? data[0] : data return appointmentMapper.toUi(normalizeItem(data))
return appointmentMapper.toUi(item) },
}
async update(id, uiData) {
const response = await fetch(`${apiConfig.restUrl}/appointments?id=eq.${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
body: JSON.stringify(appointmentMapper.toApi(uiData, 'supabase')),
})
if (!response.ok) throw new Error(await getResponseError(response, 'Falha ao atualizar o agendamento.'))
const data = await response.json()
return appointmentMapper.toUi(normalizeItem(data))
},
async cancel(id, uiData) {
return this.update(id, { ...uiData, status: 'Cancelada' })
},
} }

View File

@@ -8,6 +8,7 @@ import {
hasAuthenticatedSession, hasAuthenticatedSession,
saveAuthSession, saveAuthSession,
} from '../config/api.js' } from '../config/api.js'
import { getResponseError } from './repositoryUtils.js'
export const authRepository = { export const authRepository = {
async login({ email, password }) { async login({ email, password }) {
@@ -18,12 +19,12 @@ export const authRepository = {
}) })
if (!response.ok) { if (!response.ok) {
throw new Error(await getResponseError(response, 'Erro de autenticacao.')) throw new Error(await getResponseError(response, 'Erro de autenticação.'))
} }
const session = await response.json() const session = await response.json()
if (!session?.access_token) { if (!session?.access_token) {
throw new Error('Falha no login. Token nao recebido.') throw new Error('Falha no login. Token não recebido.')
} }
saveAuthSession(session) saveAuthSession(session)
@@ -32,7 +33,7 @@ export const authRepository = {
async requestPasswordReset(email) { async requestPasswordReset(email) {
const payload = { email: email?.trim() } const payload = { email: email?.trim() }
const apiResponse = await fetch(apiEndpoint('/solicitar-reset-de-senha'), { const apiResponse = await fetch(apiEndpoint('/request-password-reset'), {
method: 'POST', method: 'POST',
headers: getAnonHeaders(), headers: getAnonHeaders(),
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -59,25 +60,32 @@ export const authRepository = {
return true return true
}, },
async sendMagicLink(email) {
const response = await fetch(`${apiConfig.supabaseUrl}/auth/v1/otp`, {
method: 'POST',
headers: getAnonHeaders(),
body: JSON.stringify({ email: email?.trim() }),
})
if (!response.ok) {
throw new Error(await getResponseError(response, 'Erro ao enviar Magic Link.'))
}
return true
},
async getUser() { async getUser() {
const apiEndpoints = [ const apiResponse = await fetch(`${apiConfig.functionsUrl.replace(/\/+$/, '')}/user-info`, {
apiEndpoint('/user-info'), method: 'POST',
apiEndpoint('/informacoes-do-usuario-autenticado'), headers: getAuthenticatedHeaders(),
] }).catch(() => null)
for (const url of apiEndpoints) { if (apiResponse?.ok) {
const apiResponse = await fetch(url, { return apiResponse.json()
method: 'GET', }
headers: getAuthenticatedHeaders(),
}).catch(() => null)
if (apiResponse?.ok) { if (apiResponse && !shouldFallback(apiResponse)) {
return apiResponse.json() throw new Error(await getResponseError(apiResponse, 'Erro ao resgatar perfil de usuário.'))
}
if (apiResponse && !shouldFallback(apiResponse)) {
throw new Error(await getResponseError(apiResponse, 'Erro ao resgatar perfil de usuario.'))
}
} }
const response = await fetch(`${apiConfig.supabaseUrl}/auth/v1/user`, { const response = await fetch(`${apiConfig.supabaseUrl}/auth/v1/user`, {
@@ -86,7 +94,7 @@ export const authRepository = {
}) })
if (!response.ok) { if (!response.ok) {
throw new Error(await getResponseError(response, 'Erro ao resgatar perfil de usuario.')) throw new Error(await getResponseError(response, 'Erro ao resgatar perfil de usuário.'))
} }
return response.json() return response.json()
@@ -114,7 +122,7 @@ export const authRepository = {
headers: getAuthenticatedHeaders(), headers: getAuthenticatedHeaders(),
}) })
} catch { } catch {
// A sessao local precisa ser removida mesmo quando o backend nao responde. // A sessão local precisa ser removida mesmo quando o backend não responde.
} finally { } finally {
clearAuthSession() clearAuthSession()
} }
@@ -124,8 +132,3 @@ export const authRepository = {
function shouldFallback(response) { function shouldFallback(response) {
return [404, 405].includes(response.status) return [404, 405].includes(response.status)
} }
async function getResponseError(response, fallbackMessage) {
const error = await response.json().catch(() => ({}))
return error.error_description || error.msg || error.message || error.error || fallbackMessage
}

View File

@@ -0,0 +1,199 @@
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
import { getResponseError, normalizeCollection, normalizeItem } from './repositoryUtils.js'
const availabilityBaseUrl = `${apiConfig.restUrl}/doctor_availability`
const exceptionsBaseUrl = `${apiConfig.restUrl}/doctor_exceptions`
export const availabilityRepository = {
async getAll(filters = {}) {
const query = buildRestQuery(filters)
const response = await fetch(`${availabilityBaseUrl}?${query.toString()}`, {
headers: getAuthenticatedHeaders(),
})
if (!response.ok) {
throw new Error(await getResponseError(response, 'Falha ao listar disponibilidades.'))
}
return normalizeCollection(await response.json(), []).map(mapAvailability)
},
async create(data) {
const response = await fetch(availabilityBaseUrl, {
method: 'POST',
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
body: JSON.stringify(toAvailabilityPayload(data)),
})
if (!response.ok) {
throw new Error(await getResponseError(response, 'Falha ao criar disponibilidade.'))
}
return mapAvailability(normalizeItem(await response.json()))
},
async update(id, data) {
const response = await fetch(`${availabilityBaseUrl}?id=eq.${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
body: JSON.stringify(toAvailabilityPayload(data)),
})
if (!response.ok) {
throw new Error(await getResponseError(response, 'Falha ao atualizar disponibilidade.'))
}
return mapAvailability(normalizeItem(await response.json()))
},
async remove(id) {
const response = await fetch(`${availabilityBaseUrl}?id=eq.${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: getAuthenticatedHeaders(),
})
if (!response.ok) {
throw new Error(await getResponseError(response, 'Falha ao deletar disponibilidade.'))
}
return true
},
async getExceptions(filters = {}) {
const query = buildRestQuery(filters)
const response = await fetch(`${exceptionsBaseUrl}?${query.toString()}`, {
headers: getAuthenticatedHeaders(),
})
if (!response.ok) {
throw new Error(await getResponseError(response, 'Falha ao listar excecoes de agenda.'))
}
return normalizeCollection(await response.json(), []).map(mapException)
},
async createException(data) {
const response = await fetch(exceptionsBaseUrl, {
method: 'POST',
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
body: JSON.stringify(toExceptionPayload(data)),
})
if (!response.ok) {
throw new Error(await getResponseError(response, 'Falha ao criar excecao de agenda.'))
}
return mapException(normalizeItem(await response.json()))
},
async getAvailableSlots({ appointmentType, date, doctorId }) {
const payload = {
doctor_id: doctorId,
date,
start_date: date,
end_date: date,
appointment_type: normalizeAppointmentType(appointmentType),
}
const response = await fetch(`${apiConfig.functionsUrl.replace(/\/+$/, '')}/get-available-slots`, {
method: 'POST',
headers: getAuthenticatedHeaders(),
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error(await getResponseError(response, 'Falha ao calcular slots disponíveis.'))
}
const data = await response.json()
return normalizeCollection(data, ['slots']).map(mapSlot)
},
}
function buildRestQuery(filters) {
const query = new URLSearchParams()
query.set('select', '*')
if (filters.doctorId) query.set('doctor_id', `eq.${filters.doctorId}`)
if (filters.weekday !== undefined) query.set('weekday', `eq.${filters.weekday}`)
if (filters.active !== undefined) query.set('active', `eq.${filters.active}`)
if (filters.appointmentType) query.set('appointment_type', `eq.${filters.appointmentType}`)
if (filters.date) query.set('date', `eq.${filters.date}`)
if (filters.kind) query.set('kind', `eq.${filters.kind}`)
if (filters.order) query.set('order', filters.order)
return query
}
function toAvailabilityPayload(data) {
return cleanPayload({
doctor_id: data.doctorId,
weekday: data.weekday,
start_time: data.startTime,
end_time: data.endTime,
slot_minutes: data.slotMinutes,
appointment_type: data.appointmentType,
active: data.active,
})
}
function toExceptionPayload(data) {
return cleanPayload({
doctor_id: data.doctorId,
date: data.date,
kind: data.kind,
start_time: data.startTime,
end_time: data.endTime,
reason: data.reason,
created_by: data.createdBy,
})
}
function mapAvailability(item) {
return {
id: item.id,
doctorId: item.doctor_id,
weekday: item.weekday,
startTime: item.start_time,
endTime: item.end_time,
slotMinutes: item.slot_minutes,
appointmentType: item.appointment_type,
active: item.active,
createdAt: item.created_at,
updatedAt: item.updated_at,
}
}
function mapException(item) {
return {
id: item.id,
doctorId: item.doctor_id,
date: item.date,
kind: item.kind,
startTime: item.start_time,
endTime: item.end_time,
reason: item.reason,
createdBy: item.created_by,
}
}
function mapSlot(slot) {
return {
date: slot.date,
datetime: slot.datetime,
time: slot.time,
available: Boolean(slot.available),
}
}
function normalizeAppointmentType(type) {
// API enum documented for availability currently accepts "presencial".
void type
return 'presencial'
}
function cleanPayload(payload) {
return Object.fromEntries(
Object.entries(payload).filter(([, value]) => value !== undefined),
)
}

View File

@@ -1,27 +1,17 @@
import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js' import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
import { fetchJsonWithFallback } from './repositoryUtils.js' import { fetchJsonWithFallback } from './repositoryUtils.js'
export const communicationRepository = { export const communicationRepository = {
async sendSms({ patientName, phone, content }) { async sendSms({ patientId, patientName, phone, content }) {
const message = `[MediConnect] Ola ${patientName}, ${content}` const message = `[MediConnect] Ola ${patientName}, ${content}`
const payload = { const payload = {
telefone: normalizePhone(phone), phone_number: normalizePhone(phone),
phone: normalizePhone(phone),
mensagem: message,
message, message,
paciente: patientName, patient_id: patientId || undefined,
} }
await fetchJsonWithFallback( await fetchJsonWithFallback(
[ [
{
url: apiEndpoint('/enviar-sms-via-twilio'),
options: {
method: 'POST',
headers: getAuthenticatedHeaders(),
body: JSON.stringify(payload),
},
},
{ {
url: `${apiConfig.functionsUrl.replace(/\/+$/, '')}/send-sms`, url: `${apiConfig.functionsUrl.replace(/\/+$/, '')}/send-sms`,
options: { options: {
@@ -39,9 +29,9 @@ export const communicationRepository = {
getCampaigns() { getCampaigns() {
return [ return [
{ title: 'Lembretes Anti-Falta', desc: 'Envio automatico 48h e 4h antes', count: '324 pacientes elegiveis' }, { title: 'Lembretes Anti-Falta', desc: 'Envio automático 48h e 4h antes', count: '324 pacientes elegíveis' },
{ title: 'Vacinacao 2026', desc: 'Campanha de vacinacao anual', count: '156 pacientes elegiveis' }, { title: 'Vacinação 2026', desc: 'Campanha de vacinação anual', count: '156 pacientes elegíveis' },
{ title: 'Retorno Pendente', desc: 'Pacientes com retorno atrasado', count: '42 pacientes elegiveis' }, { title: 'Retorno Pendente', desc: 'Pacientes com retorno atrasado', count: '42 pacientes elegíveis' },
] ]
}, },
@@ -50,7 +40,7 @@ export const communicationRepository = {
{ id: '1', patient: 'Carlos Eduardo Santos', channel: 'whatsapp', template: 'Lembrete 48h', sentAt: '25/03/2026 09:00', status: 'lida', response: 'Confirmado!' }, { id: '1', patient: 'Carlos Eduardo Santos', channel: 'whatsapp', template: 'Lembrete 48h', sentAt: '25/03/2026 09:00', status: 'lida', response: 'Confirmado!' },
{ id: '2', patient: 'Mariana Costa', channel: 'whatsapp', template: 'Lembrete 48h', sentAt: '25/03/2026 09:05', status: 'entregue' }, { id: '2', patient: 'Mariana Costa', channel: 'whatsapp', template: 'Lembrete 48h', sentAt: '25/03/2026 09:05', status: 'entregue' },
{ id: '3', patient: 'Joao Pedro Alves', channel: 'whatsapp', template: 'Lembrete 4h', sentAt: '27/03/2026 05:00', status: 'pendente' }, { id: '3', patient: 'Joao Pedro Alves', channel: 'whatsapp', template: 'Lembrete 4h', sentAt: '27/03/2026 05:00', status: 'pendente' },
{ id: '4', patient: 'Fernanda Lima', channel: 'email', template: 'Confirmacao de Agendamento', sentAt: '24/03/2026 15:30', status: 'lida' }, { id: '4', patient: 'Fernanda Lima', channel: 'email', template: 'Confirmação de Agendamento', sentAt: '24/03/2026 15:30', status: 'lida' },
{ id: '5', patient: 'Roberto Campos', channel: 'whatsapp', template: 'Lembrete Extra (Risco Alto)', sentAt: '26/03/2026 10:00', status: 'entregue' }, { id: '5', patient: 'Roberto Campos', channel: 'whatsapp', template: 'Lembrete Extra (Risco Alto)', sentAt: '26/03/2026 10:00', status: 'entregue' },
{ id: '6', patient: 'Sandra Oliveira', channel: 'sms', template: 'Lembrete 48h', sentAt: '24/03/2026 08:00', status: 'falha' }, { id: '6', patient: 'Sandra Oliveira', channel: 'sms', template: 'Lembrete 48h', sentAt: '24/03/2026 08:00', status: 'falha' },
{ id: '7', patient: 'Lucia Ferreira', channel: 'email', template: 'Resultado de Exames', sentAt: '26/03/2026 14:00', status: 'lida' }, { id: '7', patient: 'Lucia Ferreira', channel: 'email', template: 'Resultado de Exames', sentAt: '26/03/2026 14:00', status: 'lida' },
@@ -60,11 +50,11 @@ export const communicationRepository = {
getInitialTemplates() { getInitialTemplates() {
return [ return [
{ id: 't1', name: 'Lembrete 48h', channel: 'whatsapp', content: 'Ola {nome}! Lembramos que sua consulta esta agendada para {data} as {hora}. Confirme respondendo SIM.', category: 'Lembrete' }, { id: 't1', name: 'Lembrete 48h', channel: 'whatsapp', content: 'Ola {nome}! Lembramos que sua consulta esta agendada para {data} às {hora}. Confirme respondendo SIM.', category: 'Lembrete' },
{ id: 't2', name: 'Lembrete 4h', channel: 'whatsapp', content: 'Ola {nome}! Sua consulta e hoje as {hora}. Estamos te esperando!', category: 'Lembrete' }, { id: 't2', name: 'Lembrete 4h', channel: 'whatsapp', content: 'Ola {nome}! Sua consulta e hoje às {hora}. Estamos te esperando!', category: 'Lembrete' },
{ id: 't3', name: 'Lembrete Extra (Risco Alto)', channel: 'whatsapp', content: 'Ola {nome}! Notamos que sua presenca e muito importante. Podemos confirmar sua consulta de {data}?', category: 'IA' }, { id: 't3', name: 'Lembrete Extra (Risco Alto)', channel: 'whatsapp', content: 'Ola {nome}! Notamos que sua presenca e muito importante. Podemos confirmar sua consulta de {data}?', category: 'IA' },
{ id: 't4', name: 'Confirmacao de Agendamento', channel: 'email', content: 'Prezado(a) {nome}, confirmamos seu agendamento para {data} as {hora} com {medico}.', category: 'Agendamento' }, { id: 't4', name: 'Confirmação de Agendamento', channel: 'email', content: 'Prezado(a) {nome}, confirmamos seu agendamento para {data} às {hora} com {medico}.', category: 'Agendamento' },
{ id: 't5', name: 'Resultado de Exames', channel: 'email', content: 'Prezado(a) {nome}, seus resultados de exames estao disponiveis. Acesse o portal do paciente.', category: 'Exames' }, { id: 't5', name: 'Resultado de Exames', channel: 'email', content: 'Prezado(a) {nome}, seus resultados de exames estão disponíveis. Acesse o portal do paciente.', category: 'Exames' },
{ id: 't6', name: 'Reagendamento Sugerido (IA)', channel: 'whatsapp', content: 'Ola {nome}! Que tal reagendar sua consulta para um horario mais conveniente? Temos vagas em {sugestoes}.', category: 'IA' }, { id: 't6', name: 'Reagendamento Sugerido (IA)', channel: 'whatsapp', content: 'Ola {nome}! Que tal reagendar sua consulta para um horario mais conveniente? Temos vagas em {sugestoes}.', category: 'IA' },
] ]
}, },

View File

@@ -1,15 +1,308 @@
const STORAGE_KEY = 'mediconnect.medicalRecords.v2'
const INITIAL_RECORDS = [
{
id: 'record-1',
patientId: 'mock-carlos-eduardo',
patient: 'Carlos Eduardo Santos',
patientDocument: 'CPF nao informado',
patientEmail: 'carlos.santos@example.com',
patientPhone: '11999990001',
dateTime: '2026-03-27T10:30',
createdAt: '2026-03-27T10:30:00.000Z',
updatedAt: '2026-03-27T10:30:00.000Z',
doctor: 'Dra. Ana Silva',
type: 'Consulta Retorno',
cid: 'I10 - Hipertensao',
status: 'completo',
summary: 'Paciente relata melhora com medicacao. PA: 130/85. Mantida conduta.',
diagnosticReasoning: 'Quadro compativel com hipertensao arterial sistemica em acompanhamento, com melhora apos adesao medicamentosa.',
diagnosticHypotheses: 'HAS primaria; efeito de baixa adesao previa ao tratamento; risco cardiovascular global moderado.',
definitiveDiagnosis: 'Hipertensao arterial sistemica controlada.',
prescriptions: 'Manter losartana 50 mg de 12/12h e hidroclorotiazida 25 mg pela manha.',
procedures: 'Afericao pressorica seriada e orientacao sobre automonitoramento domiciliar.',
surgeries: 'Nao se aplica no atendimento atual.',
orientations: 'Reduzir sodio, manter atividade fisica regular e retornar com diario pressorico.',
labResults: 'Exames laboratoriais sem alteracoes relevantes no periodo.',
imageResults: 'Sem exames de imagem novos para este atendimento.',
multiprofessionalNotes: 'Enfermagem orientou tecnica correta de afericao de pressao arterial.',
signature: 'Dra. Ana Silva - CRM 123456',
professionalStamp: 'Assinado digitalmente por Dra. Ana Silva em 27/03/2026 10:30',
},
{
id: 'record-2',
patientId: 'mock-mariana-costa',
patient: 'Mariana Costa',
patientDocument: 'CPF nao informado',
patientEmail: 'mariana.costa@example.com',
patientPhone: '11999990002',
dateTime: '2026-03-26T15:00',
createdAt: '2026-03-26T15:00:00.000Z',
updatedAt: '2026-03-26T15:00:00.000Z',
doctor: 'Dra. Ana Silva',
type: 'Exame',
cid: 'Z01.7 - Exame laboratorial',
status: 'completo',
summary: 'Resultados de hemograma dentro da normalidade. Solicitar retorno em 6 meses.',
diagnosticReasoning: 'Resultados laboratoriais analisados em conjunto com quadro clinico estavel.',
diagnosticHypotheses: 'Acompanhamento preventivo sem sinais laboratoriais de alarme.',
definitiveDiagnosis: 'Exame laboratorial sem alteracoes clinicamente significativas.',
prescriptions: 'Sem nova prescricao medicamentosa.',
procedures: 'Revisao de exames laboratoriais e comparacao com historico previo.',
surgeries: 'Nao se aplica.',
orientations: 'Manter rotina preventiva e retorno em 6 meses ou antes se houver sintomas.',
labResults: 'Hemograma completo dentro dos parametros de referencia.',
imageResults: 'Sem exames de imagem relacionados.',
multiprofessionalNotes: 'Equipe administrativa orientou retirada de copia dos exames.',
signature: 'Dra. Ana Silva - CRM 123456',
professionalStamp: 'Assinado digitalmente por Dra. Ana Silva em 26/03/2026 15:00',
},
{
id: 'record-3',
patientId: 'mock-joao-pedro',
patient: 'Joao Pedro Alves',
patientDocument: 'CPF nao informado',
patientEmail: 'joao.alves@example.com',
patientPhone: '11999990003',
dateTime: '2026-03-25T09:15',
createdAt: '2026-03-25T09:15:00.000Z',
updatedAt: '2026-03-25T09:15:00.000Z',
doctor: 'Dr. Carlos Mendes',
type: 'Primeira Consulta',
cid: 'R10 - Dor abdominal',
status: 'rascunho',
summary: 'Queixa de dor abdominal ha 2 semanas. Solicitados exames complementares.',
diagnosticReasoning: 'Dor abdominal subaguda, sem sinais de peritonite, em investigacao etiologica.',
diagnosticHypotheses: 'Dispepsia funcional; gastrite; doenca biliar; sindrome do intestino irritavel.',
definitiveDiagnosis: 'Diagnostico definitivo pendente de exames complementares.',
prescriptions: 'Sintomatico conforme dor e orientacao de retorno se piora.',
procedures: 'Exame fisico abdominal e solicitacao de exames complementares.',
surgeries: 'Nao indicada ate o momento.',
orientations: 'Retornar com exames, procurar urgencia se febre, vomitos persistentes ou dor intensa.',
labResults: 'Hemograma, PCR e funcao hepatica solicitados.',
imageResults: 'Ultrassonografia abdominal solicitada.',
multiprofessionalNotes: 'Nutricionista podera ser acionada conforme resultado dos exames.',
signature: 'Dr. Carlos Mendes - CRM 654321',
professionalStamp: 'Rascunho criado por Dr. Carlos Mendes em 25/03/2026 09:15',
},
{
id: 'record-4',
patientId: 'mock-fernanda-lima',
patient: 'Fernanda Lima',
patientDocument: 'CPF nao informado',
patientEmail: 'fernanda.lima@example.com',
patientPhone: '11999990004',
dateTime: '2026-03-24T11:00',
createdAt: '2026-03-24T11:00:00.000Z',
updatedAt: '2026-03-24T11:00:00.000Z',
doctor: 'Dra. Ana Silva',
type: 'Avaliacao Pre-Op',
cid: 'K80 - Colelitiase',
status: 'completo',
summary: 'Apta para procedimento cirurgico. Exames pre-operatorios normais.',
diagnosticReasoning: 'Colelitiase sintomatica com avaliacao clinica favoravel para procedimento proposto.',
diagnosticHypotheses: 'Colelitiase sintomatica; baixo risco cardiopulmonar para cirurgia eletiva.',
definitiveDiagnosis: 'Colelitiase com indicacao de abordagem cirurgica eletiva.',
prescriptions: 'Manter medicacoes habituais conforme orientacao anestesica.',
procedures: 'Avaliacao pre-operatoria e revisao de exames.',
surgeries: 'Colecistectomia videolaparoscopica proposta pela equipe cirurgica.',
orientations: 'Jejum e orientacoes pre-operatorias conforme protocolo institucional.',
labResults: 'Hemograma, coagulograma e funcao renal sem contraindicacoes.',
imageResults: 'Ultrassonografia com colelitíase, sem sinais de colecistite aguda.',
multiprofessionalNotes: 'Anestesia orientou avaliacao pre-anestesica complementar.',
signature: 'Dra. Ana Silva - CRM 123456',
professionalStamp: 'Assinado digitalmente por Dra. Ana Silva em 24/03/2026 11:00',
},
{
id: 'record-5',
patientId: 'mock-roberto-campos',
patient: 'Roberto Campos',
patientDocument: 'CPF nao informado',
patientEmail: 'roberto.campos@example.com',
patientPhone: '11999990005',
dateTime: '2026-03-22T16:20',
createdAt: '2026-03-22T16:20:00.000Z',
updatedAt: '2026-03-22T16:20:00.000Z',
doctor: 'Dr. Roberto Nunes',
type: 'Consulta Retorno',
cid: 'E11 - DM Tipo 2',
status: 'completo',
summary: 'HbA1c: 7.2%. Ajuste de metformina. Retorno em 3 meses.',
diagnosticReasoning: 'Diabetes mellitus tipo 2 com controle parcial, necessitando ajuste terapeutico.',
diagnosticHypotheses: 'DM2 em controle parcial; risco metabolico associado.',
definitiveDiagnosis: 'Diabetes mellitus tipo 2.',
prescriptions: 'Ajuste de metformina conforme tolerancia e manutencao de medidas nao farmacologicas.',
procedures: 'Revisao de exames metabolicos e avaliacao de adesao.',
surgeries: 'Nao se aplica.',
orientations: 'Dieta, atividade fisica, monitoramento glicemico e retorno em 3 meses.',
labResults: 'HbA1c 7,2%; demais exames revisados em consulta.',
imageResults: 'Sem exames de imagem novos.',
multiprofessionalNotes: 'Encaminhado para orientacao nutricional.',
signature: 'Dr. Roberto Nunes - CRM 778899',
professionalStamp: 'Assinado digitalmente por Dr. Roberto Nunes em 22/03/2026 16:20',
},
]
export const medicalRecordRepository = { export const medicalRecordRepository = {
getRecordTypes() { getRecordTypes() {
return ['Consulta Retorno', 'Primeira Consulta', 'Exame', 'Avaliacao Pre-Op'] return ['Consulta Retorno', 'Primeira Consulta', 'Exame', 'Avaliacao Pre-Op', 'Evolucao Clinica', 'Registro Multiprofissional']
}, },
getInitialRecords() { getInitialRecords() {
return readRecords()
},
getAll() {
return readRecords()
},
getById(recordId) {
return readRecords().find((record) => String(record.id) === String(recordId)) || null
},
create(data) {
const records = readRecords()
const now = new Date().toISOString()
const record = normalizeRecord({
...data,
id: data.id || `record-${Date.now()}`,
createdAt: data.createdAt || now,
updatedAt: now,
})
writeRecords([record, ...records])
return record
},
update(recordId, data) {
const records = readRecords()
const now = new Date().toISOString()
let updatedRecord = null
const nextRecords = records.map((record) => {
if (String(record.id) !== String(recordId)) return record
updatedRecord = normalizeRecord({ ...record, ...data, id: record.id, updatedAt: now })
return updatedRecord
})
writeRecords(nextRecords)
return updatedRecord
},
getMockReportHistory(patientId, patientName) {
const baseName = patientName || 'Paciente'
return [ return [
{ id: 'record-1', patient: 'Carlos Eduardo Santos', date: '27/03/2026', doctor: 'Dra. Ana Silva', type: 'Consulta Retorno', cid: 'I10 - Hipertensao', status: 'completo', summary: 'Paciente relata melhora com medicacao. PA: 130/85. Mantida conduta.' }, {
{ id: 'record-2', patient: 'Mariana Costa', date: '26/03/2026', doctor: 'Dra. Ana Silva', type: 'Exame', cid: 'Z01.7 - Exame laboratorial', status: 'completo', summary: 'Resultados de hemograma dentro da normalidade. Solicitar retorno em 6 meses.' }, id: `${patientId || 'mock'}-report-1`,
{ id: 'record-3', patient: 'Joao Pedro Alves', date: '25/03/2026', doctor: 'Dr. Carlos Mendes', type: 'Primeira Consulta', cid: 'R10 - Dor abdominal', status: 'rascunho', summary: 'Queixa de dor abdominal ha 2 semanas. Solicitados exames complementares.' }, title: 'Relatorio de consulta medica',
{ id: 'record-4', patient: 'Fernanda Lima', date: '24/03/2026', doctor: 'Dra. Ana Silva', type: 'Avaliacao Pre-Op', cid: 'K80 - Colelitiase', status: 'completo', summary: 'Apta para procedimento cirurgico. Exames pre-operatorios normais.' }, status: 'Finalizado',
{ id: 'record-5', patient: 'Roberto Campos', date: '22/03/2026', doctor: 'Dr. Roberto Nunes', type: 'Consulta Retorno', cid: 'E11 - DM Tipo 2', status: 'completo', summary: 'HbA1c: 7.2%. Ajuste de metformina. Retorno em 3 meses.' }, createdAt: '2026-03-27T13:30:00.000Z',
author: 'Dra. Ana Silva',
summary: `Resumo clinico recente de ${baseName}, com conduta registrada em prontuario.`,
},
{
id: `${patientId || 'mock'}-report-2`,
title: 'Laudo de exame',
status: 'Finalizado',
createdAt: '2026-03-20T09:00:00.000Z',
author: 'Dr. Carlos Mendes',
summary: 'Resultado complementar revisado pela equipe assistencial.',
},
] ]
}, },
} }
function readRecords() {
if (typeof window === 'undefined') return INITIAL_RECORDS.map(normalizeRecord)
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) {
const initial = INITIAL_RECORDS.map(normalizeRecord)
writeRecords(initial)
return initial
}
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return INITIAL_RECORDS.map(normalizeRecord)
return parsed.map(normalizeRecord).sort(sortByDateDesc)
} catch {
return INITIAL_RECORDS.map(normalizeRecord)
}
}
function writeRecords(records) {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(records.map(normalizeRecord).sort(sortByDateDesc)))
} catch {
// Local persistence is best-effort while the module is still mock-backed.
}
}
function normalizeRecord(record) {
const dateTime = record.dateTime || parseLegacyDate(record.date) || toLocalInputValue(new Date())
const summary = record.summary || record.orientations || record.diagnosticReasoning || 'Registro de prontuario sem resumo.'
return {
id: String(record.id || `record-${Date.now()}`),
patientId: String(record.patientId || ''),
patient: record.patient || 'Paciente sem nome',
patientDocument: record.patientDocument || record.document || '',
patientEmail: record.patientEmail || record.email || '',
patientPhone: record.patientPhone || record.phone || '',
dateTime,
date: record.date || formatDateTime(dateTime),
createdAt: record.createdAt || toIso(dateTime),
updatedAt: record.updatedAt || record.createdAt || toIso(dateTime),
doctor: record.doctor || record.professional || 'Profissional nao informado',
type: record.type || 'Primeira Consulta',
cid: record.cid || 'CID nao informado',
status: record.status === 'rascunho' ? 'rascunho' : 'completo',
summary,
diagnosticReasoning: record.diagnosticReasoning || record.anamnesis || summary,
diagnosticHypotheses: record.diagnosticHypotheses || record.cid || 'Hipoteses diagnosticas nao informadas.',
definitiveDiagnosis: record.definitiveDiagnosis || record.cid || 'Diagnostico definitivo nao informado.',
prescriptions: record.prescriptions || 'Prescricao nao informada.',
procedures: record.procedures || record.physicalExam || 'Procedimentos nao informados.',
surgeries: record.surgeries || 'Cirurgias nao informadas ou nao se aplica.',
orientations: record.orientations || record.conduct || 'Orientacoes nao informadas.',
labResults: record.labResults || 'Laudos laboratoriais nao informados.',
imageResults: record.imageResults || 'Laudos de imagem nao informados.',
multiprofessionalNotes: record.multiprofessionalNotes || 'Notas multiprofissionais nao informadas.',
signature: record.signature || record.doctor || 'Assinatura nao informada.',
professionalStamp: record.professionalStamp || `Registro assinado por ${record.doctor || 'profissional nao informado'}.`,
}
}
function sortByDateDesc(a, b) {
return new Date(b.dateTime || b.createdAt).getTime() - new Date(a.dateTime || a.createdAt).getTime()
}
function parseLegacyDate(value) {
const match = String(value || '').match(/^(\d{2})\/(\d{2})\/(\d{4})$/)
if (!match) return ''
return `${match[3]}-${match[2]}-${match[1]}T09:00`
}
function toIso(value) {
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString()
}
function toLocalInputValue(date) {
const parsed = date instanceof Date ? date : new Date(date)
const safeDate = Number.isNaN(parsed.getTime()) ? new Date() : parsed
const year = safeDate.getFullYear()
const month = String(safeDate.getMonth() + 1).padStart(2, '0')
const day = String(safeDate.getDate()).padStart(2, '0')
const hours = String(safeDate.getHours()).padStart(2, '0')
const minutes = String(safeDate.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}`
}
function formatDateTime(value) {
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return 'Data nao informada'
return parsed.toLocaleString('pt-BR')
}

View File

@@ -1,22 +1,34 @@
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js' import { apiConfig, getAnonHeaders, getAuthenticatedHeaders } from '../config/api.js'
import { getResponseError } from './repositoryUtils.js'
export const patientRepository = { export const patientRepository = {
// 1. Listar pacientes // 1. Listar pacientes
async getAll() { async getAll() {
const response = await fetch(`${apiConfig.restUrl}/patients?select=*`, { headers: getAuthenticatedHeaders() }) const response = await fetch(`${apiConfig.restUrl}/patients?select=*`, { headers: getAuthenticatedHeaders() })
if (!response.ok) throw new Error('Erro ao buscar pacientes') if (!response.ok) throw new Error(await getResponseError(response, 'Erro ao buscar pacientes.'))
return response.json() return response.json()
}, },
async getById(patientId) { async getById(patientId) {
const patients = await this.getAll() const [patients, appointments] = await Promise.all([
this.getAll(),
getAppointments().catch(() => []),
])
const patient = patients.find((p) => String(p.id) === String(patientId)) || null const patient = patients.find((p) => String(p.id) === String(patientId)) || null
return patient ? mapPatientToDetail(patient) : null return patient ? mapPatientToDetail(patient, appointments) : null
}, },
async getDirectoryRows() { async getDirectoryRows({ doctorId } = {}) {
const patients = await this.getAll() const [patients, appointments] = await Promise.all([
return patients.map(mapPatientToDirectory) this.getAll(),
getAppointments({ doctorId }).catch(() => []),
])
const visiblePatients = doctorId
? getPatientsFromDoctorAppointments(patients, appointments)
: patients
return visiblePatients.map((patient) => mapPatientToDirectory(patient, appointments))
}, },
// 2. Criar paciente (direto) // 2. Criar paciente (direto)
@@ -26,7 +38,7 @@ export const patientRepository = {
cpf: data.cpf, cpf: data.cpf,
email: data.email, email: data.email,
phone_mobile: data.phone, phone_mobile: data.phone,
birth_date: data.birthDate || null, birth_date: data.birthDate || data.birth_date || null,
created_by: data.createdBy || '00000000-0000-0000-0000-000000000000', created_by: data.createdBy || '00000000-0000-0000-0000-000000000000',
} }
@@ -37,9 +49,11 @@ export const patientRepository = {
}) })
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({})) if ([401, 403].includes(response.status)) {
console.error('Erro da API ao criar paciente:', error) return this.createWithValidation(data)
throw new Error(error.message || error.hint || JSON.stringify(error)) }
throw new Error(await getResponseError(response, 'Erro ao criar paciente.'))
} }
return response.json() return response.json()
@@ -52,7 +66,7 @@ export const patientRepository = {
cpf: data.cpf, cpf: data.cpf,
email: data.email, email: data.email,
phone_mobile: data.phone, phone_mobile: data.phone,
birth_date: data.birthDate || null, birth_date: data.birthDate || data.birth_date || null,
created_by: data.createdBy || '00000000-0000-0000-0000-000000000000', created_by: data.createdBy || '00000000-0000-0000-0000-000000000000',
} }
@@ -63,8 +77,30 @@ export const patientRepository = {
}) })
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({})) throw new Error(await getResponseError(response, 'Erro ao criar paciente com validação.'))
throw new Error(error.message || 'Erro ao criar paciente com validacao') }
return response.json()
},
async registerPublic(data) {
const body = cleanPayload({
full_name: data.name || data.full_name,
cpf: data.cpf,
email: data.email,
phone_mobile: data.phone || data.phone_mobile,
birth_date: data.birthDate || data.birth_date || null,
redirect_url: data.redirectUrl || data.redirect_url,
})
const response = await fetch(`${apiConfig.functionsUrl}/register-patient`, {
method: 'POST',
headers: getAnonHeaders(),
body: JSON.stringify(body),
})
if (!response.ok) {
throw new Error(await getResponseError(response, 'Erro ao realizar auto-cadastro de paciente.'))
} }
return response.json() return response.json()
@@ -77,7 +113,7 @@ export const patientRepository = {
cpf: data.cpf, cpf: data.cpf,
email: data.email, email: data.email,
phone_mobile: data.phone, phone_mobile: data.phone,
birth_date: data.birthDate || null, birth_date: data.birthDate || data.birth_date || null,
} }
const response = await fetch(`${apiConfig.restUrl}/patients?id=eq.${patientId}`, { const response = await fetch(`${apiConfig.restUrl}/patients?id=eq.${patientId}`, {
@@ -86,7 +122,7 @@ export const patientRepository = {
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
if (!response.ok) throw new Error('Erro ao atualizar paciente') if (!response.ok) throw new Error(await getResponseError(response, 'Erro ao atualizar paciente.'))
return response.json() return response.json()
}, },
@@ -97,46 +133,205 @@ export const patientRepository = {
headers: getAuthenticatedHeaders(), headers: getAuthenticatedHeaders(),
}) })
if (!response.ok) throw new Error('Erro ao deletar paciente') if (!response.ok) throw new Error(await getResponseError(response, 'Erro ao deletar paciente.'))
return true return true
}, },
} }
function mapPatientToDirectory(patient) { function mapPatientToDirectory(patient, appointments = []) {
const appointmentSummary = summarizeAppointments(patient.id, appointments)
const city = getFirstValue(patient, ['city', 'cidade', 'address_city', 'municipio'], patient.address?.city)
const state = getFirstValue(patient, ['state', 'uf', 'address_state', 'estado'], patient.address?.state)
const insurance = getFirstValue(patient, ['insurance', 'convenio', 'health_insurance', 'insurance_name'])
return { return {
...patient, ...patient,
name: patient.name || patient.full_name || patient.nome || 'Paciente', name: patient.name || patient.full_name || patient.nome || 'Paciente',
phone: patient.phone || patient.phone_mobile || patient.telefone || '', phone: patient.phone || patient.phone_mobile || patient.telefone || '',
detailId: patient.id, detailId: patient.id,
insurance: patient.insurance || patient.convenio || 'Particular', insurance: normalizeInsurance(insurance),
city: patient.city || patient.cidade || 'Recife', city,
state: patient.state || patient.uf || 'PE', state,
vip: Boolean(patient.vip), vip: Boolean(patient.vip),
lastVisitIso: patient.lastVisitIso || patient.last_visit_iso || null, birthDate: patient.birthDate || patient.birth_date || '',
lastVisit: patient.lastVisit || patient.last_visit || 'Ainda nao houve atendimento', motherName: patient.motherName || patient.mother_name || patient.nome_mae || '',
nextVisit: patient.nextVisit || patient.next_visit || 'Nenhum atendimento agendado', fatherName: patient.fatherName || patient.father_name || patient.nome_pai || '',
ethnicity: patient.ethnicity || patient.etnia || '',
maritalStatus: patient.maritalStatus || patient.marital_status || patient.estado_civil || '',
phoneSecondary: patient.phoneSecondary || patient.phone_secondary || patient.phone_home || '',
zipCode: patient.zipCode || patient.zip_code || patient.cep || '',
addressStreet: patient.addressStreet || patient.address_street || patient.street || patient.logradouro || patient.address || '',
addressNumber: patient.addressNumber || patient.address_number || patient.numero || '',
addressComplement: patient.addressComplement || patient.address_complement || patient.complemento || '',
plan: patient.plan || patient.plano || patient.insurance_plan || '',
notesText: patient.notesText || patient.notes_text || patient.observations || patient.observacoes || '',
lastVisitIso: patient.lastVisitIso || patient.last_visit_iso || appointmentSummary.lastVisitIso || null,
lastVisit: patient.lastVisit || patient.last_visit || appointmentSummary.lastVisit || '',
nextVisit: patient.nextVisit || patient.next_visit || appointmentSummary.nextVisit || '',
} }
} }
function mapPatientToDetail(patient) { function mapPatientToDetail(patient, appointments = []) {
const directory = mapPatientToDirectory(patient) const directory = mapPatientToDirectory(patient, appointments)
return { return {
...directory, ...directory,
age: patient.age || patient.idade || calculateAge(patient.birth_date), age: patient.age || patient.idade || calculateAge(patient.birth_date),
document: patient.document || patient.cpf || 'CPF nao informado', document: patient.document || patient.cpf || 'CPF não informado',
plan: directory.insurance, plan: directory.plan || directory.insurance,
condition: patient.condition || patient.condicao || 'Sem condicao principal', condition: patient.condition || patient.condicao || 'Sem condicao principal',
status: patient.status || 'Acompanhamento', status: patient.status || 'Acompanhamento',
risk: patient.risk || patient.risco || 'Baixo', risk: patient.risk || patient.risco || 'Baixo',
email: patient.email || '', email: patient.email || '',
address: patient.address || patient.endereco || 'Endereco nao informado', address: formatAddress(directory) || patient.address || patient.endereco || 'Endereço não informado',
team: patient.team || patient.equipe || [], team: patient.team || patient.equipe || [],
notes: patient.notes || patient.observacoes || [], notes: normalizeNotes(patient.notes || patient.observacoes || directory.notesText),
exams: patient.exams || patient.exames || [], exams: patient.exams || patient.exames || [],
} }
} }
async function getAppointments({ doctorId } = {}) {
const query = new URLSearchParams()
query.set('select', '*,patients(*)')
if (doctorId) {
query.set('doctor_id', `eq.${doctorId}`)
}
const response = await fetch(`${apiConfig.restUrl}/appointments?${query.toString()}`, {
headers: getAuthenticatedHeaders(),
})
if (!response.ok) return []
return response.json()
}
function getPatientsFromDoctorAppointments(patients, appointments) {
const patientById = new Map(
patients
.map((patient) => [normalizeId(patient.id), patient])
.filter(([id]) => id),
)
const visibleIds = new Set()
for (const appointment of appointments) {
const patientId = normalizeId(
appointment.patient_id ||
appointment.patientId ||
appointment.paciente_id ||
appointment.patients?.id ||
appointment.patient?.id ||
appointment.paciente?.id,
)
if (!patientId) continue
visibleIds.add(patientId)
if (!patientById.has(patientId)) {
const embeddedPatient = appointment.patients || appointment.patient || appointment.paciente
if (embeddedPatient) {
patientById.set(patientId, { ...embeddedPatient, id: embeddedPatient.id || patientId })
}
}
}
return [...visibleIds]
.map((patientId) => patientById.get(patientId))
.filter(Boolean)
}
function summarizeAppointments(patientId, appointments) {
const now = new Date()
const normalizedPatientId = String(patientId)
const patientAppointments = appointments
.filter((appointment) => String(appointment.patient_id || appointment.patientId || appointment.paciente_id || '') === normalizedPatientId)
.map((appointment) => ({
...appointment,
date: getAppointmentDate(appointment),
}))
.filter((appointment) => appointment.date)
.sort((a, b) => a.date - b.date)
const past = patientAppointments.filter((appointment) => appointment.date < now)
const future = patientAppointments.filter((appointment) => appointment.date >= now)
const last = past.at(-1)
const next = future[0]
return {
lastVisitIso: last ? formatDateInput(last.date) : null,
lastVisit: last ? formatAppointmentLabel(last.date) : '',
nextVisit: next ? formatAppointmentLabel(next.date) : '',
}
}
function getAppointmentDate(appointment) {
if (appointment.scheduled_at) {
const date = new Date(appointment.scheduled_at)
return Number.isNaN(date.getTime()) ? null : date
}
const dateValue = appointment.date || appointment.appointment_date || appointment.data
const timeValue = appointment.time || appointment.appointment_time || appointment.hora || '00:00'
if (!dateValue) return null
const date = new Date(`${dateValue}T${timeValue}`)
return Number.isNaN(date.getTime()) ? null : date
}
function formatAppointmentLabel(date) {
return new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
function formatDateInput(date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function getFirstValue(source, keys, fallback = '') {
for (const key of keys) {
if (source?.[key]) return source[key]
}
return fallback || ''
}
function normalizeId(value) {
return String(value || '').trim()
}
function formatAddress(patient) {
return [
patient.addressStreet,
patient.addressNumber,
patient.addressComplement,
patient.city,
patient.state,
patient.zipCode,
]
.filter(Boolean)
.join(', ')
}
function normalizeNotes(notes) {
if (Array.isArray(notes)) return notes
if (!notes) return []
return [String(notes)]
}
function normalizeInsurance(value) {
const normalized = String(value || '').trim()
if (normalized.toLowerCase() === 'bradesco saude') return 'Bradesco Saúde'
return normalized
}
function calculateAge(birthDate) { function calculateAge(birthDate) {
if (!birthDate) return 0 if (!birthDate) return 0
@@ -153,3 +348,9 @@ function calculateAge(birthDate) {
return age return age
} }
function cleanPayload(payload) {
return Object.fromEntries(
Object.entries(payload).filter(([, value]) => value !== undefined && value !== null && value !== ''),
)
}

View File

@@ -1,4 +1,5 @@
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js' import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
import { getResponseError, normalizeItem } from './repositoryUtils.js'
export const professionalRepository = { export const professionalRepository = {
async getAll() { async getAll() {
@@ -6,30 +7,80 @@ export const professionalRepository = {
headers: getAuthenticatedHeaders() headers: getAuthenticatedHeaders()
}) })
if (!response.ok) throw new Error('Erro ao buscar medicos.') if (!response.ok) {
throw new Error(await getResponseError(response, 'Erro ao buscar médicos.'))
}
const data = await response.json() const data = await response.json()
return (Array.isArray(data) ? data : []).map(mapProfessional) return (Array.isArray(data) ? data : []).map(mapProfessional)
}, },
async create(data) {
const response = await fetch(`${apiConfig.functionsUrl}/create-doctor`, {
method: 'POST',
headers: getAuthenticatedHeaders(),
body: JSON.stringify(cleanPayload({
full_name: data.fullName || data.full_name || data.name,
email: data.email,
cpf: data.cpf,
crm: data.crm,
crm_uf: data.crmUf || data.crm_uf,
phone_mobile: data.phoneMobile || data.phone_mobile || data.phone,
specialty: data.specialty || data.specialidade,
birth_date: data.birthDate || data.birth_date,
})),
})
if (!response.ok) {
throw new Error(await getResponseError(response, 'Erro ao criar médico.'))
}
return mapProfessional(normalizeItem(await response.json(), ['doctor']))
},
getCoverageMap() { getCoverageMap() {
return { return {
slots: ['08-12', '09-13', '10-15', '13-18', '08-14'], slots: ['08-12', '09-13', '10-15', '13-18', '08-14'],
weekdays: ['Seg', 'Ter', 'Qua', 'Qui', 'Sex'], weekdays: ['Seg', 'Ter', 'Qua', 'Qui', 'Sex'],
} }
}, },
resolveCurrentProfessional(profile, professionals = []) {
const doctorId = normalizeValue(profile?.doctorId)
const userId = normalizeValue(profile?.id)
const email = normalizeValue(profile?.email)
return (
professionals.find((professional) => normalizeValue(professional.id) === doctorId) ||
professionals.find((professional) => normalizeValue(professional.userId) === userId) ||
professionals.find((professional) => normalizeValue(professional.id) === userId) ||
professionals.find((professional) => normalizeValue(professional.email) === email) ||
null
)
},
} }
function mapProfessional(doctor) { function mapProfessional(doctor) {
return { return {
id: String(doctor.id || doctor.medico_id || doctor.user_id || doctor.name || doctor.nome), id: String(doctor.id || doctor.medico_id || doctor.user_id || doctor.name || doctor.nome),
userId: doctor.user_id || doctor.userId || doctor.usuario_id || doctor.auth_user_id || null, userId: doctor.user_id || doctor.userId || doctor.usuario_id || doctor.auth_user_id || null,
name: doctor.name || doctor.nome || doctor.full_name || 'Medico(a)', name: doctor.name || doctor.nome || doctor.full_name || 'Médico(a)',
email: doctor.email || doctor.user_email || doctor.usuario_email || '', email: doctor.email || doctor.user_email || doctor.usuario_email || '',
role: doctor.specialty || doctor.speciality || doctor.especialidade || doctor.role || 'Medico(a)', unit: doctor.unit || doctor.unidade || doctor.clinic_unit || doctor.clinica || doctor.location || '',
role: doctor.specialty || doctor.speciality || doctor.especialidade || doctor.role || 'Médico(a)',
schedule: doctor.schedule || doctor.agenda || doctor.disponibilidade || 'Seg a Sex, 08h as 18h', schedule: doctor.schedule || doctor.agenda || doctor.disponibilidade || 'Seg a Sex, 08h as 18h',
nextSlot: doctor.nextSlot || doctor.proximo_horario || doctor.next_slot || 'Consulta pendente', nextSlot: doctor.nextSlot || doctor.proximo_horario || doctor.next_slot || 'Consulta pendente',
patients: doctor.patients || doctor.pacientes_ativos || doctor.active_patients || 0, patients: doctor.patients || doctor.pacientes_ativos || doctor.active_patients || 0,
status: doctor.status || doctor.situacao || 'Disponivel', status: doctor.status || doctor.situacao || 'Disponivel',
} }
} }
function normalizeValue(value) {
return String(value || '').trim().toLowerCase()
}
function cleanPayload(payload) {
return Object.fromEntries(
Object.entries(payload).filter(([, value]) => value !== undefined && value !== null && value !== ''),
)
}

View File

@@ -1,4 +1,5 @@
import { authRepository } from './authRepository.js' import { authRepository } from './authRepository.js'
import { normalizeRole, ROLE_LABELS } from '../config/permissions.js'
import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js' import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js'
import { getResponseError } from './repositoryUtils.js' import { getResponseError } from './repositoryUtils.js'
@@ -9,7 +10,8 @@ export const profileRepository = {
const user = data?.user || data?.usuario || profile || data const user = data?.user || data?.usuario || profile || data
const meta = user?.user_metadata || user?.metadata || user?.app_metadata || {} const meta = user?.user_metadata || user?.metadata || user?.app_metadata || {}
const permissions = data?.permissions || {} const permissions = data?.permissions || {}
const roles = Array.isArray(data?.roles) ? data.roles : [] const roles = collectRoles({ data, meta, profile, user })
const normalizedRole = resolveNormalizedRole({ permissions, roles, user, meta })
const avatarUrl = const avatarUrl =
profile?.avatar_url || profile?.avatar_url ||
profile?.avatarUrl || profile?.avatarUrl ||
@@ -22,17 +24,19 @@ export const profileRepository = {
return { return {
id: profile?.id || user?.id || user?.user_id || user?.uid || '', id: profile?.id || user?.id || user?.user_id || user?.uid || '',
email: profile?.email || user?.email || meta.email || '', email: profile?.email || user?.email || meta.email || '',
name: profile?.full_name || user?.name || user?.nome || user?.full_name || meta.full_name || meta.name || 'Usuario', name: profile?.full_name || user?.name || user?.nome || user?.full_name || meta.full_name || meta.name || 'Usuário',
phone: profile?.phone || user?.phone || user?.telefone || meta.phone || meta.telefone || '', phone: profile?.phone || user?.phone || user?.telefone || meta.phone || meta.telefone || '',
role: resolveProfileRole({ permissions, roles, user, meta }), role: ROLE_LABELS[normalizedRole] || user?.role || user?.cargo || meta.role || meta.cargo || 'Usuário do Sistema',
unit: profile?.unit || user?.unit || user?.unidade || meta.unit || meta.unidade || 'Clinica Boa Vista', unit: profile?.unit || user?.unit || user?.unidade || meta.unit || meta.unidade || 'Clínica Boa Vista',
avatarUrl, avatarUrl,
doctorId: data?.doctor_id || data?.doctorId || null, doctorId: data?.doctor_id || data?.doctorId || null,
patientId: data?.patient_id || data?.patientId || null, patientId: data?.patient_id || data?.patientId || null,
roles, roles,
permissions, permissions,
isDoctor: Boolean(permissions.isDoctor || roles.includes('doctor') || data?.doctor_id), isDoctor: normalizedRole === 'medico',
isAdmin: Boolean(permissions.isAdmin || roles.includes('admin')), isAdmin: normalizedRole === 'admin',
isManager: normalizedRole === 'gestor',
isSecretary: normalizedRole === 'secretaria',
} }
}, },
@@ -57,7 +61,7 @@ export const profileRepository = {
} }
if (!profile.id) { if (!profile.id) {
throw new Error('Nao foi possivel identificar o usuario para enviar o avatar.') throw new Error('Não foi possível identificar o usuário para enviar o avatar.')
} }
const extension = file.name?.split('.').pop() || 'jpg' const extension = file.name?.split('.').pop() || 'jpg'
@@ -80,6 +84,24 @@ export const profileRepository = {
path: objectPath, path: objectPath,
} }
}, },
async downloadAvatar(path) {
const objectPath = String(path || '').replace(/^\/+/, '')
const response = await fetch(`${apiConfig.storageUrl}/object/avatars/${objectPath}`, {
method: 'GET',
headers: getAuthenticatedHeaders({ 'Content-Type': undefined }),
})
if (!response.ok) {
throw new Error(await getResponseError(response, 'Falha ao baixar avatar.'))
}
return {
blob: await response.blob(),
contentType: response.headers.get('content-type') || 'application/octet-stream',
path: objectPath,
}
},
} }
function normalizeAvatarResponse(data) { function normalizeAvatarResponse(data) {
@@ -89,12 +111,31 @@ function normalizeAvatarResponse(data) {
} }
} }
function resolveProfileRole({ permissions, roles, user, meta }) { function collectRoles({ data, meta, profile, user }) {
if (permissions.isAdmin || roles.includes('admin')) return 'Administrador' return [
if (permissions.isManager || roles.includes('manager')) return 'Gestor' ...(Array.isArray(data?.roles) ? data.roles : []),
if (permissions.isDoctor || roles.includes('doctor')) return 'Medico(a)' ...(Array.isArray(user?.roles) ? user.roles : []),
if (permissions.isSecretary || roles.includes('secretary')) return 'Secretaria' data?.role,
if (permissions.isPatient || roles.includes('patient')) return 'Paciente' data?.cargo,
profile?.role,
return user?.role || user?.cargo || meta.role || meta.cargo || 'Usuario do Sistema' profile?.cargo,
user?.role,
user?.cargo,
meta.role,
meta.cargo,
].filter(Boolean)
}
function resolveNormalizedRole({ permissions, roles, user, meta }) {
for (const role of roles) {
const normalized = normalizeRole(role)
if (normalized) return normalized
}
if (permissions.isAdmin) return 'admin'
if (permissions.isManager) return 'gestor'
if (permissions.isDoctor) return 'medico'
if (permissions.isSecretary) return 'secretaria'
return normalizeRole(user?.role || user?.cargo || meta.role || meta.cargo)
} }

View File

@@ -10,6 +10,8 @@ export const reportRepository = {
if (filters.patientId) { if (filters.patientId) {
query.set('patient_id', `eq.${filters.patientId}`) query.set('patient_id', `eq.${filters.patientId}`)
} else if (filters.patientIds?.length) {
query.set('patient_id', `in.(${filters.patientIds.join(',')})`)
} }
if (filters.status) { if (filters.status) {
@@ -18,6 +20,10 @@ export const reportRepository = {
if (filters.createdBy) { if (filters.createdBy) {
query.set('created_by', `eq.${filters.createdBy}`) query.set('created_by', `eq.${filters.createdBy}`)
} else if (filters.createdByValues?.length === 1) {
query.set('created_by', `eq.${filters.createdByValues[0]}`)
} else if (filters.createdByValues?.length > 1) {
query.set('created_by', `in.(${filters.createdByValues.join(',')})`)
} }
const response = await fetch(`${apiConfig.restUrl}/reports?${query.toString()}`, { const response = await fetch(`${apiConfig.restUrl}/reports?${query.toString()}`, {
@@ -25,7 +31,7 @@ export const reportRepository = {
}) })
if (!response.ok) { if (!response.ok) {
throw new Error(await getResponseError(response, 'Falha ao buscar relatorios medicos.')) throw new Error(await getResponseError(response, 'Falha ao buscar relatórios médicos.'))
} }
const data = await response.json() const data = await response.json()
@@ -33,18 +39,28 @@ export const reportRepository = {
}, },
async create(uiData) { async create(uiData) {
const response = await fetch(`${apiConfig.restUrl}/reports`, { let lastResponse = null
method: 'POST',
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
body: JSON.stringify(reportMapper.toApi(uiData)),
})
if (!response.ok) { for (const payload of buildCreatePayloads(reportMapper.toApi(uiData))) {
throw new Error(await getResponseError(response, 'Falha ao criar relatorio medico.')) const response = await fetch(`${apiConfig.restUrl}/reports`, {
method: 'POST',
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
body: JSON.stringify(payload),
})
if (response.ok) {
const data = await response.json()
return reportMapper.toUi(normalizeItem(data))
}
lastResponse = response
if (response.status !== 400) {
break
}
} }
const data = await response.json() throw new Error(await getResponseError(lastResponse, 'Falha ao criar relatório médico.'))
return reportMapper.toUi(normalizeItem(data))
}, },
async update(id, uiData) { async update(id, uiData) {
@@ -55,10 +71,45 @@ export const reportRepository = {
}) })
if (!response.ok) { if (!response.ok) {
throw new Error(await getResponseError(response, 'Falha ao atualizar relatorio medico.')) throw new Error(await getResponseError(response, 'Falha ao atualizar relatório médico.'))
} }
const data = await response.json() const data = await response.json()
return reportMapper.toUi(normalizeItem(data)) return reportMapper.toUi(normalizeItem(data))
}, },
} }
function buildCreatePayloads(payload) {
return uniquePayloads([
omitFields(payload, ['order_number', 'created_by', 'updated_by']),
omitFields(payload, ['order_number', 'created_by', 'updated_by', 'content_json']),
omitFields(payload, ['order_number', 'created_by', 'updated_by', 'content_json', 'hide_date', 'hide_signature', 'due_at']),
pickFields(payload, ['patient_id', 'status', 'exam', 'requested_by', 'cid_code', 'diagnosis', 'conclusion', 'content_html']),
payload,
])
}
function omitFields(payload, fields) {
return Object.fromEntries(
Object.entries(payload).filter(([field]) => !fields.includes(field)),
)
}
function pickFields(payload, fields) {
return Object.fromEntries(
fields
.filter((field) => payload[field] !== undefined)
.map((field) => [field, payload[field]]),
)
}
function uniquePayloads(payloads) {
const seen = new Set()
return payloads.filter((payload) => {
const signature = JSON.stringify(payload)
if (seen.has(signature)) return false
seen.add(signature)
return true
})
}

View File

@@ -23,7 +23,7 @@ export async function fetchJsonWithFallback(requests, fallbackMessage) {
} }
if (lastError && !lastResponse) { if (lastError && !lastResponse) {
throw new Error(lastError.message || fallbackMessage) throw new Error(translateErrorMessage(lastError.message || fallbackMessage, fallbackMessage))
} }
throw new Error(await getResponseError(lastResponse, fallbackMessage)) throw new Error(await getResponseError(lastResponse, fallbackMessage))
@@ -49,11 +49,95 @@ export function normalizeItem(data, keys = []) {
return data || null return data || null
} }
export async function getResponseError(response, fallbackMessage) { export async function getResponseError(response, fallbackMessage = 'Erro inesperado.') {
if (!response) return fallbackMessage if (!response) return translateErrorMessage(fallbackMessage)
const error = await response.json().catch(() => ({})) const text = await response.text().catch(() => '')
return error.error_description || error.msg || error.message || error.error || fallbackMessage const error = parseErrorBody(text)
const message = translateErrorMessage(
getErrorMessage(error, text) || fallbackMessage,
fallbackMessage,
)
return response.status ? `${fallbackMessage} (${response.status}): ${message}` : message
}
export function translateErrorMessage(message, fallbackMessage = 'Erro inesperado.') {
const rawMessage = String(message || '').trim()
const normalized = rawMessage.toLowerCase()
if (!rawMessage) return fallbackMessage
if (isPortugueseMessage(rawMessage)) return rawMessage
const translations = [
[/failed to fetch|networkerror|load failed|network request failed/, 'Não foi possível conectar ao servidor. Verifique sua conexão e tente novamente.'],
[/fetch failed|failed sending request|connection refused|timeout|timed out/, 'Não foi possível conectar ao servidor. Verifique sua conexão e tente novamente.'],
[/invalid login credentials|invalid credentials/, 'E-mail ou senha inválidos.'],
[/signup requires a valid password|password should be at least|weak password|invalid password/, 'Informe uma senha válida para continuar.'],
[/email rate limit exceeded|rate limit exceeded|too many requests|for security purposes.*request this after/, 'Muitas tentativas em pouco tempo. Aguarde alguns minutos e tente novamente.'],
[/invalid email|email address.*invalid|unable to validate email address/, 'Informe um e-mail válido.'],
[/email not confirmed/, 'E-mail ainda não confirmado. Verifique sua caixa de entrada.'],
[/user already registered|already registered/, 'Este e-mail já está cadastrado.'],
[/user not found/, 'Usuário não encontrado.'],
[/signup.*disabled|signups not allowed|user signups are disabled/, 'O cadastro de novos usuários está desabilitado no momento.'],
[/database error saving new user|database error.*user/, 'Não foi possível salvar o usuário. Tente novamente ou contate o suporte.'],
[/database error|unexpected failure|internal server error|server error/, 'A API encontrou um erro interno. Tente novamente ou contate o suporte.'],
[/jwt expired|invalid jwt|jwt malformed|invalid token|token is expired/, 'Sessão expirada. Faça login novamente.'],
[/missing required parameters?/, 'Parâmetros obrigatórios não foram enviados.'],
[/required field|field .* is required|required parameter|missing .* field/, 'Campo obrigatório não preenchido.'],
[/duplicate key value violates unique constraint/, 'Já existe um registro com essas informações.'],
[/new row violates row-level security policy|row-level security policy|permission denied|insufficient privileges|not authorized|unauthorized|forbidden/, 'Você não tem permissão para realizar esta ação.'],
[/violates foreign key constraint/, 'Não foi possível salvar porque há um vínculo obrigatório ausente ou inválido.'],
[/violates check constraint/, 'Os dados enviados não atendem às regras de validação.'],
[/null value in column "([^"]+)".*violates not-null constraint/, 'Campo obrigatório não preenchido.'],
[/invalid input value for enum ([^:]+): "([^"]+)"/, 'Valor inválido para uma opção do sistema.'],
[/invalid input syntax for type uuid/, 'Identificador inválido enviado para a API.'],
[/invalid input syntax for type (integer|bigint|numeric|date|timestamp|boolean)/, 'Valor inválido enviado para a API.'],
[/value too long for type|too long/, 'Um dos campos excede o tamanho permitido.'],
[/relation .* does not exist/, 'Recurso da API não encontrado.'],
[/function .* does not exist/, 'Endpoint da API não encontrado.'],
[/endpoint.*not found|not found/, 'Recurso da API não encontrado.'],
[/cors|preflight/, 'A API bloqueou a requisição por configuração de CORS.'],
]
for (const [pattern, translation] of translations) {
if (pattern.test(normalized)) return translation
}
return isLikelyEnglishMessage(rawMessage) ? fallbackMessage : rawMessage
}
function getErrorMessage(error, text) {
return error.error_description ||
error.msg ||
error.message ||
error.error ||
error.detail ||
error.details ||
error.hint ||
formatFieldErrors(error.errors) ||
text
}
function formatFieldErrors(errors) {
if (!errors || typeof errors !== 'object') return ''
const messages = Object.entries(errors)
.flatMap(([field, fieldErrors]) => {
const values = Array.isArray(fieldErrors) ? fieldErrors : [fieldErrors]
return values.filter(Boolean).map((message) => `${field}: ${message}`)
})
return messages.join('; ')
}
function isPortugueseMessage(message) {
return /[ãõáéíóúâêôç]/i.test(message) ||
/\b(erro|falha|não|nao|usuário|usuario|senha|campo|obrigatório|obrigatorio|sessão|sessao)\b/i.test(message)
}
function isLikelyEnglishMessage(message) {
return /[a-z]/i.test(message) && !/[ãõáéíóúâêôç]/i.test(message)
} }
function shouldFallback(response) { function shouldFallback(response) {
@@ -72,3 +156,13 @@ async function parseJsonResponse(response) {
return { message: text } return { message: text }
} }
} }
function parseErrorBody(text) {
if (!text) return {}
try {
return JSON.parse(text)
} catch {
return { message: text }
}
}

View File

@@ -1,23 +1,9 @@
export const settingsRepository = { export const settingsRepository = {
getIntegrations() {
return [
['WhatsApp Business', 'Envio automatico de lembretes e confirmacoes', true, 'bg-emerald-500'],
['Google Calendar', 'Sincronizacao bidirecional de agenda', false, 'bg-blue-500'],
['Stripe / PagSeguro', 'Pagamentos online e links de cobranca', true, 'bg-violet-500'],
['CFM - Conselho Federal de Medicina', 'Validacao automatica de CRM', false, 'bg-amber-500'],
['ANS - Planos de Saude', 'Integracao com tabela TUSS e convenios', false, 'bg-rose-500'],
['API de IA Preditiva', 'Score de absenteismo e predicao de faltas', true, 'bg-[#3b82f6]'],
]
},
getSections() { getSections() {
return [ return [
{ id: 'aparencia', label: 'Aparencia', description: 'Tema, cores e exibicao', icon: 'palette' }, { id: 'aparencia', label: 'Aparência e Acessibilidade', description: 'Tema, cores e exibição', icon: 'palette' },
{ id: 'notificacoes', label: 'Notificacoes', description: 'Alertas e lembretes', icon: 'bell' },
{ id: 'privacidade', label: 'Privacidade & LGPD', description: 'Dados e conformidade', icon: 'shield' }, { id: 'privacidade', label: 'Privacidade & LGPD', description: 'Dados e conformidade', icon: 'shield' },
{ id: 'conta', label: 'Conta & Perfil', description: 'Informacoes pessoais', icon: 'user' }, { id: 'dados', label: 'Dados & Backup', description: 'Exportação e backup', icon: 'database' },
{ id: 'integracoes', label: 'Integracoes', description: 'APIs e sistemas externos', icon: 'globe' },
{ id: 'dados', label: 'Dados & Backup', description: 'Exportacao e backup', icon: 'database' },
] ]
}, },
} }

View File

@@ -0,0 +1,120 @@
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
import { getResponseError, normalizeCollection } from './repositoryUtils.js'
const USER_PROFILE_TABLES = ['profiles', 'user_profiles']
const USER_LIST_KEYS = ['users', 'usuarios', 'data', 'items', 'results']
export const userRepository = {
async getAll() {
let lastResponse = null
for (const table of USER_PROFILE_TABLES) {
const query = new URLSearchParams({
select: '*',
})
const response = await fetch(`${apiConfig.restUrl}/${table}?${query.toString()}`, {
headers: getAuthenticatedHeaders(),
}).catch(() => null)
if (!response) continue
lastResponse = response
if (response.ok) {
const data = await response.json().catch(() => null)
return normalizeCollection(data, USER_LIST_KEYS).map(normalizeListedUser)
}
if (![404, 406].includes(response.status)) {
throw new Error(await getResponseError(response, 'Erro ao listar usuários.'))
}
}
throw new Error(await getResponseError(lastResponse, 'Tabela de perfis de usuários não encontrada.'))
},
async getById(userId) {
const response = await fetch(`${apiConfig.functionsUrl}/user-info-by-id/${encodeURIComponent(userId)}`, {
method: 'POST',
headers: getAuthenticatedHeaders(),
})
if (!response.ok) {
throw new Error(await getResponseError(response, 'Erro ao buscar usuário.'))
}
return response.json()
},
async create(data) {
const response = await fetch(`${apiConfig.functionsUrl}/create-user`, {
method: 'POST',
headers: getAuthenticatedHeaders(),
body: JSON.stringify(buildCreateUserBody(data)),
})
if (!response.ok) {
throw new Error(await getResponseError(response, 'Erro ao criar usuário.'))
}
return response.json()
},
async createWithPassword(data) {
const body = {
...buildCreateUserBody(data),
password: data.password,
}
const response = await fetch(`${apiConfig.functionsUrl}/create-user-with-password`, {
method: 'POST',
headers: getAuthenticatedHeaders(),
body: JSON.stringify(body),
})
if (!response.ok) {
throw new Error(await getResponseError(response, 'Erro ao criar usuário com senha.'))
}
return response.json()
},
async remove(userId) {
const response = await fetch(`${apiConfig.functionsUrl}/delete-user`, {
method: 'POST',
headers: getAuthenticatedHeaders(),
body: JSON.stringify({ userId, user_id: userId }),
})
if (!response.ok) {
throw new Error(await getResponseError(response, 'Erro ao deletar usuário.'))
}
return true
},
}
function buildCreateUserBody(data) {
const body = {
email: data.email?.trim(),
full_name: data.full_name?.trim(),
phone: data.phone?.trim(),
cpf: data.cpf?.trim(),
role: data.role,
}
if (data.create_patient_record) {
body.create_patient_record = true
body.phone_mobile = data.phone_mobile?.trim() || data.phone?.trim()
}
return body
}
function normalizeListedUser(user) {
return {
...user,
email: user.email || user.user_email || '',
full_name: user.full_name || user.name || user.nome || '',
role: Array.isArray(user.roles) ? user.roles[0] : (user.role || user.cargo || ''),
}
}

View File

@@ -8,7 +8,7 @@ export const visitRepository = {
getStages() { getStages() {
return [ return [
{ title: 'Triagem', description: 'Sinais vitais, queixa principal e alerta de risco antes da chamada medica.' }, { title: 'Triagem', description: 'Sinais vitais, queixa principal e alerta de risco antes da chamada medica.' },
{ title: 'Atendimento medico', description: 'Consulta em andamento, conduta, prescricao e solicitacao de exames.' }, { title: 'Atendimento médico', description: 'Consulta em andamento, conduta, prescrição e solicitação de exames.' },
{ title: 'Pos-consulta', description: 'Orientacoes finais, documentos emitidos e retorno sugerido pela equipe.' }, { title: 'Pos-consulta', description: 'Orientacoes finais, documentos emitidos e retorno sugerido pela equipe.' },
] ]
}, },

27
src/utils/theme.js Normal file
View File

@@ -0,0 +1,27 @@
export const THEME_STORAGE_KEY = 'mediconnect.theme'
export function getStoredTheme() {
if (typeof window === 'undefined') return 'light'
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY)
return storedTheme === 'dark' ? 'dark' : 'light'
}
export function applyTheme(theme) {
if (typeof document === 'undefined') return
const normalizedTheme = theme === 'light' ? 'light' : 'dark'
document.documentElement.dataset.theme = normalizedTheme
document.documentElement.style.colorScheme = normalizedTheme
}
export function setStoredTheme(theme) {
const normalizedTheme = theme === 'light' ? 'light' : 'dark'
if (typeof window !== 'undefined') {
window.localStorage.setItem(THEME_STORAGE_KEY, normalizedTheme)
}
applyTheme(normalizedTheme)
return normalizedTheme
}

8
vercel.json Normal file
View File

@@ -0,0 +1,8 @@
{
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}