módulo-prontuário #7
@@ -1,49 +1,73 @@
|
||||
# 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**
|
||||
- Login com email e senha via Supabase Auth (`/auth/v1/token`).
|
||||
- Solicitar reset de senha: tenta `/solicitar-reset-de-senha` e usa `/auth/v1/recover` como fallback.
|
||||
- Dados do usuario autenticado: tenta `/informacoes-do-usuario-autenticado` e usa `/auth/v1/user` como fallback.
|
||||
- Logout: tenta `/logout`, usa `/auth/v1/logout` como fallback e sempre limpa a sessao local.
|
||||
- `POST /auth/v1/token?grant_type=password` em `authRepository.login`
|
||||
- `POST /auth/v1/otp` em `authRepository.sendMagicLink`
|
||||
- `POST /functions/v1/request-password-reset` em `authRepository.requestPasswordReset`
|
||||
- `GET /auth/v1/user` em `authRepository.getUser` como fallback
|
||||
- `POST /functions/v1/user-info` em `authRepository.getUser`
|
||||
- `POST /auth/v1/logout` em `authRepository.logout`
|
||||
|
||||
- **Pacientes**
|
||||
- Listar, criar, atualizar e deletar pacientes via Supabase REST.
|
||||
- Criar paciente com validacao via Edge Function quando disponivel.
|
||||
## Usuarios
|
||||
|
||||
- **Agendamentos**
|
||||
- Listar agendamentos: tenta `GET /agendamentos` e usa Supabase REST `appointments` como fallback.
|
||||
- Criar agendamento: tenta `POST /agendamentos` e usa Supabase REST `appointments` como fallback.
|
||||
- `POST /functions/v1/create-user` em `userRepository.create`
|
||||
- `POST /functions/v1/create-user-with-password` em `userRepository.createWithPassword`
|
||||
- `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**
|
||||
- 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.
|
||||
## Pacientes
|
||||
|
||||
- **Medicos / Profissionais**
|
||||
- Listar medicos: tenta `GET /listar-medicos` e usa Supabase REST `doctors` como fallback.
|
||||
- `GET /rest/v1/patients` em `patientRepository.getAll`
|
||||
- `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**
|
||||
- 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.
|
||||
## Medicos
|
||||
|
||||
- **Storage**
|
||||
- Upload de avatar: tenta `/upload-avatar` e usa Supabase Storage no bucket `avatars` como fallback.
|
||||
- A tela de perfil atualiza a imagem exibida apos upload bem-sucedido.
|
||||
- `GET /rest/v1/doctors` em `professionalRepository.getAll`
|
||||
- `POST /functions/v1/create-doctor` em `professionalRepository.create`
|
||||
|
||||
## Ainda sem endpoint consolidado documentado
|
||||
## Agendamentos
|
||||
|
||||
- Dashboard / Inicio (`HomePage` / `homeRepository.js`).
|
||||
- Estatisticas e BI (`AnalyticsPage` / `analyticsRepository.js`).
|
||||
- Prontuarios especificos separados de laudos (`MedicalRecordsPage` / `medicalRecordRepository.js`).
|
||||
- Consultas isoladas fora de agendamento (`VisitsPage` / `visitRepository.js`).
|
||||
- Configuracoes gerais do tenant (`SettingsPage` / `settingsRepository.js`).
|
||||
- `GET /rest/v1/appointments` em `appointmentRepository.getAll`
|
||||
- `POST /rest/v1/appointments` em `appointmentRepository.create`
|
||||
- `PATCH /rest/v1/appointments?id=eq.ID` em `appointmentRepository.update`
|
||||
- Cancelamento via `PATCH /rest/v1/appointments?id=eq.ID` em `appointmentRepository.cancel`
|
||||
|
||||
## 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
|
||||
|
||||
- `VITE_API_BASE_URL` define a base dos endpoints nomeados da API. Quando nao informado, o front usa `VITE_SUPABASE_FUNCTIONS_URL`.
|
||||
- Os reposititorios aceitam formatos de resposta comuns como arrays diretos ou objetos com chaves `data`, `reports`, `agendamentos`, `medicos` etc.
|
||||
- Os fallbacks existem para manter o front funcional em ambientes onde parte das Edge Functions ainda nao foi publicada.
|
||||
- O Supabase real responde as rotas REST em `/rest/v1/...`.
|
||||
- As Edge Functions reais respondem em `/functions/v1/...`.
|
||||
- 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.
|
||||
|
||||
654
package-lock.json
generated
654
package-lock.json
generated
@@ -8,6 +8,11 @@
|
||||
"name": "projeto-residencia",
|
||||
"version": "0.0.0",
|
||||
"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",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
@@ -459,6 +464,34 @@
|
||||
"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": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -1156,6 +1189,447 @@
|
||||
"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": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
@@ -1185,7 +1659,6 @@
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1195,12 +1668,17 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@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": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
|
||||
@@ -1493,7 +1971,6 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
@@ -1776,6 +2253,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -2513,6 +3005,12 @@
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
@@ -2644,6 +3142,135 @@
|
||||
"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": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -2726,6 +3353,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
@@ -2901,6 +3534,15 @@
|
||||
"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": {
|
||||
"version": "8.0.10",
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"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",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
|
||||
67
src/App.jsx
67
src/App.jsx
@@ -1,30 +1,36 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import './App.css'
|
||||
import { AppShell } from './components/AppShell.jsx'
|
||||
import { canAccess } from './config/permissions.js'
|
||||
import { useAuth } from './hooks/useAuth.js'
|
||||
import { AgendaPage } from './pages/AgendaPage.jsx'
|
||||
import { AnalyticsPage } from './pages/AnalyticsPage.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 { 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 { UsersPage } from './pages/UsersPage.jsx'
|
||||
import { VisitsPage } from './pages/VisitsPage.jsx'
|
||||
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() {
|
||||
const [location, setLocation] = useState(() => readLocation())
|
||||
const { isAuthenticated, role, loading: authLoading } = useAuth()
|
||||
@@ -72,7 +78,7 @@ function App() {
|
||||
|
||||
// Rotas públicas (sem shell)
|
||||
if (!route.withShell) {
|
||||
return route.element
|
||||
return <RouteSuspense>{route.element}</RouteSuspense>
|
||||
}
|
||||
|
||||
// Usuário não autenticado
|
||||
@@ -97,11 +103,27 @@ function App() {
|
||||
|
||||
return (
|
||||
<AppShell currentPath={location.pathname} navigate={navigate} role={role} routeTitle={route.title}>
|
||||
{route.element}
|
||||
<RouteSuspense>{route.element}</RouteSuspense>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
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') {
|
||||
return {
|
||||
@@ -159,6 +181,23 @@ function resolveRoute(pathname, navigate, role) {
|
||||
}
|
||||
}
|
||||
|
||||
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/')) {
|
||||
const patientId = pathname.split('/')[2]
|
||||
return {
|
||||
|
||||
@@ -43,6 +43,7 @@ const titles = {
|
||||
|
||||
export function AppShell({ children, currentPath, navigate, role, routeTitle }) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [profileMenuOpen, setProfileMenuOpen] = useState(false)
|
||||
const [notificationsOpen, setNotificationsOpen] = useState(false)
|
||||
const [viewerProfile, setViewerProfile] = useState({ name: 'Usuário', role: 'Usuário do Sistema' })
|
||||
@@ -139,6 +140,14 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
function toggleSidebarCollapsed() {
|
||||
if (typeof window !== 'undefined' && !window.matchMedia('(min-width: 1024px)').matches) {
|
||||
return
|
||||
}
|
||||
|
||||
setSidebarCollapsed((current) => !current)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#171717] text-[#e5e5e5]">
|
||||
<a
|
||||
@@ -149,15 +158,19 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
||||
</a>
|
||||
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-40 flex w-56 -translate-x-full flex-col border-r border-[#404040] bg-[#262626] transition-transform duration-200 lg:translate-x-0 ${
|
||||
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' : ''
|
||||
}`}
|
||||
>
|
||||
<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
|
||||
iconClassName="size-8 rounded-sm"
|
||||
iconButtonLabel={sidebarCollapsed ? 'Expandir sidebar' : 'Recolher sidebar'}
|
||||
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>
|
||||
|
||||
@@ -169,6 +182,7 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
||||
item={item}
|
||||
key={`${item.label}-${item.href}`}
|
||||
onNavigate={goTo}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -176,12 +190,21 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
||||
|
||||
<div className="p-3">
|
||||
<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')}
|
||||
title={sidebarCollapsed ? `${viewerProfile.name} - ${viewerProfile.role}` : undefined}
|
||||
type="button"
|
||||
>
|
||||
<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>
|
||||
{sidebarCollapsed ? (
|
||||
<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>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -195,7 +218,7 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="lg:pl-56">
|
||||
<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">
|
||||
<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">
|
||||
@@ -236,7 +259,7 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
||||
>
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<p className="text-sm font-semibold text-[#e5e5e5]">Notificações</p>
|
||||
<span className="rounded bg-amber-500/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.08em] text-amber-300">
|
||||
<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>
|
||||
@@ -346,10 +369,11 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
||||
)
|
||||
}
|
||||
|
||||
function NavItem({ active, item, onNavigate }) {
|
||||
function NavItem({ active, item, onNavigate, sidebarCollapsed = false }) {
|
||||
return (
|
||||
<a
|
||||
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 ${
|
||||
active ? 'bg-[#3b82f6]/10 text-[#3b82f6]' : 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
|
||||
}`}
|
||||
@@ -358,9 +382,10 @@ function NavItem({ active, item, onNavigate }) {
|
||||
event.preventDefault()
|
||||
onNavigate(item.href)
|
||||
}}
|
||||
title={sidebarCollapsed ? item.label : undefined}
|
||||
>
|
||||
<AppIcon className="size-5 shrink-0" name={item.icon} />
|
||||
<span>{item.label}</span>
|
||||
<AppIcon className={`size-5 shrink-0 ${sidebarCollapsed ? 'lg:mx-auto' : ''}`} name={item.icon} />
|
||||
<span className={sidebarCollapsed ? 'lg:hidden' : ''}>{item.label}</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
export function BrandLogo({
|
||||
className = '',
|
||||
iconClassName = 'size-10 rounded-[6px]',
|
||||
iconButtonLabel = 'MediConnect',
|
||||
markClassName = 'size-6',
|
||||
onIconClick,
|
||||
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 (
|
||||
<div className={`flex items-center gap-3 ${className}`}>
|
||||
<div className={`grid place-items-center bg-[#3b82f6] text-white ${iconClassName}`}>
|
||||
<StethoscopeIcon className={markClassName} />
|
||||
</div>
|
||||
{onIconClick ? (
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
|
||||
121
src/index.css
121
src/index.css
@@ -61,6 +61,14 @@ button:disabled {
|
||||
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;
|
||||
}
|
||||
@@ -114,11 +122,21 @@ button:disabled {
|
||||
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;
|
||||
@@ -302,6 +320,109 @@ button:disabled {
|
||||
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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -796,7 +796,7 @@ export function PatientDetailPage({ navigate, patient, role }) {
|
||||
async function deletePatient() {
|
||||
if (!canHardDeletePatients) return
|
||||
|
||||
if (!window.confirm('Tem certeza que deseja excluir este paciente definitivamente?')) {
|
||||
if (!window.confirm('Tem certeza que deseja excluir este paciente definitivamente? Esta ação não poderá ser desfeita.')) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -900,21 +900,15 @@ export function PatientDetailPage({ navigate, patient, role }) {
|
||||
</section>
|
||||
|
||||
{canHardDeletePatients ? (
|
||||
<section className="rounded-2xl border border-red-500/30 bg-red-500/10 p-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-red-300">Zona de exclusão</h2>
|
||||
<p className="mt-1 text-sm text-red-100/80">Remove definitivamente o paciente e seus dados locais carregados.</p>
|
||||
</div>
|
||||
<button
|
||||
className="h-10 rounded-sm border border-red-500/40 bg-red-500/10 px-4 text-sm font-semibold text-red-300 transition hover:bg-red-500/20"
|
||||
onClick={deletePatient}
|
||||
type="button"
|
||||
>
|
||||
Excluir paciente
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
className="h-10 rounded-sm border border-red-700 bg-red-600 px-4 text-sm font-semibold text-white shadow-sm transition hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500/40"
|
||||
onClick={deletePatient}
|
||||
type="button"
|
||||
>
|
||||
Excluir paciente
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{messageShortcutOpen ? (
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
import TextAlign from '@tiptap/extension-text-align'
|
||||
|
||||
import { normalizeRole } from '../config/permissions.js'
|
||||
import { patientRepository } from '../repositories/patientRepository.js'
|
||||
import { professionalRepository } from '../repositories/professionalRepository.js'
|
||||
import { profileRepository } from '../repositories/profileRepository.js'
|
||||
import { reportRepository } from '../repositories/reportRepository.js'
|
||||
import { StethoscopeIcon } from '../components/Brand.jsx'
|
||||
|
||||
const ITEMS_PER_PAGE = 25
|
||||
|
||||
@@ -30,8 +35,6 @@ const orderOptions = [
|
||||
|
||||
const inputClass =
|
||||
'h-10 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]'
|
||||
const textareaClass =
|
||||
'min-h-24 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 py-2 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]'
|
||||
const labelClass = 'mb-1.5 block text-xs font-medium text-[#e5e5e5]'
|
||||
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||
|
||||
@@ -118,8 +121,6 @@ const reportTemplates = [
|
||||
},
|
||||
]
|
||||
|
||||
const templateCategories = ['Todos', ...Array.from(new Set(reportTemplates.map((template) => template.category)))]
|
||||
|
||||
const emptyEditor = {
|
||||
id: null,
|
||||
orderNumber: '',
|
||||
@@ -349,18 +350,25 @@ export function ReportsPage({ role }) {
|
||||
|
||||
setSaving(true)
|
||||
|
||||
const plainContent = stripHtml(editor.contentHtml)
|
||||
const fallbackAuthor =
|
||||
currentProfessional?.name ||
|
||||
viewerProfile?.name ||
|
||||
viewerProfile?.email ||
|
||||
'Profissional MediConnect'
|
||||
|
||||
const payload = {
|
||||
orderNumber: editor.id ? editor.orderNumber : `REL-${Date.now()}`,
|
||||
patientId: editor.patientId,
|
||||
patientId: editor.patientId || patientOptions[0]?.id || '',
|
||||
status: editor.status,
|
||||
exam: editor.exam,
|
||||
requestedBy: editor.requestedBy,
|
||||
cidCode: editor.cidCode,
|
||||
diagnosis: editor.diagnosis,
|
||||
conclusion: editor.conclusion,
|
||||
exam: editor.exam || 'Relatório médico',
|
||||
requestedBy: editor.requestedBy || fallbackAuthor,
|
||||
cidCode: editor.cidCode || 'Z00.0',
|
||||
diagnosis: editor.diagnosis || plainContent.slice(0, 240) || 'Relatório médico registrado em prontuário.',
|
||||
conclusion: editor.conclusion || plainContent.slice(0, 240) || 'Relatório médico salvo no sistema.',
|
||||
contentHtml: editor.contentHtml,
|
||||
contentJson: editor.contentJson,
|
||||
dueAt: editor.dueAt ? new Date(editor.dueAt).toISOString() : '',
|
||||
dueAt: editor.dueAt ? new Date(editor.dueAt).toISOString() : new Date().toISOString(),
|
||||
createdBy: editor.id ? undefined : viewerProfile?.id || currentProfessional?.userId || currentProfessional?.id || undefined,
|
||||
updatedBy: viewerProfile?.id || currentProfessional?.userId || currentProfessional?.id || undefined,
|
||||
}
|
||||
@@ -557,7 +565,7 @@ export function ReportsPage({ role }) {
|
||||
</section>
|
||||
|
||||
{editorOpen ? (
|
||||
<ReportEditorModalV2
|
||||
<ReportEditorModalV3
|
||||
editor={editor}
|
||||
onChange={setEditor}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
@@ -605,44 +613,29 @@ function ReportRow({ onEdit, onView, report }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ReportEditorModalV2({ editor, onChange, onClose, onSave, patientOptions, professionalOptions, saving }) {
|
||||
const editorRef = useRef(null)
|
||||
const [requesterSearch, setRequesterSearch] = useState(editor.requestedBy || '')
|
||||
const [patientSearch, setPatientSearch] = useState('')
|
||||
function ReportEditorModalV3({ editor, onChange, onClose, onSave, saving }) {
|
||||
const [templateSearch, setTemplateSearch] = useState('')
|
||||
const [templateCategory, setTemplateCategory] = useState('Todos')
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState('')
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
const [templatesOpen, setTemplatesOpen] = useState(false)
|
||||
const isValid = isReportEditorValid(editor)
|
||||
const selectedPatient = patientOptions.find((patient) => patient.id === String(editor.patientId))
|
||||
const filteredPatients = patientOptions
|
||||
.filter((patient) => normalizeSearch(patient.name).includes(normalizeSearch(patientSearch)))
|
||||
.slice(0, 5)
|
||||
const filteredRequesterOptions = professionalOptions
|
||||
.filter((professional) => normalizeSearch(professional.name).includes(normalizeSearch(requesterSearch)))
|
||||
.slice(0, 5)
|
||||
const filteredTemplates = reportTemplates.filter((template) => {
|
||||
const matchesCategory = templateCategory === 'Todos' || template.category === templateCategory
|
||||
const query = normalizeSearch(templateSearch)
|
||||
const matchesSearch = !query || normalizeSearch([template.title, template.description, template.tags.join(' ')].join(' ')).includes(query)
|
||||
return matchesCategory && matchesSearch
|
||||
return matchesSearch
|
||||
})
|
||||
const selectedTemplate = reportTemplates.find((template) => template.id === selectedTemplateId)
|
||||
|
||||
function updateField(field, value) {
|
||||
onChange((current) => ({ ...current, [field]: value }))
|
||||
}
|
||||
|
||||
function applyTemplate(template) {
|
||||
setSelectedTemplateId(template.id)
|
||||
setPreviewOpen(true)
|
||||
setTemplatesOpen(false)
|
||||
onChange((current) => ({
|
||||
...current,
|
||||
exam: template.exam,
|
||||
cidCode: template.cidCode,
|
||||
diagnosis: template.diagnosis,
|
||||
conclusion: template.conclusion,
|
||||
contentHtml: template.contentHtml,
|
||||
exam: current.exam || template.exam,
|
||||
cidCode: current.cidCode || template.cidCode,
|
||||
diagnosis: current.diagnosis || template.diagnosis,
|
||||
conclusion: current.conclusion || template.conclusion,
|
||||
contentHtml: current.contentHtml ? `${current.contentHtml}<hr>${template.contentHtml}` : template.contentHtml,
|
||||
contentJson: {
|
||||
templateId: template.id,
|
||||
templateTitle: template.title,
|
||||
@@ -651,243 +644,96 @@ function ReportEditorModalV2({ editor, onChange, onClose, onSave, patientOptions
|
||||
}))
|
||||
}
|
||||
|
||||
function runCommand(command, value = null) {
|
||||
editorRef.current?.focus()
|
||||
document.execCommand(command, false, value)
|
||||
updateField('contentHtml', editorRef.current?.innerHTML || '')
|
||||
}
|
||||
|
||||
function insertToken(token) {
|
||||
const values = {
|
||||
patient: selectedPatient?.name || '[Paciente]',
|
||||
date: new Date().toLocaleDateString('pt-BR'),
|
||||
doctor: editor.requestedBy || '[Médico]',
|
||||
}
|
||||
runCommand('insertText', values[token] || '')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-3" onClick={onClose}>
|
||||
<div className="report-editor-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-3" onClick={onClose}>
|
||||
<div
|
||||
className="flex max-h-[94vh] w-full max-w-6xl flex-col overflow-hidden rounded-xl border border-[#404040] bg-[#242424] shadow-2xl"
|
||||
className="report-editor-shell flex max-h-[94vh] w-full max-w-6xl flex-col overflow-hidden rounded-xl border border-[#404040] bg-[#242424] shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[#404040] px-6 py-4">
|
||||
<div className="report-editor-header flex items-center justify-between border-b border-[#404040] px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid size-9 place-items-center rounded-sm bg-[#0f2f66] text-[#3b82f6]">
|
||||
<ReportIcon className="size-5" name="bolt" />
|
||||
<span className="grid size-9 place-items-center rounded-sm bg-[#3b82f6] text-white">
|
||||
<StethoscopeIcon className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-[#f5f5f5]">{editor.id ? 'Editar relatório' : 'Novo relatório'}</h2>
|
||||
<p className="text-xs text-[#a3a3a3]">Selecione um template e finalize o conteúdo no editor rico.</p>
|
||||
<p className="text-xs text-[#a3a3a3]">Escolha um template opcional e edite o conteúdo do relatório.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="inline-flex h-9 items-center gap-2 rounded-sm border border-[#404040] bg-[#1a1a1a] px-3 text-sm font-semibold text-[#d4d4d4] transition hover:bg-[#303030]"
|
||||
onClick={() => setPreviewOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<ReportIcon className="size-4" name="eye" />
|
||||
Pré-visualizar
|
||||
</button>
|
||||
<button className="grid size-9 place-items-center rounded-sm text-[#a3a3a3] transition hover:bg-[#303030] hover:text-[#e5e5e5]" onClick={onClose} type="button">
|
||||
<ReportIcon className="size-4" name="x" />
|
||||
</button>
|
||||
</div>
|
||||
<button className="grid size-9 place-items-center rounded-sm text-[#a3a3a3] transition hover:bg-[#303030] hover:text-[#e5e5e5]" onClick={onClose} type="button">
|
||||
<ReportIcon className="size-4" name="x" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 lg:grid-cols-[230px_minmax(0,1fr)_300px]">
|
||||
<aside className="min-h-0 border-b border-[#404040] bg-[#202020] p-4 lg:border-b-0 lg:border-r">
|
||||
<p className="mb-3 text-xs font-bold uppercase tracking-[0.12em] text-[#a3a3a3]">Categorias</p>
|
||||
<div className="space-y-1">
|
||||
{templateCategories.map((category) => {
|
||||
const count = category === 'Todos' ? reportTemplates.length : reportTemplates.filter((template) => template.category === category).length
|
||||
return (
|
||||
<button
|
||||
className={`flex w-full items-center justify-between rounded-sm px-3 py-2 text-left text-sm font-semibold transition ${
|
||||
templateCategory === category
|
||||
? 'bg-[#3b82f6]/15 text-[#3b82f6]'
|
||||
: 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
|
||||
}`}
|
||||
key={category}
|
||||
onClick={() => setTemplateCategory(category)}
|
||||
type="button"
|
||||
>
|
||||
<span>{category}</span>
|
||||
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[10px]">{count}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="grid min-h-0 flex-1">
|
||||
<main className="report-editor-body min-h-0 overflow-y-auto p-5">
|
||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<DarkField label="Status *">
|
||||
<select className={`${inputClass} md:w-52`} onChange={(event) => updateField('status', event.target.value)} value={editor.status}>
|
||||
<option value="draft">Rascunho</option>
|
||||
<option value="finalized">Finalizado</option>
|
||||
</select>
|
||||
</DarkField>
|
||||
|
||||
<main className="min-h-0 overflow-y-auto p-5">
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<ReportIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#a3a3a3]" name="search" />
|
||||
<input
|
||||
className="h-10 w-full rounded-sm border border-[#404040] bg-[#171717] pl-10 pr-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6]"
|
||||
onChange={(event) => setTemplateSearch(event.target.value)}
|
||||
placeholder="Buscar templates..."
|
||||
value={templateSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 grid gap-3 md:grid-cols-2">
|
||||
{filteredTemplates.map((template) => (
|
||||
<button
|
||||
className={`min-h-[132px] rounded-md border p-4 text-left transition hover:border-[#3b82f6] ${
|
||||
selectedTemplateId === template.id ? 'border-[#3b82f6] bg-[#2a2f3a]' : 'border-[#404040] bg-[#262626]'
|
||||
}`}
|
||||
key={template.id}
|
||||
onClick={() => applyTemplate(template)}
|
||||
className="report-template-trigger inline-flex h-10 items-center gap-2 rounded-sm border border-[#404040] bg-[#171717] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
|
||||
onClick={() => setTemplatesOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex items-start justify-between gap-3">
|
||||
<span className="text-sm font-bold leading-5 text-[#f5f5f5]">{template.title}</span>
|
||||
{template.popular ? (
|
||||
<span className="rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-bold text-amber-300">Popular</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="mt-2 block text-xs leading-5 text-[#b8b8b8]">{template.description}</span>
|
||||
<span className="mt-3 flex flex-wrap gap-1.5">
|
||||
{template.tags.map((tag) => (
|
||||
<span className="rounded bg-[#1f1f1f] px-2 py-1 text-[10px] font-semibold text-[#a3a3a3]" key={tag}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<ReportIcon className="size-4" name="file" />
|
||||
Templates
|
||||
<ReportIcon className="size-4" name="chevron-right" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
{templatesOpen ? (
|
||||
<div className="report-template-menu absolute right-0 top-12 z-10 w-[min(28rem,calc(100vw-2rem))] rounded-md border border-[#404040] bg-[#202020] p-3 shadow-2xl">
|
||||
<div className="relative mb-3">
|
||||
<ReportIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#a3a3a3]" name="search" />
|
||||
<input
|
||||
className="h-10 w-full rounded-sm border border-[#404040] bg-[#171717] pl-10 pr-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6]"
|
||||
onChange={(event) => setTemplateSearch(event.target.value)}
|
||||
placeholder="Buscar templates..."
|
||||
value={templateSearch}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{filteredTemplates.length ? (
|
||||
filteredTemplates.map((template) => (
|
||||
<button
|
||||
className="block w-full rounded-sm border border-transparent px-3 py-3 text-left transition hover:border-[#3b82f6]/40 hover:bg-[#303030]"
|
||||
key={template.id}
|
||||
onClick={() => applyTemplate(template)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
<span className="font-semibold text-[#f5f5f5]">{template.title}</span>
|
||||
{template.popular ? <span className="rounded bg-amber-500/15 px-2 py-0.5 text-[10px] font-bold text-amber-300">Popular</span> : null}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-[#a3a3a3]">{template.description}</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p className="px-3 py-4 text-sm text-[#a3a3a3]">Nenhum template encontrado.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 border-t border-[#404040] pt-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DarkField label="Tipo de relatório *">
|
||||
<input
|
||||
className={inputClass}
|
||||
onChange={(event) => updateField('exam', event.target.value)}
|
||||
placeholder="Ex: Relatório de consulta médica"
|
||||
value={editor.exam}
|
||||
/>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Paciente *">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
className={inputClass}
|
||||
onChange={(event) => setPatientSearch(event.target.value)}
|
||||
placeholder="Digite o nome do paciente..."
|
||||
value={patientSearch || selectedPatient?.name || ''}
|
||||
/>
|
||||
<SearchPickList
|
||||
emptyText="Nenhum paciente encontrado."
|
||||
items={filteredPatients}
|
||||
labelKey="name"
|
||||
onSelect={(patient) => {
|
||||
updateField('patientId', patient.id)
|
||||
setPatientSearch(patient.name)
|
||||
}}
|
||||
selectedValue={editor.patientId}
|
||||
valueKey="id"
|
||||
/>
|
||||
</div>
|
||||
</DarkField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_160px]">
|
||||
<DarkField label="Médico responsável *">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
className={inputClass}
|
||||
onChange={(event) => {
|
||||
setRequesterSearch(event.target.value)
|
||||
updateField('requestedBy', event.target.value)
|
||||
}}
|
||||
placeholder="Pesquisar médico"
|
||||
value={requesterSearch}
|
||||
/>
|
||||
<SearchPickList
|
||||
emptyText="Nenhum médico encontrado."
|
||||
items={filteredRequesterOptions}
|
||||
labelKey="name"
|
||||
onSelect={(professional) => {
|
||||
setRequesterSearch(professional.name)
|
||||
updateField('requestedBy', professional.name)
|
||||
}}
|
||||
selectedValue={editor.requestedBy}
|
||||
valueKey="name"
|
||||
/>
|
||||
</div>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Status *">
|
||||
<select className={inputClass} onChange={(event) => updateField('status', event.target.value)} value={editor.status}>
|
||||
<option value="draft">Rascunho</option>
|
||||
<option value="finalized">Finalizado</option>
|
||||
</select>
|
||||
</DarkField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DarkField label="CID-10 *">
|
||||
<input className={inputClass} onChange={(event) => updateField('cidCode', event.target.value)} placeholder="Ex: Z01.7" value={editor.cidCode} />
|
||||
</DarkField>
|
||||
<DarkField label="Prazo *">
|
||||
<input className={`${inputClass} [color-scheme:dark]`} onChange={(event) => updateField('dueAt', event.target.value)} type="datetime-local" value={editor.dueAt} />
|
||||
</DarkField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DarkField label="Diagnóstico *">
|
||||
<textarea className={textareaClass} onChange={(event) => updateField('diagnosis', event.target.value)} value={editor.diagnosis} />
|
||||
</DarkField>
|
||||
<DarkField label="Conclusão *">
|
||||
<textarea className={textareaClass} onChange={(event) => updateField('conclusion', event.target.value)} value={editor.conclusion} />
|
||||
</DarkField>
|
||||
</div>
|
||||
|
||||
<DarkField label="Conteúdo">
|
||||
<RichTextEditor
|
||||
editorRef={editorRef}
|
||||
onChange={(value) => updateField('contentHtml', value)}
|
||||
onCommand={runCommand}
|
||||
onInsertToken={insertToken}
|
||||
value={editor.contentHtml}
|
||||
/>
|
||||
</DarkField>
|
||||
</div>
|
||||
<DarkField label="Editor de texto">
|
||||
<RichTextEditor
|
||||
onChange={(value) => updateField('contentHtml', value)}
|
||||
value={editor.contentHtml}
|
||||
/>
|
||||
</DarkField>
|
||||
</main>
|
||||
|
||||
<aside className="hidden min-h-0 border-l border-[#404040] bg-[#202020] p-5 lg:block">
|
||||
{previewOpen || selectedTemplate ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.12em] text-[#a3a3a3]">Pré-visualização</p>
|
||||
<h3 className="mt-4 text-lg font-bold text-[#f5f5f5]">{editor.exam || selectedTemplate?.title || 'Relatório médico'}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-[#a3a3a3]">
|
||||
{selectedTemplate?.description || 'Use o editor para preencher o conteúdo do relatório.'}
|
||||
</p>
|
||||
<div className="mt-5 rounded-xl border border-[#404040] bg-[#171717] p-4 text-sm leading-6 text-[#d4d4d4]">
|
||||
<div dangerouslySetInnerHTML={{ __html: sanitizePreviewHtml(editor.contentHtml || selectedTemplate?.contentHtml || '') }} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<span className="grid size-16 place-items-center rounded-full bg-[#2a2a2a] text-[#a3a3a3]">
|
||||
<ReportIcon className="size-8" name="file" />
|
||||
</span>
|
||||
<h3 className="mt-4 text-base font-bold text-[#f5f5f5]">Selecione um template</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-[#a3a3a3]">Clique em qualquer modelo para preencher o editor automaticamente.</p>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-[#404040] px-6 py-4">
|
||||
<div className="report-editor-footer flex flex-wrap items-center justify-between gap-3 border-t border-[#404040] px-6 py-4">
|
||||
<p className="text-xs font-semibold text-amber-300">
|
||||
{!isValid ? '* Preencha paciente, tipo, médico, CID, prazo, diagnóstico e conclusão para salvar.' : 'Relatório pronto para salvar.'}
|
||||
{!isValid ? '* Preencha o editor de texto para salvar.' : 'Relatório pronto para salvar.'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button className="rounded-sm border border-[#404040] bg-[#262626] px-4 py-2 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]" onClick={onClose} type="button">
|
||||
@@ -909,255 +755,113 @@ function ReportEditorModalV2({ editor, onChange, onClose, onSave, patientOptions
|
||||
)
|
||||
}
|
||||
|
||||
function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions, professionalOptions, saving }) {
|
||||
const [requesterSearch, setRequesterSearch] = useState(editor.requestedBy || '')
|
||||
const isValid = isReportEditorValid(editor)
|
||||
const filteredRequesterOptions = professionalOptions
|
||||
.filter((professional) => normalizeSearch(professional.name).includes(normalizeSearch(requesterSearch)))
|
||||
.slice(0, 6)
|
||||
function RichTextEditor({ onChange, value }) {
|
||||
const lastSyncedHtmlRef = useRef(value || '')
|
||||
const applyingExternalContentRef = useRef(false)
|
||||
const tiptapEditor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Underline,
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
],
|
||||
content: value || '',
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'report-rich-surface min-h-[560px] px-4 py-3 text-sm leading-6 text-[#e5e5e5] outline-none',
|
||||
},
|
||||
},
|
||||
shouldRerenderOnTransaction: false,
|
||||
onUpdate: ({ editor: currentEditor }) => {
|
||||
if (applyingExternalContentRef.current) return
|
||||
|
||||
function updateField(field, value) {
|
||||
onChange((current) => ({ ...current, [field]: value }))
|
||||
}
|
||||
const nextHtml = currentEditor.getHTML()
|
||||
lastSyncedHtmlRef.current = nextHtml
|
||||
onChange(nextHtml)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!tiptapEditor) return
|
||||
|
||||
const nextValue = value || ''
|
||||
if (lastSyncedHtmlRef.current === nextValue) return
|
||||
|
||||
if (tiptapEditor.getHTML() === nextValue) {
|
||||
lastSyncedHtmlRef.current = nextValue
|
||||
return
|
||||
}
|
||||
|
||||
applyingExternalContentRef.current = true
|
||||
try {
|
||||
tiptapEditor.commands.setContent(nextValue, { emitUpdate: false })
|
||||
} finally {
|
||||
applyingExternalContentRef.current = false
|
||||
}
|
||||
lastSyncedHtmlRef.current = nextValue
|
||||
}, [tiptapEditor, value])
|
||||
|
||||
const blockFormat = tiptapEditor?.isActive('heading', { level: 2 })
|
||||
? 'h2'
|
||||
: tiptapEditor?.isActive('heading', { level: 3 })
|
||||
? 'h3'
|
||||
: 'p'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
||||
<div
|
||||
className="flex max-h-[92vh] w-full max-w-4xl flex-col rounded-2xl border border-[#404040] bg-[#262626] shadow-xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[#404040] px-6 py-4">
|
||||
<h2 className="text-lg font-bold text-[#e5e5e5]">
|
||||
{editor.id ? 'Editar relatório' : 'Novo relatório'}
|
||||
</h2>
|
||||
<button className="rounded-lg p-1.5 transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
|
||||
<ReportIcon className="size-4 text-[#a3a3a3]" name="x" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DarkField label="Paciente *">
|
||||
<select className={inputClass} onChange={(event) => updateField('patientId', event.target.value)} value={editor.patientId}>
|
||||
<option value="">Selecione um paciente</option>
|
||||
{patientOptions.map((patient) => (
|
||||
<option key={patient.id} value={patient.id}>
|
||||
{patient.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Status *">
|
||||
<select className={inputClass} onChange={(event) => updateField('status', event.target.value)} value={editor.status}>
|
||||
<option value="draft">Rascunho</option>
|
||||
<option value="finalized">Finalizado</option>
|
||||
</select>
|
||||
</DarkField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DarkField label="Exame *">
|
||||
<input
|
||||
className={inputClass}
|
||||
onChange={(event) => updateField('exam', event.target.value)}
|
||||
placeholder="Nome do exame"
|
||||
value={editor.exam}
|
||||
/>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Solicitante *">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
className={inputClass}
|
||||
onChange={(event) => {
|
||||
setRequesterSearch(event.target.value)
|
||||
updateField('requestedBy', event.target.value)
|
||||
}}
|
||||
placeholder="Pesquisar médico"
|
||||
type="search"
|
||||
value={requesterSearch}
|
||||
/>
|
||||
<div className="max-h-36 overflow-y-auto rounded-md border border-[#404040] bg-[#1a1a1a] p-1">
|
||||
{filteredRequesterOptions.length ? (
|
||||
filteredRequesterOptions.map((professional) => (
|
||||
<button
|
||||
className={`flex w-full items-center justify-between gap-3 rounded-sm px-3 py-2 text-left text-sm font-medium transition hover:bg-[#303030] ${
|
||||
editor.requestedBy === professional.name ? 'text-[#51a2ff]' : 'text-[#e5e5e5]'
|
||||
}`}
|
||||
key={professional.id || professional.createdByValue || professional.name}
|
||||
onClick={() => {
|
||||
setRequesterSearch(professional.name)
|
||||
updateField('requestedBy', professional.name)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{professional.name}</span>
|
||||
{editor.requestedBy === professional.name ? <ReportIcon className="size-3.5" name="check" /> : null}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p className="px-3 py-2 text-sm text-[#a3a3a3]">Nenhum médico encontrado.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DarkField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DarkField label="CID-10 *">
|
||||
<input
|
||||
className={inputClass}
|
||||
onChange={(event) => updateField('cidCode', event.target.value)}
|
||||
placeholder="Ex: Z01.7"
|
||||
value={editor.cidCode}
|
||||
/>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Prazo *">
|
||||
<input
|
||||
className={`${inputClass} [color-scheme:dark]`}
|
||||
onChange={(event) => updateField('dueAt', event.target.value)}
|
||||
type="datetime-local"
|
||||
value={editor.dueAt}
|
||||
/>
|
||||
</DarkField>
|
||||
</div>
|
||||
|
||||
<DarkField label="Diagnóstico *">
|
||||
<textarea
|
||||
className={textareaClass}
|
||||
onChange={(event) => updateField('diagnosis', event.target.value)}
|
||||
placeholder="Diagnóstico do relatório"
|
||||
value={editor.diagnosis}
|
||||
/>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Conclusão *">
|
||||
<textarea
|
||||
className={textareaClass}
|
||||
onChange={(event) => updateField('conclusion', event.target.value)}
|
||||
placeholder="Conclusão do relatório"
|
||||
value={editor.conclusion}
|
||||
/>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Complemento">
|
||||
<textarea
|
||||
className={`${textareaClass} min-h-72`}
|
||||
onChange={(event) => updateField('contentHtml', event.target.value)}
|
||||
value={editor.contentHtml}
|
||||
/>
|
||||
</DarkField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-[#404040] px-6 py-4">
|
||||
<button
|
||||
className="rounded-lg border border-[#404040] bg-[#262626] px-4 py-2 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#2a2a2a]"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-[#3b82f6] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#2563eb] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={!isValid || saving}
|
||||
onClick={onSave}
|
||||
type="button"
|
||||
>
|
||||
<ReportIcon className="size-3.5" name="save" />
|
||||
{saving ? 'Salvando...' : 'Salvar relatório'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchPickList({ emptyText, items, labelKey, onSelect, selectedValue, valueKey }) {
|
||||
return (
|
||||
<div className="max-h-32 overflow-y-auto rounded-md border border-[#404040] bg-[#1a1a1a] p-1">
|
||||
{items.length ? (
|
||||
items.map((item) => {
|
||||
const value = String(item[valueKey] || '')
|
||||
const selected = String(selectedValue || '') === value
|
||||
return (
|
||||
<button
|
||||
className={`flex w-full items-center justify-between gap-3 rounded-sm px-3 py-2 text-left text-sm font-medium transition hover:bg-[#303030] ${
|
||||
selected ? 'text-[#51a2ff]' : 'text-[#e5e5e5]'
|
||||
}`}
|
||||
key={value || item[labelKey]}
|
||||
onClick={() => onSelect(item)}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{item[labelKey]}</span>
|
||||
{selected ? <ReportIcon className="size-3.5" name="check" /> : null}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<p className="px-3 py-2 text-sm text-[#a3a3a3]">{emptyText}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RichTextEditor({ editorRef, onChange, onCommand, onInsertToken, value }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-sm border border-[#404040] bg-[#171717]">
|
||||
<div className="flex flex-wrap items-center gap-1 border-b border-[#404040] bg-[#202020] px-3 py-2">
|
||||
<ToolbarButton label="Desfazer" name="undo" onClick={() => onCommand('undo')} />
|
||||
<ToolbarButton label="Refazer" name="redo" onClick={() => onCommand('redo')} />
|
||||
<div className="report-rich-editor overflow-hidden rounded-sm border border-[#404040] bg-[#171717]">
|
||||
<div className="report-rich-toolbar flex flex-wrap items-center gap-1 border-b border-[#404040] bg-[#202020] px-3 py-2">
|
||||
<TipTapToolbarButton disabled={!tiptapEditor?.can().undo()} label="Desfazer" name="undo" onClick={() => tiptapEditor?.chain().focus().undo().run()} />
|
||||
<TipTapToolbarButton disabled={!tiptapEditor?.can().redo()} label="Refazer" name="redo" onClick={() => tiptapEditor?.chain().focus().redo().run()} />
|
||||
<span className="mx-1 h-5 w-px bg-[#404040]" />
|
||||
<select className="h-8 rounded-sm border border-[#404040] bg-[#171717] px-2 text-xs font-semibold text-[#d4d4d4]" onChange={(event) => onCommand('formatBlock', event.target.value)} defaultValue="p">
|
||||
<option value="p">Padrão</option>
|
||||
<option value="h2">Título</option>
|
||||
<option value="h3">Subtítulo</option>
|
||||
<select
|
||||
className="h-8 rounded-sm border border-[#404040] bg-[#171717] px-2 text-xs font-semibold text-[#d4d4d4]"
|
||||
onChange={(event) => {
|
||||
const selected = event.target.value
|
||||
|
||||
if (selected === 'h2') {
|
||||
tiptapEditor?.chain().focus().toggleHeading({ level: 2 }).run()
|
||||
return
|
||||
}
|
||||
|
||||
if (selected === 'h3') {
|
||||
tiptapEditor?.chain().focus().toggleHeading({ level: 3 }).run()
|
||||
return
|
||||
}
|
||||
|
||||
tiptapEditor?.chain().focus().setParagraph().run()
|
||||
}}
|
||||
value={blockFormat}
|
||||
>
|
||||
<option value="p">Padrao</option>
|
||||
<option value="h2">Titulo</option>
|
||||
<option value="h3">Subtitulo</option>
|
||||
</select>
|
||||
<ToolbarButton active label="Negrito" name="bold" onClick={() => onCommand('bold')} />
|
||||
<ToolbarButton label="Itálico" name="italic" onClick={() => onCommand('italic')} />
|
||||
<ToolbarButton label="Sublinhado" name="underline" onClick={() => onCommand('underline')} />
|
||||
<ToolbarButton label="Tachado" name="strike" onClick={() => onCommand('strikeThrough')} />
|
||||
<TipTapToolbarButton active={tiptapEditor?.isActive('bold')} label="Negrito" name="bold" onClick={() => tiptapEditor?.chain().focus().toggleBold().run()} />
|
||||
<TipTapToolbarButton active={tiptapEditor?.isActive('italic')} label="Italico" name="italic" onClick={() => tiptapEditor?.chain().focus().toggleItalic().run()} />
|
||||
<TipTapToolbarButton active={tiptapEditor?.isActive('underline')} label="Sublinhado" name="underline" onClick={() => tiptapEditor?.chain().focus().toggleUnderline().run()} />
|
||||
<TipTapToolbarButton active={tiptapEditor?.isActive('strike')} label="Tachado" name="strike" onClick={() => tiptapEditor?.chain().focus().toggleStrike().run()} />
|
||||
<span className="mx-1 h-5 w-px bg-[#404040]" />
|
||||
<ToolbarButton label="Alinhar à esquerda" name="align-left" onClick={() => onCommand('justifyLeft')} />
|
||||
<ToolbarButton label="Centralizar" name="align-center" onClick={() => onCommand('justifyCenter')} />
|
||||
<ToolbarButton label="Alinhar à direita" name="align-right" onClick={() => onCommand('justifyRight')} />
|
||||
<ToolbarButton label="Lista" name="list" onClick={() => onCommand('insertUnorderedList')} />
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<span className="mr-1 text-[11px] text-[#a3a3a3]">Inserir:</span>
|
||||
<button className="h-8 rounded-sm border border-[#3b82f6]/40 px-2 text-xs font-semibold text-[#3b82f6] hover:bg-[#3b82f6]/10" onClick={() => onInsertToken('patient')} type="button">
|
||||
+ Paciente
|
||||
</button>
|
||||
<button className="h-8 rounded-sm border border-[#3b82f6]/40 px-2 text-xs font-semibold text-[#3b82f6] hover:bg-[#3b82f6]/10" onClick={() => onInsertToken('date')} type="button">
|
||||
+ Data
|
||||
</button>
|
||||
<button className="h-8 rounded-sm border border-[#3b82f6]/40 px-2 text-xs font-semibold text-[#3b82f6] hover:bg-[#3b82f6]/10" onClick={() => onInsertToken('doctor')} type="button">
|
||||
+ Médico
|
||||
</button>
|
||||
</div>
|
||||
<TipTapToolbarButton active={tiptapEditor?.isActive({ textAlign: 'left' })} label="Alinhar a esquerda" name="align-left" onClick={() => tiptapEditor?.chain().focus().setTextAlign('left').run()} />
|
||||
<TipTapToolbarButton active={tiptapEditor?.isActive({ textAlign: 'center' })} label="Centralizar" name="align-center" onClick={() => tiptapEditor?.chain().focus().setTextAlign('center').run()} />
|
||||
<TipTapToolbarButton active={tiptapEditor?.isActive({ textAlign: 'right' })} label="Alinhar a direita" name="align-right" onClick={() => tiptapEditor?.chain().focus().setTextAlign('right').run()} />
|
||||
<TipTapToolbarButton active={tiptapEditor?.isActive('bulletList')} label="Lista" name="list" onClick={() => tiptapEditor?.chain().focus().toggleBulletList().run()} />
|
||||
</div>
|
||||
<div
|
||||
className="min-h-[320px] px-4 py-3 text-sm leading-6 text-[#e5e5e5] outline-none empty:before:text-[#737373]"
|
||||
contentEditable
|
||||
dangerouslySetInnerHTML={{ __html: value || '' }}
|
||||
onInput={(event) => onChange(event.currentTarget.innerHTML)}
|
||||
ref={editorRef}
|
||||
role="textbox"
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
<EditorContent editor={tiptapEditor} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolbarButton({ active = false, label, name, onClick }) {
|
||||
function TipTapToolbarButton({ active = false, disabled = false, label, name, onClick }) {
|
||||
return (
|
||||
<button
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
className={`grid size-8 place-items-center rounded-sm transition ${
|
||||
active ? 'bg-[#3b82f6]/20 text-[#3b82f6]' : 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
|
||||
}`}
|
||||
} disabled:cursor-not-allowed disabled:opacity-40`}
|
||||
disabled={disabled}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
type="button"
|
||||
@@ -1249,10 +953,10 @@ function FilterField({ children, label }) {
|
||||
|
||||
function DarkField({ children, label }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="block">
|
||||
<span className={labelClass}>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1340,17 +1044,19 @@ function uniqueValues(values) {
|
||||
|
||||
function isReportEditorValid(editor) {
|
||||
return [
|
||||
editor.patientId,
|
||||
editor.status,
|
||||
editor.exam,
|
||||
editor.requestedBy,
|
||||
editor.cidCode,
|
||||
editor.diagnosis,
|
||||
editor.conclusion,
|
||||
editor.dueAt,
|
||||
stripHtml(editor.contentHtml),
|
||||
].every((value) => String(value || '').trim())
|
||||
}
|
||||
|
||||
function stripHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function normalizeSearch(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFD')
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { FeatureCallout } from '../components/FeatureState.jsx'
|
||||
import { settingsRepository } from '../repositories/settingsRepository.js'
|
||||
import { getStoredTheme, setStoredTheme } from '../utils/theme.js'
|
||||
|
||||
@@ -16,13 +15,6 @@ export function SettingsPage() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
@@ -135,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() {
|
||||
const [twoFactor, setTwoFactor] = useState(false)
|
||||
const [audit, setAudit] = useState(true)
|
||||
@@ -228,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-[#303030] text-[#d4d4d4]' : 'bg-[#303030] text-[#a3a3a3]'}`}>
|
||||
{connected ? 'Conectado' : 'Desconectado'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionFrame>
|
||||
)
|
||||
}
|
||||
|
||||
function DataSection() {
|
||||
return (
|
||||
<SectionFrame description="Exporte, importe e gerencie backups do sistema." title="Dados & Backup">
|
||||
@@ -392,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 }) {
|
||||
return (
|
||||
<button
|
||||
@@ -426,15 +290,6 @@ function SettingsIcon({ className = 'size-4', name }) {
|
||||
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') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
@@ -443,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') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
hasAuthenticatedSession,
|
||||
saveAuthSession,
|
||||
} from '../config/api.js'
|
||||
import { translateErrorMessage } from './repositoryUtils.js'
|
||||
import { getResponseError } from './repositoryUtils.js'
|
||||
|
||||
export const authRepository = {
|
||||
async login({ email, password }) {
|
||||
@@ -60,6 +60,20 @@ export const authRepository = {
|
||||
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() {
|
||||
const apiResponse = await fetch(`${apiConfig.functionsUrl.replace(/\/+$/, '')}/user-info`, {
|
||||
method: 'POST',
|
||||
@@ -118,8 +132,3 @@ export const authRepository = {
|
||||
function shouldFallback(response) {
|
||||
return [404, 405].includes(response.status)
|
||||
}
|
||||
|
||||
async function getResponseError(response, fallbackMessage) {
|
||||
const error = await response.json().catch(() => ({}))
|
||||
return translateErrorMessage(error.error_description || error.msg || error.message || error.error || fallbackMessage)
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
getRecordTypes() {
|
||||
return ['Consulta Retorno', 'Primeira Consulta', 'Exame', 'Avaliacao Pre-Op']
|
||||
return ['Consulta Retorno', 'Primeira Consulta', 'Exame', 'Avaliacao Pre-Op', 'Evolucao Clinica', 'Registro Multiprofissional']
|
||||
},
|
||||
|
||||
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 [
|
||||
{ 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: '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.' },
|
||||
{ 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.' },
|
||||
{ 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.' },
|
||||
{
|
||||
id: `${patientId || 'mock'}-report-1`,
|
||||
title: 'Relatorio de consulta medica',
|
||||
status: 'Finalizado',
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
|
||||
import { apiConfig, getAnonHeaders, getAuthenticatedHeaders } from '../config/api.js'
|
||||
import { getResponseError } from './repositoryUtils.js'
|
||||
|
||||
export const patientRepository = {
|
||||
@@ -83,6 +83,29 @@ export const patientRepository = {
|
||||
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()
|
||||
},
|
||||
|
||||
// 4. Atualizar paciente
|
||||
async update(patientId, data) {
|
||||
const body = {
|
||||
@@ -325,3 +348,9 @@ function calculateAge(birthDate) {
|
||||
|
||||
return age
|
||||
}
|
||||
|
||||
function cleanPayload(payload) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(payload).filter(([, value]) => value !== undefined && value !== null && value !== ''),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
|
||||
import { getResponseError, normalizeItem } from './repositoryUtils.js'
|
||||
|
||||
export const professionalRepository = {
|
||||
async getAll() {
|
||||
@@ -6,12 +7,37 @@ export const professionalRepository = {
|
||||
headers: getAuthenticatedHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Erro ao buscar médicos.')
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Erro ao buscar médicos.'))
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
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() {
|
||||
return {
|
||||
slots: ['08-12', '09-13', '10-15', '13-18', '08-14'],
|
||||
@@ -52,3 +78,9 @@ function mapProfessional(doctor) {
|
||||
function normalizeValue(value) {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function cleanPayload(payload) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(payload).filter(([, value]) => value !== undefined && value !== null && value !== ''),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,6 +84,24 @@ export const profileRepository = {
|
||||
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) {
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function fetchJsonWithFallback(requests, fallbackMessage) {
|
||||
}
|
||||
|
||||
if (lastError && !lastResponse) {
|
||||
throw new Error(translateErrorMessage(lastError.message || fallbackMessage))
|
||||
throw new Error(translateErrorMessage(lastError.message || fallbackMessage, fallbackMessage))
|
||||
}
|
||||
|
||||
throw new Error(await getResponseError(lastResponse, fallbackMessage))
|
||||
@@ -49,48 +49,54 @@ export function normalizeItem(data, keys = []) {
|
||||
return data || null
|
||||
}
|
||||
|
||||
export async function getResponseError(response, fallbackMessage) {
|
||||
if (!response) return fallbackMessage
|
||||
export async function getResponseError(response, fallbackMessage = 'Erro inesperado.') {
|
||||
if (!response) return translateErrorMessage(fallbackMessage)
|
||||
|
||||
const text = await response.text().catch(() => '')
|
||||
const error = parseErrorBody(text)
|
||||
const message = translateErrorMessage(
|
||||
error.error_description ||
|
||||
error.msg ||
|
||||
error.message ||
|
||||
error.error ||
|
||||
error.details ||
|
||||
error.hint ||
|
||||
text ||
|
||||
getErrorMessage(error, text) || fallbackMessage,
|
||||
fallbackMessage,
|
||||
)
|
||||
|
||||
return response.status ? `${fallbackMessage} (${response.status}): ${message}` : message
|
||||
}
|
||||
|
||||
export function translateErrorMessage(message) {
|
||||
export function translateErrorMessage(message, fallbackMessage = 'Erro inesperado.') {
|
||||
const rawMessage = String(message || '').trim()
|
||||
const normalized = rawMessage.toLowerCase()
|
||||
|
||||
if (!rawMessage) return 'Erro inesperado.'
|
||||
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/, 'Você não tem permissão para realizar esta ação.'],
|
||||
[/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.'],
|
||||
]
|
||||
|
||||
@@ -98,7 +104,31 @@ export function translateErrorMessage(message) {
|
||||
if (pattern.test(normalized)) return translation
|
||||
}
|
||||
|
||||
return rawMessage
|
||||
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) {
|
||||
@@ -106,6 +136,10 @@ function isPortugueseMessage(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) {
|
||||
return [404, 405].includes(response.status)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
export const settingsRepository = {
|
||||
getIntegrations() {
|
||||
return [
|
||||
['WhatsApp Business', 'Envio automático de lembretes e confirmações', true, 'bg-[#3b82f6]'],
|
||||
['Google Calendar', 'Sincronização bidirecional de agenda', false, 'bg-blue-500'],
|
||||
['Stripe / PagSeguro', 'Pagamentos online e links de cobrança', true, 'bg-violet-500'],
|
||||
['CFM - Conselho Federal de Medicina', 'Validação automática de CRM', false, 'bg-amber-500'],
|
||||
['ANS - Planos de Saúde', 'Integração com tabela TUSS e convênios', false, 'bg-rose-500'],
|
||||
['API de IA Preditiva', 'Score de absenteísmo e predição de faltas', true, 'bg-[#3b82f6]'],
|
||||
]
|
||||
},
|
||||
|
||||
getSections() {
|
||||
return [
|
||||
{ id: 'aparencia', label: 'Aparência e Acessibilidade', description: 'Tema, cores e exibição', icon: 'palette' },
|
||||
|
||||
@@ -33,10 +33,9 @@ export const userRepository = {
|
||||
},
|
||||
|
||||
async getById(userId) {
|
||||
const response = await fetch(`${apiConfig.functionsUrl}/user-info-by-id`, {
|
||||
const response = await fetch(`${apiConfig.functionsUrl}/user-info-by-id/${encodeURIComponent(userId)}`, {
|
||||
method: 'POST',
|
||||
headers: getAuthenticatedHeaders(),
|
||||
body: JSON.stringify({ user_id: userId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -83,7 +82,7 @@ export const userRepository = {
|
||||
const response = await fetch(`${apiConfig.functionsUrl}/delete-user`, {
|
||||
method: 'POST',
|
||||
headers: getAuthenticatedHeaders(),
|
||||
body: JSON.stringify({ user_id: userId }),
|
||||
body: JSON.stringify({ userId, user_id: userId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export const THEME_STORAGE_KEY = 'mediconnect.theme'
|
||||
|
||||
export function getStoredTheme() {
|
||||
if (typeof window === 'undefined') return 'dark'
|
||||
if (typeof window === 'undefined') return 'light'
|
||||
|
||||
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY)
|
||||
return storedTheme === 'light' ? 'light' : 'dark'
|
||||
return storedTheme === 'dark' ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
export function applyTheme(theme) {
|
||||
|
||||
Reference in New Issue
Block a user