Compare commits

...

427 Commits

Author SHA1 Message Date
80dc09f2e3 Merge pull request 'develop' (#83) from develop into main
Reviewed-on: #83
2025-12-04 04:13:14 +00:00
2bee422770 Merge pull request 'fix/visual-adjustments' (#82) from fix/visual-adjustments into develop
Reviewed-on: #82
2025-12-04 04:12:13 +00:00
733a4188c1 fix: appoiment-date 2025-12-04 01:09:26 -03:00
a01c7cf286 fix(doutores): Improved responsiveness to the 'Detalhes do Médico' modal 2025-12-04 00:16:09 -03:00
3eb33e5eee fix(pacientes): Improved responsiveness to the 'Detalhes do Paciente' modal 2025-12-04 00:13:00 -03:00
03285799f6 fix(dashboard): expand upcoming appointments card to full width" 2025-12-03 23:50:29 -03:00
b971cc38ba fix: doctor-exceptions 2025-12-03 23:45:25 -03:00
4cec1582ce fix:fix export pdf in reports 2025-12-03 23:41:51 -03:00
a0dfcd671c fix: add hover calendar view options 2025-12-03 23:41:51 -03:00
5858886efd fix(dashboard):removed the display of pending reports from the administrator/secretary 2025-12-03 23:41:51 -03:00
62cced521f fix(paciente):removed the option to edit patient photos 2025-12-03 23:41:51 -03:00
171c954a78 fix: image-report 2025-12-03 23:37:51 -03:00
0eb7fd7171 fix: responsiveness of the "Foto do Perfil" card profile page 2025-12-03 22:40:47 -03:00
a83dc5e347 fix: fix hovers in patient 2025-12-03 22:34:51 -03:00
ead38e1132 fix(profissional): correction of dates in exceptions 2025-12-03 21:57:32 -03:00
e5304894b4 fix(readme): remove emojis 2025-12-03 21:54:17 -03:00
178d140307 Merge branch 'develop' into fix/visual-adjustments 2025-12-03 21:48:01 -03:00
814d640278 fix(laudo/id): remove button more options 2025-12-03 21:45:41 -03:00
15e90a4ea9 fix(profissional): report search 2025-12-03 21:36:59 -03:00
57310f6621 fix(profissional): Standardized the creation of physician availability on the physician's page 2025-12-03 21:31:34 -03:00
ba41468f37 Merge branch 'backup/visual-adjustments' into fix/visual-adjustments 2025-12-03 21:20:24 -03:00
4b9f0695f2 feat: addavailability and exceptions on the doctor's page 2025-12-03 21:19:06 -03:00
78e37220b6 fix: doctor-availabity 2025-12-03 18:18:00 -03:00
31b4472aee fix: deploy 2025-12-03 17:21:38 -03:00
667ef625e4 fix: edit-report 2025-12-03 17:16:04 -03:00
4f197aafd5 fix: hover-errors 2025-12-03 16:37:20 -03:00
dddbd1e15b fix: birth-date-calendar 2025-12-03 16:16:33 -03:00
47965fe78a fix: appoiments-in-admin-page 2025-12-03 15:58:48 -03:00
5251719123 fix: exception-endpoints 2025-12-03 15:36:30 -03:00
73530ab1bc Merge pull request 'feature/ajust-interface-Chat' (#81) from feature/ajust-interface-Chat into develop
Reviewed-on: #81
2025-11-29 07:13:19 +00:00
c8607556e0 fix-pop-up-message 2025-11-27 23:20:47 -03:00
8d532271e9 fix: report-visual-adjustment 2025-11-27 22:44:59 -03:00
621817e963 fix: report-edit 2025-11-27 21:07:27 -03:00
6c0a6f7367 fix: laudo-page 2025-11-27 20:34:23 -03:00
João Gustavo
386202bce0 add: logo-in-patient-and-doctor-page 2025-11-27 18:10:48 -03:00
João Gustavo
cfb22ebb76 fix: calendar-registration 2025-11-27 18:03:41 -03:00
João Gustavo
33643df28b fix: appoiment-colors 2025-11-27 17:56:00 -03:00
João Gustavo
73eb35b21b fix: calendar-colors 2025-11-27 17:52:01 -03:00
João Gustavo
19260f7e27 fix: hover-errors 2025-11-27 17:25:47 -03:00
João Gustavo
fda4e5651a fix: changes-in-patients-appoiments 2025-11-27 17:10:18 -03:00
João Gustavo
8df4239406 fix: patient-section 2025-11-27 16:00:39 -03:00
f6fad55ff3 fix-visual-adjustments 2025-11-25 12:39:50 -03:00
68a03c0bea feat(readme): add readme 2025-11-25 00:22:39 -03:00
d966e40608 ajustes na interface do paciente 2025-11-24 22:10:03 -03:00
70262448fb ajustes 2025-11-24 22:01:14 -03:00
07c0533224 style(dashboard) ajuste na responsividade 2025-11-24 21:51:05 -03:00
77b7fdd599 style(chat) ajuste na interface do chat 2025-11-24 00:14:00 -03:00
06b7f0d497 style(chat) ajuste no modo escuro na interface do chat 2025-11-24 00:00:41 -03:00
66758614eb style(chat) ajuste visual na interface do chat 2025-11-23 23:43:07 -03:00
f05df3efe3 feat: integrar Zoe com captura de áudio e resposta em voz 2025-11-22 22:04:54 -03:00
82942ebe14 fix(voice) adiocionei a função de audio no chat 2025-11-22 18:12:18 -03:00
964e25bd7e fix(ia) ajuste na integração da IA para suportar pdf 2025-11-21 23:12:29 -03:00
a8d9b1f896 feat(ia) adicionei a integração a api do n8n e fiz uns testes inicias paraa validar a funcionalidade 2025-11-21 22:19:58 -03:00
f12be49830 fix(chat) correção na api do n8n 2025-11-21 21:35:51 -03:00
56b87aae0a feat(ai): replace ZoeIA with FileUploadChat; add voice overlay hook; UI polish & mobile 2025-11-18 03:27:47 -03:00
João Gustavo
dd0a5abb04 add-report-notification 2025-11-15 22:04:25 -03:00
fb7aec765b Merge pull request 'fix/mode-dark' (#80) from fix/mode-dark into develop
Reviewed-on: #80
2025-11-13 23:25:20 +00:00
c7ffb568f5 Merge branch 'backup/dark-mode' into fix/mode-dark 2025-11-13 18:09:35 -03:00
44f546a65e style(selects): standardizes blue hover on filters (admin/patients/doctors/appointments) 2025-11-13 20:53:42 -03:00
db1774beda feat: add standardized select hover styles with blue-500 2025-11-13 20:06:18 -03:00
f885ee485d fixes moon icon regardless of operating system dark/light mode 2025-11-13 19:16:19 -03:00
João Gustavo
37b3864271 fix-patient-page 2025-11-13 18:02:37 -03:00
João Gustavo
a6251fd2ea fix-doctor-list 2025-11-13 17:21:16 -03:00
João Gustavo
72898a48ff fix/neighborhood 2025-11-13 17:04:27 -03:00
João Gustavo
82ac502553 fix-appoiments-save 2025-11-13 16:48:34 -03:00
João Gustavo
383390ce8e Merge branch 'feature/agenteIa' into backup/dark-mode 2025-11-13 16:12:50 -03:00
7ca2c21ba3 fix(ia) adicionei o endepoint de producao do agente 2025-11-13 16:05:58 -03:00
João Gustavo
bd20c0f6f2 fix-solicitante 2025-11-13 16:03:32 -03:00
d32cf44191 Merge pull request 'feature/add-search' (#79) from feature/add-search into develop
Reviewed-on: #79
2025-11-13 18:23:26 +00:00
e927de1821 fix(ia) adicionei um componente Voice Visualization 2025-11-13 13:54:26 -03:00
8ce02ac5ad fix(paciente) adicionei o chat interface 2025-11-13 13:39:51 -03:00
20e3253472 fix(pacientes) adicionei o Chat Widget no modulo de paciemtes 2025-11-13 12:57:22 -03:00
6c163f3b86 feat(patient): dropdown suggestions respect dark mode 2025-11-13 02:25:32 -03:00
João Gustavo
85a02d4184 fix-search 2025-11-13 01:58:35 -03:00
5622e8c025 feat(ResultadosClient): change search option with
filters
2025-11-13 02:01:39 -03:00
João Gustavo
9739fc5687 add-search-in-patient-page 2025-11-12 15:55:12 -03:00
734de0e562 Merge pull request 'fix/report-editor' (#78) from fix/report-editor into develop
Reviewed-on: #78
2025-11-11 03:00:13 +00:00
João Gustavo
fb39de0f35 fix-patch-reports 2025-11-10 23:46:24 -03:00
João Gustavo
6b293a2799 add-import-image 2025-11-10 23:37:57 -03:00
João Gustavo
63776d638c fix-hover-errors 2025-11-10 23:33:33 -03:00
João Gustavo
3c636ff537 fix-new-laudo-editor 2025-11-10 22:27:59 -03:00
João Gustavo
f6cbfad75b fix-editor 2025-11-10 19:34:55 -03:00
João Gustavo
96df6c7289 fix-laudo-editor 2025-11-10 18:10:50 -03:00
ef133fc9c0 Merge pull request 'fix/avatar-endpoint' (#76) from fix/avatar-endpoint into develop
Reviewed-on: #76
2025-11-10 17:13:41 +00:00
67505be530 Merge branch 'develop' into fix/avatar-endpoint 2025-11-10 14:07:13 -03:00
b50cabc8e6 fix(paciente): display avatar in header and profile using useAvatarUrl and AvatarImage and remove patient responses for the doctor 2025-11-10 14:04:48 -03:00
9290498f7b Merge pull request 'fix(perfil) corregir o bug do avatar no perfil de adiministrador' (#75) from feature/ajusAvatar into develop
Reviewed-on: #75
2025-11-09 00:44:50 +00:00
47fa4d0ff4 fix(perfil) corregir o bug do avatar no perfil de adiministrador 2025-11-08 21:28:43 -03:00
334c847916 Merge pull request 'feature/confirm-appoiments' (#74) from feature/confirm-appoiments into develop
Reviewed-on: #74
2025-11-08 06:22:50 +00:00
João Gustavo
6e3c11c5d3 Merge branch 'fix/avatar-endpoint' into feature/confirm-appoiments 2025-11-08 03:22:11 -03:00
João Gustavo
da8ee7244b Merge branch 'feature/ajusAvatar' into feature/confirm-appoiments 2025-11-08 03:21:12 -03:00
João Gustavo
dce7b485e2 fix-appoiments-admin 2025-11-08 03:19:14 -03:00
8de421fd76 fix(avatar): correct URL mismatch (use extension) and try jpg/png/webp in hook 2025-11-08 03:18:15 -03:00
João Gustavo
8fee2cf6e1 fix-appoiments-confirm 2025-11-08 01:37:39 -03:00
ca282e721e fix(consulta) corrigir o erro na parte de cancelar consulta 2025-11-08 01:36:44 -03:00
68c38dba88 feat(header): mostrar foto do usuário no avatar do topo
Usa profile.foto_url|fotoUrl|avatar_url como src e fallback com iniciais geradas a partir do nome/e-mail.
2025-11-08 01:22:29 -03:00
4e0c246e05 fix(configuracao): exclui botão de configuração que não levava para nenhum lugar 2025-11-08 01:08:10 -03:00
5047ab75dc Merge pull request 'backup/user' (#73) from backup/user into develop
Reviewed-on: #73
2025-11-08 03:40:50 +00:00
João Gustavo
c45203f9b6 Merge branch 'feature/auth-simplify-login' into backup/user 2025-11-08 00:39:40 -03:00
João Gustavo
d79a5acb07 Merge branch 'feature/ajustsFinais' into fix/user 2025-11-08 00:36:10 -03:00
João Gustavo
5d78e9f066 fix-dialog-modal 2025-11-08 00:32:00 -03:00
eea59f5063 style(calendário) corrigir o modal quando clica em um paciente 2025-11-08 00:23:51 -03:00
c8813749c6 feat(auth): unify login page and redirect by role 2025-11-08 00:05:31 -03:00
9adf479e90 Merge pull request 'backup/reports' (#72) from backup/reports into develop
Reviewed-on: #72
2025-11-07 05:11:08 +00:00
João Gustavo
6b3dc6255f Merge branch 'feature/admin-improve-responsiveness' into backup/reports 2025-11-07 02:07:27 -03:00
67d73b2231 feat: responsiveness to the administrator area on the mobile site and
remove card de financeiro no form de médico
 remove financial card from doctor's form
2025-11-07 02:04:41 -03:00
João Gustavo
177c63ab54 fix-preview 2025-11-07 01:57:11 -03:00
82c5f61f2a Merge pull request 'fix-reponse' (#71) from backup/reports into develop
Reviewed-on: #71
2025-11-07 04:31:18 +00:00
João Gustavo
5883cedc61 fix-reponse 2025-11-07 01:28:12 -03:00
3404103bc9 Merge pull request 'fix/report' (#70) from fix/report into develop
Reviewed-on: #70
2025-11-07 03:25:36 +00:00
João Gustavo
77afb91c70 Merge branch 'feature/response' into fix/report 2025-11-07 00:23:14 -03:00
João Gustavo
4d02b55ce7 fix-report-page 2025-11-07 00:21:01 -03:00
eb8570d3e3 ajustes na responsividade do modulo de pacientes 2025-11-07 00:17:40 -03:00
444daa553b adicionei a responsividade no modolo de pacientes 2025-11-07 00:05:36 -03:00
João Gustavo
e22ad305c4 fix-response-on-patient-page 2025-11-06 23:22:58 -03:00
João Gustavo
2cc3687628 fix-delete-appoiment 2025-11-06 22:58:39 -03:00
João Gustavo
dc83db3e7c fix-report-page 2025-11-06 21:44:51 -03:00
094650dba7 Merge pull request 'fix/report' (#68) from fix/report into develop
Reviewed-on: #68
2025-11-07 00:01:16 +00:00
João Gustavo
f7f8244033 Merge branch 'feature/remove-magic' into fix/report 2025-11-06 20:59:23 -03:00
2e7b3561b6 Merge pull request 'fix/appoiments' (#67) from fix/appoiments into develop
Reviewed-on: #67
2025-11-06 23:47:46 +00:00
João Gustavo
216b631ba7 Merge branch 'feature/ajustGeral' into fix/appoiments 2025-11-06 20:46:19 -03:00
João Gustavo
6d79ec2321 fix-doctor-page 2025-11-06 20:43:05 -03:00
João Gustavo
4344ccedca Merge branch 'backup/develop' into fix/appoiments 2025-11-06 20:18:01 -03:00
e85e8e9736 fix: fix TypeScript errors in profissional page 2025-11-06 20:16:12 -03:00
b42ef99471 feat(ui): adjust visual elements and remove scheduling button 2025-11-06 19:17:09 -03:00
a2f4a37eb0 remove botões Procedimento e Financeiro e o botão de recarregar 2025-11-06 18:42:34 -03:00
ec11b015ee crie uma mensagem de commit para oq eu fiz agora, adicionei uma scroolbaar 2025-11-06 18:05:33 -03:00
fbc7c46f4d Remove Magic Link client UI and helpers; keep email+password login 2025-11-06 17:50:51 -03:00
João Gustavo
5f902e0899 fix-report-page 2025-11-06 17:48:22 -03:00
280d314b5d WeekView: limita renderização às horas do menor/maior horário da semana visível 2025-11-06 17:30:53 -03:00
b302bf1c66 remove ThreeDWallCalendar import/componente 2025-11-06 17:11:18 -03:00
add30c54a3 feat(doutores/pacientes): adiciona filtros e ordenação avançados na listagem de médicos e pacientes 2025-11-06 16:50:40 -03:00
João Gustavo
c37ff3c44c fix-calendar-registration-form 2025-11-06 16:37:34 -03:00
b265b72f75 fix(resultados-client): mostrar apenas horários posteriores ao horário atual 2025-11-06 16:24:10 -03:00
6611055211 fix(profissional): uncomment Communication 2025-11-06 16:09:52 -03:00
7b4353ef7b Merge pull request 'backup/develop' (#66) from backup/develop into develop
Reviewed-on: #66
2025-11-06 18:49:45 +00:00
João Gustavo
c6397244ef fixing-patient-appoiments 2025-11-06 15:30:47 -03:00
João Gustavo
59a9f8da15 fix: fixing-patient-list 2025-11-06 15:27:18 -03:00
d775d1fe0c fix(profissional): remove the add patients button in the doctors area 2025-11-06 15:23:39 -03:00
João Gustavo
b760ee1833 chore: sync pnpm-lock.yaml with package.json 2025-11-06 01:29:44 -03:00
João Gustavo
bbf6e9f4b7 fix-typo in .gitignore and add pnpm-lock.yaml 2025-11-06 01:26:11 -03:00
d8e43f40fa Merge pull request 'feature/perfis-cal3d' (#65) from feature/perfis-cal3d into develop
Reviewed-on: #65
2025-11-06 04:16:58 +00:00
c6aacc7df4 fix(calendar): fix import 2025-11-06 01:15:24 -03:00
b96f9e56bb Merge branch 'fix/patient-page' into feature/perfis-cal3d 2025-11-06 01:09:03 -03:00
5dd0764f0e feat: improve patient registration auth,
debugging in 3D calendar and register profiles
2025-11-06 01:01:17 -03:00
João Gustavo
1af15a16b7 Merge branch 'feature/ajustCalendar' into fix/patient-page 2025-11-06 01:00:04 -03:00
214da568c3 ajustes finais no calendario 2025-11-06 00:54:58 -03:00
1aed4c6164 ajust calendario 2025-11-06 00:40:07 -03:00
João Gustavo
e2a6b28080 fixing-patient-page 2025-11-06 00:35:59 -03:00
334adb5ba1 ajuste visual calendario 2025-11-05 23:58:53 -03:00
93e7b20390 ajuste no calendario 2025-11-05 23:22:41 -03:00
2729fdc868 Remove ListaEspera e botões Procedimento/Financeiro 2025-11-05 22:59:35 -03:00
3022cbfc4b fix(search): mover ícone de busca para fora do input e estilizar limpeza 2025-11-05 22:23:41 -03:00
a857e25d2f Merge pull request 'fix/avatar' (#64) from fix/avatar into develop
Reviewed-on: #64
2025-11-06 00:33:49 +00:00
bcd5ce9bac fix(resultados/profissional): type callbacks and
mute warnings from dynamic images
2025-11-05 21:31:00 -03:00
d4cb5f98e0 refactor(structure): Organizes the structure of the app and components folders
- Reorganizes the components folder into ui, layout, features, shared, and providers for better modularity.
- Groups routes in the app folder using a route group (auth).
- Updates all imports to reflect the new file structure.
2025-11-05 18:06:13 -03:00
34e2f4d05b fix: reorganize files into components 2025-11-05 00:28:16 -03:00
21636401b3 fix:undefined checks, parsing simplification, and Tailwind standardization 2025-11-04 11:45:43 -03:00
2dc7b00afc fix: remove error message when starting local environment 2025-11-03 14:56:26 -03:00
João Gustavo
7dbca95bdd fix-result-page 2025-10-31 17:56:35 -03:00
João Gustavo
076ec25fd4 fix-patient-and-report-page 2025-10-31 17:51:13 -03:00
1088e66f55 Merge branch 'feature/caledarAjust' into fix/avatar 2025-10-31 01:59:09 -03:00
135c416758 feat: add page of patients of consults day 2025-10-31 01:55:54 -03:00
ac66a68c04 feat: Adiciona EventManager e conecta à API de agendamentos 2025-10-31 01:43:20 -03:00
fbdeb7e462 `
feat: Substitui FullCalendar pelo EventManager conectado à API
2025-10-31 01:09:44 -03:00
10b439056e feat(calendar): integrar EventManager, manter FullCalendar como fallback e adicionar calendário 3D; compactar controles do cabeçalho 2025-10-31 00:42:29 -03:00
d2e6d8948e fix(calendar): Improvements to the 3D calendar
- Adaptive dots based on number of patients (20px→10px)
- Color system by status (green/yellow/red/blue) with legend
- Optimized layout: grid without overlap, centered on the page
- Detailed HoverCard and drag vs. click detection
- Complete pt-BR translation and terminology "patients" instead of "events"
- Integration with FullCalendar calendar endpoints
2025-10-31 00:28:30 -03:00
44ddc4d03a feat: Substitui calendário antigo pelo novo EventManager 2025-10-31 00:27:48 -03:00
5b3faab1bd Merge branch 'fix/avatar' of https://git.popcode.com.br/RiseUP/riseup-squad20 into fix/avatar 2025-10-30 23:50:59 -03:00
489f25b2e9 feat(calendar): add calendar 3d 2025-10-30 23:50:29 -03:00
f2e7e26b2f Merge pull request 'fix/avatar' (#63) from fix/avatar into develop
Reviewed-on: #63
2025-10-31 02:35:17 +00:00
João Gustavo
82d5644c28 fix-doctor-ID 2025-10-30 23:20:49 -03:00
João Gustavo
8443abd785 Merge branch 'fix/visual-adjustments' into fix/avatar 2025-10-30 23:04:03 -03:00
João Gustavo
94a4176bc8 fix-report-page 2025-10-30 23:01:37 -03:00
d3c897d95a fix: Redesign of the patient portal and add hovers
- placed hovers on the medical and administrator/manager/secretary pages
- Transformed "Scheduled Appointments" modal into a normal page section
- Reorganized flex layout for responsive grid [220px_1fr]
- Redesigned header with avatar, patient info, and rounded edges, now sticky
- Side menu revamped with rounded edges, sticky and responsive
- Appointment cards with gradients, smooth hovers, and responsive grid layout
- Unified visual standard with professional/doctor page
- Improvements: reduced icons, consistent spacing, smooth transitions
- Content remains centered without forced margins
- Responsiveness guaranteed for mobile, tablet, and desktop
2025-10-30 22:57:26 -03:00
3ff349905d style(paciente): padronizar fonte e tamanho dos rótulos e números nos cards do dashboard 2025-10-30 18:59:24 -03:00
75a87b895a style(paciente): padronizar fonte e tamanho dos rótulos e números nos cards do dashboard 2025-10-30 18:59:09 -03:00
d2bb921b69 feat(paciente): padronizar tipografia e ajustar estilo dos cards do dashboard 2025-10-30 18:47:41 -03:00
c1471ea4fa fix: fix of hovers 2025-10-30 18:26:01 -03:00
João Gustavo
a2d90bf68f fix-report-page 2025-10-30 18:21:30 -03:00
João Gustavo
35535b3779 fix-timezone 2025-10-30 17:51:45 -03:00
0122bd0fd0 Merge pull request 'fix/visual-adjustments' (#62) from fix/visual-adjustments into develop
Reviewed-on: #62
2025-10-30 02:48:05 +00:00
714ce9cda8 Merge branch 'feature/ajust-claro' into fix/visual-adjustments 2025-10-29 23:44:24 -03:00
90dc9823b7 feat(main-routes): add pagination 2025-10-29 23:41:48 -03:00
João Gustavo
093b873e86 new-visual-ajustments 2025-10-29 23:38:19 -03:00
26d4077784 fix(main-routes): security, layout, and form formatting
- Removed sensitive logs from the console
- Added sidebar to the Schedule, Procedure, and Financial pages
- Standardized spacing between labels and inputs in all forms
- Added automatic formatting for ID, date of birth, and phone number in patient registration
- Removed duplicate "Cell Phone" field in doctor registration
- Adjusted page layout to follow standard
2025-10-29 23:26:09 -03:00
cecadafac8 feat(paciente): remover campos de especialidade e localização do formulário de pesquisa na tela do paciente 2025-10-29 23:04:37 -03:00
João Gustavo
b478a1f8d3 fix-hover-errors 2025-10-29 22:45:43 -03:00
62e4a19e1e Merge pull request 'feature/reset-password' (#61) from feature/reset-password into develop
Reviewed-on: #61
2025-10-30 00:41:34 +00:00
João Gustavo
0366289894 add-register-patient 2025-10-28 21:04:10 -03:00
João Gustavo
a0ecaf27ab fix-cretate-patient-and-doctor 2025-10-28 10:34:42 -03:00
3c52ec5e3a Merge pull request 'backup/agendamento' (#60) from backup/agendamento into develop
Reviewed-on: #60
2025-10-28 02:57:05 +00:00
João Gustavo
708ec3cd93 Merge branch 'feature/endpoint-user-info-id' into backup/agendamento 2025-10-27 23:56:26 -03:00
7e17a9847b feat(dashboard): add dashboard 2025-10-27 23:54:36 -03:00
João Gustavo
a37dbb4c75 fix-visual-adjustments 2025-10-27 23:51:22 -03:00
1693a415e2 feat(perfil): add profile for admin and user information endpoint by id 2025-10-27 23:21:17 -03:00
João Gustavo
bb6e3b0d25 fix-reports 2025-10-27 23:11:33 -03:00
João Gustavo
79eb63ad96 add-user-endpoint 2025-10-27 22:25:57 -03:00
João Gustavo
b53401ff39 list-reports-and-appoiments 2025-10-27 12:00:18 -03:00
João Gustavo
0c8bc4534a add-appoimnets-in-patient-page 2025-10-25 00:35:40 -03:00
João Gustavo
cc43a2e9a9 add-doctor-availability 2025-10-24 17:21:08 -03:00
João Gustavo
7c9b2b6ca3 Merge branch 'feature/add-api-sms' into backup/agendamento 2025-10-24 16:52:56 -03:00
João Gustavo
2527d28f6b fix-patient-role 2025-10-24 16:51:17 -03:00
a2ca13607e feat(profissional): add endpoint de sms 2025-10-24 16:33:46 -03:00
99bd827c4c feat(agendamento): tornar parte de agendamento do paciente dinâmica de acordo com a API 2025-10-23 19:28:02 -03:00
770eab9afe Merge pull request 'backup/fix-patient-page' (#59) from backup/fix-patient-page into develop
Reviewed-on: #59
2025-10-23 17:48:58 +00:00
João Gustavo
2c39f404d8 add-upload 2025-10-23 14:23:45 -03:00
João Gustavo
6a8a4af756 Merge branch 'feature/up-dow-avatar' into backup/fix-patient-page 2025-10-23 14:23:33 -03:00
d21ed34715 feat: upload/download de avatar e correções 2025-10-23 14:07:32 -03:00
João Gustavo
f67ff8df8c fixpatient-page 2025-10-23 02:50:57 -03:00
João Gustavo
653b21e2d2 fix-doctor-and-patient 2025-10-22 23:29:26 -03:00
4687d88d2f Merge pull request 'fix: repair patient route protector' (#58) from feature/add-new-user-endpoint into develop
Reviewed-on: #58
2025-10-22 21:51:12 +00:00
7fc51a833d fix: repair patient route protector 2025-10-22 18:48:05 -03:00
2a860a27ce Merge pull request 'add-doctor-create-user' (#57) from feature/add-new-user-endpoint into develop
Reviewed-on: #57
2025-10-22 21:29:51 +00:00
João Gustavo
a033a24cd4 add-doctor-create-user 2025-10-22 18:28:46 -03:00
827899ea47 Merge pull request 'add-create-user-with-password' (#56) from feature/add-new-user-endpoint into develop
Reviewed-on: #56
2025-10-22 21:18:37 +00:00
João Gustavo
7e04d889ea add-create-user-with-password 2025-10-22 18:17:55 -03:00
442d11da0c Merge pull request 'backup-feature/add-appointments-endpoints' (#55) from backup-feature/add-appointments-endpoints into develop
Reviewed-on: #55
2025-10-22 19:08:18 +00:00
2bc03de6d2 Merge branch 'feature/add-appointments-endpoint' into backup-feature/add-appointments-endpoints 2025-10-22 16:01:47 -03:00
95920e1168 fix(api): criarMedico fallback — return existing doctor when email already registered 2025-10-22 15:57:40 -03:00
João Gustavo
a79cdf60a1 fix-appoiments-endpoint 2025-10-22 15:55:49 -03:00
João Gustavo
a92bd87710 add-create-doctor 2025-10-21 23:31:23 -03:00
João Gustavo
0fd8cc631e add-patients-endpoint 2025-10-21 21:57:21 -03:00
bb6dbe4841 Merge pull request 'feature/add-appointments-endpoint' (#54) from feature/add-appointments-endpoint into develop
Reviewed-on: #54
2025-10-21 03:17:50 +00:00
João Gustavo
369846f1c2 fix-search-laudo 2025-10-20 23:22:40 -03:00
João Gustavo
905caa14ad fix-appoinments-endpoints 2025-10-20 22:32:37 -03:00
30c37adbed Merge pull request 'fix-exceptions-endpoint' (#53) from feature/add-appointments-endpoint into develop
Reviewed-on: #53
2025-10-20 18:40:38 +00:00
João Gustavo
7c077fbf45 fix-exceptions-endpoint 2025-10-20 15:23:27 -03:00
8281d2ce5c Merge pull request 'feature/add-appointments-endpoint' (#52) from feature/add-appointments-endpoint into develop
Reviewed-on: #52
2025-10-19 05:15:10 +00:00
João Gustavo
99986dbdd7 fix-appoiment-type 2025-10-19 02:10:57 -03:00
João Gustavo
82a5caac1c fix-medical-page 2025-10-19 00:46:11 -03:00
João Gustavo
05d1b68ddb fix-cancel-button 2025-10-18 23:22:00 -03:00
João Gustavo
6a95120c50 add-delete-appointment-endpoint 2025-10-18 23:11:58 -03:00
João Gustavo
075fa92eb9 add-create-appoiments-endpoint 2025-10-18 21:23:27 -03:00
João Gustavo
ee3b855f8a fix-search-appoinments-endpoint 2025-10-18 18:13:58 -03:00
João Gustavo
6e33d6406e add-search-appoinments-endpoint 2025-10-18 17:41:47 -03:00
João Gustavo
83018b8854 add-edit-appointments-endpoints 2025-10-18 17:02:59 -03:00
João Gustavo
23e0765c5b add-list-appointments-endpoints 2025-10-18 14:52:26 -03:00
84cc56b017 Merge pull request 'backup/remove-server-side' (#51) from backup/remove-server-side into develop
Reviewed-on: #51
2025-10-18 17:07:49 +00:00
João Gustavo
60630cd9db add-avatar-endpoint 2025-10-17 18:30:15 -03:00
João Gustavo
f95fb7d3ce fix/create-user 2025-10-17 00:23:28 -03:00
João Gustavo
26a20225f6 fix-magic-link-endpoint 2025-10-16 23:08:48 -03:00
João Gustavo
8f443b63e5 chore: atualizar pnpm-lock.yaml (nova dependência lightningcss) 2025-10-16 22:21:38 -03:00
João Gustavo
199a3197be add-magic-link-endpoint 2025-10-16 22:20:03 -03:00
João Gustavo
37a87af28d add-new-authentication-endpoints 2025-10-16 22:03:40 -03:00
b2bdc68319 backup: removals of server-side proxies (backup branch) 2025-10-16 13:24:51 -03:00
9244c7d8f1 hotfix(api): remove duplicate API routes and extract client component (resultados)
Removed pages/api/create-user.ts and pages/api/signin-user.ts; added app/resultados/ResultadosClient.tsx and updated app/resultados/page.tsx. Fixes route conflict and build error (useSearchParams used in server component).
2025-10-15 20:30:59 -03:00
João Gustavo
be789948ee add-new-login-endpoint 2025-10-15 20:04:13 -03:00
7a1885d882 Merge pull request 'fix-assgnment-message' (#50) from feature/add-exceptions-endpoints into develop
Reviewed-on: #50
2025-10-15 22:26:28 +00:00
João Gustavo
c07705a2fc fix-assgnment-message 2025-10-15 19:25:13 -03:00
eaf5b3f983 Merge pull request 'add-exceptions-endpoints' (#49) from feature/add-exceptions-endpoints into develop
Reviewed-on: #49
2025-10-15 21:35:19 +00:00
João Gustavo
38fd9668d6 add-exceptions-endpoints 2025-10-15 16:27:36 -03:00
ab8905859c Merge pull request 'feature/patiente-medical-assignment' (#48) from feature/patiente-medical-assignment into develop
Reviewed-on: #48
2025-10-15 18:20:06 +00:00
João Gustavo
597bcbc007 Merge branch 'develop' into feature/patiente-medical-assignment 2025-10-15 15:19:13 -03:00
João Gustavo
a9bbd2f872 add-availability-endpoints 2025-10-15 15:17:44 -03:00
481a951428 Merge pull request 'fixing-patient-page' (#47) from feature/fix-patients-role into develop
Reviewed-on: #47
2025-10-14 17:07:26 +00:00
João Gustavo
b1ded5752e fixing-patient-page 2025-10-14 13:23:20 -03:00
ca84c563b6 Merge pull request 'fixing-laudo-editor' (#46) from feature/patiente-medical-assignment into develop
Reviewed-on: #46
2025-10-12 04:54:38 +00:00
João Gustavo
bf926cd9b7 fixing-laudo-editor 2025-10-12 01:53:38 -03:00
c262b9a472 Merge pull request 'feature/patiente-medical-assignment' (#45) from feature/patiente-medical-assignment into develop
Reviewed-on: #45
2025-10-12 03:57:36 +00:00
João Gustavo
92fc293d0d fixing-laudo-section 2025-10-12 00:56:16 -03:00
João Gustavo
8cb00bb499 Merge branch 'develop' into feature/patiente-medical-assignment 2025-10-11 23:56:52 -03:00
João Gustavo
04cd640f63 Merge branch 'feature/add-duties-enpoints' into feature/patiente-medical-assignment 2025-10-11 23:52:59 -03:00
João Gustavo
d6cf44b1d4 fixing-user-endpoint 2025-10-11 23:49:16 -03:00
e770826fb6 fix(auth): merge profile and persist to localStorage
- Impact: prevents profile loss on reload
chore(assignment): add professional assignment form
- Impact: enables assigning professionals to patients via UI
2025-10-11 23:04:08 -03:00
João Gustavo
1a471357b7 add-userID 2025-10-11 22:30:05 -03:00
João Gustavo
8bf953a689 add-assignament-endpoints 2025-10-11 19:20:26 -03:00
João Gustavo
52c3e544f3 Merge branch 'feature/add-duties-enpoints' into feature/patiente-medical-assignment 2025-10-11 18:53:49 -03:00
5698a5faa9 feat(api): Standardizes CRUD for profiles and derivations
- Standardizes listing/searching/creating/updating/deleting for profiles
- Adds UI for assigning professionals
- Integrates UI into patient listing (menu item)
- Corrects hydration mismatch in ProtectedRoute
2025-10-11 18:50:56 -03:00
João Gustavo
535dfa0503 loging-medical-page 2025-10-11 18:43:41 -03:00
2c774b8859 Merge feature/fix-report-endpoints em feature/add-duties-enpoints 2025-10-11 12:38:54 -03:00
25a0df3dca Merge branch 'feature/fix-report-endpoints' into feature/add-duties-enpoints 2025-10-11 12:38:38 -03:00
c62883a5cc chore(api):remove unnecessary comments 2025-10-11 12:24:48 -03:00
535f400f2d feat(auth):refaz fluxo de cadastro 2025-10-11 00:35:52 -03:00
João Gustavo
db72ad33a1 add-ID-search-endpoint 2025-10-10 23:38:38 -03:00
João Gustavo
a1b801b068 fix-endpoints 2025-10-10 23:27:43 -03:00
João Gustavo
ab57567f11 add-response 2025-10-10 21:19:49 -03:00
2161a9c210 feat(user-creation): ajustar fluxo de criação de usuários e fallback\n\n- Unificou criação de perfis de pacientes e médicos\n- Removeu tentativas inseguras de escrita direta em user_roles no cliente\n- Reconciliou userType no login com roles retornadas por /functions/v1/user-info\- Desabilitou criação automática de usuário Auth enquanto Edge Function está com erro\- Adicionou fallback e rota server-side para atribuição de roles (requere service role key)\- Adicionou mensagens de erro e checagem antes de excluir pacientes 2025-10-10 19:50:14 -03:00
João Gustavo
f934c0f33c add-list-and-edit-report-endpoitns 2025-10-10 19:46:31 -03:00
aeed6f3f0d feat(api): Ajustar fluxo de criação de usuários e mensagens de erro
- Removido fallback Direct Auth no frontend

- Removida tentativa de atribuir role no cliente

- Mensagens de erro aprimoradas para 'failed to assign user role' e email duplicado

- Atualizados formulários de médico e paciente para instruções claras
2025-10-10 16:40:04 -03:00
João Gustavo
2bb0b06375 add-create-report-endpoints 2025-10-10 03:04:10 -03:00
790a1e1aba Merge pull request 'fix: corrigir erro na parte de profissional de saúde' (#44) from feature/ajust-glob-css into develop
Reviewed-on: #44
2025-10-10 02:24:11 +00:00
f15bcf9954 fix: corrigir erro na parte de profissional de saúde 2025-10-09 23:18:13 -03:00
a281cd8f42 Merge pull request 'fix: corrigir pequenos problemas na seção de profissionais' (#43) from feature/ajust-resul into develop
Reviewed-on: #43
2025-10-10 01:51:04 +00:00
e639cec7f8 fix: corrigir pequenos problemas na seção de profissionais 2025-10-09 22:46:15 -03:00
51f598b3b3 Merge pull request 'fixing-profissional-page-errors' (#42) from feature/fix-report-endpoints into develop
Reviewed-on: #42
2025-10-09 22:02:21 +00:00
João Gustavo
735eb521d0 fixing-profissional-page-errors 2025-10-09 19:01:24 -03:00
e5df9918b2 Merge pull request 'feature/pacientes-consulta' (#41) from feature/pacientes-consulta into develop
Reviewed-on: #41
2025-10-09 17:48:20 +00:00
429c5c2eeb feat: adicionar listagem e rolagem de médicos por especialização 2025-10-09 14:42:04 -03:00
1de9176e94 feat: adicionar página de resultados de busca ao clicar em pesquisar 2025-10-09 14:15:08 -03:00
b6c160bffd fix: hover azul no botão Ver consultas agendadas 2025-10-09 11:52:42 -03:00
50119c7bf6 Merge feature/mudança-vissual into develop 2025-10-08 21:50:42 -03:00
5334afc761 feat(ui): formatar datas para dd/MM/yyyy e controlar forma de pagamento 2025-10-08 21:47:57 -03:00
João Gustavo
6951dcec96 add-report-endpoints 2025-10-08 21:40:06 -03:00
João Gustavo
0a7d3f3ae4 fix-laudos-module 2025-10-07 02:56:13 -03:00
João Gustavo
a43fdcc655 add-patient-page 2025-10-07 02:35:49 -03:00
João Gustavo
d7fbcab6a6 Merge branch 'develop' into feature/add-report-endpoints 2025-10-07 00:22:58 -03:00
João Gustavo
40bf3e0e6f fix-report-page 2025-10-07 00:16:39 -03:00
João Gustavo
a994a70d90 atualizing-api.ts 2025-10-06 23:24:57 -03:00
8955446bc7 Merge pull request 'feature/api-usuarios-perfis' (#40) from feature/api-usuarios-perfis into develop
Reviewed-on: #40
2025-10-07 01:51:25 +00:00
11c8b790ba Merge branch 'develop' into feature/api-usuarios-perfis 2025-10-06 22:47:22 -03:00
caed16fabd Merge pull request 'fix-new-laudo' (#39) from feature/ajustes-visuais into develop
Reviewed-on: #39
2025-10-07 01:15:16 +00:00
João Gustavo
b1359a2cad fix-new-laudo 2025-10-06 22:13:03 -03:00
0510ef8a36 feat(api):add profile API 2025-10-06 02:35:21 -03:00
8284ccbadd Merge branch 'develop' into feature/api-usuarios-perfis 2025-10-04 23:44:02 -03:00
ae678e2b92 Merge pull request 'fix-profissional-page-and-admin-page' (#38) from feature/ajustes-visuais into develop
Reviewed-on: #38
2025-10-04 01:44:51 +00:00
João Gustavo
b64a488d1a Merge branch 'develop' into feature/ajustes-visuais 2025-10-03 22:37:25 -03:00
08109c1f5b Merge pull request 'feat(visual): visual adjustments' (#37) from feature/visual-adjustments into develop
Reviewed-on: #37
2025-10-04 01:21:56 +00:00
João Gustavo
37f406995d fix-profissional-page-dark-mode 2025-10-03 22:19:41 -03:00
5daad0b063 Merge branch 'develop' into feature/visual-adjustments 2025-10-03 22:07:50 -03:00
7b0406f815 feat(agenda): Replace textarea with select in Documents and attachments 2025-10-03 22:04:47 -03:00
João Gustavo
8ddb02ef43 fixing-visual 2025-10-03 20:46:35 -03:00
João Gustavo
52e3d13767 Merge branch 'feature/fix-erros' into feature/ajustes-visuais 2025-10-03 20:42:02 -03:00
João Gustavo
33e82f9462 fix-hover-errors 2025-10-03 20:18:48 -03:00
5d00ffb508 feat(ui): standardize form and navigation styles
- Standardize borders for all fields (inputs, selects, textareas) with gray-300
- Add consistent hover effect (gray-400) across all fields
- Implement active highlight (blue) on navigation buttons
- Adjust field height from h-10 to h-11 for better proportion
- Add blue hover effect on back button
- Remove unnecessary icons from information card
- Ensure visual consistency only in light mode
- Apply changes to: Input, Textarea, HeaderAgenda, FooterAgenda,
  calendar-registration-form and financeiro page

BREAKING CHANGE: Input and Textarea components now use border-gray-300
by default in light mode instead of border-input
2025-10-03 20:17:28 -03:00
7061909480 fix: corrigir hover dos botões para azul padronizado
- Corrigir botão "Hoje" para ter hover azul ao invés de branco
- Adicionar CSS para padronizar todos os hovers para azul
- Corrigir ícones que ficavam invisíveis no hover
- Melhorar consistência visual entre modo claro e escuro
2025-10-03 20:15:51 -03:00
c26bb01bf3 Merge pull request 'fixing-dark-mode-errors' (#36) from feature/fix-erros into develop
Reviewed-on: #36
2025-10-03 22:26:03 +00:00
João Gustavo
4ac663f9e7 fix-dark-mode-page 2025-10-03 19:22:46 -03:00
6585187efd feat: Melhorias na UI e consolidação de
ações para formulários de agendamento

  - calendar-registration-form.tsx:
      - Otimizadas importações de ícones e
  adicionadas novas caixas de seleção para
  reembolso e impressão de etiquetas.
      - Introduzido campo 'Profissional
  solicitante' com funcionalidade de busca.
      - Removido botão 'Cancelar' interno.
  - FooterAgenda.tsx:
      - Consolidado botões de ação,
  removendo 'Cancelar' e 'Salvar as
  alterações' redundantes.
2025-10-03 17:53:54 -03:00
584306377c feat: ajustes visuais nas páginas de login e componentes UI
- Atualização das páginas de login (admin, paciente e geral)
- Melhorias nos componentes input e textarea
- Atualizações nas páginas do profissional
- Atualização de dependências
2025-10-03 17:17:52 -03:00
63e5a2ca9d chore(eslint): Configure and adjust
ESLint rules for the project
2025-10-03 14:38:54 -03:00
90ea08cead Merge pull request 'add-dark-mode-and-removing-pages' (#35) from feature/fix-erros into develop
Reviewed-on: #35
2025-10-03 17:28:32 +00:00
João Gustavo
ca3ec16c27 removing-config-pages 2025-10-03 11:59:10 -03:00
João Gustavo
d975e0f554 fix-laudo-editor 2025-10-03 06:50:17 -03:00
f7e9081426 revert 97acdf79cf850fcf5985ec6c2d844e22b4fd5e60
revert Resolve merge conflicts - keep current branch version
2025-10-03 09:18:30 +00:00
João Gustavo
97acdf79cf Resolve merge conflicts - keep current branch version 2025-10-03 05:57:11 -03:00
João Gustavo
63a8fea9ee add-black-laudo 2025-10-03 05:50:03 -03:00
20f7d79474 feat: add email confirmation on user registration
Implements automatic creation in Supabase Auth with mandatory
email confirmation. Adds credentials popup and clear messages
about the confirmation process.

BREAKING CHANGE: Users must confirm email before login
2025-10-03 04:42:24 -03:00
ba93bb0f57 Merge pull request 'fixing-laudo' (#32) from feature/fix-laudo into develop
Reviewed-on: #32
2025-10-03 06:55:10 +00:00
dcc4a8ff86 feat: atualiza página de login do paciente
- Melhora layout e funcionalidades da página de login do paciente
- Ajustes na interface e experiência do usuário
2025-10-03 03:52:18 -03:00
f4abd44efe feat: implementa melhorias no sistema de tema claro padrão
- Adiciona hook customizado para forçar tema claro como padrão
- Melhora CSS para prevenir flash de tema escuro
- Otimiza transições entre temas
2025-10-03 03:35:25 -03:00
f0a90afe55 refactor: Ajustes manuais em componentes do header e sidebar
- Atualiza componentes de header com melhorias visuais
- Refina sidebar com ajustes de navegação
- Aprimora footer com correções de estilo
- Mantém funcionalidades existentes intactas

Alterações realizadas manualmente para otimização da interface.
2025-10-03 03:10:49 -03:00
8536a8dd20 fix: Corrige navegação do menu lateral e melhora alinhamento de formulários
🔧 Correções no Menu Lateral:
- Fix: Resolve problema onde clicar em "Relatórios" também ativava "Dashboard"
- Ajusta lógica de detecção do item ativo na sidebar
- Evita conflito entre rotas /dashboard e /dashboard/relatorios
- Funciona corretamente em ambos os modos (claro e escuro)

🎨 Padronização das Páginas de Agendamento:
- Padroniza sistema completo de agendamento (agenda, procedimento, financeiro)
- Adiciona página /financeiro com informações financeiras e formas de pagamento
- Padroniza componentes HeaderAgenda e FooterAgenda para modo escuro
- Padroniza ListaEspera com cores responsivas e tabela temática
- Padroniza página de calendário principal com dropdown melhorado
- Padroniza página de relatórios com cards e gráficos adaptativos

📐 Melhorias no Alinhamento dos Formulários:
- Fix: Corrige alinhamento dos campos no formulário de agendamento
- Reorganiza layout de "Data de nascimento" e "Telefone" em colunas separadas
- Alinha perfeitamente campos "Início" e "Término" em grid 2 colunas
- Melhora layout geral da seção "Informações do atendimento"
- Ajusta proporções do textarea de observações
- Layout mais limpo e profissional em todas as telas

 Benefícios:
- Navegação precisa no menu lateral
- Sistema de agendamento completamente padronizado
- Formulários com campos perfeitamente alinhados
- Experiência visual consistente em modo claro e escuro
- Interface mais profissional e intuitiva
2025-10-03 02:42:32 -03:00
47967eb37f feat: Implementa sistema completo de modo escuro
- Adiciona ThemeProvider com next-themes para controle de tema
- Implementa componente SimpleThemeToggle com ícones sol/lua
- Configura CSS variables completas para light/dark modes no globals.css
- Padroniza todas as páginas de autenticação (login, login-admin, login-paciente)
- Padroniza todos os módulos principais (dashboard, pacientes, doutores, consultas, calendar, configuração)
- Padroniza completamente área profissional com todas as seções:
  * Calendário e agendamentos
  * Busca e gestão de pacientes
  * Prontuários médicos completos
  * Comunicação e relatórios
  * Seções de exames (solicitados, resultados, diagnósticos, prescrições, evolução, anexos)
- Atualiza componentes UI (input, select, textarea) com bordas visíveis
- Implementa suporte dark mode em tooltips, badges de status e mensagens
- Garante acessibilidade e consistência visual em ambos os modos
- Mantém funcionalidades existentes sem breaking changes

Todos os elementos agora respondem adequadamente ao toggle de tema,
proporcionando experiência de usuário consistente e acessível.
2025-10-03 02:22:40 -03:00
João Gustavo
c1ff652423 fixing-laudo 2025-10-03 00:47:45 -03:00
b8369dd248 Merge pull request 'feat: ajustes na página do profissional' (#30) from feature/pacientes-corect into develop
Reviewed-on: #30
2025-10-02 14:25:08 +00:00
efdf89e2f5 feat: ajustes na página do profissional 2025-10-02 11:21:46 -03:00
398c409187 Merge pull request 'chore: update pnpm-lock.yaml to sync dependencies' (#29) from fix/pnpm-lock-update into develop
Reviewed-on: #29
2025-10-02 13:57:36 +00:00
0196b9b5e8 feat: Update pnpm-lock.yaml to synchronize dependencies 2025-10-02 03:25:46 -03:00
e85fbdeb15 Merge pull request 'adicionando-atualização-medicos' (#28) from feature/ajustes-form-medico into develop
Reviewed-on: #28
2025-10-02 06:07:30 +00:00
João Gustavo
ca3df1d1cf Merge remote-tracking branch 'origin/develop' into feature/ajustes-form-medico 2025-10-02 03:05:35 -03:00
João Gustavo
e9929e04f7 removing-test-pages 2025-10-02 02:59:50 -03:00
João Gustavo
6030263128 add-doctor-edit 2025-10-02 02:51:18 -03:00
389aa8adfb fix(doctor-form): load existing doctor data on edit mode
- Fix doctorId type to accept string | number | null to handle UUID values
- Remove Number() conversion that was causing NaN errors on edit
- Add debug console logs to track data loading process
- Improve error handling in useEffect for doctor and attachments loading
- Ensure form fields are properly populated with doctor data when editing
2025-10-02 02:14:39 -03:00
a1f8a7995c fix(patient-form): load existing patient data on edit mode
- Fix patientId type to accept string | number | null to handle UUID values
- Remove Number() conversion that was causing NaN errors on edit
- Add debug console logs to track data loading process
- Remove reference to non-existent photo_url field from Paciente type                                - Ensure form fields are properly populated with patient data when editing
2025-10-02 01:53:21 -03:00
João Gustavo
8d1473a148 add-doctor-id 2025-10-02 01:49:54 -03:00
ed6e33890a merge(feature/correc-api): merge branch 'feature/correc-api' into feature/ajustes-form-medico 2025-10-02 01:14:18 -03:00
a032465773 feat(doctor-form): add search doctor by ID button and logic to registration form 2025-10-02 00:54:43 -03:00
João Gustavo
b2ee5987c6 correcting-id-endpoint 2025-10-02 00:44:45 -03:00
ea63a73b43 feat(api): implementações e ajustes nas APIs de médicos e pacientes 2025-10-01 23:40:01 -03:00
9795011028 Merge pull request 'feature/setup-eslint' (#26) from feature/setup-eslint into develop
Reviewed-on: #26
2025-10-02 00:46:33 +00:00
3e2fd84287 chore: update package dependencies and types 2025-10-01 18:21:54 -03:00
a123013b51 chore: add prettier config to eslint 2025-10-01 18:00:39 -03:00
4da0388c27 fix(forms): correct broken re-export in appointment form 2025-10-01 15:37:39 -03:00
e8ab0a2970 chore: install eslint and plugins 2025-10-01 15:34:01 -03:00
ea815ff96c feat: ajustes no formulário de laudo e seleção de paciente 2025-09-30 19:22:30 -03:00
c56cd9ff63 Merge pull request 'feat(api): add doctors and patients API integration' (#25) from feature/api-med-pac into develop
Reviewed-on: #25
2025-09-30 17:01:36 +00:00
84cb4c36eb feat(api): add doctors and patients API integration 2025-09-30 13:57:43 -03:00
56dd05c963 Merge pull request 'integrando os endpoints de login e logout' (#24) from feature/add-authentication-api into develop
Reviewed-on: #24
2025-09-28 18:55:00 +00:00
João Gustavo
e4afaa5743 removing-test-pages 2025-09-28 04:17:18 -03:00
João Gustavo
a6ae27876e add-login-and-logout-endpoints 2025-09-28 04:10:40 -03:00
e389b0894e Merge pull request 'feature/consultations' (#22) from feature/consultations into develop
Reviewed-on: #22
2025-09-25 20:36:25 +00:00
8bd4344670 chore(header): standardize company name to MEDIConecta 2025-09-25 17:01:32 -03:00
956a8ff016 fix(pages): Fix imports and type errors in agenda and patients - Fixes the form import in the scheduling page. - Adds optional chaining (?.) for safe accessto the patient's address. 2025-09-25 14:58:34 -03:00
92b598b14a fix(sidebar): resolve wrong navigation paths in sidebar menu 2025-09-25 13:40:01 -03:00
9cd35a0cc5 Merge pull request 'feature/settings' (#20) from feature/settings into develop
Reviewed-on: #20
2025-09-25 16:32:50 +00:00
ca7ab7a0fa merge: resolvidos conflitos entre feature/settings e develop (sidebar e package-lock.json) 2025-09-25 13:22:03 -03:00
67e52aa21f Merge branch 'develop' into feature/settings 2025-09-25 13:15:21 -03:00
5030ae38d0 " " 2025-09-25 11:10:20 -03:00
3c9bb1de4d feat(sidebar): remove medical record (prontuário) menu item from sidebar 2025-09-25 10:50:08 -03:00
de0d5b41a9 Merge pull request 'feature/consultations' (#17) from feature/consultations into develop
Reviewed-on: #17
2025-09-25 13:07:01 +00:00
23fad33ef9 feat: implement settings module 2025-09-25 10:05:33 -03:00
e17e709c01 Merge branch 'develop' into feature/consultations 2025-09-25 02:26:53 -03:00
f14643fa6a feat(ui): implements a visualization mode and standardizes the layout for patients, physicians, and appointments 2025-09-25 02:09:47 -03:00
2399fdfac9 fix(consultas): corrects the name of the form component onthe query page 2025-09-24 23:03:40 -03:00
31b02fdf2d Merge pull request 'feature/report' (#16) from feature/report into develop
Reviewed-on: #16
2025-09-25 00:27:27 +00:00
f8f32a9db7 feat(relatorios): adiciona gráfico financeiro com dados fictícios 2025-09-24 20:52:01 -03:00
19a9905b0c feat: adicionar página de relatórios 2025-09-24 10:48:28 -03:00
72a23cba69 Ignorando pasta riseup-squad20 2025-09-24 09:23:17 -03:00
d69e8408fe Ignorando pasta susconecta/riseup-squad20 2025-09-24 09:22:27 -03:00
b50b429d16 Removendo repositório embutido riseup-squad20 do versionamento 2025-09-24 09:20:36 -03:00
616853220b feat(consultas): implements full editing with reusable form
- Refactors the scheduling form, extracting it from the /agenda page into a new reusable component at components/forms/appointment-form.tsx                             - The appointment creation page (/agenda) now uses the new form component.          - The consultations page (/consultas) now implements in-place editing, rendering the same reusable form when clicking
- The appointment creation page (/agenda) now uses the new form component.
- The consultations page (/consultas) now implements in-place editing, rendering the same reusable form when clicking "Edit", pre-filled with the consultation data.
2025-09-24 03:32:38 -03:00
ab422746c8 feat(consultas, deps): Creates query page and adds dependencies
- Creates new query management page at /queries with view and delete functionality (frontend).
- Adds react-quill and react-signature-canvas libraries.
- Moves patient and doctor pages out of /dashboard nesting.
- Updates the sidebar to reflect the new routes, fixing 404 errors.
2025-09-24 03:05:15 -03:00
ba8b7881a4 Merge pull request 'adicionar login e informações do perfil' (#15) from feature/add-login-screen into develop
Reviewed-on: #15
2025-09-23 17:19:16 +00:00
João Gustavo
af7de1dd0c add login-screen 2025-09-23 01:23:41 -03:00
c36a16be06 feat: ajustes na seção de laudos, cpf, imagem e assinatura digital 2025-09-22 22:33:38 -03:00
913fd6ad64 Merge pull request 'feat(api): implementação e integração das APIs de médicos' (#12) from feature/api-medicos into develop
Reviewed-on: #12
2025-09-18 17:00:52 +00:00
791d31a5a6 feat(api): implementação e integração das APIs de médicos 2025-09-18 13:58:52 -03:00
e53d7fb96e Merge pull request 'feature/scheduling' (#11) from feature/scheduling into develop
Reviewed-on: #11
2025-09-18 02:35:27 +00:00
7aadcefb86 Fix: folder organization 2025-09-17 23:15:24 -03:00
c6b18b7f34 Merge branch 'develop' of https://git.popcode.com.br/RiseUP/riseup-squad20 into feature/scheduling 2025-09-17 22:52:29 -03:00
945c6eafb6 fix: Calendar and sidebar 2025-09-17 22:51:30 -03:00
dfb70c6d6e Merge pull request 'feature/doctor-register' (#10) from feature/doctor-register into develop
Reviewed-on: #10
2025-09-17 01:30:43 +00:00
30b5609f2f feat: adds new fields and cards to the physician registry 2025-09-16 22:25:59 -03:00
9dfba10baa Merge branch 'develop' into feature/scheduling 2025-09-16 11:22:11 -03:00
f435ade0d6 Ajuste no .gitignore 2025-09-16 11:18:59 -03:00
9c7ce7d7d2 Finalizando merge da branch develop com origin/develop 2025-09-16 11:15:23 -03:00
76feb4bb39 feat:implements CRUD for doctors 2025-09-16 02:00:58 -03:00
70c67e4331 Merge pull request 'change doctors page' (#8) from feature/changes-doctors-painel into develop
Reviewed-on: #8
2025-09-14 21:55:48 +00:00
João Gustavo
ba64fdee8d add: new doctor page 2025-09-12 13:55:18 -03:00
a7c9c90ebb chore: update components config 2025-09-11 22:09:50 -03:00
a5d89b3fff Merge pull request 'feature/image-doctor' (#7) from feature/image-doctor into develop
Reviewed-on: #7
2025-09-11 16:00:36 +00:00
0d416cacc9 resolvendo erro de imagens 2025-09-11 12:56:15 -03:00
e405cc5f89 WIP: alterações locais 2025-09-11 12:53:07 -03:00
bb4cc3895c Ajustes no .gitignore 2025-09-11 12:45:39 -03:00
953a4e7fa0 WIP: alterações locais 2025-09-11 12:42:54 -03:00
debc92dd45 chore(calendar): adjust naming for calendar component consistency 2025-09-11 01:49:36 -03:00
João Gustavo
ae637c480a fix/errors-medical-page 2025-09-11 00:53:39 -03:00
df530f7035 Merge pull request 'Adicionando calendario interativo do medico' (#6) from feature/crud-medico into develop
Reviewed-on: #6
2025-09-11 03:13:39 +00:00
94839cca87 Adicionando calendario interativo do medico 2025-09-11 00:03:33 -03:00
93a4389f22 fix(merge): prefer feature versions (layout.tsx, package-lock.json) 2025-09-10 23:21:16 -03:00
f2db8663e1 fix(merge): resolve conflicts between develop and feature/patient-register 2025-09-10 23:18:01 -03:00
cdd44da18b chore: save changes before switching branch 2025-09-10 22:57:45 -03:00
b2a9ea047a feat(api): add and wire all mock endpoints
- Patients: list, get by id, create, update, delete
- Photo: upload, remove
- Attachments: list, add, remove
- Validations: validate CPF, lookup CEP
- Hook up env vars and shared fetch wrapper
2025-09-10 22:00:32 -03:00
a1ba4e5ee3 Merge pull request 'feature/scheduling' (#5) from feature/scheduling into develop
Reviewed-on: #5
2025-09-10 16:31:12 +00:00
40f05cadf8 ajeitando erro dos botões 2025-09-10 13:27:36 -03:00
a9d093e900 adicionando agendamento-incompleto 2025-09-10 12:59:25 -03:00
6ca8524e3e Merge pull request 'feat: add medical page' (#4) from feature/crud-medico into develop
Reviewed-on: #4
2025-09-10 15:52:22 +00:00
João Gustavo
7385e648b0 feat: add medical page 2025-09-08 22:46:59 -03:00
a44e9bcf81 Merge branch 'feature/patient-register' of https://git.popcode.com.br/RiseUP/riseup-squad20 into feature/patient-register 2025-09-07 19:38:26 -03:00
372383fb42 feat: connect patient registration form to create patient API 2025-09-07 18:52:31 -03:00
3cce8a9774 fix: fix ref error in actions menu 2025-09-04 16:42:54 -03:00
91c84b6a46 fix: secure setting of onOpenChange on the patient form 2025-09-04 15:48:51 -03:00
8258fac83c feat: implement patient recorder 2025-09-03 21:13:13 -03:00
20d070ed13 chore: remove Website folderfrom repository 2025-09-03 02:16:28 -03:00
0ba1590c10 feat: add initial project files and patient list 2025-09-03 01:58:42 -03:00
631f7f2a77 feat: add initial structure 2025-08-31 01:20:02 -03:00
170 changed files with 56769 additions and 1 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
.vercel

379
README.md
View File

@ -1,2 +1,379 @@
# riseup-squad20
<div align="center">
# 🏥 MEDIConnect
### Plataforma de Gestão de Saúde Inteligente
*Combatendo o absenteísmo em clínicas e hospitais através de tecnologia e inovação*
[![Next.js](https://img.shields.io/badge/Next.js_15-000000?style=flat&logo=next.js&logoColor=white)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-2B7FFF?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![React](https://img.shields.io/badge/React_19-2B7FFF?style=flat&logo=react&logoColor=white)](https://react.dev/)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-2B7FFF?style=flat&logo=tailwind-css&logoColor=white)](https://tailwindcss.com/)
[![Supabase](https://img.shields.io/badge/Supabase-2B7FFF?style=flat&logo=supabase&logoColor=white)](https://supabase.com/)
</div>
---
## Índice
1. [Visão Geral](#-visão-geral)
2. [Problema e Solução](#-problema-e-solução)
3. [Funcionalidades](#-funcionalidades)
4. [Tecnologias](#-tecnologias)
5. [Instalação](#-instalação)
6. [Como Usar](#-como-usar)
7. [Fluxos de Usuário](#-fluxos-de-usuário)
8. [Componentes Principais](#-componentes-principais)
9. [Contribuindo](#-contribuindo)
10. [Licença](#-licença)
11. [Contato](#-contato)
---
## Visão Geral
**MEDIConnect** é uma plataforma web moderna e intuitiva desenvolvida para revolucionar a gestão de saúde em clínicas e hospitais. Com foco na redução do absenteísmo (faltas em consultas), a plataforma oferece uma experiência completa para pacientes, profissionais de saúde e administradores.
### Diferenciais
- **Zoe IA Assistant**: Assistente virtual inteligente para suporte aos usuários
- **Interface Responsiva**: Design moderno e adaptável a qualquer dispositivo
- **Autenticação Segura**: Sistema robusto com perfis diferenciados
- **Performance**: Construído com Next.js 15 para máxima velocidade
- **UX/UI Premium**: Interface limpa e profissional voltada para área da saúde
---
## Problema e Solução
### O Problema
O **absenteísmo** (não comparecimento a consultas agendadas) é um problema crítico em clínicas e hospitais, causando:
- Desperdício de tempo dos profissionais
- Perda de receita para estabelecimentos
- Redução da eficiência operacional
- Impacto negativo no atendimento de outros pacientes
### Nossa Solução
MEDIConnect oferece um sistema inteligente de gestão que:
- Facilita o agendamento e reagendamento de consultas
- Permite visualização clara da agenda para profissionais
- Oferece assistência via IA para dúvidas e suporte
---
## Funcionalidades
### Para Pacientes
- **Dashboard Personalizado**: Visão geral de consultas e exames
- **Agendamento**: Sistema fácil de marcar consultas
- **Resultados de Exames**: Acesso seguro a laudos e resultados
- **Busca de Profissionais**: Encontre médicos por especialidade
- **Zoe IA Assistant**: Tire dúvidas 24/7 com nossa assistente virtual
### Para Profissionais
- **Dashboard Profissional**: Visão completa de atendimentos
- **Editor de Laudos**: Crie e edite laudos médicos de forma rápida
- **Gestão de Pacientes**: Acesse informações dos pacientes
- **Agenda**: Visualização clara de consultas
### Para Administradores
- **Dashboard Administrativo**: Métricas e estatísticas em tempo real
- **Relatórios Detalhados**: Análise de comparecimento e absenteísmo
- **Gestão Completa**: Gerencie pacientes, profissionais e agendamentos
- **Painel de Controle**: Visão 360° da operação da clínica
---
## Tecnologias
### Frontend (Atual)
- **[Next.js 15](https://nextjs.org/)** - Framework React com Server Components
- **[React 19](https://react.dev/)** - Biblioteca JavaScript para interfaces
- **[TypeScript](https://www.typescriptlang.org/)** - Tipagem estática para JavaScript
- **[Tailwind CSS](https://tailwindcss.com/)** - Framework CSS utilitário
- **[Shadcn/ui](https://ui.shadcn.com/)** - Componentes UI reutilizáveis
- **[React Hook Form](https://react-hook-form.com/)** - Gerenciamento de formulários
- **[Zod](https://zod.dev/)** - Validação de schemas
- **[date-fns](https://date-fns.org/)** - Manipulação de datas
### Backend (Integrado)
- **[Supabase](https://supabase.com/)** - Backend as a Service (PostgreSQL)
- **Authentication** - Sistema de autenticação completo
- **Storage** - Armazenamento de arquivos e documentos
- **REST API** - Endpoints integrados para todas as funcionalidades
### Ferramentas de Desenvolvimento
- **[ESLint](https://eslint.org/)** - Linter para código JavaScript/TypeScript
- **[PostCSS](https://postcss.org/)** - Transformação de CSS
- **[Autoprefixer](https://github.com/postcss/autoprefixer)** - Prefixos CSS automáticos
---
## Instalação
### Pré-requisitos
Certifique-se de ter instalado:
- **Node.js** 18.17 ou superior
- **npm**
- **Git**
### Passo a Passo
1. **Clone o repositório**
```bash
git clone https://git.popcode.com.br/RiseUP/riseup-squad20.git
cd susconecta
```
2. **Instale as dependências**
```bash
npm install
```
3. **Configuração de ambiente (desenvolvimento)**
> Observação: o projeto possui valores _fallback_ em `susconecta/lib/env-config.ts`, mas o recomendado é criar um arquivo `.env.local` não versionado com suas credenciais locais.
```env
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://seu-projeto.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=pk_... (anon key)
# Aplicação
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:3000/api
```
**Boas práticas de segurança**
- Nunca exponha a `service_role` key no frontend.
- Proteja operações sensíveis com Row-Level Security (RLS) no Supabase ou mova-as para rotas/Edge Functions server-side.
- Não commite `.env.local` no repositório (adicione ao `.gitignore`).
4. **Inicie o servidor de desenvolvimento**
```bash
npm run dev
```
5. **Acesse a aplicação**
Abra [http://localhost:3000](http://localhost:3000) no seu navegador.
---
## Como Usar
### Navegação Principal
#### Página Inicial
Acesse `/home` para conhecer a plataforma e suas funcionalidades.
#### Autenticação
O sistema possui três níveis de acesso:
- **Pacientes**: `/login-paciente`
- **Profissionais**: `/login-profissional`
- **Administradores**: `/login-admin`
#### Funcionalidades por Perfil
**Como Paciente:**
1. Faça login em `/login-paciente`
2. Acesse seu dashboard em `/paciente`
3. Agende consultas em `/consultas`
4. Visualize resultados em `/paciente/resultados`
5. Gerencie seu perfil em `/perfil`
**Como Profissional:**
1. Faça login em `/login-profissional`
2. Acesse seu dashboard em `/profissional`
3. Gerencie sua agenda em `/agenda`
4. Crie laudos em `/laudos-editor`
5. Visualize pacientes em `/pacientes`
**Como Administrador:**
1. Faça login em `/login-admin`
2. Acesse o painel em `/dashboard`
3. Visualize relatórios em `/dashboard/relatorios`
4. Gerencie o sistema completo
---
## Fluxos de Usuário
### Fluxo de Agendamento (Paciente)
```mermaid
graph LR
A[Login Paciente] --> B[Dashboard]
B --> C[Buscar Médico]
C --> D[Selecionar Especialidade]
D --> E[Escolher Horário]
E --> F[Confirmar Agendamento]
F --> G[Receber Confirmação]
```
### Fluxo de Atendimento (Profissional)
```mermaid
graph LR
A[Login Profissional] --> B[Ver Agenda]
B --> C[Realizar Consulta]
C --> D[Criar Laudo]
D --> E[Enviar para Paciente]
E --> F[Atualizar Status]
```
### Fluxo Administrativo
```mermaid
graph LR
A[Login Admin] --> B[Dashboard]
B --> C[Visualizar Métricas]
C --> D[Gerar Relatórios]
D --> E[Analisar Absenteísmo]
E --> F[Tomar Decisões]
```
---
## Componentes Principais
### Zoe IA Assistant
Assistente virtual inteligente que oferece:
- Suporte 24/7 aos usuários
- Respostas a dúvidas frequentes
- Upload de arquivos para análise
- Interação por voz
**Arquivos:**
- `components/ZoeIA/ai-assistant-interface.tsx`
- `components/ZoeIA/voice-powered-orb.tsx`
- `components/ZoeIA/demo.tsx`
### Sistema de Agendamento
Gerenciamento completo de consultas e exames:
- Calendário interativo
- Seleção de horários disponíveis
- Confirmação automática
- Lembretes e notificações
**Arquivos:**
- `components/features/agendamento/`
- `components/features/Calendario/`
- `app/(main-routes)/consultas/`
### Editor de Laudos
Ferramenta profissional para criação de laudos médicos:
- Interface intuitiva
- Frases pré-definidas
- Exportação em PDF
**Arquivos:**
- `app/laudos-editor/`
- `lib/laudo-exemplos.ts`
- `lib/laudo-notification.ts`
### Dashboard Analytics
Painéis administrativos com:
- Métricas em tempo real
- Gráficos interativos
- Relatórios de absenteísmo
- Análise de desempenho
**Arquivos:**
- `components/features/dashboard/`
- `app/(main-routes)/dashboard/`
- `lib/reportService.ts`
---
## Contribuindo
Contribuições são bem-vindas! Siga estes passos:
### 1. Fork o projeto
Clique no botão "Fork" no topo da página.
### 2. Clone seu fork
```bash
git clone https://git.popcode.com.br/RiseUP/riseup-squad20.git
cd susconecta
```
### 3. Crie uma branch
```bash
git checkout -b feature/nova-funcionalidade
```
### 4. Faça suas alterações
Desenvolva sua funcionalidade seguindo os padrões do projeto.
### 5. Commit suas mudanças
```bash
git add .
git commit -m "feat: adiciona nova funcionalidade X"
```
**Padrão de commits:**
- `feat:` Nova funcionalidade
- `fix:` Correção de bug
- `docs:` Documentação
- `style:` Formatação
- `refactor:` Refatoração
- `test:` Testes
- `chore:` Manutenção
### 6. Push para seu fork
```bash
git push origin feature/nova-funcionalidade
```
### 7. Abra um Pull Request
Descreva suas mudanças detalhadamente.
---
## Licença
Este projeto está sob a licença **MIT**. Veja o arquivo [LICENSE](LICENSE) para mais detalhes.
## Contato
**MEDIConnect Team**
- Website: [mediconnect.com](https://mediconecta-app-liart.vercel.app/)
- Email dos Desenvolvedores:
- [Jonas Francisco](mailto:jonastom478@gmail.com)
- [João Gustavo](mailto:jgcmendonca@gmail.com)
- [Maria Gabrielly](mailto:maria.gabrielly221106@gmail.com)
- [Pedro Gomes](mailto:pedrogomes5913@gmail.com)
---
<div align="center">
**Desenvolvido pelo squad 20**
*Transformando a gestão de saúde através da tecnologia*
[![Next.js](https://img.shields.io/badge/Powered%20by-Next.js-black?style=for-the-badge&logo=next.js)](https://nextjs.org/)
</div>

45
et --hard 23fad33 Normal file
View File

@ -0,0 +1,45 @@
a0d527c (HEAD -> feature/settings) ajuste no package.json da raiz
23fad33 (origin/feature/settings) feat: implement settings module
c36a16b (develop) feat: ajustes na seção de laudos, cpf, imagem e assinatura digital
913fd6a (origin/feature/doctor-laudo, feature/api-medic) Merge pull request 'feat(api): implementação e integração das APIs de médicos' (#12) from feature/api-medicos into develop
791d31a (origin/feature/api-medicos, feature/api-medicos) feat(api): implementação e integração das APIs de médicos
e53d7fb (feature/crud-medi-api) Merge pull request 'feature/scheduling' (#11) from feature/scheduling into develop
7aadcef Fix: folder organization
c6b18b7 Merge branch 'develop' of https://git.popcode.com.br/RiseUP/riseup-squad20 into feature/scheduling
945c6ea fix: Calendar and sidebar
dfb70c6 Merge pull request 'feature/doctor-register' (#10) from feature/doctor-register into develop
30b5609 feat: adds new fields and cards to the physician registry
9dfba10 Merge branch 'develop' into feature/scheduling
f435ade Ajuste no .gitignore
9c7ce7d Finalizando merge da branch develop com origin/develop
76feb4b feat:implements CRUD for doctors
70c67e4 Merge pull request 'change doctors page' (#8) from feature/changes-doctors-painel into develop
ba64fde add: new doctor page
a7c9c90 chore: update components config
a5d89b3 Merge pull request 'feature/image-doctor' (#7) from feature/image-doctor into develop
0d416ca (origin/feature/image-doctor) resolvendo erro de imagens
e405cc5 WIP: alterações locais
bb4cc38 Ajustes no .gitignore
953a4e7 WIP: alterações locais
debc92d chore(calendar): adjust naming for calendar component consistency
ae637c4 fix/errors-medical-page
df530f7 Merge pull request 'Adicionando calendario interativo do medico' (#6) from feature/crud-medico into develop
94839cc (origin/feature/crud-medico, feature/crud-medico) Adicionando calendario interativo do medico
93a4389 fix(merge): prefer feature versions (layout.tsx, package-lock.json)
f2db866 (feature/patient-register) fix(merge): resolve conflicts between develop and feature/patient-register
cdd44da chore: save changes before switching branch
b2a9ea0 (origin/feature/patient-register) feat(api): add and wire all mock endpoints
a1ba4e5 Merge pull request 'feature/scheduling' (#5) from feature/scheduling into develop
40f05ca (origin/feature/scheduling) ajeitando erro dos botões
a9d093e adicionando agendamento-incompleto
6ca8524 Merge pull request 'feat: add medical page' (#4) from feature/crud-medico into develop
7385e64 feat: add medical page
a44e9bc Merge branch 'feature/patient-register' of https://git.popcode.com.br/RiseUP/riseup-squad20 into feature/patient-register
372383f feat: connect patient registration form to create patient API
3cce8a9 fix: fix ref error in actions menu
91c84b6 fix: secure setting of onOpenChange on the patient form
8258fac feat: implement patient recorder
20d070e (origin/feature/patient-list, feature/patient-list) chore: remove Website folderfrom repository
0ba1590 feat: add initial project files and patient list
631f7f2 (origin/feature/cadastro-pacientes, origin/developer, feature/cadastro-pacientes) feat: add initial structure
6414f69 (origin/main, origin/HEAD) Initial commit

14
next.config.mjs Normal file
View File

@ -0,0 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
// Proxy local → Supabase (bypass CORS no navegador)
{
source: '/proxy/supabase/:path*',
destination: 'https://yuanqfswhberkoevtmfr.supabase.co/:path*',
},
];
},
};
export default nextConfig;

10
package.json Normal file
View File

@ -0,0 +1,10 @@
{
"dependencies": {
"@headlessui/react": "^2.2.7",
"@heroicons/react": "^2.2.0",
"@supabase/supabase-js": "^2.75.0",
"date-fns": "^4.1.0",
"react-big-calendar": "^1.19.4",
"react-signature-canvas": "^1.1.0-alpha.2"
}
}

622
pnpm-lock.yaml generated Normal file
View File

@ -0,0 +1,622 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@headlessui/react':
specifier: ^2.2.7
version: 2.2.9(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@heroicons/react':
specifier: ^2.2.0
version: 2.2.0(react@19.2.0)
'@supabase/supabase-js':
specifier: ^2.75.0
version: 2.79.0
date-fns:
specifier: ^4.1.0
version: 4.1.0
react-big-calendar:
specifier: ^1.19.4
version: 1.19.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-signature-canvas:
specifier: ^1.1.0-alpha.2
version: 1.1.0-alpha.2(@types/react@19.2.2)(prop-types@15.8.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
packages:
'@babel/runtime@7.28.4':
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
engines: {node: '>=6.9.0'}
'@floating-ui/core@1.7.3':
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
'@floating-ui/dom@1.7.4':
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
'@floating-ui/react-dom@2.1.6':
resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/react@0.26.28':
resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@headlessui/react@2.2.9':
resolution: {integrity: sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==}
engines: {node: '>=10'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
'@heroicons/react@2.2.0':
resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==}
peerDependencies:
react: '>= 16 || ^19.0.0-rc'
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
'@react-aria/focus@3.21.2':
resolution: {integrity: sha512-JWaCR7wJVggj+ldmM/cb/DXFg47CXR55lznJhZBh4XVqJjMKwaOOqpT5vNN7kpC1wUpXicGNuDnJDN1S/+6dhQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-aria/interactions@3.25.6':
resolution: {integrity: sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-aria/ssr@3.9.10':
resolution: {integrity: sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==}
engines: {node: '>= 12'}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-aria/utils@3.31.0':
resolution: {integrity: sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-stately/flags@3.1.2':
resolution: {integrity: sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==}
'@react-stately/utils@3.10.8':
resolution: {integrity: sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-types/shared@3.32.1':
resolution: {integrity: sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@restart/hooks@0.4.16':
resolution: {integrity: sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==}
peerDependencies:
react: '>=16.8.0'
'@supabase/auth-js@2.79.0':
resolution: {integrity: sha512-p2GKvdbF9d/6C+dtS6iNcSicPr6eUfkvovD60HWlWsD+oOjC483DzFWrzGjNpBwnswhfMRP8Qn3rYA0VWaOfjw==}
engines: {node: '>=20.0.0'}
'@supabase/functions-js@2.79.0':
resolution: {integrity: sha512-WaiU6b+Z+ZfJOjFhpMKdajt42weiFUrA6TVW5oGd6WfPGajFiKZJJIAvuK0g7KDKaYowtQrOo5+Ais+PcuZ1qA==}
engines: {node: '>=20.0.0'}
'@supabase/postgrest-js@2.79.0':
resolution: {integrity: sha512-2i8EFm3/49ecjt6dk/TGVROBbtOmhryiC4NL3u0FBIrm2hqj+FvbELv1jjM6r+a6abnh+uzIV/bFsWHAa/k3/w==}
engines: {node: '>=20.0.0'}
'@supabase/realtime-js@2.79.0':
resolution: {integrity: sha512-foaZujNBycAqLizUcuLyyFyDitfPnEMVO4CiKXNwaMCDVMoVX4QR6n4gpJLUC5BGzc20Mte6vSJLbk4MN90Prw==}
engines: {node: '>=20.0.0'}
'@supabase/storage-js@2.79.0':
resolution: {integrity: sha512-PLSeKX1/BZhGWCT972w4TvVOCcw/xh4TsowtUBiZvPx4OdHT7dB1q0DXKwVUfKbWk5UUC+6XAq4ZU/ZCtdgn6w==}
engines: {node: '>=20.0.0'}
'@supabase/supabase-js@2.79.0':
resolution: {integrity: sha512-x9ndEaBSwoRnFOOZGhh2CeV69Uz4B/EOSGCbKysDhTiYakiCAdDXaNuLPluviKU/Aot+F7BglXZDZ0YJ3GpGrw==}
engines: {node: '>=20.0.0'}
'@swc/helpers@0.5.17':
resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
'@tanstack/react-virtual@3.13.12':
resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.13.12':
resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==}
'@types/node@24.10.0':
resolution: {integrity: sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==}
'@types/phoenix@1.6.6':
resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==}
'@types/react@19.2.2':
resolution: {integrity: sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==}
'@types/signature_pad@2.3.6':
resolution: {integrity: sha512-v3j92gCQJoxomHhd+yaG4Vsf8tRS/XbzWKqDv85UsqjMGy4zhokuwKe4b6vhbgncKkh+thF+gpz6+fypTtnFqQ==}
'@types/warning@3.0.3':
resolution: {integrity: sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
clsx@1.2.1:
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
engines: {node: '>=6'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
date-arithmetic@4.1.0:
resolution: {integrity: sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg==}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
dayjs@1.11.19:
resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
globalize@0.1.1:
resolution: {integrity: sha512-5e01v8eLGfuQSOvx2MsDMOWS0GFtCx1wPzQSmcHw4hkxFzrQDBO3Xwg/m8Hr/7qXMrHeOIE29qWVzyv06u1TZA==}
invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
moment-timezone@0.5.48:
resolution: {integrity: sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==}
moment@2.30.1:
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
react-big-calendar@1.19.4:
resolution: {integrity: sha512-FrvbDx2LF6JAWFD96LU1jjloppC5OgIvMYUYIPzAw5Aq+ArYFPxAjLqXc4DyxfsQDN0TJTMuS/BIbcSB7Pg0YA==}
peerDependencies:
react: ^16.14.0 || ^17 || ^18 || ^19
react-dom: ^16.14.0 || ^17 || ^18 || ^19
react-dom@19.2.0:
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
peerDependencies:
react: ^19.2.0
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-lifecycles-compat@3.0.4:
resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==}
react-overlays@5.2.1:
resolution: {integrity: sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA==}
peerDependencies:
react: '>=16.3.0'
react-dom: '>=16.3.0'
react-signature-canvas@1.1.0-alpha.2:
resolution: {integrity: sha512-tKUNk3Gmh04Ug4K8p5g8Is08BFUKvbXxi0PyetQ/f8OgCBzcx4vqNf9+OArY/TdNdfHtswXQNRwZD6tyELjkjQ==}
peerDependencies:
'@types/prop-types': ^15.7.3
'@types/react': 0.14 - 19
prop-types: ^15.5.8
react: 0.14 - 19
react-dom: 0.14 - 19
peerDependenciesMeta:
'@types/prop-types':
optional: true
'@types/react':
optional: true
react@19.2.0:
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
engines: {node: '>=0.10.0'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
signature_pad@2.3.2:
resolution: {integrity: sha512-peYXLxOsIY6MES2TrRLDiNg2T++8gGbpP2yaC+6Ohtxr+a2dzoaqWosWDY9sWqTAAk6E/TyQO+LJw9zQwyu5kA==}
tabbable@6.3.0:
resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==}
trim-canvas@0.1.2:
resolution: {integrity: sha512-nd4Ga3iLFV94mdhW9JFMLpQbHUyCQuhFOD71PEAt1NjtMD5wbZctzhX8c3agHNybMR5zXD1XTGoIEWk995E6pQ==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
uncontrollable@7.2.1:
resolution: {integrity: sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==}
peerDependencies:
react: '>=15.0.0'
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
warning@4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
ws@8.18.3:
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
snapshots:
'@babel/runtime@7.28.4': {}
'@floating-ui/core@1.7.3':
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.7.4':
dependencies:
'@floating-ui/core': 1.7.3
'@floating-ui/utils': 0.2.10
'@floating-ui/react-dom@2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@floating-ui/dom': 1.7.4
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
'@floating-ui/react@0.26.28(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@floating-ui/utils': 0.2.10
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
tabbable: 6.3.0
'@floating-ui/utils@0.2.10': {}
'@headlessui/react@2.2.9(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@floating-ui/react': 0.26.28(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@react-aria/focus': 3.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@react-aria/interactions': 3.25.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@tanstack/react-virtual': 3.13.12(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
use-sync-external-store: 1.6.0(react@19.2.0)
'@heroicons/react@2.2.0(react@19.2.0)':
dependencies:
react: 19.2.0
'@popperjs/core@2.11.8': {}
'@react-aria/focus@3.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@react-aria/interactions': 3.25.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@react-aria/utils': 3.31.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@react-types/shared': 3.32.1(react@19.2.0)
'@swc/helpers': 0.5.17
clsx: 2.1.1
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
'@react-aria/interactions@3.25.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@react-aria/ssr': 3.9.10(react@19.2.0)
'@react-aria/utils': 3.31.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@react-stately/flags': 3.1.2
'@react-types/shared': 3.32.1(react@19.2.0)
'@swc/helpers': 0.5.17
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
'@react-aria/ssr@3.9.10(react@19.2.0)':
dependencies:
'@swc/helpers': 0.5.17
react: 19.2.0
'@react-aria/utils@3.31.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@react-aria/ssr': 3.9.10(react@19.2.0)
'@react-stately/flags': 3.1.2
'@react-stately/utils': 3.10.8(react@19.2.0)
'@react-types/shared': 3.32.1(react@19.2.0)
'@swc/helpers': 0.5.17
clsx: 2.1.1
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
'@react-stately/flags@3.1.2':
dependencies:
'@swc/helpers': 0.5.17
'@react-stately/utils@3.10.8(react@19.2.0)':
dependencies:
'@swc/helpers': 0.5.17
react: 19.2.0
'@react-types/shared@3.32.1(react@19.2.0)':
dependencies:
react: 19.2.0
'@restart/hooks@0.4.16(react@19.2.0)':
dependencies:
dequal: 2.0.3
react: 19.2.0
'@supabase/auth-js@2.79.0':
dependencies:
tslib: 2.8.1
'@supabase/functions-js@2.79.0':
dependencies:
tslib: 2.8.1
'@supabase/postgrest-js@2.79.0':
dependencies:
tslib: 2.8.1
'@supabase/realtime-js@2.79.0':
dependencies:
'@types/phoenix': 1.6.6
'@types/ws': 8.18.1
tslib: 2.8.1
ws: 8.18.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@supabase/storage-js@2.79.0':
dependencies:
tslib: 2.8.1
'@supabase/supabase-js@2.79.0':
dependencies:
'@supabase/auth-js': 2.79.0
'@supabase/functions-js': 2.79.0
'@supabase/postgrest-js': 2.79.0
'@supabase/realtime-js': 2.79.0
'@supabase/storage-js': 2.79.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@swc/helpers@0.5.17':
dependencies:
tslib: 2.8.1
'@tanstack/react-virtual@3.13.12(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@tanstack/virtual-core': 3.13.12
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
'@tanstack/virtual-core@3.13.12': {}
'@types/node@24.10.0':
dependencies:
undici-types: 7.16.0
'@types/phoenix@1.6.6': {}
'@types/react@19.2.2':
dependencies:
csstype: 3.1.3
'@types/signature_pad@2.3.6': {}
'@types/warning@3.0.3': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 24.10.0
clsx@1.2.1: {}
clsx@2.1.1: {}
csstype@3.1.3: {}
date-arithmetic@4.1.0: {}
date-fns@4.1.0: {}
dayjs@1.11.19: {}
dequal@2.0.3: {}
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.28.4
csstype: 3.1.3
globalize@0.1.1: {}
invariant@2.2.4:
dependencies:
loose-envify: 1.4.0
js-tokens@4.0.0: {}
lodash-es@4.17.21: {}
lodash@4.17.21: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
luxon@3.7.2: {}
memoize-one@6.0.0: {}
moment-timezone@0.5.48:
dependencies:
moment: 2.30.1
moment@2.30.1: {}
object-assign@4.1.1: {}
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
react-big-calendar@1.19.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@babel/runtime': 7.28.4
clsx: 1.2.1
date-arithmetic: 4.1.0
dayjs: 1.11.19
dom-helpers: 5.2.1
globalize: 0.1.1
invariant: 2.2.4
lodash: 4.17.21
lodash-es: 4.17.21
luxon: 3.7.2
memoize-one: 6.0.0
moment: 2.30.1
moment-timezone: 0.5.48
prop-types: 15.8.1
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
react-overlays: 5.2.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
uncontrollable: 7.2.1(react@19.2.0)
react-dom@19.2.0(react@19.2.0):
dependencies:
react: 19.2.0
scheduler: 0.27.0
react-is@16.13.1: {}
react-lifecycles-compat@3.0.4: {}
react-overlays@5.2.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@babel/runtime': 7.28.4
'@popperjs/core': 2.11.8
'@restart/hooks': 0.4.16(react@19.2.0)
'@types/warning': 3.0.3
dom-helpers: 5.2.1
prop-types: 15.8.1
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
uncontrollable: 7.2.1(react@19.2.0)
warning: 4.0.3
react-signature-canvas@1.1.0-alpha.2(@types/react@19.2.2)(prop-types@15.8.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@babel/runtime': 7.28.4
'@types/signature_pad': 2.3.6
prop-types: 15.8.1
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
signature_pad: 2.3.2
trim-canvas: 0.1.2
optionalDependencies:
'@types/react': 19.2.2
react@19.2.0: {}
scheduler@0.27.0: {}
signature_pad@2.3.2: {}
tabbable@6.3.0: {}
trim-canvas@0.1.2: {}
tslib@2.8.1: {}
uncontrollable@7.2.1(react@19.2.0):
dependencies:
'@babel/runtime': 7.28.4
'@types/react': 19.2.2
invariant: 2.2.4
react: 19.2.0
react-lifecycles-compat: 3.0.4
undici-types@7.16.0: {}
use-sync-external-store@1.6.0(react@19.2.0):
dependencies:
react: 19.2.0
warning@4.0.3:
dependencies:
loose-envify: 1.4.0
ws@8.18.3: {}

29
susconecta/.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
# next.js
/.next/
/out/
# production
/build
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.tsriseup-squad20/
susconecta/riseup-squad20/
riseup-squad20/

14
susconecta/.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,14 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Build Next.js susconecta",
"type": "shell",
"command": "npm run build",
"problemMatcher": [
"$tsc"
],
"group": "build"
}
]
}

View File

@ -0,0 +1,17 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
export default function LoginAdminRedirect() {
const router = useRouter()
useEffect(() => {
router.replace('/login')
}, [router])
return (
<div className="min-h-screen flex items-center justify-center">
<p>Redirecionando para a página de login...</p>
</div>
)
}

View File

@ -0,0 +1,17 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
export default function LoginAdminRedirect() {
const router = useRouter()
useEffect(() => {
router.replace('/login')
}, [router])
return (
<div className="min-h-screen flex items-center justify-center">
<p>Redirecionando para a página de login...</p>
</div>
)
}

View File

@ -0,0 +1,17 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
export default function LoginPacienteRedirect() {
const router = useRouter()
useEffect(() => {
router.replace('/login')
}, [router])
return (
<div className="min-h-screen flex items-center justify-center">
<p>Redirecionando...</p>
</div>
)
}

View File

@ -0,0 +1,17 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
export default function LoginProfissionalRedirect() {
const router = useRouter()
useEffect(() => {
router.replace('/login')
}, [router])
return (
<div className="min-h-screen flex items-center justify-center">
<p>Redirecionando para a página de login...</p>
</div>
)
}

View File

@ -0,0 +1,190 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useAuth } from '@/hooks/useAuth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { AuthenticationError } from '@/lib/auth'
export default function LoginPage() {
const [credentials, setCredentials] = useState({ email: '', password: '' })
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
const { login, user } = useAuth()
// Mapeamento de redirecionamento baseado em role
const getRoleRedirectPath = (userType: string): string => {
switch (userType) {
case 'paciente':
return '/paciente'
case 'profissional':
return '/profissional'
case 'administrador':
return '/dashboard'
default:
return '/'
}
}
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
try {
// Tentar fazer login com cada tipo de usuário até conseguir
// Ordem de prioridade: profissional (inclui médico), paciente, administrador
const userTypes: Array<'paciente' | 'profissional' | 'administrador'> = [
'profissional', // Tentar profissional PRIMEIRO pois inclui médicos
'paciente',
'administrador'
]
let lastError: AuthenticationError | Error | null = null
let loginAttempted = false
for (const userType of userTypes) {
try {
console.log(`[LOGIN] Tentando login como ${userType}...`)
const loginSuccess = await login(credentials.email, credentials.password, userType)
if (loginSuccess) {
loginAttempted = true
console.log('[LOGIN] Login bem-sucedido como', userType)
console.log('[LOGIN] User state:', user)
// Aguardar um pouco para o state do usuário ser atualizado
await new Promise(resolve => setTimeout(resolve, 500))
// Obter o userType atualizado do localStorage (que foi salvo pela função login)
const storedUser = localStorage.getItem('auth_user')
if (storedUser) {
try {
const userData = JSON.parse(storedUser)
const redirectPath = getRoleRedirectPath(userData.userType)
console.log('[LOGIN] Redirecionando para:', redirectPath)
router.push(redirectPath)
} catch (parseErr) {
console.error('[LOGIN] Erro ao parsear user do localStorage:', parseErr)
router.push('/')
}
} else {
console.warn('[LOGIN] Usuário não encontrado no localStorage')
router.push('/')
}
return
}
} catch (err) {
lastError = err as AuthenticationError | Error
const errorMsg = err instanceof Error ? err.message : String(err)
console.log(`[LOGIN] Falha ao tentar como ${userType}:`, errorMsg)
continue
}
}
// Se chegou aqui, nenhum tipo funcionou
console.error('[LOGIN] Nenhum tipo de usuário funcionou. Erro final:', lastError)
if (lastError instanceof AuthenticationError) {
const errorMsg = lastError.message || lastError.details?.error_code || ''
if (lastError.code === '400' || errorMsg.includes('invalid_credentials') || errorMsg.includes('Email or password')) {
setError('❌ Email ou senha incorretos. Verifique suas credenciais.')
} else {
setError(lastError.message || 'Erro ao fazer login. Tente novamente.')
}
} else if (lastError instanceof Error) {
setError(lastError.message || 'Erro desconhecido ao fazer login.')
} else {
setError('Falha ao fazer login. Credenciais inválidas ou conta não encontrada.')
}
} catch (err) {
console.error('[LOGIN] Erro no login:', err)
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<h2 className="mt-6 text-3xl font-extrabold text-foreground">
Entrar
</h2>
<p className="mt-2 text-sm text-muted-foreground">
Entre com suas credenciais para acessar o sistema
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-center">Login</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground">
Email
</label>
<Input
id="email"
type="email"
placeholder="Digite seu email"
value={credentials.email}
onChange={(e) => setCredentials({...credentials, email: e.target.value})}
required
className="mt-1"
disabled={loading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
Senha
</label>
<Input
id="password"
type="password"
placeholder="Digite sua senha"
value={credentials.password}
onChange={(e) => setCredentials({...credentials, password: e.target.value})}
required
className="mt-1"
disabled={loading}
/>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full cursor-pointer"
disabled={loading}
>
{loading ? 'Entrando...' : 'Entrar'}
</Button>
</form>
<div className="mt-4 text-center">
<Button variant="ghost" asChild className="w-full">
<Link href="/">
Voltar ao Início
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@ -0,0 +1,114 @@
"use client";
import { useRouter } from "next/navigation";
import { CalendarRegistrationForm } from "@/components/features/forms/calendar-registration-form";
import HeaderAgenda from "@/components/features/agenda/HeaderAgenda";
import FooterAgenda from "@/components/features/agenda/FooterAgenda";
import { useState } from "react";
import { criarAgendamento } from '@/lib/api';
import { toast } from '@/hooks/use-toast';
interface FormData {
patientName?: string;
patientId?: string;
doctorId?: string;
cpf?: string;
rg?: string;
birthDate?: string;
phoneCode?: string;
phoneNumber?: string;
email?: string;
convenio?: string;
matricula?: string;
validade?: string;
documentos?: string;
professionalName?: string;
unit?: string;
appointmentDate?: string;
startTime?: string;
endTime?: string;
requestingProfessional?: string;
appointmentType?: string;
notes?: string;
duration_minutes?: number;
chief_complaint?: string | null;
patient_notes?: string | null;
insurance_provider?: string | null;
}
export default function NovoAgendamentoPage() {
const router = useRouter();
const [formData, setFormData] = useState<FormData>({});
const handleFormChange = (data: FormData) => {
setFormData(data);
};
const handleSave = () => {
(async () => {
try {
// basic validation
if (!formData.patientId && !(formData as any).patient_id) throw new Error('Patient ID é obrigatório');
if (!formData.doctorId && !(formData as any).doctor_id) throw new Error('Doctor ID é obrigatório');
if (!formData.appointmentDate) throw new Error('Data é obrigatória');
if (!formData.startTime) throw new Error('Horário de início é obrigatório');
const payload: any = {
patient_id: formData.patientId || (formData as any).patient_id,
doctor_id: formData.doctorId || (formData as any).doctor_id,
scheduled_at: new Date(`${formData.appointmentDate}T${formData.startTime}`).toISOString(),
duration_minutes: formData.duration_minutes ?? 30,
appointment_type: formData.appointmentType ?? 'presencial',
chief_complaint: formData.chief_complaint ?? null,
patient_notes: formData.patient_notes ?? null,
insurance_provider: formData.insurance_provider ?? null,
};
await criarAgendamento(payload);
// success
try { toast({ title: 'Agendamento criado', description: 'O agendamento foi criado com sucesso.' }); } catch {}
router.push('/consultas');
} catch (err: any) {
// If the API threw a blocking exception message, surface it as a toast with additional info
const msg = err?.message ?? String(err);
// Heuristic: messages from criarAgendamento about exceptions start with "Não é possível agendar"
if (typeof msg === 'string' && msg.includes('Não é possível agendar')) {
try {
toast({ title: 'Data indisponível', description: msg });
} catch (_) {}
} else {
// fallback to generic alert for unexpected errors
alert(msg);
}
}
})();
};
const handleCancel = () => {
// If origin was provided (eg: consultas), return there. Default to calendar.
try {
const origin = (typeof window !== 'undefined') ? new URLSearchParams(window.location.search).get('origin') : null;
if (origin === 'consultas') {
router.push('/consultas');
return;
}
} catch (e) {
// fallback
}
router.push("/calendar");
};
return (
<div className="flex flex-col h-full bg-background">
<HeaderAgenda />
<main className="flex-1 mx-auto w-full max-w-7xl px-8 py-8 overflow-auto">
<CalendarRegistrationForm
formData={formData as any}
onFormChange={handleFormChange as any}
createMode
/>
</main>
<FooterAgenda onSave={handleSave} onCancel={handleCancel} />
</div>
);
}

View File

@ -0,0 +1,83 @@
.fc-media-screen {
flex-grow: 1;
height: 74vh;
}
.fc-prev-button,
.fc-next-button {
background-color: var(--color-blue-600) !important;
border: none !important;
transition: 0.2s ease;
}
.fc-prev-button:hover,
.fc-next-button:hover {
background-color: var(--color-blue-700) !important;
}
.fc-timeGridWeek-button,
.fc-timeGridDay-button,
.fc-dayGridMonth-button {
border: none !important;
background-color: var(--color-blue-600) !important;
transition: 0.2s ease;
}
.fc-timeGridWeek-button:hover,
.fc-timeGridDay-button:hover,
.fc-dayGridMonth-button:hover {
background-color: var(--color-blue-700) !important;
}
.fc-button-active {
background-color: var(--color-blue-500) !important;
}
.fc-toolbar-title {
font-weight: bold;
color: var(--color-gray-900);
}
/* Compact mode for embedded EventManager */
.compact-event-manager {
gap: 0.5rem;
}
.compact-event-manager h2 {
font-size: 1rem; /* menor título */
margin-bottom: 0.25rem;
}
.compact-event-manager .sm\\:flex { /* reduz grupo de botões */
gap: 0.25rem;
}
.compact-event-manager .button,
.compact-event-manager .btn,
.compact-event-manager .chakra-button {
padding: 6px 8px;
font-size: 0.9rem;
}
/* Inputs dentro do EventManager compactos */
.compact-event-manager input,
.compact-event-manager .input {
padding: 6px 8px;
font-size: 0.95rem;
}
/* reduzir padding dos cards e dos toolbars internos */
.compact-event-manager .p-4 { padding: 0.5rem; }
.compact-event-manager .p-3 { padding: 0.4rem; }
/* reduzir altura das linhas na vista semana/dia custom */
.compact-event-manager .min-h-16 { min-height: 3.2rem; }
.compact-event-manager .min-h-20 { min-height: 3.6rem; }
/* tornar os botões de filtro menores */
.compact-event-manager .dropdown-trigger,
.compact-event-manager .dropdown-menu-trigger {
padding: 6px 8px;
font-size: 0.9rem;
}
/* melhorar harmonia: menos margem entre header e calendário */
.compact-event-manager { margin-top: 0.25rem; margin-bottom: 0.25rem; }

View File

@ -0,0 +1,332 @@
"use client";
// Imports mantidos
import { useEffect, useState } from "react";
// --- Imports do EventManager (NOVO) - MANTIDOS ---
import { EventManager, type Event } from "@/components/features/general/event-manager";
import { v4 as uuidv4 } from 'uuid'; // Usado para IDs de fallback
// Imports mantidos
import "./index.css";
export default function AgendamentoPage() {
const [appointments, setAppointments] = useState<any[]>([]);
// REMOVIDO: abas e 3D → não há mais alternância de abas
// const [activeTab, setActiveTab] = useState<"calendar" | "3d">("calendar");
// REMOVIDO: estados do 3D e formulário do paciente (eram usados pelo 3D)
// const [threeDEvents, setThreeDEvents] = useState<CalendarEvent[]>([]);
// const [showPatientForm, setShowPatientForm] = useState(false);
// --- NOVO ESTADO ---
// Estado para alimentar o NOVO EventManager com dados da API
const [managerEvents, setManagerEvents] = useState<Event[]>([]);
const [managerLoading, setManagerLoading] = useState<boolean>(true);
// Padroniza idioma da página para pt-BR (afeta componentes que usam o lang do documento)
useEffect(() => {
try {
// Atributos no <html>
document.documentElement.lang = "pt-BR";
document.documentElement.setAttribute("xml:lang", "pt-BR");
document.documentElement.setAttribute("data-lang", "pt-BR");
// Cookie de locale (usado por apps com i18n)
const oneYear = 60 * 60 * 24 * 365;
document.cookie = `NEXT_LOCALE=pt-BR; Path=/; Max-Age=${oneYear}; SameSite=Lax`;
} catch {
// ignore
}
}, []);
useEffect(() => {
let mounted = true;
(async () => {
try {
setManagerLoading(true);
const api = await import('@/lib/api');
const arr = await api.listarAgendamentos('select=*&order=scheduled_at.desc&limit=500').catch(() => []);
if (!mounted) return;
if (!arr || !arr.length) {
setAppointments([]);
// REMOVIDO: setThreeDEvents([])
setManagerEvents([]);
setManagerLoading(false);
return;
}
const patientIds = Array.from(new Set(arr.map((a: any) => a.patient_id).filter(Boolean)));
const patients = (patientIds && patientIds.length) ? await api.buscarPacientesPorIds(patientIds) : [];
const patientsById: Record<string, any> = {};
(patients || []).forEach((p: any) => { if (p && p.id) patientsById[String(p.id)] = p; });
// Tentar enriquecer com médicos/profissionais quando houver doctor_id
const doctorIds = Array.from(new Set(arr.map((a: any) => a.doctor_id).filter(Boolean)));
const doctors = (doctorIds && doctorIds.length) ? await api.buscarMedicosPorIds(doctorIds) : [];
const doctorsById: Record<string, any> = {};
(doctors || []).forEach((d: any) => { if (d && d.id) doctorsById[String(d.id)] = d; });
setAppointments(arr || []);
// --- LÓGICA DE TRANSFORMAÇÃO PARA O NOVO EVENTMANAGER ---
const newManagerEvents: Event[] = (arr || []).map((obj: any) => {
const scheduled = obj.scheduled_at || obj.scheduledAt || obj.time || null;
const start = scheduled ? new Date(scheduled) : new Date();
const duration = Number(obj.duration_minutes ?? obj.duration ?? 30) || 30;
const end = new Date(start.getTime() + duration * 60 * 1000);
const patient = (patientsById[String(obj.patient_id)]?.full_name) || obj.patient_name || obj.patient_full_name || obj.patient || 'Paciente';
const title = `${patient}: ${obj.appointment_type ?? obj.type ?? ''}`.trim();
// Mapeamento de cores padronizado
const status = String(obj.status || "").toLowerCase();
let color: Event["color"] = "blue";
if (status === "confirmed" || status === "confirmado") color = "green";
else if (status === "pending" || status === "pendente") color = "orange";
else if (status === "canceled" || status === "cancelado" || status === "cancelled") color = "red";
else if (status === "requested" || status === "solicitado") color = "blue";
const professional = (doctorsById[String(obj.doctor_id)]?.full_name) || obj.doctor_name || obj.professional_name || obj.professional || obj.executante || 'Profissional';
const appointmentType = obj.appointment_type || obj.type || obj.appointmentType || '';
const insurance = obj.insurance_provider || obj.insurance || obj.convenio || obj.insuranceProvider || null;
const completedAt = obj.completed_at || obj.completedAt || null;
const cancelledAt = obj.cancelled_at || obj.cancelledAt || null;
const cancellationReason = obj.cancellation_reason || obj.cancellationReason || obj.cancel_reason || null;
return {
id: obj.id || uuidv4(),
title,
description: `Agendamento para ${patient}. Status: ${obj.status || 'N/A'}.`,
startTime: start,
endTime: end,
color,
// Campos adicionais para visualização detalhada
patientName: patient,
professionalName: professional,
appointmentType,
status: obj.status || null,
insuranceProvider: insurance,
completedAt,
cancelledAt,
cancellationReason,
};
});
setManagerEvents(newManagerEvents);
setManagerLoading(false);
// --- FIM DA LÓGICA ---
// REMOVIDO: conversão para 3D e setThreeDEvents
} catch (err) {
console.warn('[AgendamentoPage] falha ao carregar agendamentos', err);
setAppointments([]);
// REMOVIDO: setThreeDEvents([])
setManagerEvents([]);
setManagerLoading(false);
}
})();
return () => { mounted = false; };
}, []);
// Handlers mantidos
const handleSaveAppointment = (appointment: any) => {
if (appointment.id) {
setAppointments((prev) =>
prev.map((a) => (a.id === appointment.id ? appointment : a))
);
} else {
const newAppointment = {
...appointment,
id: Date.now().toString(),
};
setAppointments((prev) => [...prev, newAppointment]);
}
};
// Mapeia cor do calendário -> status da API
const statusFromColor = (color?: string) => {
switch ((color || "").toLowerCase()) {
case "green": return "confirmed";
case "orange": return "pending";
case "red": return "canceled";
default: return "requested";
}
};
// Componente auxiliar: legenda dinâmica que lista as cores/statuss presentes nos agendamentos
function DynamicLegend({ events }: { events: Event[] }) {
// Mapa de classes para cores conhecidas
const colorClassMap: Record<string, string> = {
blue: "bg-blue-500 ring-blue-500/20",
green: "bg-[#10B981] ring-[#10B981]/20",
orange: "bg-orange-500 ring-orange-500/20",
red: "bg-red-500 ring-red-500/20",
purple: "bg-purple-500 ring-purple-500/20",
pink: "bg-pink-500 ring-pink-500/20",
teal: "bg-teal-400 ring-teal-400/20",
}
const hashToColor = (s: string) => {
// gera cor hex simples a partir de hash da string
let h = 0
for (let i = 0; i < s.length; i++) h = (h << 5) - h + s.charCodeAt(i)
const c = (h & 0x00ffffff).toString(16).toUpperCase()
return "#" + "00000".substring(0, 6 - c.length) + c
}
// Agrupa por cor e coleta os status associados
const entries = new Map<string, Set<string>>()
for (const ev of events) {
const col = (ev.color || "blue").toString()
const st = (ev.status || statusFromColor(ev.color) || "").toString().toLowerCase()
if (!entries.has(col)) entries.set(col, new Set())
if (st) entries.get(col)!.add(st)
}
// Painel principal: sempre exibe os 3 status primários (Solicitado, Confirmado, Cancelado)
const statusDisplay = (s: string) => {
switch (s) {
case "requested":
case "request":
case "solicitado":
return "Solicitado"
case "confirmed":
case "confirmado":
return "Confirmado"
case "canceled":
case "cancelled":
case "cancelado":
return "Cancelado"
case "pending":
case "pendente":
return "Pendente"
case "governo":
case "government":
return "Governo"
default:
return s.charAt(0).toUpperCase() + s.slice(1)
}
}
// Ordem preferencial para exibição (tenta manter Solicitação/Confirmado/Cancelado em primeiro)
const priorityList = [
'solicitado','requested',
'confirmed','confirmado',
'pending','pendente',
'canceled','cancelled','cancelado',
'governo','government'
]
const items = Array.from(entries.entries()).map(([col, statuses]) => {
const statusArr = Array.from(statuses)
let priority = 999
for (const s of statusArr) {
const idx = priorityList.indexOf(s)
if (idx >= 0) priority = Math.min(priority, idx)
}
// if none matched, leave priority high so they appear after known statuses
return { col, statuses: statusArr, priority }
})
items.sort((a, b) => a.priority - b.priority || a.col.localeCompare(b.col))
// Separar itens extras (fora os três principais) para renderizar depois
const primaryColors = new Set(['blue', 'green', 'red'])
const extras = items.filter(i => !primaryColors.has(i.col.toLowerCase()))
return (
<div className="max-w-full sm:max-w-[520px] rounded-lg border border-slate-700 bg-gradient-to-b from-card/70 to-card/50 px-3 py-2 shadow-md flex items-center gap-4 text-sm overflow-x-auto whitespace-nowrap">
{/* Bloco grande com os três status principais sempre visíveis e responsivos */}
<div className="flex items-center gap-4 shrink-0">
<div className="flex items-center gap-2">
<span aria-hidden className="h-2 w-2 sm:h-3 sm:w-3 rounded-full bg-blue-500 ring-1 ring-white/6" />
<span className="text-foreground text-xs sm:text-sm font-medium">Solicitado</span>
</div>
<div className="flex items-center gap-2">
<span aria-hidden className="h-2 w-2 sm:h-3 sm:w-3 rounded-full ring-1 ring-white/6" style={{ backgroundColor: '#10B981' }} />
<span className="text-foreground text-xs sm:text-sm font-medium">Confirmado</span>
</div>
<div className="flex items-center gap-2">
<span aria-hidden className="h-2 w-2 sm:h-3 sm:w-3 rounded-full bg-red-500 ring-1 ring-white/6" />
<span className="text-foreground text-xs sm:text-sm font-medium">Cancelado</span>
</div>
</div>
{/* Itens extras detectados dinamicamente (menores) */}
{extras.length > 0 && (
<div className="flex items-center gap-3 ml-3 flex-wrap">
{extras.map(({ col, statuses }) => {
const statusList = statuses.map(statusDisplay).filter(Boolean).join(', ')
const cls = colorClassMap[col.toLowerCase()]
return (
<div key={col} className="flex items-center gap-2">
{cls ? (
<span aria-hidden className={`h-2 w-2 rounded-full ${cls} ring-1`} />
) : (
<span aria-hidden className="h-2 w-2 rounded-full ring-1" style={{ backgroundColor: hashToColor(col) }} />
)}
<span className="text-foreground text-xs">{statusList || col}</span>
</div>
)
})}
</div>
)}
</div>
)
}
// Envia atualização para a API e atualiza UI
const handleEventUpdate = async (id: string, partial: Partial<Event>) => {
try {
const payload: any = {};
if (partial.startTime) payload.scheduled_at = partial.startTime.toISOString();
if (partial.startTime && partial.endTime) {
const minutes = Math.max(1, Math.round((partial.endTime.getTime() - partial.startTime.getTime()) / 60000));
payload.duration_minutes = minutes;
}
if (partial.color) payload.status = statusFromColor(partial.color);
if (typeof partial.description === "string") payload.notes = partial.description;
if (Object.keys(payload).length) {
const api = await import('@/lib/api');
await api.atualizarAgendamento(id, payload);
}
// Otimista: reflete mudanças locais
setManagerEvents((prev) => prev.map((e) => (e.id === id ? { ...e, ...partial } : e)));
} catch (e) {
console.warn("[Calendário] Falha ao atualizar agendamento na API:", e);
}
};
return (
<div className="bg-background">
<div className="w-full">
<div className="w-full max-w-full mx-0 flex flex-col gap-0 p-0 pl-4 sm:pl-6">
<div className="relative flex items-center justify-between gap-0 p-0 py-2 sm:py-0">
<div>
<h1 className="text-lg font-semibold text-foreground m-0 p-0">Calendário</h1>
<p className="text-muted-foreground m-0 p-0 text-xs">Navegue através do atalho: Calendário (C).</p>
</div>
{/* legenda dinâmica: mostra as cores presentes nos agendamentos do dia atual */}
<div className="sm:absolute sm:top-2 sm:right-2 mt-2 sm:mt-0 z-10">
<DynamicLegend events={managerEvents} />
</div>
</div>
<div className="w-full m-0 p-0">
{managerLoading ? (
<div className="flex items-center justify-center w-full min-h-[70vh] m-0 p-0">
<div className="text-xs text-muted-foreground">Conectando ao calendário carregando agendamentos...</div>
</div>
) : (
<div className="w-full min-h-[80vh] m-0 p-0">
<EventManager events={managerEvents} className="compact-event-manager" onEventUpdate={handleEventUpdate} />
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,832 @@
"use client";
import Link from "next/link";
import { useEffect, useState, useCallback, useMemo } from "react";
import {
MoreHorizontal,
PlusCircle,
Search,
Eye,
Edit,
Trash2,
ArrowLeft,
Loader2,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { mockProfessionals } from "@/lib/mocks/appointment-mocks";
import { listarAgendamentos, buscarPacientesPorIds, buscarMedicosPorIds, atualizarAgendamento, buscarAgendamentoPorId, deletarAgendamento, addDeletedAppointmentId } from "@/lib/api";
import { CalendarRegistrationForm } from "@/components/features/forms/calendar-registration-form";
const formatDate = (date: string | Date) => {
if (!date) return "";
return new Date(date).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const capitalize = (s: string) => {
if (typeof s !== "string" || s.length === 0) return "";
return s.charAt(0).toUpperCase() + s.slice(1);
};
const translateStatus = (status: string) => {
const statusMap: { [key: string]: string } = {
'requested': 'Solicitado',
'confirmed': 'Confirmado',
'checked_in': 'Check-in',
'in_progress': 'Em Andamento',
'completed': 'Concluído',
'cancelled': 'Cancelado',
'no_show': 'Não Compareceu',
'pending': 'Pendente',
};
return statusMap[status?.toLowerCase()] || capitalize(status || '');
};
export default function ConsultasPage() {
const [appointments, setAppointments] = useState<any[]>([]);
const [originalAppointments, setOriginalAppointments] = useState<any[]>([]);
const [searchValue, setSearchValue] = useState<string>('');
const [selectedStatus, setSelectedStatus] = useState<string>('all');
const [filterDate, setFilterDate] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(true);
const [showForm, setShowForm] = useState(false);
const [editingAppointment, setEditingAppointment] = useState<any | null>(null);
const [viewingAppointment, setViewingAppointment] = useState<any | null>(null);
// Local form state used when editing. Keep hook at top-level to avoid Hooks order changes.
const [localForm, setLocalForm] = useState<any | null>(null);
// Paginação
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const mapAppointmentToFormData = (appointment: any) => {
// prefer scheduled_at (ISO) if available
const scheduledBase = appointment.scheduled_at || appointment.time || appointment.created_at || null;
const baseDate = scheduledBase ? new Date(scheduledBase) : new Date();
const duration = appointment.duration_minutes ?? appointment.duration ?? 30;
// compute start and end times (HH:MM) and date using local time to avoid timezone issues
const year = baseDate.getFullYear();
const month = String(baseDate.getMonth() + 1).padStart(2, '0');
const day = String(baseDate.getDate()).padStart(2, '0');
const appointmentDateStr = `${year}-${month}-${day}`;
const startTime = `${String(baseDate.getHours()).padStart(2, '0')}:${String(baseDate.getMinutes()).padStart(2, '0')}`;
const endDate = new Date(baseDate.getTime() + duration * 60000);
const endTime = `${String(endDate.getHours()).padStart(2, '0')}:${String(endDate.getMinutes()).padStart(2, '0')}`;
return {
id: appointment.id,
patientName: appointment.patient,
patientId: appointment.patient_id || appointment.patientId || null,
// include doctor id so the form can run availability/exception checks when editing
doctorId: appointment.doctor_id || appointment.doctorId || null,
professionalName: appointment.professional || "",
appointmentDate: appointmentDateStr,
startTime,
endTime,
status: appointment.status,
appointmentType: appointment.appointment_type || appointment.type,
notes: appointment.notes || appointment.patient_notes || "",
cpf: "",
rg: "",
birthDate: "",
phoneCode: "+55",
phoneNumber: "",
email: "",
unit: "nei",
// API-editable fields (populate so the form shows existing values)
duration_minutes: duration,
chief_complaint: appointment.chief_complaint ?? null,
patient_notes: appointment.patient_notes ?? null,
insurance_provider: appointment.insurance_provider ?? null,
checked_in_at: appointment.checked_in_at ?? null,
completed_at: appointment.completed_at ?? null,
cancelled_at: appointment.cancelled_at ?? null,
cancellation_reason: appointment.cancellation_reason ?? appointment.cancellationReason ?? "",
};
};
const handleDelete = async (appointmentId: string) => {
if (!window.confirm("Tem certeza que deseja excluir esta consulta?")) return;
try {
// call server DELETE
await deletarAgendamento(appointmentId);
// Mark as deleted in cache so it won't appear again
addDeletedAppointmentId(appointmentId);
// remove from UI
setAppointments((prev) => prev.filter((a) => a.id !== appointmentId));
// also update originalAppointments cache
setOriginalAppointments((prev) => (prev || []).filter((a) => a.id !== appointmentId));
alert('Agendamento excluído com sucesso.');
} catch (err) {
console.error('[ConsultasPage] Falha ao excluir agendamento', err);
try {
const msg = err instanceof Error ? err.message : String(err);
alert('Falha ao excluir agendamento: ' + msg);
} catch (e) {
// ignore
}
}
};
const handleEdit = (appointment: any) => {
const formData = mapAppointmentToFormData(appointment);
setEditingAppointment(formData);
setShowForm(true);
};
const handleView = (appointment: any) => {
setViewingAppointment(appointment);
};
const handleCancel = () => {
setEditingAppointment(null);
setShowForm(false);
setLocalForm(null);
};
const handleSave = async (formData: any) => {
try {
// build scheduled_at ISO (formData.startTime is 'HH:MM')
const scheduled_at = new Date(`${formData.appointmentDate}T${formData.startTime}`).toISOString();
// compute duration from start/end times when available
let duration_minutes = 30;
try {
if (formData.startTime && formData.endTime) {
const [sh, sm] = String(formData.startTime).split(":").map(Number);
const [eh, em] = String(formData.endTime).split(":").map(Number);
const start = (sh || 0) * 60 + (sm || 0);
const end = (eh || 0) * 60 + (em || 0);
if (!Number.isNaN(start) && !Number.isNaN(end) && end > start) duration_minutes = end - start;
}
} catch (e) {
// fallback to default
duration_minutes = 30;
}
const payload: any = {
scheduled_at,
duration_minutes,
status: 'confirmed',
notes: formData.notes ?? null,
chief_complaint: formData.chief_complaint ?? null,
patient_notes: formData.patient_notes ?? null,
insurance_provider: formData.insurance_provider ?? null,
// convert local datetime-local inputs (which may be in 'YYYY-MM-DDTHH:MM' format) to proper ISO if present
checked_in_at: formData.checked_in_at ? new Date(formData.checked_in_at).toISOString() : null,
completed_at: formData.completed_at ? new Date(formData.completed_at).toISOString() : null,
cancelled_at: formData.cancelled_at ? new Date(formData.cancelled_at).toISOString() : null,
cancellation_reason: formData.cancellation_reason ?? null,
};
// Call PATCH endpoint
const updated = await atualizarAgendamento(formData.id, payload);
// Build UI-friendly row using server response and existing local fields
const existing = appointments.find((a) => a.id === formData.id) || {};
const mapped = {
id: updated.id,
patient: formData.patientName || existing.patient || '',
patient_id: existing.patient_id ?? null,
// preserve doctor id so future edits retain the selected professional
doctor_id: existing.doctor_id ?? (formData.doctorId || (formData as any).doctor_id) ?? null,
// preserve server-side fields so future edits read them
scheduled_at: updated.scheduled_at ?? scheduled_at,
duration_minutes: updated.duration_minutes ?? duration_minutes,
appointment_type: updated.appointment_type ?? formData.appointmentType ?? existing.type ?? 'presencial',
status: updated.status ?? formData.status ?? existing.status,
professional: existing.professional || formData.professionalName || '',
notes: updated.notes ?? updated.patient_notes ?? formData.notes ?? existing.notes ?? '',
chief_complaint: updated.chief_complaint ?? formData.chief_complaint ?? existing.chief_complaint ?? null,
patient_notes: updated.patient_notes ?? formData.patient_notes ?? existing.patient_notes ?? null,
insurance_provider: updated.insurance_provider ?? formData.insurance_provider ?? existing.insurance_provider ?? null,
checked_in_at: updated.checked_in_at ?? formData.checked_in_at ?? existing.checked_in_at ?? null,
completed_at: updated.completed_at ?? formData.completed_at ?? existing.completed_at ?? null,
cancelled_at: updated.cancelled_at ?? formData.cancelled_at ?? existing.cancelled_at ?? null,
cancellation_reason: updated.cancellation_reason ?? formData.cancellation_reason ?? existing.cancellation_reason ?? null,
};
setAppointments((prev) => prev.map((a) => (a.id === mapped.id ? mapped : a)));
handleCancel();
} catch (err) {
console.error('[ConsultasPage] Falha ao atualizar agendamento', err);
// Inform the user
try {
const msg = err instanceof Error ? err.message : String(err);
alert('Falha ao salvar alterações: ' + msg);
} catch (e) {
// ignore
}
}
};
// Fetch and map appointments (used at load and when clearing search)
const fetchAndMapAppointments = async () => {
const arr = await listarAgendamentos("select=*&order=scheduled_at.desc&limit=200");
// Collect unique patient_ids and doctor_ids
const patientIds = new Set<string>();
const doctorIds = new Set<string>();
for (const a of arr || []) {
if (a.patient_id) patientIds.add(String(a.patient_id));
if (a.doctor_id) doctorIds.add(String(a.doctor_id));
}
// Batch fetch patients and doctors
const patientsMap = new Map<string, any>();
const doctorsMap = new Map<string, any>();
try {
if (patientIds.size) {
const list = await buscarPacientesPorIds(Array.from(patientIds));
for (const p of list || []) patientsMap.set(String(p.id), p);
}
} catch (e) {
console.warn("[ConsultasPage] Falha ao buscar pacientes em lote", e);
}
try {
if (doctorIds.size) {
const list = await buscarMedicosPorIds(Array.from(doctorIds));
for (const d of list || []) doctorsMap.set(String(d.id), d);
}
} catch (e) {
console.warn("[ConsultasPage] Falha ao buscar médicos em lote", e);
}
// Map appointments using the maps
const mapped = (arr || []).map((a: any) => {
const patient = a.patient_id ? patientsMap.get(String(a.patient_id))?.full_name || String(a.patient_id) : "";
const professional = a.doctor_id ? doctorsMap.get(String(a.doctor_id))?.full_name || String(a.doctor_id) : "";
return {
id: a.id,
patient,
patient_id: a.patient_id,
// preserve the doctor's id so later edit flows can access it
doctor_id: a.doctor_id ?? null,
// keep some server-side fields so edit can access them later
scheduled_at: a.scheduled_at ?? a.time ?? a.created_at ?? null,
duration_minutes: a.duration_minutes ?? a.duration ?? null,
appointment_type: a.appointment_type ?? a.type ?? null,
status: a.status ?? "requested",
professional,
notes: a.notes || a.patient_notes || "",
// additional editable fields
chief_complaint: a.chief_complaint ?? null,
patient_notes: a.patient_notes ?? null,
insurance_provider: a.insurance_provider ?? null,
checked_in_at: a.checked_in_at ?? null,
completed_at: a.completed_at ?? null,
cancelled_at: a.cancelled_at ?? null,
cancellation_reason: a.cancellation_reason ?? a.cancellationReason ?? null,
};
});
return mapped;
};
useEffect(() => {
let mounted = true;
(async () => {
try {
const mapped = await fetchAndMapAppointments();
if (!mounted) return;
setAppointments(mapped);
setOriginalAppointments(mapped || []);
setIsLoading(false);
} catch (err) {
console.warn("[ConsultasPage] Falha ao carregar agendamentos, usando mocks", err);
if (!mounted) return;
setAppointments([]);
setIsLoading(false);
}
})();
return () => { mounted = false; };
}, []);
// Search box: allow fetching a single appointment by ID when pressing Enter
// Perform a local-only search against the already-loaded appointments.
// This intentionally does not call the server — it filters the cached list.
const applyFilters = (val?: string) => {
const trimmed = String((val ?? searchValue) || '').trim();
let list = (originalAppointments || []).slice();
// search
if (trimmed) {
const q = trimmed.toLowerCase();
list = list.filter((a) => {
const patient = String(a.patient || '').toLowerCase();
const professional = String(a.professional || '').toLowerCase();
const pid = String(a.patient_id || '').toLowerCase();
const aid = String(a.id || '').toLowerCase();
return (
patient.includes(q) ||
professional.includes(q) ||
pid === q ||
aid === q
);
});
}
// status filter
if (selectedStatus && selectedStatus !== 'all') {
list = list.filter((a) => String(a.status || '').toLowerCase() === String(selectedStatus).toLowerCase());
}
// date filter (YYYY-MM-DD)
if (filterDate) {
list = list.filter((a) => {
try {
const sched = a.scheduled_at || a.time || a.created_at || null;
if (!sched) return false;
const iso = new Date(sched).toISOString().split('T')[0];
return iso === filterDate;
} catch (e) { return false; }
});
}
setAppointments(list as any[]);
};
const performSearch = (val: string) => { applyFilters(val); };
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
// keep behavior consistent: perform a local filter immediately
performSearch(searchValue);
} else if (e.key === 'Escape') {
setSearchValue('');
setAppointments(originalAppointments || []);
}
};
const handleClearSearch = async () => {
setSearchValue('');
setIsLoading(true);
try {
// Reset to the original cached list without refetching from server
setAppointments(originalAppointments || []);
} catch (err) {
setAppointments([]);
} finally {
setIsLoading(false);
}
};
// Debounce live filtering as the user types. Operates only on the cached originalAppointments.
useEffect(() => {
const t = setTimeout(() => {
performSearch(searchValue);
}, 250);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchValue, originalAppointments]);
useEffect(() => {
applyFilters();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedStatus, filterDate, originalAppointments]);
// Dados paginados
const paginatedAppointments = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
return appointments.slice(startIndex, endIndex);
}, [appointments, currentPage, itemsPerPage]);
const totalPages = Math.ceil(appointments.length / itemsPerPage);
// Reset para página 1 quando mudar a busca ou itens por página
useEffect(() => {
setCurrentPage(1);
}, [searchValue, selectedStatus, filterDate, itemsPerPage]);
// Keep localForm synchronized with editingAppointment
useEffect(() => {
if (showForm && editingAppointment) {
setLocalForm(editingAppointment);
}
if (!showForm) setLocalForm(null);
}, [showForm, editingAppointment]);
const onFormChange = (d: any) => setLocalForm(d);
const saveLocal = async () => {
if (!localForm) return;
await handleSave(localForm);
};
// If editing, render the edit form as a focused view (keeps hooks stable)
if (showForm && localForm) {
return (
<div className="space-y-6 p-6 bg-background">
<div className="flex items-center gap-4">
<Button type="button" variant="ghost" size="icon" onClick={handleCancel}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-lg font-semibold md:text-2xl">Editar Consulta</h1>
</div>
<CalendarRegistrationForm formData={localForm} onFormChange={onFormChange} createMode={true} />
<div className="flex gap-2 justify-end">
<Button variant="outline" className="hover:bg-primary/10 hover:text-primary dark:hover:bg-accent dark:hover:text-accent-foreground" onClick={handleCancel}>
Cancelar
</Button>
<Button onClick={saveLocal}>Salvar</Button>
</div>
</div>
);
}
return (
<div className="space-y-4 sm:space-y-6 p-3 sm:p-4 md:p-6 bg-background">
{/* Header responsivo */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold">Consultas</h1>
<p className="text-xs sm:text-sm text-muted-foreground">Gerencie todas as consultas da clínica</p>
</div>
<Link href="/agenda?origin=consultas">
<Button className="w-full sm:w-auto h-8 sm:h-9 gap-1 bg-blue-600 text-xs sm:text-sm">
<PlusCircle className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Agendar</span>
<span className="sm:hidden">Nova</span>
</Button>
</Link>
</div>
{/* Filtros e busca responsivos */}
<div className="space-y-2 sm:space-y-3 p-3 sm:p-4 border rounded-lg bg-card">
{/* Linha 1: Busca */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Buscar…"
className="pl-8 w-full text-xs sm:text-sm h-8 sm:h-9 shadow-sm border"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onKeyDown={handleSearchKeyDown}
/>
</div>
</div>
{/* Linha 2: Selects responsivos */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<Select onValueChange={(v) => { setSelectedStatus(String(v)); }}>
<SelectTrigger className="h-8 sm:h-9 text-xs sm:text-sm">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="confirmed">Confirmada</SelectItem>
<SelectItem value="requested">Pendente</SelectItem>
<SelectItem value="cancelled">Cancelada</SelectItem>
</SelectContent>
</Select>
<Input type="date" className="h-8 sm:h-9 text-xs sm:text-sm" value={filterDate} onChange={(e) => setFilterDate(e.target.value)} />
</div>
</div>
{/* Loading state */}
{isLoading ? (
<div className="w-full py-12 flex justify-center items-center border rounded-lg">
<Loader2 className="animate-spin mr-2" />
<span className="text-xs sm:text-sm">Carregando agendamentos...</span>
</div>
) : (
<>
{/* Desktop Table - Hidden on mobile */}
<div className="hidden md:block border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-primary hover:bg-primary">
<TableHead className="text-primary-foreground text-xs sm:text-sm">Paciente</TableHead>
<TableHead className="text-primary-foreground text-xs sm:text-sm">Médico</TableHead>
<TableHead className="text-primary-foreground text-xs sm:text-sm">Status</TableHead>
<TableHead className="text-primary-foreground text-xs sm:text-sm">Data e Hora</TableHead>
<TableHead className="text-primary-foreground text-xs sm:text-sm">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedAppointments.map((appointment) => {
const professionalLookup = mockProfessionals.find((p) => p.id === appointment.professional);
const professionalName = typeof appointment.professional === "string" && appointment.professional && !professionalLookup
? appointment.professional
: (professionalLookup ? professionalLookup.name : (appointment.professional || "Não encontrado"));
return (
<TableRow key={appointment.id}>
<TableCell className="font-medium text-xs sm:text-sm">{appointment.patient}</TableCell>
<TableCell className="text-xs sm:text-sm">{professionalName}</TableCell>
<TableCell>
<Badge
variant={
appointment.status === "confirmed" || appointment.status === "confirmado"
? "default"
: appointment.status === "pending" || appointment.status === "pendente"
? "secondary"
: appointment.status === "requested" || appointment.status === "solicitado"
? "default"
: "destructive"
}
className={
appointment.status === "confirmed" || appointment.status === "confirmado" ? "bg-[#10B981]" :
appointment.status === "requested" || appointment.status === "solicitado" ? "bg-blue-500" :
appointment.status === "canceled" || appointment.status === "cancelled" || appointment.status === "cancelado" ? "bg-red-500" : ""
}
>
{translateStatus(appointment.status)}
</Badge>
</TableCell>
<TableCell className="text-xs sm:text-sm">{formatDate(appointment.scheduled_at ?? appointment.time)}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="h-8 w-8 p-0 flex items-center justify-center rounded-md hover:bg-primary hover:text-white transition-colors">
<span className="sr-only">Menu</span>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleView(appointment)}>
<Eye className="mr-2 h-4 w-4" />
Ver
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEdit(appointment)}>
<Edit className="mr-2 h-4 w-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(appointment.id)} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Excluir
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{/* Mobile Cards - Hidden on desktop */}
<div className="md:hidden space-y-2">
{paginatedAppointments.length > 0 ? (
paginatedAppointments.map((appointment) => {
const professionalLookup = mockProfessionals.find((p) => p.id === appointment.professional);
const professionalName = typeof appointment.professional === "string" && appointment.professional && !professionalLookup
? appointment.professional
: (professionalLookup ? professionalLookup.name : (appointment.professional || "Não encontrado"));
return (
<div key={appointment.id} className="bg-card p-3 sm:p-4 rounded-lg border border-border hover:border-primary transition-colors">
<div className="grid grid-cols-2 gap-2">
<div className="col-span-2 flex justify-between items-start">
<div className="flex-1">
<div className="text-[10px] sm:text-xs font-semibold text-primary">Paciente</div>
<div className="text-xs sm:text-sm font-medium truncate">{appointment.patient}</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="h-7 w-7 p-0 flex items-center justify-center rounded-md hover:bg-primary hover:text-white transition-colors flex-shrink-0">
<span className="sr-only">Menu</span>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleView(appointment)}>
<Eye className="mr-2 h-4 w-4" />
Ver
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEdit(appointment)}>
<Edit className="mr-2 h-4 w-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(appointment.id)} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Excluir
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Médico</div>
<div className="text-[10px] sm:text-xs font-medium truncate">{professionalName}</div>
</div>
<div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Status</div>
<Badge
variant={
appointment.status === "confirmed" || appointment.status === "confirmado"
? "default"
: appointment.status === "pending" || appointment.status === "pendente"
? "secondary"
: appointment.status === "requested" || appointment.status === "solicitado"
? "default"
: "destructive"
}
className={
`text-[10px] sm:text-xs ${
appointment.status === "confirmed" || appointment.status === "confirmado" ? "bg-[#10B981]" :
appointment.status === "requested" || appointment.status === "solicitado" ? "bg-blue-500" :
appointment.status === "canceled" || appointment.status === "cancelled" || appointment.status === "cancelado" ? "bg-red-500" : ""
}`
}
>
{translateStatus(appointment.status)}
</Badge>
</div>
<div className="col-span-2">
<div className="text-[10px] sm:text-xs text-muted-foreground">Data e Hora</div>
<div className="text-[10px] sm:text-xs font-medium">{formatDate(appointment.scheduled_at ?? appointment.time)}</div>
</div>
</div>
</div>
);
})
) : (
<div className="text-center text-xs sm:text-sm text-muted-foreground py-4">
Nenhuma consulta encontrada
</div>
)}
</div>
</>
)}
{/* Controles de paginação - Responsivos */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4 text-xs sm:text-sm">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-muted-foreground text-xs sm:text-sm">Itens por página:</span>
<select
value={itemsPerPage}
onChange={(e) => setItemsPerPage(Number(e.target.value))}
className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value={10}>10</option>
<option value={15}>15</option>
<option value={20}>20</option>
</select>
<span className="text-muted-foreground text-xs sm:text-sm">
Mostrando {paginatedAppointments.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0} a{" "}
{Math.min(currentPage * itemsPerPage, appointments.length)} de {appointments.length}
</span>
</div>
<div className="flex items-center gap-1 sm:gap-2 flex-wrap justify-center sm:justify-end">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
>
<span className="hidden sm:inline">Primeira</span>
<span className="sm:hidden">1ª</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
>
<span className="hidden sm:inline">Anterior</span>
<span className="sm:hidden">«</span>
</Button>
<span className="text-muted-foreground text-xs sm:text-sm">
Pág {currentPage} de {totalPages || 1}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages || totalPages === 0}
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
>
<span className="hidden sm:inline">Próxima</span>
<span className="sm:hidden">»</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages || totalPages === 0}
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
>
<span className="hidden sm:inline">Última</span>
<span className="sm:hidden">Últ</span>
</Button>
</div>
</div>
{viewingAppointment && (
<Dialog open={!!viewingAppointment} onOpenChange={() => setViewingAppointment(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Detalhes da Consulta</DialogTitle>
<DialogDescription>Informações detalhadas da consulta de {viewingAppointment?.patient}.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">Paciente</Label>
<span className="col-span-3">{viewingAppointment?.patient}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Médico</Label>
<span className="col-span-3">{viewingAppointment?.professional || 'Não encontrado'}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Data e Hora</Label>
<span className="col-span-3">{(viewingAppointment?.scheduled_at ?? viewingAppointment?.time) ? formatDate(viewingAppointment?.scheduled_at ?? viewingAppointment?.time) : ''}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Status</Label>
<span className="col-span-3">
<Badge
variant={
viewingAppointment?.status === "confirmed" || viewingAppointment?.status === "confirmado"
? "default"
: viewingAppointment?.status === "pending" || viewingAppointment?.status === "pendente"
? "secondary"
: viewingAppointment?.status === "requested" || viewingAppointment?.status === "solicitado"
? "default"
: "destructive"
}
className={
viewingAppointment?.status === "confirmed" || viewingAppointment?.status === "confirmado" ? "bg-[#10B981]" :
viewingAppointment?.status === "requested" || viewingAppointment?.status === "solicitado" ? "bg-blue-500" :
viewingAppointment?.status === "canceled" || viewingAppointment?.status === "cancelled" || viewingAppointment?.status === "cancelado" ? "bg-red-500" : ""
}
>
{translateStatus(viewingAppointment?.status || "")}
</Badge>
</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Tipo</Label>
<span className="col-span-3">{capitalize(viewingAppointment?.appointment_type || viewingAppointment?.type || "")}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Observações</Label>
<span className="col-span-3">{viewingAppointment?.notes || "Nenhuma"}</span>
</div>
</div>
<DialogFooter>
<Button onClick={() => setViewingAppointment(null)}>Fechar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
}

View File

@ -0,0 +1,369 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
countTotalPatients,
countTotalDoctors,
countAppointmentsToday,
getUpcomingAppointments,
getAppointmentsByDateRange,
getNewUsersLastDays,
getDisabledUsers,
getDoctorsAvailabilityToday,
getPatientById,
getDoctorById,
} from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle, Calendar, Users, Stethoscope, Clock, AlertTriangle, Plus, ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import { PatientRegistrationForm } from '@/components/features/forms/patient-registration-form';
import { DoctorRegistrationForm } from '@/components/features/forms/doctor-registration-form';
interface DashboardStats {
totalPatients: number;
totalDoctors: number;
appointmentsToday: number;
}
interface UpcomingAppointment {
id: string;
scheduled_at: string;
status: string;
doctor_id: string;
patient_id: string;
doctor?: { full_name?: string };
patient?: { full_name?: string };
}
export default function DashboardPage() {
const router = useRouter();
const [stats, setStats] = useState<DashboardStats>({
totalPatients: 0,
totalDoctors: 0,
appointmentsToday: 0,
});
const [appointments, setAppointments] = useState<UpcomingAppointment[]>([]);
const [appointmentData, setAppointmentData] = useState<any[]>([]);
const [newUsers, setNewUsers] = useState<any[]>([]);
const [disabledUsers, setDisabledUsers] = useState<any[]>([]);
const [doctors, setDoctors] = useState<Map<string, any>>(new Map());
const [patients, setPatients] = useState<Map<string, any>>(new Map());
const [loading, setLoading] = useState(true);
// Estados para os modais de formulário
const [showPatientForm, setShowPatientForm] = useState(false);
const [showDoctorForm, setShowDoctorForm] = useState(false);
const [editingPatientId, setEditingPatientId] = useState<string | null>(null);
const [editingDoctorId, setEditingDoctorId] = useState<string | null>(null);
useEffect(() => {
loadDashboardData();
}, []);
const loadDashboardData = async () => {
try {
setLoading(true);
// 1. Carrega stats
const [patientCount, doctorCount, todayCount] = await Promise.all([
countTotalPatients(),
countTotalDoctors(),
countAppointmentsToday(),
]);
setStats({
totalPatients: patientCount,
totalDoctors: doctorCount,
appointmentsToday: todayCount,
});
// 2. Carrega dados dos widgets em paralelo
const [upcomingAppts, appointmentDataRange, newUsersList, disabledUsersList] = await Promise.all([
getUpcomingAppointments(5),
getAppointmentsByDateRange(7),
getNewUsersLastDays(7),
getDisabledUsers(5),
]);
setAppointments(upcomingAppts);
setAppointmentData(appointmentDataRange);
setNewUsers(newUsersList);
setDisabledUsers(disabledUsersList);
// 3. Busca detalhes de pacientes e médicos para as próximas consultas
const doctorMap = new Map();
const patientMap = new Map();
for (const appt of upcomingAppts) {
if (appt.doctor_id && !doctorMap.has(appt.doctor_id)) {
const doctor = await getDoctorById(appt.doctor_id);
if (doctor) doctorMap.set(appt.doctor_id, doctor);
}
if (appt.patient_id && !patientMap.has(appt.patient_id)) {
const patient = await getPatientById(appt.patient_id);
if (patient) patientMap.set(appt.patient_id, patient);
}
}
setDoctors(doctorMap);
setPatients(patientMap);
} catch (err) {
console.error('[Dashboard] Erro ao carregar dados:', err);
} finally {
setLoading(false);
}
};
const handlePatientFormSaved = () => {
setShowPatientForm(false);
setEditingPatientId(null);
loadDashboardData();
};
const handleDoctorFormSaved = () => {
setShowDoctorForm(false);
setEditingDoctorId(null);
loadDashboardData();
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
const getStatusBadge = (status: string) => {
const statusMap: Record<string, { variant: any; label: string }> = {
confirmed: { variant: 'default', label: 'Confirmado' },
completed: { variant: 'secondary', label: 'Concluído' },
cancelled: { variant: 'destructive', label: 'Cancelado' },
requested: { variant: 'outline', label: 'Solicitado' },
};
const s = statusMap[status] || { variant: 'outline', label: status };
return <Badge variant={s.variant as any}>{s.label}</Badge>;
};
if (loading) {
return (
<div className="space-y-4 sm:space-y-6 p-3 sm:p-4 md:p-6 bg-background">
<div className="animate-pulse space-y-4">
<div className="h-6 sm:h-8 bg-muted rounded w-1/4"></div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6">
{[1, 2, 3, 4].map(i => (
<div key={i} className="h-24 sm:h-32 bg-muted rounded"></div>
))}
</div>
</div>
</div>
);
}
// Se está exibindo formulário de paciente
if (showPatientForm) {
return (
<div className="space-y-4 sm:space-y-6 p-3 sm:p-4 md:p-6 bg-background min-h-screen">
<div className="flex items-center gap-2 sm:gap-4">
<Button variant="ghost" size="icon" onClick={() => {
setShowPatientForm(false);
setEditingPatientId(null);
}} className="h-8 w-8 sm:h-10 sm:w-10">
<ArrowLeft className="h-4 w-4 sm:h-5 sm:w-5" />
</Button>
<h1 className="text-xl sm:text-2xl font-bold">{editingPatientId ? "Editar paciente" : "Novo paciente"}</h1>
</div>
<PatientRegistrationForm
inline
mode={editingPatientId ? "edit" : "create"}
patientId={editingPatientId}
onSaved={handlePatientFormSaved}
onClose={() => {
setShowPatientForm(false);
setEditingPatientId(null);
}}
/>
</div>
);
}
// Se está exibindo formulário de médico
if (showDoctorForm) {
return (
<div className="space-y-4 sm:space-y-6 p-3 sm:p-4 md:p-6 bg-background min-h-screen">
<div className="flex items-center gap-2 sm:gap-4">
<Button variant="ghost" size="icon" onClick={() => {
setShowDoctorForm(false);
setEditingDoctorId(null);
}} className="h-8 w-8 sm:h-10 sm:w-10">
<ArrowLeft className="h-4 w-4 sm:h-5 sm:w-5" />
</Button>
<h1 className="text-xl sm:text-2xl font-bold">{editingDoctorId ? "Editar Médico" : "Novo Médico"}</h1>
</div>
<DoctorRegistrationForm
inline
mode={editingDoctorId ? "edit" : "create"}
doctorId={editingDoctorId}
onSaved={handleDoctorFormSaved}
onClose={() => {
setShowDoctorForm(false);
setEditingDoctorId(null);
}}
/>
</div>
);
}
return (
<div className="space-y-4 sm:space-y-6 p-3 sm:p-4 md:p-6 bg-background min-h-screen">
{/* Header - Responsivo */}
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">Dashboard</h1>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-2">Bem-vindo ao painel de controle</p>
</div>
{/* 1. CARDS RESUMO - Responsivo com 1/2/4 colunas */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6">
<div className="bg-card p-4 sm:p-5 md:p-6 rounded-lg border border-border hover:shadow-md transition">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="text-xs sm:text-sm font-medium text-muted-foreground truncate">Total de Pacientes</h3>
<p className="text-2xl sm:text-3xl font-bold text-foreground mt-1 sm:mt-2">{stats.totalPatients}</p>
</div>
<Users className="h-6 sm:h-8 w-6 sm:w-8 text-blue-500 opacity-20 flex-shrink-0" />
</div>
</div>
<div className="bg-card p-4 sm:p-5 md:p-6 rounded-lg border border-border hover:shadow-md transition">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="text-xs sm:text-sm font-medium text-muted-foreground truncate">Total de Médicos</h3>
<p className="text-2xl sm:text-3xl font-bold text-foreground mt-1 sm:mt-2">{stats.totalDoctors}</p>
</div>
<Stethoscope className="h-6 sm:h-8 w-6 sm:w-8 text-green-500 opacity-20 flex-shrink-0" />
</div>
</div>
<div className="bg-card p-4 sm:p-5 md:p-6 rounded-lg border border-border hover:shadow-md transition">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="text-xs sm:text-sm font-medium text-muted-foreground truncate">Consultas Hoje</h3>
<p className="text-2xl sm:text-3xl font-bold text-foreground mt-1 sm:mt-2">{stats.appointmentsToday}</p>
</div>
<Calendar className="h-6 sm:h-8 w-6 sm:w-8 text-purple-500 opacity-20 flex-shrink-0" />
</div>
</div>
</div>
{/* 6. AÇÕES RÁPIDAS - Responsivo: stack em mobile, wrap em desktop */}
<div className="bg-card p-4 sm:p-5 md:p-6 rounded-lg border border-border">
<h2 className="text-base sm:text-lg font-semibold text-foreground mb-3 sm:mb-4">Ações Rápidas</h2>
<div className="flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
<Button onClick={() => setShowPatientForm(true)} className="gap-2 text-sm sm:text-base w-full sm:w-auto">
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Novo Paciente</span>
<span className="sm:hidden">Paciente</span>
</Button>
<Button onClick={() => router.push('/agenda')} variant="outline" className="gap-2 text-sm sm:text-base w-full sm:w-auto hover:bg-primary! hover:text-white! transition-colors">
<Calendar className="h-4 w-4" />
<span className="hidden sm:inline">Novo Agendamento</span>
<span className="sm:hidden">Agendamento</span>
</Button>
<Button onClick={() => setShowDoctorForm(true)} variant="outline" className="gap-2 text-sm sm:text-base w-full sm:w-auto hover:bg-primary! hover:text-white! transition-colors">
<Stethoscope className="h-4 w-4" />
<span className="hidden sm:inline">Novo Médico</span>
<span className="sm:hidden">Médico</span>
</Button>
</div>
</div>
{/* 2. PRÓXIMAS CONSULTAS */}
<div className="grid grid-cols-1 gap-4 md:gap-6">
<div className="bg-card p-4 sm:p-5 md:p-6 rounded-lg border border-border">
<h2 className="text-base sm:text-lg font-semibold text-foreground mb-3 sm:mb-4">Próximas Consultas (7 dias)</h2>
{appointments.length > 0 ? (
<div className="space-y-2 sm:space-y-3">
{appointments.map(appt => (
<div key={appt.id} className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 p-3 sm:p-4 bg-muted rounded-lg hover:bg-muted/80 transition">
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground text-sm sm:text-base truncate">
{patients.get(appt.patient_id)?.full_name || 'Paciente desconhecido'}
</p>
<p className="text-xs sm:text-sm text-muted-foreground truncate">
Médico: {doctors.get(appt.doctor_id)?.full_name || 'Médico desconhecido'}
</p>
<p className="text-[11px] sm:text-xs text-muted-foreground mt-1">{formatDate(appt.scheduled_at)}</p>
</div>
<div className="flex items-center gap-2">
{getStatusBadge(appt.status)}
</div>
</div>
))}
</div>
) : (
<p className="text-xs sm:text-sm text-muted-foreground">Nenhuma consulta agendada para os próximos 7 dias</p>
)}
</div>
</div>
{/* 4. NOVOS USUÁRIOS */}
<div className="bg-card p-4 sm:p-5 md:p-6 rounded-lg border border-border">
<h2 className="text-base sm:text-lg font-semibold text-foreground mb-3 sm:mb-4">Novos Usuários (últimos 7 dias)</h2>
{newUsers.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 sm:gap-3">
{newUsers.map(user => (
<div key={user.id} className="p-2 sm:p-3 bg-muted rounded-lg">
<p className="font-medium text-foreground text-xs sm:text-sm truncate">{user.full_name || 'Sem nome'}</p>
<p className="text-[10px] sm:text-xs text-muted-foreground truncate">{user.email}</p>
</div>
))}
</div>
) : (
<p className="text-xs sm:text-sm text-muted-foreground">Nenhum novo usuário nos últimos 7 dias</p>
)}
</div>
{/* 8. ALERTAS */}
{disabledUsers.length > 0 && (
<div className="bg-card p-4 sm:p-5 md:p-6 rounded-lg border border-destructive/50">
<h2 className="text-base sm:text-lg font-semibold text-destructive mb-3 sm:mb-4 flex items-center gap-2">
<AlertTriangle className="h-4 sm:h-5 w-4 sm:w-5" />
<span className="truncate">Usuários Desabilitados</span>
</h2>
<div className="space-y-2">
{disabledUsers.map(user => (
<Alert key={user.id} variant="destructive" className="text-xs sm:text-sm">
<AlertCircle className="h-3 sm:h-4 w-3 sm:w-4" />
<AlertDescription className="ml-2">
<strong className="truncate">{user.full_name}</strong> ({user.email}) está desabilitado
</AlertDescription>
</Alert>
))}
</div>
</div>
)}
{/* 11. LINK PARA RELATÓRIOS */}
<div className="bg-linear-to-r from-blue-500/10 to-purple-500/10 p-4 sm:p-5 md:p-6 rounded-lg border border-blue-500/20">
<h2 className="text-base sm:text-lg font-semibold text-foreground mb-2">Seção de Relatórios</h2>
<p className="text-xs sm:text-sm text-muted-foreground mb-3 sm:mb-4">
Acesse a seção de relatórios médicos para gerenciar, visualizar e exportar documentos.
</p>
<Button asChild className="w-full sm:w-auto text-sm sm:text-base">
<Link href="/dashboard/relatorios">Ir para Relatórios</Link>
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,347 @@
"use client";
import React, { useEffect, useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import { FileDown, BarChart2, Users, CalendarCheck } from "lucide-react";
import jsPDF from "jspdf";
import html2canvas from "html2canvas";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
import {
countAppointmentsToday,
getAppointmentsByDateRange,
listarAgendamentos,
buscarMedicosPorIds,
buscarPacientesPorIds,
} from "@/lib/api";
// ============================================================================
// Constants
// ============================================================================
const FALLBACK_MEDICOS = [
{ nome: "Dr. Carlos Andrade", consultas: 62 },
{ nome: "Dra. Paula Silva", consultas: 58 },
{ nome: "Dr. João Pedro", consultas: 54 },
{ nome: "Dra. Marina Costa", consultas: 51 },
];
// ============================================================================
// Helper Functions
// ============================================================================
async function exportPDF(title: string, content: string, chartElementId?: string) {
const doc = new jsPDF();
let yPosition = 15;
// Add title
doc.setFontSize(16);
doc.setFont(undefined, "bold");
doc.text(title, 15, yPosition);
yPosition += 10;
// Add description/content
doc.setFontSize(11);
doc.setFont(undefined, "normal");
const contentLines = doc.splitTextToSize(content, 180);
doc.text(contentLines, 15, yPosition);
yPosition += contentLines.length * 5 + 15;
// Capture chart if chartElementId is provided
if (chartElementId) {
try {
const chartElement = document.getElementById(chartElementId);
if (chartElement) {
// Create a canvas from the chart element
const canvas = await html2canvas(chartElement, {
backgroundColor: "#ffffff",
scale: 2,
logging: false,
});
// Convert canvas to image
const imgData = canvas.toDataURL("image/png");
const imgWidth = 180;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
// Add image to PDF
doc.addImage(imgData, "PNG", 15, yPosition, imgWidth, imgHeight);
yPosition += imgHeight + 10;
}
} catch (error) {
console.error("Error capturing chart:", error);
doc.text("(Erro ao capturar gráfico)", 15, yPosition);
yPosition += 10;
}
}
doc.save(`${title.toLowerCase().replace(/ /g, "-")}.pdf`);
}
// ============================================================================
// Main Component
// ============================================================================
export default function RelatoriosPage() {
// State
const [metricsState, setMetricsState] = useState<Array<{ label: string; value: any; icon: any }>>([]);
const [consultasData, setConsultasData] = useState<Array<{ periodo: string; consultas: number }>>([]);
const [pacientesTop, setPacientesTop] = useState<Array<{ nome: string; consultas: number }>>([]);
const [medicosTop, setMedicosTop] = useState(FALLBACK_MEDICOS);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Data Loading
useEffect(() => {
let mounted = true;
async function load() {
setLoading(true);
try {
// Fetch appointments
let appointments: any[] = [];
try {
appointments = await listarAgendamentos(
"select=patient_id,doctor_id,scheduled_at,status&order=scheduled_at.desc&limit=1000"
);
} catch (e) {
console.warn("[relatorios] listarAgendamentos failed, using fallback", e);
appointments = await getAppointmentsByDateRange(30).catch(() => []);
}
// Fetch today's appointments count
let appointmentsToday = 0;
try {
appointmentsToday = await countAppointmentsToday().catch(() => 0);
} catch (e) {
appointmentsToday = 0;
}
if (!mounted) return;
// ===== Build Consultas Chart (last 30 days) =====
const daysCount = 30;
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const startTs = start.getTime() - (daysCount - 1) * 86400000;
const dayBuckets: Record<string, { periodo: string; consultas: number }> = {};
for (let i = 0; i < daysCount; i++) {
const d = new Date(startTs + i * 86400000);
const iso = d.toISOString().split("T")[0];
const periodo = `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}`;
dayBuckets[iso] = { periodo, consultas: 0 };
}
const appts = Array.isArray(appointments) ? appointments : [];
for (const a of appts) {
try {
const iso = (a.scheduled_at || "").toString().split("T")[0];
if (iso && dayBuckets[iso]) dayBuckets[iso].consultas += 1;
} catch (e) {
// ignore malformed
}
}
setConsultasData(Object.values(dayBuckets));
// ===== Aggregate Counts =====
const patientCounts: Record<string, number> = {};
const doctorCounts: Record<string, number> = {};
const doctorNoShowCounts: Record<string, number> = {};
for (const a of appts) {
if (a.patient_id) {
patientCounts[String(a.patient_id)] = (patientCounts[String(a.patient_id)] || 0) + 1;
}
if (a.doctor_id) {
const did = String(a.doctor_id);
doctorCounts[did] = (doctorCounts[did] || 0) + 1;
if (String(a.status || "").toLowerCase() === "no_show" || String(a.status || "").toLowerCase() === "no-show") {
doctorNoShowCounts[did] = (doctorNoShowCounts[did] || 0) + 1;
}
}
}
// ===== Top 5 Patients & Doctors =====
const topPatientIds = Object.entries(patientCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map((x) => x[0]);
const topDoctorIds = Object.entries(doctorCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map((x) => x[0]);
const [patientsFetched, doctorsFetched] = await Promise.all([
topPatientIds.length ? buscarPacientesPorIds(topPatientIds) : Promise.resolve([]),
topDoctorIds.length ? buscarMedicosPorIds(topDoctorIds) : Promise.resolve([]),
]);
// ===== Build Patient List =====
const pacientesList = topPatientIds.map((id) => {
const p = (patientsFetched || []).find((x: any) => String(x.id) === String(id));
return { nome: p ? p.full_name : id, consultas: patientCounts[id] || 0 };
});
// ===== Build Doctor List =====
const medicosList = topDoctorIds.map((id) => {
const m = (doctorsFetched || []).find((x: any) => String(x.id) === String(id));
return { nome: m ? m.full_name : id, consultas: doctorCounts[id] || 0 };
});
// ===== Update State =====
setPacientesTop(pacientesList);
setMedicosTop(medicosList.length ? medicosList : FALLBACK_MEDICOS);
setMetricsState([
{ label: "Atendimentos", value: appointmentsToday ?? 0, icon: <CalendarCheck className="w-6 h-6 text-blue-500" /> },
] as any);
} catch (err: any) {
console.error("[relatorios] error loading data:", err);
if (mounted) setError(err?.message ?? String(err));
} finally {
if (mounted) setLoading(false);
}
}
load();
return () => {
mounted = false;
};
}, []); return (
<div className="p-6 bg-background min-h-screen">
<h1 className="text-2xl font-bold mb-6 text-foreground">Dashboard Executivo de Relatórios</h1>
{/* Métricas principais */}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-1 gap-6 mb-8">
{loading ? (
// simple skeletons while loading to avoid showing fake data
Array.from({ length: 1 }).map((_, i) => (
<div key={i} className="p-4 bg-card border border-border rounded-lg shadow flex flex-col items-center justify-center">
<div className="h-6 w-6 bg-muted rounded mb-2 animate-pulse" />
<div className="h-6 w-20 bg-muted rounded mt-2 animate-pulse" />
<div className="h-3 w-28 bg-muted rounded mt-3 animate-pulse" />
</div>
))
) : (
metricsState.map((m) => (
<div key={m.label} className="p-4 bg-card border border-border rounded-lg shadow flex flex-col items-center justify-center">
{m.icon}
<span className="text-2xl font-bold mt-2 text-foreground">{m.value}</span>
<span className="text-sm text-muted-foreground mt-1 text-center">{m.label}</span>
</div>
))
)}
</div>
{/* Consultas Chart */}
<div className="grid grid-cols-1 gap-8 mb-8">
<div className="bg-card border border-border rounded-lg shadow p-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 md:gap-0 mb-4">
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2">
<BarChart2 className="w-5 h-5" /> Consultas por Período
</h2>
<Button
size="sm"
variant="outline"
className="hover:bg-primary! hover:text-white! transition-colors w-full md:w-auto"
onClick={() => exportPDF("Consultas por Período", "Resumo das consultas realizadas por período.", "chart-consultas")}
>
<FileDown className="w-4 h-4 mr-1" /> Exportar PDF
</Button>
</div>
{loading ? (
<div className="h-[220px] flex items-center justify-center text-muted-foreground">Carregando dados...</div>
) : (
<div id="chart-consultas">
<ResponsiveContainer width="100%" height={220}>
<BarChart data={consultasData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="periodo" />
<YAxis />
<Tooltip />
<Bar dataKey="consultas" fill="#6366f1" name="Consultas" />
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
{/* Pacientes mais atendidos */}
<div className="bg-card border border-border rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-2">
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><Users className="w-5 h-5" /> Pacientes Mais Atendidos</h2>
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Pacientes Mais Atendidos", "Lista dos pacientes mais atendidos.", "table-pacientes")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
</div>
<div id="table-pacientes">
<table className="w-full text-sm mt-4">
<thead>
<tr className="text-muted-foreground">
<th className="text-left font-medium">Paciente</th>
<th className="text-left font-medium">Consultas</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td className="py-4 text-muted-foreground" colSpan={2}>Carregando pacientes...</td>
</tr>
) : pacientesTop && pacientesTop.length ? (
pacientesTop.map((p: { nome: string; consultas: number }) => (
<tr key={p.nome}>
<td className="py-1">{p.nome}</td>
<td className="py-1">{p.consultas}</td>
</tr>
))
) : (
<tr>
<td className="py-4 text-muted-foreground" colSpan={2}>Nenhum paciente encontrado</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Médicos mais produtivos */}
<div className="bg-card border border-border rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-2">
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><Users className="w-5 h-5" /> Médicos Mais Produtivos</h2>
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Médicos Mais Produtivos", "Lista dos médicos mais produtivos.", "table-medicos")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
</div>
<div id="table-medicos">
<table className="w-full text-sm mt-4">
<thead>
<tr className="text-muted-foreground">
<th className="text-left font-medium">Médico</th>
<th className="text-left font-medium">Consultas</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td className="py-4 text-muted-foreground" colSpan={2}>Carregando médicos...</td>
</tr>
) : medicosTop && medicosTop.length ? (
medicosTop.map((m) => (
<tr key={m.nome}>
<td className="py-1">{m.nome}</td>
<td className="py-1">{m.consultas}</td>
</tr>
))
) : (
<tr>
<td className="py-4 text-muted-foreground" colSpan={2}>Nenhum médico encontrado</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
import type React from "react";
import ProtectedRoute from "@/components/shared/ProtectedRoute";
import { Sidebar } from "@/components/layout/sidebar";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { PagesHeader } from "@/components/features/dashboard/header";
export default function MainRoutesLayout({
children,
}: {
children: React.ReactNode;
}) {
console.log('[MAIN-ROUTES-LAYOUT] Layout do administrador carregado')
return (
<ProtectedRoute requiredUserType={["administrador"]}>
<div className="min-h-screen bg-background flex">
<SidebarProvider>
<Sidebar />
<main className="flex-1">
<PagesHeader />
{children}
</main>
</SidebarProvider>
</div>
</ProtectedRoute>
);
}

View File

@ -0,0 +1,5 @@
import type { ReactNode } from "react";
export default function PacientesLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}

View File

@ -0,0 +1,3 @@
export default function Loading() {
return null
}

View File

@ -0,0 +1,583 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { MoreHorizontal, Plus, Search, Eye, Edit, Trash2, ArrowLeft } from "lucide-react";
import { Paciente, Endereco, listarPacientes, buscarPacientes, buscarPacientePorId, excluirPaciente } from "@/lib/api";
import { PatientRegistrationForm } from "@/components/features/forms/patient-registration-form";
import AssignmentForm from "@/components/features/admin/AssignmentForm";
function normalizePaciente(p: any): Paciente {
return {
id: String(p.id ?? p.uuid ?? p.paciente_id ?? ""),
full_name: p.full_name ?? p.name ?? p.nome ?? "",
social_name: p.social_name ?? p.nome_social ?? null,
cpf: p.cpf ?? "",
rg: p.rg ?? p.document_number ?? null,
sex: p.sex ?? p.sexo ?? null,
birth_date: p.birth_date ?? p.data_nascimento ?? null,
phone_mobile: p.phone_mobile ?? p.telefone ?? "",
email: p.email ?? "",
cep: p.cep ?? "",
street: p.street ?? p.logradouro ?? "",
number: p.number ?? p.numero ?? "",
complement: p.complement ?? p.complemento ?? "",
neighborhood: p.neighborhood ?? p.bairro ?? "",
city: p.city ?? p.cidade ?? "",
state: p.state ?? p.estado ?? "",
notes: p.notes ?? p.observacoes ?? null,
};
}
export default function PacientesPage() {
const [patients, setPatients] = useState<Paciente[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [viewingPatient, setViewingPatient] = useState<Paciente | null>(null);
const [assignDialogOpen, setAssignDialogOpen] = useState(false);
const [assignPatientId, setAssignPatientId] = useState<string | null>(null);
// Paginação
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
// Ordenação e filtros adicionais
const [sortBy, setSortBy] = useState<"name_asc" | "name_desc" | "recent" | "oldest">("name_asc");
const [stateFilter, setStateFilter] = useState<string>("");
const [cityFilter, setCityFilter] = useState<string>("");
async function loadAll() {
try {
setLoading(true);
const data = await listarPacientes({ page: 1, limit: 50 });
if (Array.isArray(data)) {
setPatients(data.map(normalizePaciente));
} else {
setPatients([]);
}
setError(null);
} catch (e: any) {
setPatients([]);
setError(e?.message || "Erro ao carregar pacientes.");
} finally {
setLoading(false);
}
}
useEffect(() => {
loadAll();
}, []);
// Opções dinâmicas para Estado e Cidade
const stateOptions = useMemo(
() =>
Array.from(
new Set((patients || []).map((p) => (p.state || "").trim()).filter(Boolean)),
).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })),
[patients],
);
const cityOptions = useMemo(() => {
const base = (patients || []).filter((p) => !stateFilter || String(p.state) === stateFilter);
return Array.from(
new Set(base.map((p) => (p.city || "").trim()).filter(Boolean)),
).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" }));
}, [patients, stateFilter]);
// Índice para ordenar por "tempo" (ordem de carregamento)
const indexById = useMemo(() => {
const map = new Map<string, number>();
(patients || []).forEach((p, i) => map.set(String(p.id), i));
return map;
}, [patients]);
// Substitui o filtered anterior: aplica busca + filtros + ordenação
const filtered = useMemo(() => {
let base = patients;
// Busca
if (search.trim()) {
const q = search.toLowerCase().trim();
const qDigits = q.replace(/\D/g, "");
base = patients.filter((p) => {
const byName = (p.full_name || "").toLowerCase().includes(q);
const byCPF = qDigits.length >= 3 && (p.cpf || "").replace(/\D/g, "").includes(qDigits);
const byId = (p.id || "").toLowerCase().includes(q);
const byEmail = (p.email || "").toLowerCase().includes(q);
return byName || byCPF || byId || byEmail;
});
}
// Filtros por UF e cidade
const withLocation = base.filter((p) => {
if (stateFilter && String(p.state) !== stateFilter) return false;
if (cityFilter && String(p.city) !== cityFilter) return false;
return true;
});
// Ordenação
const sorted = [...withLocation];
if (sortBy === "name_asc" || sortBy === "name_desc") {
sorted.sort((a, b) => {
const an = (a.full_name || "").trim();
const bn = (b.full_name || "").trim();
const cmp = an.localeCompare(bn, "pt-BR", { sensitivity: "base" });
return sortBy === "name_asc" ? cmp : -cmp;
});
} else if (sortBy === "recent" || sortBy === "oldest") {
sorted.sort((a, b) => {
const ia = indexById.get(String(a.id)) ?? 0;
const ib = indexById.get(String(b.id)) ?? 0;
return sortBy === "recent" ? ia - ib : ib - ia;
});
}
return sorted;
}, [patients, search, stateFilter, cityFilter, sortBy, indexById]);
// Dados paginados
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
return filtered.slice(startIndex, endIndex);
}, [filtered, currentPage, itemsPerPage]);
const totalPages = Math.ceil(filtered.length / itemsPerPage);
// Reset página ao mudar filtros/ordenadores
useEffect(() => {
setCurrentPage(1);
}, [search, itemsPerPage, stateFilter, cityFilter, sortBy]);
function handleAdd() {
setEditingId(null);
setShowForm(true);
}
function handleEdit(id: string) {
setEditingId(id);
setShowForm(true);
}
function handleView(patient: Paciente) {
setViewingPatient(patient);
}
async function handleDelete(id: string) {
if (!confirm("Excluir este paciente?")) return;
try {
await excluirPaciente(id);
setPatients((prev) => prev.filter((x) => String(x.id) !== String(id)));
} catch (e: any) {
alert(e?.message || "Não foi possível excluir.");
}
}
function handleSaved(p: Paciente) {
const saved = normalizePaciente(p);
setPatients((prev) => {
const i = prev.findIndex((x) => String(x.id) === String(saved.id));
if (i < 0) return [saved, ...prev];
const clone = [...prev];
clone[i] = saved;
return clone;
});
setShowForm(false);
}
async function handleBuscarServidor() {
const q = search.trim();
if (!q) return loadAll();
try {
setLoading(true);
setError(null);
// Se parece com ID (UUID), busca diretamente
if (q.includes('-') && q.length > 10) {
const one = await buscarPacientePorId(q);
setPatients(one ? [normalizePaciente(one)] : []);
setError(one ? null : "Paciente não encontrado.");
// Limpa o campo de busca para que o filtro não interfira
setSearch("");
return;
}
// Para outros termos, usa busca avançada
const results = await buscarPacientes(q);
setPatients(results.map(normalizePaciente));
setError(results.length === 0 ? "Nenhum paciente encontrado." : null);
// Limpa o campo de busca para que o filtro não interfira
setSearch("");
} catch (e: any) {
setPatients([]);
setError(e?.message || "Erro na busca.");
} finally {
setLoading(false);
}
}
if (loading) return <p>Carregando pacientes...</p>;
if (error) return <p className="text-red-500">{error}</p>;
if (showForm) {
return (
<div className="space-y-6 p-6 bg-background">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={() => setShowForm(false)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-2xl font-bold">{editingId ? "Editar paciente" : "Novo paciente"}</h1>
</div>
<PatientRegistrationForm
inline
mode={editingId ? "edit" : "create"}
patientId={editingId}
onSaved={handleSaved}
onClose={() => setShowForm(false)}
/>
</div>
);
}
return (
<div className="space-y-4 sm:space-y-6 p-3 sm:p-4 md:p-6 bg-background">
{/* Header responsivo */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold">Pacientes</h1>
<p className="text-xs sm:text-sm text-muted-foreground">Gerencie os pacientes</p>
</div>
<Button onClick={handleAdd} className="w-full sm:w-auto">
<Plus className="mr-2 h-4 w-4" />
<span className="hidden sm:inline">Novo paciente</span>
<span className="sm:hidden">Novo</span>
</Button>
</div>
{/* Filtros e busca responsivos */}
<div className="space-y-2 sm:space-y-3">
{/* Linha 1: Busca */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="pl-8 w-full text-xs sm:text-sm h-8 sm:h-9"
placeholder="Nome, CPF ou ID…"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleBuscarServidor()}
/>
</div>
<Button variant="secondary" size="sm" onClick={() => void handleBuscarServidor()} className="hover:bg-primary hover:text-white text-xs sm:text-sm h-8 sm:h-9 px-2 sm:px-4">
<span className="hidden sm:inline">Buscar</span>
<span className="sm:hidden">Ir</span>
</Button>
</div>
{/* Linha 2: Selects responsivos em grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
{/* Ordenar por */}
<select
aria-label="Ordenar por"
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value="name_asc">AZ</option>
<option value="name_desc">ZA</option>
<option value="recent">Recentes</option>
<option value="oldest">Antigos</option>
</select>
{/* Estado (UF) */}
<select
aria-label="Filtrar por estado"
value={stateFilter}
onChange={(e) => {
setStateFilter(e.target.value);
setCityFilter("");
}}
className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value="">Estado</option>
{stateOptions.map((uf) => (
<option key={uf} value={uf}>{uf}</option>
))}
</select>
{/* Cidade (dependente do estado) */}
<select
aria-label="Filtrar por cidade"
value={cityFilter}
onChange={(e) => setCityFilter(e.target.value)}
className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value="">Cidade</option>
{cityOptions.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
</div>
{/* Desktop Table - Hidden on mobile */}
<div className="hidden md:block border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-primary hover:bg-primary">
<TableHead className="text-primary-foreground text-xs sm:text-sm">Nome</TableHead>
<TableHead className="text-primary-foreground text-xs sm:text-sm">CPF</TableHead>
<TableHead className="text-primary-foreground text-xs sm:text-sm">Telefone</TableHead>
<TableHead className="text-primary-foreground text-xs sm:text-sm">Cidade</TableHead>
<TableHead className="text-primary-foreground text-xs sm:text-sm">Estado</TableHead>
<TableHead className="w-[100px] text-primary-foreground text-xs sm:text-sm">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedData.length > 0 ? (
paginatedData.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium text-xs sm:text-sm">{p.full_name || "(sem nome)"}</TableCell>
<TableCell className="text-xs sm:text-sm">{p.cpf || "-"}</TableCell>
<TableCell className="text-xs sm:text-sm">{p.phone_mobile || "-"}</TableCell>
<TableCell className="text-xs sm:text-sm">{p.city || "-"}</TableCell>
<TableCell className="text-xs sm:text-sm">{p.state || "-"}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="h-8 w-8 p-0 flex items-center justify-center rounded-md hover:bg-primary hover:text-white transition-colors">
<span className="sr-only">Abrir menu</span>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleView(p)}>
<Eye className="mr-2 h-4 w-4" />
Ver
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEdit(String(p.id))}>
<Edit className="mr-2 h-4 w-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(String(p.id))} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Excluir
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { setAssignPatientId(String(p.id)); setAssignDialogOpen(true); }}>
<Edit className="mr-2 h-4 w-4" />
Atribuir profissional
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="text-center text-xs sm:text-sm text-muted-foreground py-4">
Nenhum paciente encontrado
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Mobile Cards - Hidden on desktop */}
<div className="md:hidden space-y-2">
{paginatedData.length > 0 ? (
paginatedData.map((p) => (
<div key={p.id} className="bg-card p-3 sm:p-4 rounded-lg border border-border hover:border-primary transition-colors">
<div className="grid grid-cols-2 gap-2">
<div className="col-span-2 flex justify-between items-start">
<div className="flex-1">
<div className="text-[10px] sm:text-xs font-semibold text-primary">Nome</div>
<div className="text-xs sm:text-sm font-medium truncate">{p.full_name || "(sem nome)"}</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="h-7 w-7 p-0 flex items-center justify-center rounded-md hover:bg-primary hover:text-white transition-colors flex-shrink-0">
<span className="sr-only">Menu</span>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleView(p)}>
<Eye className="mr-2 h-4 w-4" />
Ver
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEdit(String(p.id))}>
<Edit className="mr-2 h-4 w-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(String(p.id))} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Excluir
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { setAssignPatientId(String(p.id)); setAssignDialogOpen(true); }}>
<Edit className="mr-2 h-4 w-4" />
Atribuir prof.
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div>
<div className="text-[10px] sm:text-xs text-muted-foreground">CPF</div>
<div className="text-[10px] sm:text-xs font-medium">{p.cpf || "-"}</div>
</div>
<div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Telefone</div>
<div className="text-[10px] sm:text-xs font-medium">{p.phone_mobile || "-"}</div>
</div>
<div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Cidade</div>
<div className="text-[10px] sm:text-xs font-medium truncate">{p.city || "-"}</div>
</div>
<div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Estado</div>
<div className="text-[10px] sm:text-xs font-medium">{p.state || "-"}</div>
</div>
</div>
</div>
))
) : (
<div className="text-center text-xs sm:text-sm text-muted-foreground py-4">
Nenhum paciente encontrado
</div>
)}
</div>
{/* Controles de paginação - Responsivos */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4 text-xs sm:text-sm">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-muted-foreground text-xs sm:text-sm">Itens por página:</span>
<select
value={itemsPerPage}
onChange={(e) => setItemsPerPage(Number(e.target.value))}
className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value={10}>10</option>
<option value={15}>15</option>
<option value={20}>20</option>
</select>
<span className="text-muted-foreground text-xs sm:text-sm">
Mostrando {paginatedData.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0} a{" "}
{Math.min(currentPage * itemsPerPage, filtered.length)} de {filtered.length}
</span>
</div>
<div className="flex items-center gap-1 sm:gap-2 flex-wrap justify-center sm:justify-end">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
>
<span className="hidden sm:inline">Primeira</span>
<span className="sm:hidden">1ª</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
>
<span className="hidden sm:inline">Anterior</span>
<span className="sm:hidden">«</span>
</Button>
<span className="text-muted-foreground text-xs sm:text-sm">
Pág {currentPage} de {totalPages || 1}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages || totalPages === 0}
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
>
<span className="hidden sm:inline">Próxima</span>
<span className="sm:hidden">»</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages || totalPages === 0}
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
>
<span className="hidden sm:inline">Última</span>
<span className="sm:hidden">Últ</span>
</Button>
</div>
</div>
{viewingPatient && (
<Dialog open={!!viewingPatient} onOpenChange={() => setViewingPatient(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Detalhes do Paciente</DialogTitle>
<DialogDescription>
Informações detalhadas de {viewingPatient.full_name}.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-1 sm:grid-cols-4 items-start sm:items-center gap-2 sm:gap-4">
<Label className="text-left sm:text-right">Nome</Label>
<span className="col-span-1 sm:col-span-3 font-medium">{viewingPatient.full_name}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-4 items-start sm:items-center gap-2 sm:gap-4">
<Label className="text-left sm:text-right">CPF</Label>
<span className="col-span-1 sm:col-span-3">{viewingPatient.cpf}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-4 items-start sm:items-center gap-2 sm:gap-4">
<Label className="text-left sm:text-right">Telefone</Label>
<span className="col-span-1 sm:col-span-3">{viewingPatient.phone_mobile}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-4 items-start sm:items-center gap-2 sm:gap-4">
<Label className="text-left sm:text-right">Endereço</Label>
<span className="col-span-1 sm:col-span-3">
{`${viewingPatient.street || ''}, ${viewingPatient.number || ''} - ${viewingPatient.neighborhood || ''}, ${viewingPatient.city || ''} - ${viewingPatient.state || ''}`}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-4 items-start sm:items-center gap-2 sm:gap-4">
<Label className="text-left sm:text-right">Observações</Label>
<span className="col-span-1 sm:col-span-3">{viewingPatient.notes || "Nenhuma"}</span>
</div>
</div>
<DialogFooter>
<Button onClick={() => setViewingPatient(null)}>Fechar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{/* Assignment dialog */}
{assignDialogOpen && assignPatientId && (
<AssignmentForm
patientId={assignPatientId}
open={assignDialogOpen}
onClose={() => { setAssignDialogOpen(false); setAssignPatientId(null); }}
onSaved={() => { setAssignDialogOpen(false); setAssignPatientId(null); loadAll(); }}
/>
)}
</div>
);
}

View File

@ -0,0 +1,34 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function PerfillLoading() {
return (
<div className="space-y-6 p-6">
<div className="flex items-center gap-4 mb-8">
<Skeleton className="h-20 w-20 rounded-full" />
<div className="space-y-2 flex-1">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-4 w-48" />
</div>
</div>
<div className="grid gap-6">
<div className="rounded-lg border border-border p-6 space-y-4">
<Skeleton className="h-6 w-32" />
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
<div className="rounded-lg border border-border p-6 space-y-4">
<Skeleton className="h-6 w-32" />
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,697 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { UploadAvatar } from "@/components/ui/upload-avatar";
import { AlertCircle, ArrowLeft, CheckCircle, XCircle } from "lucide-react";
import { getUserInfoById } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { formatTelefone, formatCEP, validarCEP, buscarCEP } from "@/lib/utils";
interface UserProfile {
user: {
id: string;
email: string;
created_at: string;
last_sign_in_at: string | null;
email_confirmed_at: string | null;
};
profile: {
id: string;
full_name: string | null;
email: string | null;
phone: string | null;
avatar_url: string | null;
cep?: string | null;
street?: string | null;
number?: string | null;
complement?: string | null;
neighborhood?: string | null;
city?: string | null;
state?: string | null;
disabled: boolean;
created_at: string;
updated_at: string;
} | null;
roles: string[];
permissions: {
isAdmin: boolean;
isManager: boolean;
isDoctor: boolean;
isSecretary: boolean;
isAdminOrManager: boolean;
};
}
export default function PerfilPage() {
const router = useRouter();
const { user: authUser, updateUserProfile } = useAuth();
const [userInfo, setUserInfo] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [editingData, setEditingData] = useState<{
phone?: string;
full_name?: string;
avatar_url?: string;
cep?: string;
street?: string;
number?: string;
complement?: string;
neighborhood?: string;
city?: string;
state?: string;
}>({});
const [cepLoading, setCepLoading] = useState(false);
const [cepValid, setCepValid] = useState<boolean | null>(null);
useEffect(() => {
async function loadUserInfo() {
try {
setLoading(true);
if (!authUser?.id) {
throw new Error("ID do usuário não encontrado");
}
console.log('[PERFIL] Chamando getUserInfoById com ID:', authUser.id);
// Para admin/gestor, usar getUserInfoById com o ID do usuário logado
const info = await getUserInfoById(authUser.id);
console.log('[PERFIL] Sucesso ao carregar info:', info);
setUserInfo(info as UserProfile);
setError(null);
} catch (err: any) {
console.error('[PERFIL] Erro ao carregar:', err);
setError(err?.message || "Erro ao carregar informações do perfil");
setUserInfo(null);
} finally {
setLoading(false);
}
}
if (authUser) {
console.log('[PERFIL] useEffect acionado, authUser:', authUser);
loadUserInfo();
}
}, [authUser]);
if (authUser?.userType !== 'administrador') {
return (
<div className="flex flex-col h-screen">
<div className="flex-1 p-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Você não tem permissão para acessar esta página.
</AlertDescription>
</Alert>
<Button
variant="outline"
onClick={() => router.back()}
className="mt-4 hover:bg-primary/10 hover:text-primary dark:hover:bg-accent dark:hover:text-accent-foreground"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Voltar
</Button>
</div>
</div>
);
}
if (loading) {
return (
<div className="flex flex-col h-screen">
<div className="flex-1 p-6">
<div className="space-y-4">
<div className="h-20 bg-muted rounded-lg animate-pulse" />
<div className="h-64 bg-muted rounded-lg animate-pulse" />
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col h-screen">
<div className="flex-1 p-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
<Button
variant="outline"
onClick={() => window.location.reload()}
className="mt-4"
>
Tentar Novamente
</Button>
</div>
</div>
);
}
if (!userInfo) {
return (
<div className="flex flex-col h-screen">
<div className="flex-1 p-6">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Nenhuma informação de perfil disponível.
</AlertDescription>
</Alert>
</div>
</div>
);
}
const getInitials = (name: string | null | undefined) => {
if (!name) return "AD";
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
const handleEditClick = () => {
if (!isEditing && userInfo) {
setEditingData({
full_name: userInfo.profile?.full_name || "",
phone: userInfo.profile?.phone || "",
avatar_url: userInfo.profile?.avatar_url || "",
cep: userInfo.profile?.cep || "",
street: userInfo.profile?.street || "",
number: userInfo.profile?.number || "",
complement: userInfo.profile?.complement || "",
neighborhood: userInfo.profile?.neighborhood || "",
city: userInfo.profile?.city || "",
state: userInfo.profile?.state || "",
});
// Se já existe CEP, marcar como válido
if (userInfo.profile?.cep) {
setCepValid(true);
}
}
setIsEditing(!isEditing);
};
const handleSaveEdit = async () => {
try {
// Aqui você implementaria a chamada para atualizar o perfil
console.log('[PERFIL] Salvando alterações:', editingData);
// await atualizarPerfil(userInfo?.user.id, editingData);
setIsEditing(false);
setUserInfo((prev) =>
prev ? {
...prev,
profile: prev.profile ? {
...prev.profile,
full_name: editingData.full_name || prev.profile.full_name,
phone: editingData.phone || prev.profile.phone,
avatar_url: editingData.avatar_url || prev.profile.avatar_url,
cep: editingData.cep || prev.profile.cep,
street: editingData.street || prev.profile.street,
number: editingData.number || prev.profile.number,
complement: editingData.complement || prev.profile.complement,
neighborhood: editingData.neighborhood || prev.profile.neighborhood,
city: editingData.city || prev.profile.city,
state: editingData.state || prev.profile.state,
} : null,
} : null
);
// Also update global auth profile so header/avatar updates immediately
try {
if (typeof updateUserProfile === 'function') {
updateUserProfile({
// Persist common keys used across the app
foto_url: editingData.avatar_url || undefined,
telefone: editingData.phone || undefined
});
} else {
// Fallback: try to persist directly to localStorage so next reload shows it
try {
const raw = localStorage.getItem('auth_user')
if (raw) {
const u = JSON.parse(raw)
u.profile = u.profile || {}
if (editingData.avatar_url) { u.profile.foto_url = editingData.avatar_url; u.profile.avatar_url = editingData.avatar_url }
if (editingData.phone) u.profile.telefone = editingData.phone
localStorage.setItem('auth_user', JSON.stringify(u))
}
} catch (_e) {}
}
} catch (err) {
console.warn('[PERFIL] Falha ao sincronizar profile global:', err)
}
} catch (err: any) {
console.error('[PERFIL] Erro ao salvar:', err);
}
};
const handleCancelEdit = () => {
setIsEditing(false);
setEditingData({});
setCepValid(null);
};
const handleCepChange = async (cepValue: string) => {
// Formatar CEP
const formatted = formatCEP(cepValue);
setEditingData({...editingData, cep: formatted});
// Validar CEP
const isValid = validarCEP(cepValue);
setCepValid(isValid ? null : false); // null = não validado ainda, false = inválido
if (isValid) {
setCepLoading(true);
try {
const resultado = await buscarCEP(cepValue);
if (resultado) {
setCepValid(true);
// Preencher campos automaticamente
setEditingData(prev => ({
...prev,
street: resultado.street,
neighborhood: resultado.neighborhood,
city: resultado.city,
state: resultado.state,
}));
console.log('[PERFIL] CEP preenchido com sucesso:', resultado);
} else {
setCepValid(false);
}
} catch (err) {
console.error('[PERFIL] Erro ao buscar CEP:', err);
setCepValid(false);
} finally {
setCepLoading(false);
}
}
};
const handlePhoneChange = (phoneValue: string) => {
const formatted = formatTelefone(phoneValue);
setEditingData({...editingData, phone: formatted});
};
return (
<div className="flex flex-col h-screen bg-background">
<div className="flex-1 overflow-y-auto">
<div className="p-6 space-y-6">
{/* Header com Título e Botão */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold">Meu Perfil</h2>
<p className="text-muted-foreground mt-1">Bem-vindo à sua área exclusiva.</p>
</div>
{!isEditing ? (
<Button
className="bg-blue-600 hover:bg-blue-700"
onClick={handleEditClick}
>
Editar Perfil
</Button>
) : (
<div className="flex gap-2">
<Button
className="bg-green-600 hover:bg-green-700"
onClick={handleSaveEdit}
>
Salvar
</Button>
<Button
variant="outline"
onClick={handleCancelEdit}
>
Cancelar
</Button>
</div>
)}
</div>
{/* Grid de 2 colunas */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Coluna Esquerda - Informações Pessoais */}
<div className="lg:col-span-2 space-y-6">
{/* Informações Pessoais */}
<div className="border border-border rounded-lg p-6">
<h3 className="text-lg font-semibold mb-4">Informações Pessoais</h3>
<div className="space-y-4">
{/* Nome Completo */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Nome Completo
</Label>
{isEditing ? (
<Input
value={editingData.full_name || ""}
onChange={(e) => setEditingData({...editingData, full_name: e.target.value})}
className="mt-2"
/>
) : (
<>
<div className="mt-2 p-3 bg-muted rounded text-foreground font-medium">
{userInfo.profile?.full_name || "Não preenchido"}
</div>
<p className="text-xs text-muted-foreground mt-1">
Este campo não pode ser alterado
</p>
</>
)}
</div>
{/* Email */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Email
</Label>
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.user.email}
</div>
<p className="text-xs text-muted-foreground mt-1">
Este campo não pode ser alterado
</p>
</div>
{/* UUID */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
UUID
</Label>
<div className="mt-2 p-3 bg-muted rounded text-foreground font-mono text-xs break-all">
{userInfo.user.id}
</div>
<p className="text-xs text-muted-foreground mt-1">
Este campo não pode ser alterado
</p>
</div>
{/* Permissões */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Permissões
</Label>
<div className="mt-2 flex flex-wrap gap-2">
{userInfo.roles && userInfo.roles.length > 0 ? (
userInfo.roles.map((role) => (
<Badge key={role} variant="outline">
{role}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">
Nenhuma permissão atribuída
</span>
)}
</div>
</div>
</div>
</div>
{/* Endereço e Contato */}
<div className="border border-border rounded-lg p-6">
<h3 className="text-lg font-semibold mb-4">Endereço e Contato</h3>
<div className="space-y-4">
{/* Telefone */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Telefone
</Label>
{isEditing ? (
<Input
value={editingData.phone || ""}
onChange={(e) => handlePhoneChange(e.target.value)}
className="mt-2"
placeholder="(00) 00000-0000"
maxLength={15}
/>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.phone || "Não preenchido"}
</div>
)}
</div>
{/* Endereço */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Logradouro
</Label>
{isEditing ? (
<Input
value={editingData.street || ""}
onChange={(e) => setEditingData({...editingData, street: e.target.value})}
className="mt-2"
placeholder="Rua, avenida, etc."
/>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.street || "Não preenchido"}
</div>
)}
</div>
{/* Número */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Número
</Label>
{isEditing ? (
<Input
value={editingData.number || ""}
onChange={(e) => setEditingData({...editingData, number: e.target.value})}
className="mt-2"
placeholder="123"
/>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.number || "Não preenchido"}
</div>
)}
</div>
{/* Complemento */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Complemento
</Label>
{isEditing ? (
<Input
value={editingData.complement || ""}
onChange={(e) => setEditingData({...editingData, complement: e.target.value})}
className="mt-2"
placeholder="Apto 42, Bloco B, etc."
/>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.complement || "Não preenchido"}
</div>
)}
</div>
{/* Bairro */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Bairro
</Label>
{isEditing ? (
<Input
value={editingData.neighborhood || ""}
onChange={(e) => setEditingData({...editingData, neighborhood: e.target.value})}
className="mt-2"
placeholder="Vila, bairro, etc."
/>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.neighborhood || "Não preenchido"}
</div>
)}
</div>
{/* Cidade */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Cidade
</Label>
{isEditing ? (
<Input
value={editingData.city || ""}
onChange={(e) => setEditingData({...editingData, city: e.target.value})}
className="mt-2"
placeholder="São Paulo"
/>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.city || "Não preenchido"}
</div>
)}
</div>
{/* Estado */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Estado
</Label>
{isEditing ? (
<Input
value={editingData.state || ""}
onChange={(e) => setEditingData({...editingData, state: e.target.value})}
className="mt-2"
placeholder="SP"
maxLength={2}
/>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.state || "Não preenchido"}
</div>
)}
</div>
{/* CEP */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
CEP
</Label>
{isEditing ? (
<div className="space-y-2">
<div className="flex gap-2 items-end">
<div className="flex-1">
<Input
value={editingData.cep || ""}
onChange={(e) => handleCepChange(e.target.value)}
className="mt-2"
placeholder="00000-000"
maxLength={9}
disabled={cepLoading}
/>
</div>
{cepValid === true && (
<CheckCircle className="h-5 w-5 text-green-500 mb-2" />
)}
{cepValid === false && (
<XCircle className="h-5 w-5 text-red-500 mb-2" />
)}
</div>
{cepLoading && (
<p className="text-xs text-muted-foreground">Buscando CEP...</p>
)}
{cepValid === false && (
<p className="text-xs text-red-500">CEP inválido ou não encontrado</p>
)}
{cepValid === true && (
<p className="text-xs text-green-500"> CEP preenchido com sucesso</p>
)}
</div>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.cep || "Não preenchido"}
</div>
)}
</div>
</div>
</div>
</div>
{/* Coluna Direita - Foto do Perfil */}
<div>
<div className="border border-border rounded-lg p-6">
<h3 className="text-lg font-semibold mb-4">Foto do Perfil</h3>
{isEditing ? (
<div className="space-y-4">
<UploadAvatar
userId={userInfo.user.id}
currentAvatarUrl={editingData.avatar_url || userInfo.profile?.avatar_url || "/avatars/01.png"}
onAvatarChange={(newUrl) => {
setEditingData({...editingData, avatar_url: newUrl})
try {
if (typeof updateUserProfile === 'function') {
updateUserProfile({ foto_url: newUrl })
} else {
const raw = localStorage.getItem('auth_user')
if (raw) {
const u = JSON.parse(raw)
u.profile = u.profile || {}
u.profile.foto_url = newUrl
u.profile.avatar_url = newUrl
localStorage.setItem('auth_user', JSON.stringify(u))
}
}
} catch (err) {
console.warn('[PERFIL] erro ao persistir avatar no auth_user localStorage', err)
}
}}
userName={editingData.full_name || userInfo.profile?.full_name || "Usuário"}
/>
</div>
) : (
<div className="flex flex-col items-center gap-4">
<Avatar className="h-24 w-24">
<AvatarImage
src={userInfo.profile?.avatar_url || "/avatars/01.png"}
alt={userInfo.profile?.full_name || "Usuário"}
/>
<AvatarFallback className="bg-primary text-primary-foreground text-2xl font-bold">
{getInitials(userInfo.profile?.full_name)}
</AvatarFallback>
</Avatar>
<div className="text-center space-y-2">
<p className="text-sm text-muted-foreground">
{getInitials(userInfo.profile?.full_name)}
</p>
</div>
</div>
)}
{/* Informações de Status */}
<div className="mt-6 pt-6 border-t border-border space-y-4">
<div>
<Label className="text-sm font-medium text-muted-foreground">
Status
</Label>
<div className="mt-2">
<Badge
variant={
userInfo.profile?.disabled ? "destructive" : "default"
}
>
{userInfo.profile?.disabled ? "Desabilitado" : "Ativo"}
</Badge>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Botão Voltar */}
<div className="flex gap-3 pb-6">
<Button
variant="outline"
onClick={() => router.back()}
className="hover:bg-primary/10 hover:text-primary dark:hover:bg-accent dark:hover:text-accent-foreground"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Voltar
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,22 @@
"use client";
import AIVoiceFlow from "@/components/ZoeIA/ai-voice-flow";
import { useTheme } from "next-themes";
import React from "react";
export default function VozPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
// Classes condicionais para manter coerência com o chat
const bgClass = isDark
? "bg-gray-900 text-white"
: "bg-gray-50 text-gray-900";
return (
<div className={`min-h-screen flex items-center justify-center p-10 transition-colors ${bgClass}`}>
<AIVoiceFlow />
</div>
);
}

185
susconecta/app/globals.css Normal file
View File

@ -0,0 +1,185 @@
@import "tailwindcss";
@import "tw-animate-css";
/* Removed unsupported @custom-variant dark (&:is(.dark *)); */
:root {
--background: #ffffff;
--foreground: #475569;
--card: #ffffff;
--card-foreground: #334155;
--popover: #ffffff;
--popover-foreground: #475569;
--primary: var(--color-blue-500);
--primary-foreground: #ffffff;
--secondary: #e2e8f0;
--secondary-foreground: #475569;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--accent: #0891b2;
--accent-foreground: #ffffff;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #e2e8f0;
--input: #f1f5f9;
--ring: var(--color-blue-500);
--chart-1: #0891b2;
--chart-2: #0f766e;
--chart-3: #f59e0b;
--chart-4: #dc2626;
--chart-5: #475569;
--radius: 0.5rem;
--sidebar: #ffffff;
--sidebar-foreground: #475569;
--sidebar-primary: var(--color-blue-500);
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: var(--color-blue-500);
--sidebar-accent-foreground: #ffffff;
--sidebar-border: #e2e8f0;
--sidebar-ring: var(--color-blue-500);
}
.dark {
--background: #0f172a;
--foreground: #cbd5e1;
--card: #1e293b;
--card-foreground: #e2e8f0;
--popover: #1e293b;
--popover-foreground: #cbd5e1;
--primary: var(--color-blue-500);
--primary-foreground: #ffffff;
--secondary: #334155;
--secondary-foreground: #cbd5e1;
--muted: #334155;
--muted-foreground: #94a3b8;
--accent: #0891b2;
--accent-foreground: #ffffff;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #334155;
--input: #334155;
--ring: var(--color-blue-500);
--chart-1: #0891b2;
--chart-2: #0f766e;
--chart-3: #f59e0b;
--chart-4: #dc2626;
--chart-5: #94a3b8;
--sidebar: #1e293b;
--sidebar-foreground: #cbd5e1;
--sidebar-primary: var(--color-blue-500);
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: var(--color-blue-500);
--sidebar-accent-foreground: #ffffff;
--sidebar-border: #334155;
--sidebar-ring: var(--color-blue-500);
}
@theme inline {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground font-sans;
}
}
/* Esconder botões com ícones de lixo */
button:has(.lucide-trash2),
button:has(.lucide-trash),
button[class*="trash"] {
display: none !important;
}
/* Esconder campos de input embaixo do calendário 3D */
input[placeholder="Nome do paciente"],
input[placeholder^="dd/mm"],
input[type="date"][value=""] {
display: none !important;
}
/* Esconder botão "Adicionar Paciente" */
/* Removido seletor vazio - será tratado por outros seletores */
/* Afastar X do popup (dialog-close) para longe das setas */
[data-slot="dialog-close"],
button[aria-label="Close"],
.fc button[aria-label*="Close"] {
right: 16px !important;
top: 8px !important;
position: absolute !important;
}
/* Esconder footer/header extras do calendário que mostram os campos */
.fc .fc-toolbar input,
.fc .fc-toolbar [type="date"],
.fc .fc-toolbar [placeholder*="paciente"] {
display: none !important;
}
/* Esconder row com campos de pesquisa - estrutura mantida pelo calendário */
/* Esconder botões de trash/delete em todos os popups */
[role="dialog"] button[class*="hover:text-destructive"],
[role="dialog"] button[aria-label*="delete"],
[role="dialog"] button[aria-label*="excluir"],
[role="dialog"] button[aria-label*="remove"] {
display: none !important;
}
/* Classe padronizada de hover azul - consistente em todos os modos (claro/escuro SO e app) */
.hover-primary-blue {
@apply hover:bg-blue-500 hover:text-white hover:border-blue-500 transition-all duration-200;
}
/* Hover simples para ícones e botões menores */
.hover-primary-blue-soft {
@apply hover:bg-blue-500/10 hover:text-blue-500 transition-colors duration-200;
}
/* Hover padronizado para selects de filtro - apenas ao passar o mouse */
.select-hover-blue {
background-color: transparent;
@apply hover:bg-blue-500 hover:text-white hover:border-blue-500 transition-colors duration-200;
}

View File

@ -0,0 +1,993 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import ProtectedRoute from '@/components/shared/ProtectedRoute';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/use-toast';
import { listarPacientes, buscarMedicos, getUserInfo } from '@/lib/api';
import { useReports } from '@/hooks/useReports';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { FileText, Upload, Settings, Eye, ArrowLeft, BookOpen } from 'lucide-react';
// Helpers para normalizar dados
const getPatientName = (p: any) => p?.full_name ?? p?.nome ?? '';
const getPatientCpf = (p: any) => p?.cpf ?? '';
const getPatientSex = (p: any) => p?.sex ?? p?.sexo ?? '';
const getPatientAge = (p: any) => {
if (!p) return '';
const bd = p?.birth_date ?? p?.data_nascimento ?? p?.birthDate;
if (bd) {
const d = new Date(bd);
if (!isNaN(d.getTime())) {
const age = Math.floor((Date.now() - d.getTime()) / (1000 * 60 * 60 * 24 * 365.25));
return `${age}`;
}
}
return p?.idade ?? p?.age ?? '';
};
export default function LaudosEditorPage() {
const router = useRouter();
const { user, token } = useAuth();
const { toast } = useToast();
const { createNewReport } = useReports();
// Estados principais
const [pacienteSelecionado, setPacienteSelecionado] = useState<any>(null);
const [listaPacientes, setListaPacientes] = useState<any[]>([]);
const [content, setContent] = useState('');
const [activeTab, setActiveTab] = useState('editor');
const [showPreview, setShowPreview] = useState(false);
// Estados para solicitante e prazo
const [solicitanteId, setSolicitanteId] = useState<string>(user?.id || '');
// Nome exibido do solicitante (preferir nome do médico vindo da API)
const [solicitanteNome, setSolicitanteNome] = useState<string>(user?.name || '');
const [prazoDate, setPrazoDate] = useState<string>('');
const [prazoTime, setPrazoTime] = useState<string>('');
// Campos do laudo
const [campos, setCampos] = useState({
cid: '',
diagnostico: '',
conclusao: '',
exame: '',
especialidade: '',
mostrarData: true,
mostrarAssinatura: true,
});
// Imagens
const [imagens, setImagens] = useState<any[]>([]);
const [templates] = useState([
'Exame normal, sem alterações significativas',
'Paciente em acompanhamento ambulatorial',
'Recomenda-se retorno em 30 dias',
'Alterações compatíveis com processo inflamatório',
'Resultado dentro dos parâmetros de normalidade',
'Recomendo seguimento com especialista',
]);
// Frases prontas
const [frasesProntas] = useState([
'Paciente apresenta bom estado geral.',
'Recomenda-se seguimento clínico periódico.',
'Encaminhar para especialista.',
'Realizar novos exames em 30 dias.',
'Retorno em 15 dias para reavaliação.',
'Suspender medicamento em caso de efeitos colaterais.',
'Manter repouso relativo por 7 dias.',
'Seguir orientações prescritas rigorosamente.',
'Compatível com os achados clínicos.',
'Sem alterações significativas detectadas.',
]);
// Histórico
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
// Editor ref
const editorRef = useRef<HTMLDivElement>(null);
// Estado para rastrear formatações ativas
const [activeFormats, setActiveFormats] = useState({
bold: false,
italic: false,
underline: false,
strikethrough: false,
});
// Estado para controlar modal de confirmação de rascunho
const [showDraftConfirm, setShowDraftConfirm] = useState(false);
// Atualizar formatações ativas ao mudar seleção
useEffect(() => {
const updateFormats = () => {
setActiveFormats({
bold: document.queryCommandState('bold'),
italic: document.queryCommandState('italic'),
underline: document.queryCommandState('underline'),
strikethrough: document.queryCommandState('strikeThrough'),
});
};
editorRef.current?.addEventListener('mouseup', updateFormats);
editorRef.current?.addEventListener('keyup', updateFormats);
return () => {
editorRef.current?.removeEventListener('mouseup', updateFormats);
editorRef.current?.removeEventListener('keyup', updateFormats);
};
}, []);
// Carregar pacientes ao montar
useEffect(() => {
async function fetchPacientes() {
try {
if (!token) {
setListaPacientes([]);
return;
}
const pacientes = await listarPacientes();
setListaPacientes(pacientes || []);
} catch (err) {
console.warn('Erro ao carregar pacientes:', err);
setListaPacientes([]);
}
}
fetchPacientes();
// Carregar rascunho salvo ao montar
const savedDraft = localStorage.getItem('laudoDraft');
if (savedDraft) {
try {
const draft = JSON.parse(savedDraft);
// Carregar paciente do rascunho se existir
if (draft.pacienteSelecionado) {
setPacienteSelecionado(draft.pacienteSelecionado);
}
setContent(draft.content);
setCampos(draft.campos);
setSolicitanteId(draft.solicitanteId);
setPrazoDate(draft.prazoDate);
setPrazoTime(draft.prazoTime);
setImagens(draft.imagens || []);
// Sincronizar editor com conteúdo carregado
if (editorRef.current) {
editorRef.current.innerHTML = draft.content;
}
} catch (err) {
console.warn('Erro ao carregar rascunho:', err);
}
}
}, [token]);
// Import Quill CSS on client side only
useEffect(() => {
// No CSS needed for native contenteditable
}, []);
// Sincronizar conteúdo inicial com editor ao montar
useEffect(() => {
if (editorRef.current && !editorRef.current.innerHTML) {
editorRef.current.innerHTML = content;
}
}, []);
// Auto-salvar no localStorage sempre que houver mudanças (com debounce)
useEffect(() => {
const timeoutId = setTimeout(() => {
// Capturar conteúdo atual do editor antes de salvar
const currentContent = editorRef.current?.innerHTML || content;
const draft = {
pacienteSelecionado,
content: currentContent,
campos,
solicitanteId,
solicitanteNome,
prazoDate,
prazoTime,
imagens,
lastSaved: new Date().toISOString(),
};
// Só salvar se houver conteúdo ou dados preenchidos
if (currentContent || pacienteSelecionado || campos.exame || campos.diagnostico || imagens.length > 0) {
localStorage.setItem('laudoDraft', JSON.stringify(draft));
}
}, 1000); // Aguarda 1 segundo após última mudança
return () => clearTimeout(timeoutId);
}, [pacienteSelecionado, content, campos, solicitanteId, solicitanteNome, prazoDate, prazoTime, imagens]);
// Tentar obter o registro de médico correspondente ao usuário autenticado
useEffect(() => {
let mounted = true;
async function fetchDoctorName() {
try {
// Se já temos um nome razoável, não sobrescrever
if (solicitanteNome && solicitanteNome.trim().length > 1) return;
if (!user) return;
// First try: query doctors index with any available identifier (email, id or username)
try {
const term = (user.email && user.email.trim()) || user.name || user.id || '';
if (term && term.length > 1) {
const docs = await buscarMedicos(term).catch(() => []);
if (!mounted) return;
if (Array.isArray(docs) && docs.length > 0) {
const d = docs[0];
if (d && (d.full_name || (d as any).nome)) {
setSolicitanteNome((d.full_name as string) || ((d as any).nome as string) || user.name || user.email || '');
setSolicitanteId(user.id || solicitanteId);
return;
}
}
}
} catch (err) {
// non-fatal, continue to next fallback
}
// Second try: fetch consolidated user-info (may contain profile.full_name)
try {
const info = await getUserInfo().catch(() => null);
if (!mounted) return;
if (info && (info.profile as any)?.full_name) {
const full = (info.profile as any).full_name as string;
if (full && full.trim().length > 1) {
setSolicitanteNome(full);
setSolicitanteId(user.id || solicitanteId);
return;
}
}
} catch (err) {
// ignore and fallback
}
// Final fallback: use name from auth user or email/username
setSolicitanteNome(user.name || user.email || '');
setSolicitanteId(user.id || solicitanteId);
} catch (err) {
// em caso de erro, manter o fallback
setSolicitanteNome(user?.name || user?.email || '');
setSolicitanteId(user?.id || solicitanteId);
}
}
fetchDoctorName();
return () => {
mounted = false;
};
}, [user]);
// Atualizar histórico
useEffect(() => {
if (history[historyIndex] !== content) {
const newHistory = history.slice(0, historyIndex + 1);
setHistory([...newHistory, content]);
setHistoryIndex(newHistory.length);
}
}, [content]);
// Função para trocar de aba salvando conteúdo antes
const handleTabChange = (newTab: string) => {
// Salvar conteúdo do editor antes de trocar
if (editorRef.current) {
const editorContent = editorRef.current.innerHTML;
setContent(editorContent);
}
setActiveTab(newTab);
};
// Restaurar conteúdo do editor quando voltar para a aba editor
useEffect(() => {
if (activeTab === 'editor' && editorRef.current && content) {
editorRef.current.innerHTML = content;
}
}, [activeTab]);
// Desfazer
const handleUndo = () => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
setContent(history[newIndex]);
setHistoryIndex(newIndex);
// Atualizar editor com conteúdo anterior
setTimeout(() => {
if (editorRef.current) {
editorRef.current.innerHTML = history[newIndex];
editorRef.current.focus();
}
}, 0);
}
};
// Formatação com contenteditable (document.execCommand)
const applyFormat = (command: string, value?: string) => {
document.execCommand(command, false, value || undefined);
editorRef.current?.focus();
};
const makeBold = () => applyFormat('bold');
const makeItalic = () => applyFormat('italic');
const makeUnderline = () => applyFormat('underline');
const makeStrikethrough = () => applyFormat('strikeThrough');
const insertUnorderedList = () => {
document.execCommand('insertUnorderedList', false);
editorRef.current?.focus();
};
const insertOrderedList = () => {
document.execCommand('insertOrderedList', false);
editorRef.current?.focus();
};
const alignLeft = () => applyFormat('justifyLeft');
const alignCenter = () => applyFormat('justifyCenter');
const alignRight = () => applyFormat('justifyRight');
const alignJustify = () => applyFormat('justifyFull');
const insertTemplate = (template: string) => {
setContent((prev: string) => (prev ? `${prev}\n\n${template}` : template));
};
const insertFraseProta = (frase: string) => {
editorRef.current?.focus();
document.execCommand('insertText', false, frase + ' ');
setContent(editorRef.current?.innerHTML || '');
};
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
files.forEach((file) => {
const reader = new FileReader();
reader.onload = (e) => {
setImagens((prev) => [
...prev,
{
id: Date.now() + Math.random(),
name: file.name,
url: e.target?.result,
type: file.type,
},
]);
};
reader.readAsDataURL(file);
});
};
// Salvar rascunho no localStorage
const saveDraft = () => {
// Capturar conteúdo atual do editor antes de salvar
const currentContent = editorRef.current?.innerHTML || content;
const draft = {
pacienteSelecionado,
content: currentContent,
campos,
solicitanteId,
solicitanteNome,
prazoDate,
prazoTime,
imagens,
};
localStorage.setItem('laudoDraft', JSON.stringify(draft));
toast({
title: 'Rascunho salvo!',
description: 'As informações do laudo foram salvas. Você pode continuar depois.',
variant: 'default',
});
// Redirecionar para profissional após 1 segundo
setTimeout(() => {
router.push('/profissional');
}, 1000);
};
// Descartar rascunho
const discardDraft = () => {
localStorage.removeItem('laudoDraft');
router.push('/profissional');
};
// Processar cancelamento com confirmação
const handleCancel = () => {
// Verificar se há dados para salvar
const hasData = content || campos.cid || campos.diagnostico || campos.conclusao || campos.exame || imagens.length > 0;
if (hasData) {
setShowDraftConfirm(true);
} else {
router.push('/profissional');
}
};
const processContent = (content: string) => {
return content
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/__(.*?)__/g, '<u>$1</u>')
.replace(/\[left\]([\s\S]*?)\[\/left\]/g, '<div style="text-align:left">$1</div>')
.replace(/\[center\]([\s\S]*?)\[\/center\]/g, '<div style="text-align:center">$1</div>')
.replace(/\[right\]([\s\S]*?)\[\/right\]/g, '<div style="text-align:right">$1</div>')
.replace(/\[justify\]([\s\S]*?)\[\/justify\]/g, '<div style="text-align:justify">$1</div>')
.replace(/\[size=(\d+)\]([\s\S]*?)\[\/size\]/g, '<span style="font-size:$1px">$2</span>')
.replace(/\[font=([^\]]+)\]([\s\S]*?)\[\/font\]/g, '<span style="font-family:$1">$2</span>')
.replace(/\[color=([^\]]+)\]([\s\S]*?)\[\/color\]/g, '<span style="color:$1">$2</span>')
.replace(/{{diagnostico}}/g, campos.diagnostico || '[DIAGNÓSTICO]')
.replace(/{{conclusao}}/g, campos.conclusao || '[CONCLUSÃO]')
.replace(/\n/g, '<br>');
};
const handleSave = async () => {
try {
if (!pacienteSelecionado?.id) {
toast({
title: 'Erro',
description: 'Selecione um paciente para continuar.',
variant: 'destructive',
});
return;
}
// Capturar conteúdo atual do editor antes de salvar
const currentContent = editorRef.current?.innerHTML || content;
const userId = user?.id || '00000000-0000-0000-0000-000000000001';
let composedDueAt = undefined;
if (prazoDate) {
const t = prazoTime || '23:59';
composedDueAt = new Date(`${prazoDate}T${t}:00`).toISOString();
}
const payload = {
patient_id: pacienteSelecionado?.id,
order_number: '',
exam: campos.exame || '',
diagnosis: campos.diagnostico || '',
conclusion: campos.conclusao || '',
cid_code: campos.cid || '',
content_html: currentContent,
content_json: {},
requested_by: solicitanteId || userId,
due_at: composedDueAt ?? new Date().toISOString(),
hide_date: !campos.mostrarData,
hide_signature: !campos.mostrarAssinatura,
};
if (createNewReport) {
await createNewReport(payload as any);
// Limpar rascunho salvo após sucesso
localStorage.removeItem('laudoDraft');
toast({
title: 'Laudo criado com sucesso!',
description: 'O laudo foi liberado e salvo.',
variant: 'default',
});
// Redirecionar para profissional
router.push('/profissional');
}
} catch (err) {
toast({
title: 'Erro ao criar laudo',
description: (err && typeof err === 'object' && 'message' in err) ? (err as any).message : String(err) || 'Tente novamente.',
variant: 'destructive',
});
}
};
return (
<ProtectedRoute>
<div className="min-h-screen bg-background flex flex-col">
{/* Header */}
<div className="border-b border-border bg-card shadow-sm sticky top-0 z-10">
<div className="px-2 sm:px-4 md:px-6 py-3 sm:py-4 flex items-center justify-between gap-2 sm:gap-4">
<div className="flex items-center gap-2 sm:gap-4 flex-1 min-w-0">
<Button
variant="ghost"
size="sm"
onClick={() => setShowDraftConfirm(true)}
className="p-0 h-auto flex-shrink-0"
>
<ArrowLeft className="w-4 sm:w-5 h-4 sm:h-5" />
</Button>
<div className="min-w-0">
<h1 className="text-lg sm:text-2xl font-bold truncate">Novo Laudo Médico</h1>
<p className="text-xs sm:text-sm text-muted-foreground truncate">Crie um novo laudo selecionando um paciente</p>
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="flex-1 overflow-hidden flex flex-col">
{/* Seleção de Paciente */}
<div className="border-b border-border bg-card px-2 sm:px-4 md:px-6 py-3 sm:py-4 flex-shrink-0 overflow-y-auto md:max-h-56">
{!pacienteSelecionado ? (
<div className="bg-muted border border-border rounded-lg p-2 sm:p-4">
<Label htmlFor="select-paciente" className="text-xs sm:text-sm font-medium mb-2 block">
Selecionar Paciente *
</Label>
<Select
onValueChange={(value) => {
const paciente = listaPacientes.find((p) => p.id === value);
if (paciente) setPacienteSelecionado(paciente);
}}
>
<SelectTrigger className="w-full text-xs sm:text-sm">
<SelectValue placeholder="Escolha um paciente para criar o laudo" />
</SelectTrigger>
<SelectContent>
{listaPacientes.map((paciente) => (
<SelectItem key={paciente.id} value={paciente.id}>
<span className="text-xs sm:text-sm">
{paciente.full_name} {paciente.cpf ? `- CPF: ${paciente.cpf}` : ''}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<div className="flex-1 min-w-0">
<div className="font-semibold text-primary text-sm sm:text-lg truncate">{getPatientName(pacienteSelecionado)}</div>
<div className="text-xs sm:text-sm text-muted-foreground line-clamp-2">
{getPatientCpf(pacienteSelecionado) ? `CPF: ${getPatientCpf(pacienteSelecionado)} | ` : ''}
{pacienteSelecionado?.birth_date ? `Nascimento: ${pacienteSelecionado.birth_date.split('T')[0].split('-').reverse().join('/')}` : getPatientAge(pacienteSelecionado) ? `Idade: ${getPatientAge(pacienteSelecionado)} anos` : ''}
{getPatientSex(pacienteSelecionado) ? ` | Sexo: ${getPatientSex(pacienteSelecionado)}` : ''}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPacienteSelecionado(null)}
className="text-xs sm:text-sm flex-shrink-0"
>
Trocar
</Button>
</div>
)}
{/* Prazo */}
{pacienteSelecionado && (
<div className="mt-3 sm:mt-4">
<div>
<Label htmlFor="prazoDate" className="text-xs sm:text-sm">
Prazo do Laudo
</Label>
<div className="flex gap-1 sm:gap-2 mt-1">
<Input
id="prazoDate"
type="date"
value={prazoDate}
onChange={(e) => setPrazoDate(e.target.value)}
className="text-xs sm:text-sm h-8 sm:h-10 flex-1"
/>
<Input
id="prazoTime"
type="time"
value={prazoTime}
onChange={(e) => setPrazoTime(e.target.value)}
className="text-xs sm:text-sm h-8 sm:h-10 flex-1"
/>
</div>
<p className="text-xs text-muted-foreground mt-1">Defina a data e hora (opcional).</p>
</div>
</div>
)}
</div>
{/* Tabs */}
<div className="flex border-b border-border bg-card overflow-x-auto flex-shrink-0">
<button
onClick={() => handleTabChange('editor')}
className={`px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
activeTab === 'editor'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-600 dark:text-muted-foreground'
}`}
>
<FileText className="w-3 sm:w-4 h-3 sm:h-4 inline mr-1" />
Editor
</button>
<button
onClick={() => handleTabChange('campos')}
className={`px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
activeTab === 'campos'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-600 dark:text-muted-foreground'
}`}
>
<Settings className="w-3 sm:w-4 h-3 sm:h-4 inline mr-1" />
Campos
</button>
<button
onClick={() => setShowPreview(!showPreview)}
className={`px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
showPreview ? 'border-green-500 text-green-600' : 'border-transparent text-gray-600 dark:text-muted-foreground'
}`}
>
<Eye className="w-3 sm:w-4 h-3 sm:h-4 inline mr-1" />
<span>{showPreview ? 'Ocultar' : 'Pré-visualização'}</span>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden flex flex-col md:flex-row bg-background">
{/* Left Panel */}
<div className={`flex flex-col overflow-hidden transition-all ${showPreview ? 'w-full md:w-3/5 h-auto md:h-full' : 'w-full'}`}>
{/* Editor Tab */}
{activeTab === 'editor' && (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Toolbar */}
<div className="p-2 border-b border-border bg-card flex-shrink-0 overflow-x-auto">
<div className="flex flex-wrap gap-2 items-center">
{/* Font Family */}
<label className="text-xs font-medium text-foreground whitespace-nowrap">Fonte:</label>
<select
defaultValue="Arial"
onChange={(e) => applyFormat('fontName', e.target.value)}
className="border border-border rounded px-2 py-1 text-xs bg-background text-foreground"
>
<option value="Arial">Arial</option>
<option value="Helvetica">Helvetica</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Courier New">Courier New</option>
<option value="Verdana">Verdana</option>
<option value="Georgia">Georgia</option>
</select>
{/* Font Size */}
<label className="text-xs font-medium text-foreground whitespace-nowrap">Tamanho:</label>
<select
defaultValue="3"
onChange={(e) => applyFormat('fontSize', e.target.value)}
className="border border-border rounded px-2 py-1 text-xs bg-background text-foreground"
>
<option value="1">8px</option>
<option value="2">10px</option>
<option value="3">12px</option>
<option value="4">14px</option>
<option value="5">18px</option>
<option value="6">24px</option>
<option value="7">32px</option>
</select>
<div className="w-px h-6 bg-border mx-1" />
<Button
variant={activeFormats.bold ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); makeBold(); }}
title="Negrito (Ctrl+B)"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
<strong>B</strong>
</Button>
<Button
variant={activeFormats.italic ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); makeItalic(); }}
title="Itálico (Ctrl+I)"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
<em>I</em>
</Button>
<Button
variant={activeFormats.underline ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); makeUnderline(); }}
title="Sublinhado (Ctrl+U)"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
<u>U</u>
</Button>
<Button
variant={activeFormats.strikethrough ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); makeStrikethrough(); }}
title="Tachado"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
<del>S</del>
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); insertUnorderedList(); }} title="Lista com marcadores" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
</Button>
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); insertOrderedList(); }} title="Lista numerada" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
1.
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); alignLeft(); }} title="Alinhar à esquerda" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
</Button>
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); alignCenter(); }} title="Centralizar" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
·
</Button>
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); alignRight(); }} title="Alinhar à direita" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
</Button>
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); alignJustify(); }} title="Justificar" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
</Button>
<div className="w-px h-6 bg-border mx-1" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" title="Frases prontas" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
<BookOpen className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
{frasesProntas.map((frase, index) => (
<DropdownMenuItem
key={index}
onSelect={() => insertFraseProta(frase)}
className="text-xs cursor-pointer"
>
{frase}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Editor contenteditable */}
<div className="flex-1 overflow-hidden p-2 sm:p-3 md:p-4">
<div
ref={editorRef}
contentEditable
onInput={(e) => setContent(e.currentTarget.innerHTML)}
onPaste={(e) => {
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
}}
className="w-full h-full overflow-auto p-3 text-sm border border-border rounded bg-background text-foreground outline-none empty:before:content-['Digite_aqui...'] empty:before:text-muted-foreground"
style={{ caretColor: 'currentColor' }}
suppressContentEditableWarning
/>
</div>
</div>
)}
{/* Campos Tab */}
{activeTab === 'campos' && (
<div className="flex-1 p-2 sm:p-3 md:p-4 space-y-2 sm:space-y-3 md:space-y-4 overflow-y-auto">
<div>
<Label htmlFor="cid" className="text-xs sm:text-sm">
CID
</Label>
<Input
id="cid"
value={campos.cid}
onChange={(e) => setCampos((prev) => ({ ...prev, cid: e.target.value }))}
placeholder="Ex: M25.5, I10, etc."
className="text-xs sm:text-sm mt-1 h-8 sm:h-10"
/>
</div>
<div>
<Label htmlFor="exame" className="text-xs sm:text-sm">
Exame
</Label>
<Input
id="exame"
value={campos.exame}
onChange={(e) => setCampos((prev) => ({ ...prev, exame: e.target.value }))}
placeholder="Exame realizado"
className="text-xs sm:text-sm mt-1 h-8 sm:h-10"
/>
</div>
<div>
<Label htmlFor="diagnostico" className="text-xs sm:text-sm">
Diagnóstico
</Label>
<Textarea
id="diagnostico"
value={campos.diagnostico}
onChange={(e) => setCampos((prev) => ({ ...prev, diagnostico: e.target.value }))}
placeholder="Diagnóstico principal"
rows={2}
className="text-xs sm:text-sm mt-1"
/>
</div>
<div>
<Label htmlFor="conclusao" className="text-xs sm:text-sm">
Conclusão
</Label>
<Textarea
id="conclusao"
value={campos.conclusao}
onChange={(e) => setCampos((prev) => ({ ...prev, conclusao: e.target.value }))}
placeholder="Conclusão do laudo"
rows={2}
className="text-xs sm:text-sm mt-1"
/>
</div>
<div className="space-y-1 sm:space-y-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="mostrar-data"
checked={campos.mostrarData}
onChange={(e) => setCampos((prev) => ({ ...prev, mostrarData: e.target.checked }))}
className="w-4 h-4"
/>
<Label htmlFor="mostrar-data" className="text-xs sm:text-sm">
Mostrar data no laudo
</Label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="mostrar-assinatura"
checked={campos.mostrarAssinatura}
onChange={(e) => setCampos((prev) => ({ ...prev, mostrarAssinatura: e.target.checked }))}
className="w-4 h-4"
/>
<Label htmlFor="mostrar-assinatura" className="text-xs sm:text-sm">
Mostrar assinatura no laudo
</Label>
</div>
</div>
</div>
)}
</div>
{/* Preview Panel */}
{showPreview && (
<div className="w-full md:w-2/5 h-auto md:h-full border-t md:border-l md:border-t-0 border-border bg-muted/20 flex flex-col overflow-hidden">
<div className="p-2 sm:p-2.5 md:p-3 border-b border-border flex-shrink-0 bg-card">
<h3 className="font-semibold text-xs sm:text-sm text-foreground truncate">Pré-visualização</h3>
</div>
<div className="flex-1 overflow-y-auto p-2 sm:p-2.5 md:p-3">
<div className="bg-background border border-border rounded p-2 sm:p-2.5 md:p-3 text-xs space-y-1.5 sm:space-y-2 max-w-full">
{/* Header */}
<div className="text-center mb-2 pb-2 border-b border-border/40">
<h2 className="text-xs sm:text-sm font-bold leading-tight whitespace-normal">
LAUDO {campos.especialidade ? `- ${campos.especialidade.toUpperCase().substring(0, 12)}` : ''}
</h2>
{campos.exame && <p className="text-xs font-semibold mt-1 whitespace-pre-wrap break-words">{campos.exame}</p>}
{campos.mostrarData && (
<p className="text-xs text-muted-foreground mt-1">{new Date().toLocaleDateString('pt-BR')}</p>
)}
</div>
{/* Paciente */}
{pacienteSelecionado && (
<div className="mb-1.5 pb-1.5 border-b border-border/40 space-y-0.5">
<div className="text-xs whitespace-normal break-words">
<span className="font-semibold">Paciente:</span>
<div className="mt-0.5">{getPatientName(pacienteSelecionado)}</div>
</div>
<div className="text-xs whitespace-normal break-words">
<span className="font-semibold">CPF:</span>
<div className="mt-0.5">{getPatientCpf(pacienteSelecionado)}</div>
</div>
</div>
)}
{/* Informações Clínicas */}
<div className="mb-1.5 pb-1.5 border-b border-border/40 space-y-0.5">
{campos.cid && (
<div className="text-xs whitespace-normal break-words">
<div className="font-semibold">CID:</div>
<div className="mt-0.5 text-blue-600 dark:text-blue-400 font-semibold">{campos.cid}</div>
</div>
)}
</div>
{/* Diagnóstico - Completo */}
{campos.diagnostico && (
<div className="mb-1.5 pb-1.5 border-b border-border/40">
<div className="text-xs font-semibold mb-0.5">Diagnóstico:</div>
<div className="text-xs leading-tight whitespace-pre-wrap text-muted-foreground break-words">
{campos.diagnostico}
</div>
</div>
)}
{/* Conteúdo */}
{content && (
<div className="mb-1.5 pb-1.5 border-b border-border/40">
<div className="text-xs font-semibold mb-0.5">Conteúdo:</div>
<div
className="text-xs leading-tight whitespace-pre-wrap text-muted-foreground break-words overflow-hidden"
dangerouslySetInnerHTML={{
__html: processContent(content),
}}
/>
</div>
)}
{/* Conclusão - Completa */}
{campos.conclusao && (
<div className="mb-1.5 pb-1.5 border-b border-border/40">
<div className="text-xs font-semibold mb-0.5">Conclusão:</div>
<div className="text-xs leading-tight whitespace-pre-wrap text-muted-foreground break-words">
{campos.conclusao}
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="p-2 sm:p-3 md:p-4 border-t border-border bg-card flex-shrink-0">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 sm:gap-4">
<div className="text-xs text-muted-foreground hidden md:block">
Editor de relatórios com formatação de texto rica.
</div>
<div className="flex gap-2 w-full sm:w-auto">
<Button variant="outline" onClick={handleCancel} className="flex-1 sm:flex-none text-xs sm:text-sm h-8 sm:h-10 hover:bg-blue-50 dark:hover:bg-blue-950">
Cancelar
</Button>
<Button onClick={handleSave} className="flex-1 sm:flex-none text-xs sm:text-sm h-8 sm:h-10">
Liberar Laudo
</Button>
</div>
</div>
</div>
{/* Modal de Confirmação de Rascunho */}
{showDraftConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-lg p-4 sm:p-6 max-w-sm w-full">
<h2 className="text-lg sm:text-xl font-bold mb-2 text-foreground">Salvar Rascunho?</h2>
<p className="text-sm text-muted-foreground mb-6">
Você tem informações não salvas. Deseja salvar como rascunho para continuar depois?
</p>
<div className="flex gap-2 sm:gap-3 flex-col sm:flex-row">
<Button
variant="outline"
onClick={() => {
setShowDraftConfirm(false);
discardDraft();
}}
className="text-xs sm:text-sm h-9 sm:h-10 hover:bg-gray-100 dark:hover:bg-gray-800"
>
Descartar
</Button>
<Button
variant="outline"
onClick={() => setShowDraftConfirm(false)}
className="text-xs sm:text-sm h-9 sm:h-10 hover:bg-gray-100 dark:hover:bg-gray-800"
>
Voltar
</Button>
<Button
onClick={() => {
setShowDraftConfirm(false);
saveDraft();
}}
className="text-xs sm:text-sm h-9 sm:h-10"
>
Salvar Rascunho
</Button>
</div>
</div>
</div>
)}
</div>
</ProtectedRoute>
);
}

View File

@ -0,0 +1,831 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import ProtectedRoute from '@/components/shared/ProtectedRoute';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/use-toast';
import { buscarRelatorioPorId, buscarPacientePorId } from '@/lib/api';
import { useReports } from '@/hooks/useReports';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { FileText, Settings, Eye, ArrowLeft, BookOpen } from 'lucide-react';
export default function EditarLaudoPage() {
const router = useRouter();
const params = useParams();
const { user, token } = useAuth();
const { toast } = useToast();
const { updateExistingReport } = useReports();
const laudoId = params.id as string;
// Estados principais
const [reportData, setReportData] = useState<any>(null);
const [patient, setPatient] = useState<any>(null);
const [content, setContent] = useState('');
const [activeTab, setActiveTab] = useState('editor');
const [showPreview, setShowPreview] = useState(false);
const [loading, setLoading] = useState(true);
const [showExitDialog, setShowExitDialog] = useState(false);
// Campos do laudo
const [campos, setCampos] = useState({
cid: '',
diagnostico: '',
conclusao: '',
exame: '',
especialidade: '',
mostrarData: true,
mostrarAssinatura: true,
});
// Editor ref
const editorRef = useRef<HTMLDivElement>(null);
// Frases prontas
const frasesProntas = [
'Paciente apresenta bom estado geral.',
'Recomenda-se seguimento clínico periódico.',
'Encaminhar para especialista.',
'Realizar novos exames em 30 dias.',
'Retorno em 15 dias para reavaliação.',
'Suspender medicamento em caso de efeitos colaterais.',
'Manter repouso relativo por 7 dias.',
'Seguir orientações prescritas rigorosamente.',
'Compatível com os achados clínicos.',
'Sem alterações significativas detectadas.',
];
// Estado para rastrear formatações ativas
const [activeFormats, setActiveFormats] = useState({
bold: false,
italic: false,
underline: false,
strikethrough: false,
});
// Estado para rastrear alinhamento ativo
const [activeAlignment, setActiveAlignment] = useState('left');
// Salvar conteúdo no localStorage sempre que muda (com debounce)
useEffect(() => {
const timeoutId = setTimeout(() => {
if (laudoId) {
// Capturar conteúdo atual do editor antes de salvar
const currentContent = editorRef.current?.innerHTML || content;
const draft = {
content: currentContent,
campos,
lastSaved: new Date().toISOString(),
};
localStorage.setItem(`laudo-draft-${laudoId}`, JSON.stringify(draft));
}
}, 1000); // Aguarda 1 segundo após última mudança
return () => clearTimeout(timeoutId);
}, [content, campos, laudoId]);
// Função para trocar de aba salvando conteúdo antes
const handleTabChange = (newTab: string) => {
// Salvar conteúdo do editor antes de trocar
if (editorRef.current) {
const editorContent = editorRef.current.innerHTML;
setContent(editorContent);
}
// Se estiver voltando para o editor, restaurar conteúdo
if (newTab === 'editor') {
setTimeout(() => {
if (editorRef.current && content) {
editorRef.current.innerHTML = content;
}
}, 0);
}
setActiveTab(newTab);
};
// Atualizar formatações ativas ao mudar seleção
useEffect(() => {
const updateFormats = () => {
setActiveFormats({
bold: document.queryCommandState('bold'),
italic: document.queryCommandState('italic'),
underline: document.queryCommandState('underline'),
strikethrough: document.queryCommandState('strikeThrough'),
});
// Detectar alinhamento ativo
if (document.queryCommandState('justifyCenter')) {
setActiveAlignment('center');
} else if (document.queryCommandState('justifyRight')) {
setActiveAlignment('right');
} else if (document.queryCommandState('justifyFull')) {
setActiveAlignment('justify');
} else {
setActiveAlignment('left');
}
};
editorRef.current?.addEventListener('mouseup', updateFormats);
editorRef.current?.addEventListener('keyup', updateFormats);
return () => {
editorRef.current?.removeEventListener('mouseup', updateFormats);
editorRef.current?.removeEventListener('keyup', updateFormats);
};
}, []);
// Carregar laudo ao montar
useEffect(() => {
async function fetchLaudo() {
try {
if (!laudoId || !token) {
setLoading(false);
return;
}
const report = await buscarRelatorioPorId(laudoId);
setReportData(report);
// Carregar paciente se existir patient_id
const r = report as any;
if (r.patient_id) {
try {
const patientData = await buscarPacientePorId(r.patient_id);
setPatient(patientData);
} catch (err) {
console.warn('Erro ao carregar paciente:', err);
}
}
// Preencher campos
setCampos({
cid: r.cid_code || r.cid || '',
diagnostico: r.diagnosis || r.diagnostico || '',
conclusao: r.conclusion || r.conclusao || '',
exame: r.exam || r.exame || '',
especialidade: r.especialidade || '',
mostrarData: !r.hide_date,
mostrarAssinatura: !r.hide_signature,
});
// Preencher conteúdo - verificar todos os possíveis nomes de campo
const contentHtml = r.content_html || r.conteudo_html || r.contentHtml || r.conteudo || r.content || '';
console.log('[EditarLaudoPage] Loading content - report:', r);
console.log('[EditarLaudoPage] Content fields check:', {
content_html: r.content_html,
conteudo_html: r.conteudo_html,
contentHtml: r.contentHtml,
conteudo: r.conteudo,
content: r.content,
finalContent: contentHtml
});
// Verificar se existe rascunho salvo no localStorage
let finalContent = contentHtml;
let finalCampos = {
cid: r.cid_code || r.cid || '',
diagnostico: r.diagnosis || r.diagnostico || '',
conclusao: r.conclusion || r.conclusao || '',
exame: r.exam || r.exame || '',
especialidade: r.especialidade || '',
mostrarData: !r.hide_date,
mostrarAssinatura: !r.hide_signature,
};
if (typeof window !== 'undefined') {
const draftData = localStorage.getItem(`laudo-draft-${laudoId}`);
if (draftData) {
try {
const draft = JSON.parse(draftData);
if (draft.content) finalContent = draft.content;
if (draft.campos) finalCampos = { ...finalCampos, ...draft.campos };
} catch (err) {
// Se falhar parse, tentar como string simples (formato antigo)
finalContent = draftData;
}
}
}
setCampos(finalCampos);
setContent(finalContent);
console.log('[EditarLaudoPage] Setting content state with length:', finalContent.length);
// O innerHTML será setado no useEffect separado abaixo
} catch (err) {
console.warn('Erro ao carregar laudo:', err);
toast({
title: 'Erro',
description: 'Erro ao carregar o laudo.',
variant: 'destructive',
});
} finally {
setLoading(false);
}
}
fetchLaudo();
}, [laudoId, token, toast]);
// UseEffect separado para injetar o conteúdo no editor quando estiver pronto
useEffect(() => {
if (content && editorRef.current && !loading) {
console.log('[EditarLaudoPage] Injecting content into editor, length:', content.length);
editorRef.current.innerHTML = content;
}
}, [content, loading]);
// Formatação com contenteditable
const applyFormat = (command: string, value?: string) => {
document.execCommand(command, false, value || undefined);
editorRef.current?.focus();
};
const makeBold = () => applyFormat('bold');
const makeItalic = () => applyFormat('italic');
const makeUnderline = () => applyFormat('underline');
const makeStrikethrough = () => applyFormat('strikeThrough');
const insertUnorderedList = () => {
document.execCommand('insertUnorderedList', false);
editorRef.current?.focus();
};
const insertOrderedList = () => {
document.execCommand('insertOrderedList', false);
editorRef.current?.focus();
};
const alignText = (alignment: 'left' | 'center' | 'right' | 'justify') => {
editorRef.current?.focus();
const alignCommands: { [key: string]: string } = {
left: 'justifyLeft',
center: 'justifyCenter',
right: 'justifyRight',
justify: 'justifyFull',
};
document.execCommand(alignCommands[alignment], false, undefined);
if (editorRef.current) {
setContent(editorRef.current.innerHTML);
}
};
const alignLeft = () => alignText('left');
const alignCenter = () => alignText('center');
const alignRight = () => alignText('right');
const alignJustify = () => alignText('justify');
const insertFraseProta = (frase: string) => {
editorRef.current?.focus();
document.execCommand('insertText', false, frase + ' ');
if (editorRef.current) {
setContent(editorRef.current.innerHTML);
}
};
const processContent = (content: string) => {
return content
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/__(.*?)__/g, '<u>$1</u>')
.replace(/\[left\]([\s\S]*?)\[\/left\]/g, '<div style="text-align:left">$1</div>')
.replace(/\[center\]([\s\S]*?)\[\/center\]/g, '<div style="text-align:center">$1</div>')
.replace(/\[right\]([\s\S]*?)\[\/right\]/g, '<div style="text-align:right">$1</div>')
.replace(/\[justify\]([\s\S]*?)\[\/justify\]/g, '<div style="text-align:justify">$1</div>')
.replace(/\[size=(\d+)\]([\s\S]*?)\[\/size\]/g, '<span style="font-size:$1px">$2</span>')
.replace(/\[font=([^\]]+)\]([\s\S]*?)\[\/font\]/g, '<span style="font-family:$1">$2</span>')
.replace(/\[color=([^\]]+)\]([\s\S]*?)\[\/color\]/g, '<span style="color:$1">$2</span>')
.replace(/{{diagnostico}}/g, campos.diagnostico || '[DIAGNÓSTICO]')
.replace(/{{conclusao}}/g, campos.conclusao || '[CONCLUSÃO]')
.replace(/\n/g, '<br>');
};
const handleSave = async () => {
try {
if (!reportData?.id) {
toast({
title: 'Erro',
description: 'ID do laudo não encontrado.',
variant: 'destructive',
});
return;
}
// Pegar conteúdo diretamente do DOM para garantir que está atualizado
const currentContent = editorRef.current?.innerHTML || content;
const payload = {
exam: campos.exame || '',
diagnosis: campos.diagnostico || '',
conclusion: campos.conclusao || '',
cid_code: campos.cid || '',
content_html: currentContent,
content_json: {},
hide_date: !campos.mostrarData,
hide_signature: !campos.mostrarAssinatura,
};
if (updateExistingReport) {
await updateExistingReport(reportData.id, payload as any);
// Limpar rascunho do localStorage após salvar
if (typeof window !== 'undefined') {
localStorage.removeItem(`laudo-draft-${reportData.id}`);
}
toast({
title: 'Laudo atualizado com sucesso!',
description: 'As alterações foram salvas.',
variant: 'default',
});
router.push(`/laudos/${reportData.id}`);
}
} catch (err) {
toast({
title: 'Erro ao atualizar laudo',
description: (err && typeof err === 'object' && 'message' in err) ? (err as any).message : String(err) || 'Tente novamente.',
variant: 'destructive',
});
}
};
if (loading) {
return (
<ProtectedRoute>
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="text-lg text-muted-foreground">Carregando laudo...</div>
</div>
</ProtectedRoute>
);
}
return (
<ProtectedRoute>
<div className="min-h-screen bg-background flex flex-col">
{/* Header */}
<div className="border-b border-border bg-card shadow-sm sticky top-0 z-10">
<div className="px-2 sm:px-4 md:px-6 py-3 sm:py-4 flex items-center justify-between gap-2 sm:gap-4">
<div className="flex items-center gap-2 sm:gap-4 flex-1 min-w-0">
<Button
variant="ghost"
size="sm"
onClick={() => setShowExitDialog(true)}
className="p-0 h-auto flex-shrink-0"
>
<ArrowLeft className="w-4 sm:w-5 h-4 sm:h-5" />
</Button>
<div className="min-w-0">
<h1 className="text-lg sm:text-2xl font-bold truncate">Editar Laudo Médico</h1>
<div className="flex flex-col gap-0.5">
<p className="text-xs sm:text-sm text-muted-foreground truncate">Atualize as informações do laudo</p>
{patient && (
<p className="text-xs sm:text-sm font-semibold text-blue-600 dark:text-blue-400 truncate">
Paciente: {patient.full_name || patient.name || 'N/A'}
</p>
)}
</div>
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="flex-1 overflow-hidden flex flex-col">
{/* Tabs */}
<div className="flex border-b border-border bg-card overflow-x-auto flex-shrink-0">
<button
onClick={() => handleTabChange('editor')}
className={`px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
activeTab === 'editor'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-600 dark:text-muted-foreground'
}`}
>
<FileText className="w-3 sm:w-4 h-3 sm:h-4 inline mr-1" />
Editor
</button>
<button
onClick={() => handleTabChange('campos')}
className={`px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
activeTab === 'campos'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-600 dark:text-muted-foreground'
}`}
>
<Settings className="w-3 sm:w-4 h-3 sm:h-4 inline mr-1" />
Campos
</button>
<button
onClick={() => setShowPreview(!showPreview)}
className={`px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
showPreview ? 'border-green-500 text-green-600' : 'border-transparent text-gray-600 dark:text-muted-foreground'
}`}
>
<Eye className="w-3 sm:w-4 h-3 sm:h-4 inline mr-1" />
<span>{showPreview ? 'Ocultar' : 'Pré-visualização'}</span>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden flex flex-col md:flex-row bg-background">
{/* Left Panel */}
<div className={`flex flex-col overflow-hidden transition-all ${showPreview ? 'w-full md:w-3/5 h-auto md:h-full' : 'w-full'}`}>
{/* Editor Tab */}
{activeTab === 'editor' && (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Toolbar */}
<div className="p-2 border-b border-border bg-card flex-shrink-0 overflow-x-auto">
<div className="flex flex-wrap gap-2 items-center">
{/* Font Family */}
<label className="text-xs font-medium text-foreground whitespace-nowrap">Fonte:</label>
<select
defaultValue="Arial"
onChange={(e) => applyFormat('fontName', e.target.value)}
className="border border-border rounded px-2 py-1 text-xs bg-background text-foreground"
>
<option value="Arial">Arial</option>
<option value="Helvetica">Helvetica</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Courier New">Courier New</option>
<option value="Verdana">Verdana</option>
<option value="Georgia">Georgia</option>
</select>
{/* Font Size */}
<label className="text-xs font-medium text-foreground whitespace-nowrap">Tamanho:</label>
<select
defaultValue="3"
onChange={(e) => applyFormat('fontSize', e.target.value)}
className="border border-border rounded px-2 py-1 text-xs bg-background text-foreground"
>
<option value="1">8px</option>
<option value="2">10px</option>
<option value="3">12px</option>
<option value="4">14px</option>
<option value="5">18px</option>
<option value="6">24px</option>
<option value="7">32px</option>
</select>
<div className="w-px h-6 bg-border mx-1" />
<Button
variant={activeFormats.bold ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); makeBold(); }}
title="Negrito (Ctrl+B)"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
<strong>B</strong>
</Button>
<Button
variant={activeFormats.italic ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); makeItalic(); }}
title="Itálico (Ctrl+I)"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
<em>I</em>
</Button>
<Button
variant={activeFormats.underline ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); makeUnderline(); }}
title="Sublinhado (Ctrl+U)"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
<u>U</u>
</Button>
<Button
variant={activeFormats.strikethrough ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); makeStrikethrough(); }}
title="Tachado"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
<del>S</del>
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); insertUnorderedList(); }} title="Lista com marcadores" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
</Button>
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); insertOrderedList(); }} title="Lista numerada" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
1.
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button
variant={activeAlignment === 'left' ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); alignLeft(); }}
title="Alinhar à esquerda"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
</Button>
<Button
variant={activeAlignment === 'center' ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); alignCenter(); }}
title="Centralizar"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
·
</Button>
<Button
variant={activeAlignment === 'right' ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); alignRight(); }}
title="Alinhar à direita"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
</Button>
<Button
variant={activeAlignment === 'justify' ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); alignJustify(); }}
title="Justificar"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
</Button>
<div className="w-px h-6 bg-border mx-1" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" title="Frases prontas" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
<BookOpen className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
{frasesProntas.map((frase, index) => (
<DropdownMenuItem
key={index}
onSelect={() => insertFraseProta(frase)}
className="text-xs cursor-pointer"
>
{frase}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Editor contenteditable */}
<div className="flex-1 overflow-hidden p-2 sm:p-3 md:p-4">
<div
ref={editorRef}
contentEditable
onInput={(e) => setContent(e.currentTarget.innerHTML)}
onPaste={(e) => {
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
}}
className="w-full h-full overflow-auto p-3 text-sm border border-border rounded bg-background text-foreground outline-none empty:before:content-['Digite_aqui...'] empty:before:text-muted-foreground"
style={{ caretColor: 'currentColor' }}
suppressContentEditableWarning
/>
</div>
</div>
)}
{/* Campos Tab */}
{activeTab === 'campos' && (
<div className="flex-1 p-2 sm:p-3 md:p-4 space-y-2 sm:space-y-3 md:space-y-4 overflow-y-auto">
<div>
<Label htmlFor="cid" className="text-xs sm:text-sm">
CID
</Label>
<Input
id="cid"
value={campos.cid}
onChange={(e) => setCampos((prev) => ({ ...prev, cid: e.target.value }))}
placeholder="Ex: M25.5, I10, etc."
className="text-xs sm:text-sm mt-1 h-8 sm:h-10"
/>
</div>
<div>
<Label htmlFor="exame" className="text-xs sm:text-sm">
Exame
</Label>
<Input
id="exame"
value={campos.exame}
onChange={(e) => setCampos((prev) => ({ ...prev, exame: e.target.value }))}
placeholder="Exame realizado"
className="text-xs sm:text-sm mt-1 h-8 sm:h-10"
/>
</div>
<div>
<Label htmlFor="diagnostico" className="text-xs sm:text-sm">
Diagnóstico
</Label>
<Textarea
id="diagnostico"
value={campos.diagnostico}
onChange={(e) => setCampos((prev) => ({ ...prev, diagnostico: e.target.value }))}
placeholder="Diagnóstico principal"
rows={2}
className="text-xs sm:text-sm mt-1"
/>
</div>
<div>
<Label htmlFor="conclusao" className="text-xs sm:text-sm">
Conclusão
</Label>
<Textarea
id="conclusao"
value={campos.conclusao}
onChange={(e) => setCampos((prev) => ({ ...prev, conclusao: e.target.value }))}
placeholder="Conclusão do laudo"
rows={2}
className="text-xs sm:text-sm mt-1"
/>
</div>
<div className="space-y-1 sm:space-y-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="mostrar-data"
checked={campos.mostrarData}
onChange={(e) => setCampos((prev) => ({ ...prev, mostrarData: e.target.checked }))}
className="w-4 h-4"
/>
<Label htmlFor="mostrar-data" className="text-xs sm:text-sm">
Mostrar data no laudo
</Label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="mostrar-assinatura"
checked={campos.mostrarAssinatura}
onChange={(e) => setCampos((prev) => ({ ...prev, mostrarAssinatura: e.target.checked }))}
className="w-4 h-4"
/>
<Label htmlFor="mostrar-assinatura" className="text-xs sm:text-sm">
Mostrar assinatura no laudo
</Label>
</div>
</div>
</div>
)}
</div>
{/* Preview Panel */}
{showPreview && (
<div className="w-full md:w-2/5 h-auto md:h-full border-t md:border-l md:border-t-0 border-border bg-muted/20 flex flex-col overflow-hidden">
<div className="p-2 sm:p-2.5 md:p-3 border-b border-border flex-shrink-0 bg-card">
<h3 className="font-semibold text-xs sm:text-sm text-foreground truncate">Pré-visualização</h3>
</div>
<div className="flex-1 overflow-y-auto p-2 sm:p-2.5 md:p-3">
<div className="bg-background border border-border rounded p-2 sm:p-2.5 md:p-3 text-xs space-y-1.5 sm:space-y-2 max-w-full">
{/* Header */}
<div className="text-center mb-2 pb-2 border-b border-border/40">
<h2 className="text-xs sm:text-sm font-bold leading-tight whitespace-normal">
LAUDO {campos.especialidade ? `- ${campos.especialidade.toUpperCase().substring(0, 12)}` : ''}
</h2>
{campos.exame && <p className="text-xs font-semibold mt-1 whitespace-pre-wrap break-words">{campos.exame}</p>}
{campos.mostrarData && (
<p className="text-xs text-muted-foreground mt-1">{new Date().toLocaleDateString('pt-BR')}</p>
)}
</div>
{/* Informações Clínicas */}
<div className="mb-1.5 pb-1.5 border-b border-border/40 space-y-0.5">
{campos.cid && (
<div className="text-xs whitespace-normal break-words">
<div className="font-semibold">CID:</div>
<div className="mt-0.5 text-blue-600 dark:text-blue-400 font-semibold">{campos.cid}</div>
</div>
)}
</div>
{/* Diagnóstico */}
{campos.diagnostico && (
<div className="mb-1.5 pb-1.5 border-b border-border/40">
<div className="text-xs font-semibold mb-0.5">Diagnóstico:</div>
<div className="text-xs leading-tight whitespace-pre-wrap text-muted-foreground break-words">
{campos.diagnostico}
</div>
</div>
)}
{/* Conteúdo */}
{content && (
<div className="mb-1.5 pb-1.5 border-b border-border/40">
<div className="text-xs font-semibold mb-0.5">Conteúdo:</div>
<div
className="text-xs leading-tight whitespace-pre-wrap text-muted-foreground break-words overflow-hidden"
dangerouslySetInnerHTML={{
__html: processContent(content),
}}
/>
</div>
)}
{/* Conclusão */}
{campos.conclusao && (
<div className="mb-1.5 pb-1.5 border-b border-border/40">
<div className="text-xs font-semibold mb-0.5">Conclusão:</div>
<div className="text-xs leading-tight whitespace-pre-wrap text-muted-foreground break-words">
{campos.conclusao}
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="p-2 sm:p-3 md:p-4 border-t border-border bg-card flex-shrink-0">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 sm:gap-4">
<div className="text-xs text-muted-foreground hidden md:block">
Edite as informações do laudo e salve as alterações.
</div>
<div className="flex gap-2 w-full sm:w-auto">
<Button variant="outline" onClick={() => setShowExitDialog(true)} className="flex-1 sm:flex-none text-xs sm:text-sm h-8 sm:h-10 hover:bg-blue-50 dark:hover:bg-blue-950">
Cancelar
</Button>
<Button onClick={handleSave} className="flex-1 sm:flex-none text-xs sm:text-sm h-8 sm:h-10">
Salvar Alterações
</Button>
</div>
</div>
</div>
{/* Dialog de confirmação de saída */}
<Dialog open={showExitDialog} onOpenChange={setShowExitDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Salvar Rascunho?</DialogTitle>
<DialogDescription>
Você tem informações não salvas. Deseja salvar como rascunho para continuar depois?
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button
variant="outline"
onClick={() => {
// Limpar rascunho
localStorage.removeItem(`laudo-draft-${laudoId}`);
setShowExitDialog(false);
router.back();
}}
className="w-full sm:w-auto hover:bg-gray-100 dark:hover:bg-gray-800"
>
Descartar
</Button>
<Button
variant="outline"
onClick={() => {
setShowExitDialog(false);
router.back();
}}
className="w-full sm:w-auto hover:bg-gray-100 dark:hover:bg-gray-800"
>
Voltar
</Button>
<Button
onClick={() => {
// Salvar rascunho manualmente antes de sair
const currentContent = editorRef.current?.innerHTML || content;
const draft = {
content: currentContent,
campos,
lastSaved: new Date().toISOString(),
};
localStorage.setItem(`laudo-draft-${laudoId}`, JSON.stringify(draft));
toast({
title: 'Rascunho salvo!',
description: 'Suas alterações foram salvas.',
variant: 'default',
});
setShowExitDialog(false);
router.back();
}}
className="w-full sm:w-auto"
>
Salvar Rascunho
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</ProtectedRoute>
);
}

View File

@ -0,0 +1,536 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useTheme } from 'next-themes'
import Image from 'next/image'
import { Button } from '@/components/ui/button'
import { ArrowLeft, Printer, Download } from 'lucide-react'
import { buscarRelatorioPorId, getDoctorById, buscarMedicosPorIds, buscarPacientePorId } from '@/lib/api'
import { ENV_CONFIG } from '@/lib/env-config'
import ProtectedRoute from '@/components/shared/ProtectedRoute'
import { useAuth } from '@/hooks/useAuth'
export default function LaudoPage() {
const router = useRouter()
const params = useParams()
const { user } = useAuth()
const { theme } = useTheme()
const reportId = params.id as string
const isDark = theme === 'dark'
const [report, setReport] = useState<any | null>(null)
const [doctor, setDoctor] = useState<any | null>(null)
const [patient, setPatient] = useState<any | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
if (!reportId) return
let mounted = true
async function loadReport() {
try {
setLoading(true)
const reportData = await buscarRelatorioPorId(reportId)
if (!mounted) return
setReport(reportData)
// Load patient info if patient_id exists
const rd = reportData as any
const patientId = rd?.patient_id
if (patientId) {
try {
const patientData = await buscarPacientePorId(patientId).catch(() => null)
if (mounted && patientData) {
setPatient(patientData)
}
} catch (e) {
console.warn('Erro ao carregar dados do paciente:', e)
}
}
// Load doctor info using the same strategy as paciente/page.tsx
const maybeId = rd?.doctor_id ?? rd?.created_by ?? rd?.doctor ?? null
if (maybeId) {
try {
// First try: buscarMedicosPorIds
let doctors = await buscarMedicosPorIds([maybeId]).catch(() => [])
if (!doctors || doctors.length === 0) {
// Second try: getDoctorById
const doc = await getDoctorById(String(maybeId)).catch(() => null)
if (doc) doctors = [doc]
}
if (!doctors || doctors.length === 0) {
// Third try: direct REST with user_id filter
const token = (typeof window !== 'undefined')
? (localStorage.getItem('auth_token') || localStorage.getItem('token') ||
sessionStorage.getItem('auth_token') || sessionStorage.getItem('token'))
: null
const headers: Record<string,string> = {
apikey: (ENV_CONFIG as any).SUPABASE_ANON_KEY,
Accept: 'application/json'
}
if (token) headers.Authorization = `Bearer ${token}`
const url = `${(ENV_CONFIG as any).SUPABASE_URL}/rest/v1/doctors?user_id=eq.${encodeURIComponent(String(maybeId))}&limit=1`
const res = await fetch(url, { method: 'GET', headers })
if (res && res.status < 400) {
const rows = await res.json().catch(() => [])
if (rows && Array.isArray(rows) && rows.length) {
doctors = rows
}
}
}
if (mounted && doctors && doctors.length > 0) {
setDoctor(doctors[0])
}
} catch (e) {
console.warn('Erro ao carregar dados do profissional:', e)
}
}
} catch (err) {
if (mounted) setError('Erro ao carregar o laudo.')
console.error(err)
} finally {
if (mounted) setLoading(false)
}
}
loadReport()
return () => { mounted = false }
}, [reportId])
const handlePrint = () => {
window.print()
}
const handleDownloadPDF = async () => {
if (!report) return
try {
// Para simplificar, vamos usar jsPDF com html2canvas para capturar o conteúdo
const { jsPDF } = await import('jspdf')
const html2canvas = await import('html2canvas').then((m) => m.default)
// Criar um elemento temporário com o conteúdo
const element = document.createElement('div')
element.style.position = 'absolute'
element.style.left = '-9999px'
element.style.width = '210mm' // A4 width
element.style.padding = '20mm'
element.style.backgroundColor = 'white'
element.style.fontFamily = 'Arial, sans-serif'
// Extrair informações
const reportDate = new Date(report.report_date || report.created_at || Date.now()).toLocaleDateString('pt-BR')
const cid = report.cid ?? report.cid_code ?? report.cidCode ?? report.cie ?? ''
const exam = report.exam ?? report.exame ?? report.especialidade ?? report.report_type ?? ''
const diagnosis = report.diagnosis ?? report.diagnostico ?? report.diagnosis_text ?? report.diagnostico_text ?? ''
const conclusion = report.conclusion ?? report.conclusao ?? report.conclusion_text ?? report.conclusao_text ?? ''
const notesText = report.content ?? report.body ?? report.conteudo ?? report.notes ?? report.observacoes ?? ''
// Extrair nome do médico
let doctorName = ''
if (doctor) {
doctorName = doctor.full_name || doctor.name || doctor.fullName || doctor.doctor_name || ''
}
if (!doctorName) {
const rd = report as any
const tryKeys = [
'doctor_name', 'doctor_full_name', 'doctorFullName', 'doctorName',
'requested_by_name', 'requested_by', 'requester_name', 'requester',
'created_by_name', 'created_by', 'executante', 'executante_name',
]
for (const k of tryKeys) {
const v = rd[k]
if (v !== undefined && v !== null && String(v).trim() !== '') {
doctorName = String(v)
break
}
}
}
// Extrair nome do paciente
let patientName = ''
if (patient) {
patientName = patient.full_name || patient.name || ''
}
// Montar HTML do documento
element.innerHTML = `
<div style="border-bottom: 2px solid #3b82f6; padding-bottom: 10px; margin-bottom: 20px;">
<h1 style="text-align: center; font-size: 24px; font-weight: bold; color: #1f2937; margin: 0;">RELATÓRIO MÉDICO</h1>
<p style="text-align: center; font-size: 10px; color: #6b7280; margin: 5px 0;">Data: ${reportDate}</p>
${patientName ? `<p style="text-align: center; font-size: 10px; color: #6b7280; margin: 5px 0;">Paciente: ${patientName}</p>` : ''}
${doctorName ? `<p style="text-align: center; font-size: 10px; color: #6b7280; margin: 5px 0;">Profissional: ${doctorName}</p>` : ''}
</div>
<div style="background-color: #f0f9ff; border: 1px solid #bfdbfe; padding: 10px; margin-bottom: 15px;">
<div style="display: flex; gap: 20px;">
${cid ? `<div><p style="font-size: 9px; font-weight: bold; color: #475569; margin: 0 0 5px 0;">CID</p><p style="font-size: 11px; font-weight: bold; color: #1f2937; margin: 0;">${cid}</p></div>` : ''}
${exam ? `<div><p style="font-size: 9px; font-weight: bold; color: #475569; margin: 0 0 5px 0;">EXAME / TIPO</p><p style="font-size: 11px; font-weight: bold; color: #1f2937; margin: 0;">${exam}</p></div>` : ''}
</div>
</div>
${diagnosis ? `
<div style="margin-bottom: 20px;">
<h2 style="font-size: 14px; font-weight: bold; color: #1e40af; margin: 0 0 10px 0;">DIAGNÓSTICO</h2>
<p style="margin-left: 10px; padding-left: 10px; border-left: 2px solid #3b82f6; background-color: #f3f4f6; font-size: 10px; line-height: 1.5; margin: 0;">${diagnosis}</p>
</div>
` : ''}
${conclusion ? `
<div style="margin-bottom: 20px;">
<h2 style="font-size: 14px; font-weight: bold; color: #1e40af; margin: 0 0 10px 0;">CONCLUSÃO</h2>
<p style="margin-left: 10px; padding-left: 10px; border-left: 2px solid #3b82f6; background-color: #f3f4f6; font-size: 10px; line-height: 1.5; margin: 0;">${conclusion}</p>
</div>
` : ''}
${notesText ? `
<div style="margin-bottom: 20px;">
<h2 style="font-size: 14px; font-weight: bold; color: #1e40af; margin: 0 0 10px 0;">NOTAS DO PROFISSIONAL</h2>
<p style="margin-left: 10px; padding-left: 10px; border-left: 2px solid #3b82f6; background-color: #f3f4f6; font-size: 10px; line-height: 1.5; margin: 0;">${notesText}</p>
</div>
` : ''}
<div style="margin-top: 30px; padding-top: 10px; border-top: 1px solid #e5e7eb; font-size: 8px; text-align: center; color: #9ca3af;">
Documento gerado em ${new Date().toLocaleString('pt-BR')}
</div>
`
document.body.appendChild(element)
// Capturar como canvas
const canvas = await html2canvas(element, {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
})
document.body.removeChild(element)
// Converter para PDF
const imgData = canvas.toDataURL('image/png')
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
})
const imgWidth = 210 // A4 width in mm
const pageHeight = 297 // A4 height in mm
const imgHeight = (canvas.height * imgWidth) / canvas.width
let heightLeft = imgHeight
let position = 0
while (heightLeft >= 0) {
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)
heightLeft -= pageHeight
position -= pageHeight
if (heightLeft > 0) {
pdf.addPage()
}
}
// Download
pdf.save(`laudo-${reportDate}-${doctorName || 'profissional'}.pdf`)
} catch (error) {
console.error('Erro ao gerar PDF:', error)
alert('Erro ao gerar PDF. Tente novamente.')
}
}
if (loading) {
return (
<ProtectedRoute>
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="text-lg text-muted-foreground">Carregando laudo...</div>
</div>
</ProtectedRoute>
)
}
if (error || !report) {
return (
<ProtectedRoute>
<div className="flex flex-col items-center justify-center min-h-screen bg-background">
<div className="text-lg text-red-500 mb-4">{error || 'Laudo não encontrado.'}</div>
<Button onClick={() => router.back()} variant="outline">
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar
</Button>
</div>
</ProtectedRoute>
)
}
// Extract fields with fallbacks
const reportDate = new Date(report.report_date || report.created_at || Date.now()).toLocaleDateString('pt-BR')
const cid = report.cid ?? report.cid_code ?? report.cidCode ?? report.cie ?? ''
const exam = report.exam ?? report.exame ?? report.especialidade ?? report.report_type ?? ''
const diagnosis = report.diagnosis ?? report.diagnostico ?? report.diagnosis_text ?? report.diagnostico_text ?? ''
const conclusion = report.conclusion ?? report.conclusao ?? report.conclusion_text ?? report.conclusao_text ?? ''
const notesHtml = report.content_html ?? report.conteudo_html ?? report.contentHtml ?? null
const notesText = report.content ?? report.body ?? report.conteudo ?? report.notes ?? report.observacoes ?? ''
// Extract doctor name with multiple fallbacks
let doctorName = ''
if (doctor) {
doctorName = doctor.full_name || doctor.name || doctor.fullName || doctor.doctor_name || ''
}
if (!doctorName) {
const rd = report as any
const tryKeys = [
'doctor_name', 'doctor_full_name', 'doctorFullName', 'doctorName',
'requested_by_name', 'requested_by', 'requester_name', 'requester',
'created_by_name', 'created_by', 'executante', 'executante_name',
]
for (const k of tryKeys) {
const v = rd[k]
if (v !== undefined && v !== null && String(v).trim() !== '') {
doctorName = String(v)
break
}
}
}
return (
<ProtectedRoute>
<div className={`min-h-screen transition-colors duration-300 ${
isDark
? 'bg-gradient-to-br from-slate-950 to-slate-900'
: 'bg-gradient-to-br from-slate-50 to-slate-100'
}`}>
{/* Header Toolbar */}
<div className={`sticky top-0 z-40 transition-colors duration-300 print:hidden ${
isDark
? 'bg-slate-800 border-slate-700'
: 'bg-white border-slate-200'
} border-b shadow-md`}>
<div className="flex items-center justify-between px-6 py-4">
{/* Left Section */}
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.back()}
className={`${
isDark
? 'text-slate-300 hover:bg-slate-700 hover:text-white'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}`}
>
<ArrowLeft className="w-5 h-5" />
</Button>
<div className={`h-8 w-px ${isDark ? 'bg-slate-600' : 'bg-slate-300'}`} />
<div>
<p className={`text-xs font-semibold uppercase tracking-wide ${
isDark ? 'text-slate-400' : 'text-slate-500'
}`}>Laudo Médico</p>
<p className={`text-lg font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{doctorName || 'Profissional'}
</p>
</div>
</div>
{/* Right Section */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={handlePrint}
title="Imprimir"
className={`${
isDark
? 'text-slate-300 hover:bg-slate-700 hover:text-white'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}`}
>
<Printer className="w-5 h-5" />
</Button>
</div>
</div>
</div>
{/* Main Content Area */}
<div className="flex justify-center py-6 sm:py-8 md:py-12 px-2 sm:px-4 print:py-0 print:px-0 min-h-[calc(100vh-80px)] print:min-h-screen">
{/* Document Container */}
<div className={`w-full max-w-2xl sm:max-w-3xl md:max-w-4xl transition-colors duration-300 shadow-lg sm:shadow-xl rounded-lg sm:rounded-xl overflow-hidden print:shadow-none print:rounded-none print:max-w-full ${
isDark ? 'bg-slate-800' : 'bg-white'
}`}>
{/* Document Content */}
<div className="p-4 sm:p-8 md:p-12 lg:p-16 space-y-4 sm:space-y-6 md:space-y-8 print:p-12 print:space-y-6">
{/* Title */}
<div className={`text-center mb-6 sm:mb-8 md:mb-12 pb-4 sm:pb-6 md:pb-8 border-b-2 ${
isDark ? 'border-blue-900' : 'border-blue-200'
}`}>
<h1 className={`text-2xl sm:text-3xl md:text-4xl font-bold mb-2 sm:mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
RELATÓRIO MÉDICO
</h1>
<div className={`text-xs sm:text-sm space-y-0.5 sm:space-y-1 ${isDark ? 'text-slate-300' : 'text-slate-700'}`}>
<p className="font-medium">
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>Data:</span> {reportDate}
</p>
{doctorName && (
<p className="font-medium">
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>Profissional:</span>{' '}
<strong className={isDark ? 'text-blue-400' : 'text-blue-700'}>{doctorName}</strong>
</p>
)}
</div>
</div>
{/* Patient/Header Info */}
<div className={`rounded-lg p-3 sm:p-4 md:p-6 border transition-colors duration-300 ${
isDark
? 'bg-slate-900 border-slate-700'
: 'bg-slate-50 border-slate-200'
}`}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4 md:gap-6 text-xs sm:text-sm">
{patient && (
<div>
<label className={`text-xs uppercase font-semibold tracking-wide block mb-1.5 sm:mb-2 ${
isDark ? 'text-slate-400' : 'text-slate-600'
}`}>Paciente</label>
<p className={`text-base sm:text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{patient.full_name || patient.name || 'N/A'}
</p>
</div>
)}
{cid && (
<div>
<label className={`text-xs uppercase font-semibold tracking-wide block mb-1.5 sm:mb-2 ${
isDark ? 'text-slate-400' : 'text-slate-600'
}`}>CID</label>
<p className={`text-base sm:text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{cid}
</p>
</div>
)}
{exam && (
<div>
<label className={`text-xs uppercase font-semibold tracking-wide block mb-1.5 sm:mb-2 ${
isDark ? 'text-slate-400' : 'text-slate-600'
}`}>Exame / Tipo</label>
<p className={`text-base sm:text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{exam}
</p>
</div>
)}
</div>
</div>
{/* Diagnosis Section */}
{diagnosis && (
<div className="space-y-2 sm:space-y-3">
<h2 className={`text-lg sm:text-xl font-bold uppercase tracking-wide ${
isDark ? 'text-blue-400' : 'text-blue-700'
}`}>Diagnóstico</h2>
<div className={`whitespace-pre-wrap text-sm sm:text-base leading-relaxed rounded-lg p-3 sm:p-4 border-l-4 border-blue-500 transition-colors duration-300 ${
isDark
? 'bg-slate-900 text-slate-200'
: 'bg-blue-50 text-slate-800'
}`}>
{diagnosis}
</div>
</div>
)}
{/* Conclusion Section */}
{conclusion && (
<div className="space-y-2 sm:space-y-3">
<h2 className={`text-lg sm:text-xl font-bold uppercase tracking-wide ${
isDark ? 'text-blue-400' : 'text-blue-700'
}`}>Conclusão</h2>
<div className={`whitespace-pre-wrap text-sm sm:text-base leading-relaxed rounded-lg p-3 sm:p-4 border-l-4 border-blue-500 transition-colors duration-300 ${
isDark
? 'bg-slate-900 text-slate-200'
: 'bg-blue-50 text-slate-800'
}`}>
{conclusion}
</div>
</div>
)}
{/* Notes/Content Section */}
{(notesHtml || notesText) && (
<div className="space-y-2 sm:space-y-3">
<h2 className={`text-lg sm:text-xl font-bold uppercase tracking-wide ${
isDark ? 'text-blue-400' : 'text-blue-700'
}`}>Notas do Profissional</h2>
{notesHtml ? (
<div
className={`prose prose-sm max-w-none rounded-lg p-3 sm:p-4 border-l-4 border-blue-500 transition-colors duration-300 text-xs sm:text-sm ${
isDark
? 'prose-invert bg-slate-900 text-slate-200'
: 'bg-blue-50 text-slate-800'
}`}
dangerouslySetInnerHTML={{ __html: String(notesHtml) }}
/>
) : (
<div className={`whitespace-pre-wrap text-sm sm:text-base leading-relaxed rounded-lg p-3 sm:p-4 border-l-4 border-blue-500 transition-colors duration-300 ${
isDark
? 'bg-slate-900 text-slate-200'
: 'bg-blue-50 text-slate-800'
}`}>
{notesText}
</div>
)}
</div>
)}
{/* Signature Section */}
{report.doctor_signature && (
<div className={`pt-6 sm:pt-8 border-t-2 ${isDark ? 'border-slate-600' : 'border-slate-300'}`}>
<div className="flex flex-col items-center gap-3 sm:gap-4">
<div className={`rounded-lg p-2 sm:p-4 border transition-colors duration-300 ${
isDark
? 'bg-slate-900 border-slate-600'
: 'bg-slate-100 border-slate-300'
}`}>
<Image
src={report.doctor_signature}
alt="Assinatura do profissional"
width={150}
height={100}
className="h-16 sm:h-20 w-auto"
/>
</div>
{doctorName && (
<div className="text-center">
<p className={`text-xs sm:text-sm font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{doctorName}
</p>
{doctor?.crm && (
<p className={`text-xs mt-0.5 sm:mt-1 ${isDark ? 'text-slate-400' : 'text-slate-600'}`}>
CRM: {doctor.crm}
</p>
)}
</div>
)}
</div>
</div>
)}
{/* Footer */}
<div className={`pt-8 border-t-2 text-center space-y-2 ${isDark ? 'border-slate-600' : 'border-slate-300'}`}>
<p className={`text-xs ${isDark ? 'text-slate-400' : 'text-slate-600'}`}>
Documento gerado em {new Date().toLocaleString('pt-BR')}
</p>
</div>
</div>
</div>
</div>
</div>
</ProtectedRoute>
)
}

34
susconecta/app/layout.tsx Normal file
View File

@ -0,0 +1,34 @@
import type React from "react"
import type { Metadata } from "next"
import { AuthProvider } from "@/hooks/useAuth"
import { ThemeProvider } from "@/components/providers/theme-provider"
import "./globals.css"
export const metadata: Metadata = {
title: "MediConnect - Conectando Pacientes e Profissionais de Saúde",
description:
"Plataforma inovadora que conecta pacientes, clínicas, e médicos de forma prática, segura e humanizada. Experimente o futuro dos agendamentos médicos.",
keywords: "saúde, médicos, pacientes, agendamento, telemedicina, SUS",
generator: 'v0.app'
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="pt-BR" className="antialiased" suppressHydrationWarning>
<head>
<meta charSet="utf-8" />
</head>
<body style={{ fontFamily: "var(--font-geist-sans)" }}>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
</body>
</html>
)
}

View File

@ -0,0 +1,11 @@
import type { ReactNode } from "react";
import { ChatWidget } from "@/components/features/pacientes/chat-widget";
export default function PacienteLayout({ children }: { children: ReactNode }) {
return (
<>
{children}
<ChatWidget />
</>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,10 @@
import React, { Suspense } from 'react'
import ResultadosClient from './ResultadosClient'
export default function Page() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><span>Carregando...</span></div>}>
<ResultadosClient />
</Suspense>
)
}

15
susconecta/app/page.tsx Normal file
View File

@ -0,0 +1,15 @@
import { Header } from "@/components/layout/header"
import { HeroSection } from "@/components/features/general/hero-section"
import { Footer } from "@/components/layout/footer"
export default function HomePage() {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<HeroSection />
</main>
<Footer />
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,15 @@
import { Header } from "@/components/layout/header"
import { AboutSection } from "@/components/features/general/about-section"
import { Footer } from "@/components/layout/footer"
export default function AboutPage() {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<AboutSection />
</main>
<Footer />
</div>
)
}

View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -0,0 +1,321 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SimpleThemeToggle } from "@/components/ui/simple-theme-toggle";
import { Clock, Info, Lock, MessageCircle, Plus, Upload } from "lucide-react";
const API_ENDPOINT = "https://n8n.jonasbomfim.store/webhook/cd7d10e6-bcfc-4f3a-b649-351d12b714f1";
const FALLBACK_RESPONSE = "Tive um problema para responder agora. Tente novamente em alguns instantes.";
export interface ChatMessage {
id: string;
sender: "user" | "assistant";
content: string;
createdAt: string;
}
export interface ChatSession {
id: string;
startedAt: string;
updatedAt: string;
topic: string;
messages: ChatMessage[];
}
interface AIAssistantInterfaceProps {
onOpenDocuments?: () => void;
onOpenChat?: () => void;
history?: ChatSession[];
onAddHistory?: (session: ChatSession) => void;
onClearHistory?: () => void;
}
export function AIAssistantInterface({
onOpenDocuments,
onOpenChat,
history: externalHistory,
onAddHistory,
onClearHistory,
}: AIAssistantInterfaceProps) {
const [question, setQuestion] = useState("");
const [internalHistory, setInternalHistory] = useState<ChatSession[]>(externalHistory ?? []);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [manualSelection, setManualSelection] = useState(false);
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
const messageListRef = useRef<HTMLDivElement | null>(null);
const pdfInputRef = useRef<HTMLInputElement | null>(null);
const [pdfFile, setPdfFile] = useState<File | null>(null); // arquivo PDF selecionado
const history = internalHistory;
const historyRef = useRef<ChatSession[]>(history);
const baseGreeting = "Olá, eu sou Zoe. Como posso ajudar hoje?";
const greetingWords = useMemo(() => baseGreeting.split(" "), [baseGreeting]);
const [typedGreeting, setTypedGreeting] = useState("");
const [typedIndex, setTypedIndex] = useState(0);
const [isTypingGreeting, setIsTypingGreeting] = useState(true);
const [gradientGreeting, plainGreeting] = useMemo(() => {
if (!typedGreeting) return ["", ""] as const;
const separatorIndex = typedGreeting.indexOf("Como");
if (separatorIndex === -1) {
return [typedGreeting, ""] as const;
}
const gradientPart = typedGreeting.slice(0, separatorIndex).trimEnd();
const plainPart = typedGreeting.slice(separatorIndex).trimStart();
return [gradientPart, plainPart] as const;
}, [typedGreeting]);
useEffect(() => {
if (externalHistory) {
setInternalHistory(externalHistory);
}
}, [externalHistory]);
useEffect(() => {
historyRef.current = history;
}, [history]);
const activeSession = useMemo(
() => history.find((session) => session.id === activeSessionId) ?? null,
[history, activeSessionId]
);
const activeMessages = activeSession?.messages ?? [];
const formatTime = useCallback(
(value: string) =>
new Date(value).toLocaleTimeString("pt-BR", {
hour: "2-digit",
minute: "2-digit",
}),
[]
);
const upsertSession = useCallback(
(session: ChatSession) => {
if (onAddHistory) {
onAddHistory(session);
} else {
setInternalHistory((prev) => {
const index = prev.findIndex((s) => s.id === session.id);
if (index >= 0) {
const updated = [...prev];
updated[index] = session;
return updated;
}
return [...prev, session];
});
}
setActiveSessionId(session.id);
setManualSelection(false);
},
[onAddHistory]
);
const sendMessageToAssistant = useCallback(
async (prompt: string, baseSession: ChatSession) => {
const sessionId = baseSession.id;
const appendAssistantMessage = (content: string) => {
const createdAt = new Date().toISOString();
const assistantMessage: ChatMessage = {
id: `msg-assistant-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
sender: "assistant",
content,
createdAt,
};
const latestSession =
historyRef.current.find((s) => s.id === sessionId) ?? baseSession;
const updatedSession: ChatSession = {
...latestSession,
updatedAt: createdAt,
messages: [...latestSession.messages, assistantMessage],
};
upsertSession(updatedSession);
};
try {
let replyText = "";
let response: Response;
if (pdfFile) {
// Monta FormData conforme especificação: campos 'pdf' e 'message'
const formData = new FormData();
formData.append("pdf", pdfFile);
formData.append("message", prompt);
response = await fetch(API_ENDPOINT, {
method: "POST",
body: formData, // multipart/form-data gerenciado pelo browser
});
} else {
response = await fetch(API_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ message: prompt }),
});
}
const rawPayload = await response.text();
if (rawPayload.trim()) {
try {
const parsed = JSON.parse(rawPayload) as { message?: unknown; reply?: unknown };
if (typeof parsed.reply === "string") {
replyText = parsed.reply.trim();
} else if (typeof parsed.message === "string") {
replyText = parsed.message.trim();
}
} catch (error) {
console.error("[ZoeIA] Resposta JSON inválida", error, rawPayload);
}
}
appendAssistantMessage(replyText || FALLBACK_RESPONSE);
} catch (error) {
console.error("[ZoeIA] Erro ao buscar resposta da API", error);
appendAssistantMessage(FALLBACK_RESPONSE);
}
},
[upsertSession, pdfFile]
);
const handleSendMessage = () => {
const trimmed = question.trim();
if (!trimmed) return;
const now = new Date();
const userMessage: ChatMessage = {
id: `msg-user-${now.getTime()}`,
sender: "user",
content: trimmed,
createdAt: now.toISOString(),
};
const session = history.find((s) => s.id === activeSessionId);
const sessionToUse: ChatSession = session
? {
...session,
updatedAt: userMessage.createdAt,
messages: [...session.messages, userMessage],
}
: {
id: `session-${now.getTime()}`,
startedAt: now.toISOString(),
updatedAt: userMessage.createdAt,
topic: trimmed.length > 60 ? `${trimmed.slice(0, 57)}` : trimmed,
messages: [userMessage],
};
upsertSession(sessionToUse);
setQuestion("");
setHistoryPanelOpen(false);
void sendMessageToAssistant(trimmed, sessionToUse);
};
const handleSelectPdf = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file && file.type === "application/pdf") {
setPdfFile(file);
}
// Permite re-selecionar o mesmo arquivo
e.target.value = "";
};
const removePdf = () => setPdfFile(null);
return (
<div className="w-full max-w-3xl mx-auto p-4 space-y-4">
{/* Área superior exibindo PDF selecionado */}
{pdfFile && (
<div className="flex items-center justify-between border rounded-lg p-3 bg-muted/50">
<div className="flex items-center gap-3 min-w-0">
<Upload className="w-5 h-5 text-primary" />
<div className="min-w-0">
<p className="text-sm font-medium truncate" title={pdfFile.name}>{pdfFile.name}</p>
<p className="text-xs text-muted-foreground">
PDF anexado {(pdfFile.size / 1024).toFixed(1)} KB
</p>
</div>
</div>
<Button variant="secondary" size="sm" onClick={removePdf}>
Remover
</Button>
</div>
)}
{/* Lista de mensagens */}
<div
ref={messageListRef}
className="border rounded-lg p-4 h-96 overflow-y-auto space-y-3 bg-background"
>
{activeMessages.length === 0 && (
<p className="text-sm text-muted-foreground">Nenhuma mensagem ainda. Envie uma pergunta.</p>
)}
{activeMessages.map((m) => (
<div
key={m.id}
className={`flex ${m.sender === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`px-3 py-2 rounded-lg max-w-xs text-sm whitespace-pre-wrap ${
m.sender === "user" ? "bg-primary text-primary-foreground" : "bg-muted"
}`}
>
{m.content}
<div className="mt-1 text-[10px] opacity-70">
{formatTime(m.createdAt)}
</div>
</div>
</div>
))}
</div>
{/* Input & ações */}
<div className="flex items-center gap-2">
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => pdfInputRef.current?.click()}
>
PDF
</Button>
<input
ref={pdfInputRef}
type="file"
accept="application/pdf"
className="hidden"
onChange={handleSelectPdf}
/>
</div>
<Input
placeholder="Digite sua pergunta"
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
/>
<Button onClick={handleSendMessage} disabled={!question.trim()}>
Enviar
</Button>
</div>
<div className="text-xs text-muted-foreground">
{pdfFile
? "A próxima mensagem será enviada junto ao PDF como multipart/form-data."
: "Selecione um PDF para anexar ao próximo envio."}
</div>
</div>
);
}

View File

@ -0,0 +1,196 @@
"use client";
import React, { useRef, useState } from "react";
import { VoicePoweredOrb } from "@/components/ZoeIA/voice-powered-orb";
import { Button } from "@/components/ui/button";
import { Mic, MicOff } from "lucide-react";
// ⚠ Coloque aqui o webhook real do seu n8n
const N8N_WEBHOOK_URL = "https://n8n.jonasbomfim.store/webhook/zoe2";
const AIVoiceFlow: React.FC = () => {
const [isRecording, setIsRecording] = useState(false);
const [isSending, setIsSending] = useState(false);
const [voiceDetected, setVoiceDetected] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [replyAudioUrl, setReplyAudioUrl] = useState<string | null>(null); // URL do áudio retornado
const [replyAudio, setReplyAudio] = useState<HTMLAudioElement | null>(null); // elemento de áudio reproduzido
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
// 🚀 Inicia gravação
const startRecording = async () => {
try {
setError(null);
setStatus("Iniciando microfone...");
// Se estava reproduzindo áudio da IA → parar imediatamente
if (replyAudio) {
replyAudio.pause();
replyAudio.currentTime = 0;
}
setReplyAudio(null);
setReplyAudioUrl(null);
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const recorder = new MediaRecorder(stream);
mediaRecorderRef.current = recorder;
chunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = async () => {
setStatus("Processando áudio...");
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
await sendToN8N(blob);
chunksRef.current = [];
};
recorder.start();
setIsRecording(true);
setStatus("Gravando... fale algo.");
} catch (err) {
console.error(err);
setError("Erro ao acessar microfone.");
}
};
// ⏹ Finaliza gravação
const stopRecording = () => {
try {
setIsRecording(false);
setStatus("Finalizando gravação...");
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop();
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
} catch (err) {
console.error(err);
setError("Erro ao parar gravação.");
}
};
// 📤 Envia áudio ao N8N e recebe o MP3
const sendToN8N = async (audioBlob: Blob) => {
try {
setIsSending(true);
setStatus("Enviando áudio para IA...");
const formData = new FormData();
formData.append("audio", audioBlob, "voz.webm");
const resp = await fetch(N8N_WEBHOOK_URL, {
method: "POST",
body: formData,
});
if (!resp.ok) {
throw new Error("N8N retornou erro");
}
const replyBlob = await resp.blob();
// gera url local
const url = URL.createObjectURL(replyBlob);
setReplyAudioUrl(url);
const audio = new Audio(url);
setReplyAudio(audio);
setStatus("Reproduzindo resposta...");
audio.play().catch(() => {});
} catch (err) {
console.error(err);
setError("Erro ao enviar/receber áudio.");
} finally {
setIsSending(false);
}
};
const toggleRecording = () => {
if (isRecording) stopRecording();
else startRecording();
};
return (
<div className="flex flex-col items-center justify-center gap-6 p-6">
{/* ORB — agora com comportamento inteligente */}
<div className="w-72 h-72 relative">
<VoicePoweredOrb
className="w-full h-full"
/* 🔥 LÓGICA DO ORB:
- Gravando? usa microfone
- Não gravando, mas tem MP3? usa áudio da IA
- Caso contrário parado (none)
*/
{...({ sourceMode:
isRecording
? "microphone"
: replyAudio
? "playback"
: "none"
} as any)}
audioElement={replyAudio}
onVoiceDetected={setVoiceDetected}
/>
{isRecording && (
<span className="absolute bottom-4 right-4 rounded-full bg-black/70 px-3 py-1 text-xs font-medium text-white shadow-lg">
{voiceDetected ? "Ouvindo…" : "Aguardando voz…"}
</span>
)}
</div>
{/* 🟣 Botão de gravação */}
<Button
onClick={toggleRecording}
variant={isRecording ? "destructive" : "default"}
size="lg"
disabled={isSending}
>
{isRecording ? (
<>
<MicOff className="w-5 h-5 mr-2" /> Parar gravação
</>
) : (
<>
<Mic className="w-5 h-5 mr-2" /> Começar gravação
</>
)}
</Button>
{/* STATUS */}
{status && <p className="text-sm text-muted-foreground">{status}</p>}
{error && <p className="text-sm text-red-500">{error}</p>}
{/* PLAYER MANUAL DA RESPOSTA */}
{replyAudioUrl && (
<div className="w-full max-w-md mt-2 flex flex-col items-center gap-2">
<span className="text-xs text-muted-foreground">Última resposta da IA:</span>
<audio controls src={replyAudioUrl} className="w-full" />
</div>
)}
</div>
);
};
export default AIVoiceFlow;

View File

@ -0,0 +1,5 @@
import Component from "@/components/ui/file-upload-and-chat";
export default function FileUploadChat() {
return <Component />;
}

View File

@ -0,0 +1,107 @@
"use client";
import { useEffect, useState } from "react";
import { VoicePoweredOrb } from "@/components/ZoeIA/voice-powered-orb";
import { AIAssistantInterface } from "@/components/ZoeIA/ai-assistant-interface";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Mic, MicOff } from "lucide-react";
export default function VoicePoweredOrbPage() {
const [isRecording, setIsRecording] = useState(false);
const [voiceDetected, setVoiceDetected] = useState(false);
const [assistantOpen, setAssistantOpen] = useState(false);
const toggleRecording = () => {
setIsRecording(!isRecording);
};
useEffect(() => {
if (!assistantOpen) return;
const original = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = original;
};
}, [assistantOpen]);
const openAssistant = () => setAssistantOpen(true);
const closeAssistant = () => setAssistantOpen(false);
return (
<div className="min-h-screen d flex items-center justify-center p-8">
<div className="flex flex-col items-center space-y-8">
{assistantOpen && (
<div className="fixed inset-0 z-50 flex flex-col bg-background">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<Button
type="button"
variant="ghost"
className="flex items-center gap-2"
onClick={closeAssistant}
>
<ArrowLeft className="h-4 w-4" />
Voltar
</Button>
</div>
<div className="flex-1 overflow-auto">
<AIAssistantInterface />
</div>
</div>
)}
{/* Orb */}
<div
className="w-96 h-96 relative cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
role="button"
tabIndex={0}
aria-label="Abrir assistente virtual"
onClick={openAssistant}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openAssistant();
}
}}
>
<VoicePoweredOrb
enableVoiceControl={isRecording}
className="rounded-xl overflow-hidden shadow-2xl"
onVoiceDetected={setVoiceDetected}
/>
{voiceDetected && (
<span className="absolute bottom-4 right-4 rounded-full bg-primary/90 px-3 py-1 text-xs font-medium text-primary-foreground shadow-lg">
Ouvindo
</span>
)}
</div>
{/* Control Button */}
<Button
onClick={toggleRecording}
variant={isRecording ? "destructive" : "default"}
size="lg"
className="px-8 py-3"
>
{isRecording ? (
<>
<MicOff className="w-5 h-5 mr-3" />
Stop Recording
</>
) : (
<>
<Mic className="w-5 h-5 mr-3" />
Start Recording
</>
)}
</Button>
{/* Simple Instructions */}
<p className="text-muted-foreground text-center max-w-md">
Click the button to enable voice control. Speak to see the orb respond to your voice with subtle movements.
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
import * as React from "react"
import { AIAssistantInterface } from "@/components/ZoeIA/ai-assistant-interface"
export function Demo() {
return (
<div className="w-screen">
<AIAssistantInterface />
</div>
)
}

View File

@ -0,0 +1,493 @@
"use client";
import React, { useEffect, useRef, FC } from "react";
import { Renderer, Program, Mesh, Triangle, Vec3 } from "ogl";
import { cn } from "@/lib/utils";
interface VoicePoweredOrbProps {
className?: string;
hue?: number;
enableVoiceControl?: boolean;
voiceSensitivity?: number;
maxRotationSpeed?: number;
maxHoverIntensity?: number;
onVoiceDetected?: (detected: boolean) => void;
}
export const VoicePoweredOrb: FC<VoicePoweredOrbProps> = ({
className,
hue = 0,
enableVoiceControl = true,
voiceSensitivity = 1.5,
maxRotationSpeed = 1.2,
maxHoverIntensity = 0.8,
onVoiceDetected,
}) => {
const ctnDom = useRef<HTMLDivElement>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const microphoneRef = useRef<MediaStreamAudioSourceNode | null>(null);
const dataArrayRef = useRef<Uint8Array | null>(null);
const animationFrameRef = useRef<number>();
const mediaStreamRef = useRef<MediaStream | null>(null);
const vert = /* glsl */ `
precision highp float;
attribute vec2 position;
attribute vec2 uv;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}
`;
const frag = /* glsl */ `
precision highp float;
uniform float iTime;
uniform vec3 iResolution;
uniform float hue;
uniform float hover;
uniform float rot;
uniform float hoverIntensity;
varying vec2 vUv;
vec3 rgb2yiq(vec3 c) {
float y = dot(c, vec3(0.299, 0.587, 0.114));
float i = dot(c, vec3(0.596, -0.274, -0.322));
float q = dot(c, vec3(0.211, -0.523, 0.312));
return vec3(y, i, q);
}
vec3 yiq2rgb(vec3 c) {
float r = c.x + 0.956 * c.y + 0.621 * c.z;
float g = c.x - 0.272 * c.y - 0.647 * c.z;
float b = c.x - 1.106 * c.y + 1.703 * c.z;
return vec3(r, g, b);
}
vec3 adjustHue(vec3 color, float hueDeg) {
float hueRad = hueDeg * 3.14159265 / 180.0;
vec3 yiq = rgb2yiq(color);
float cosA = cos(hueRad);
float sinA = sin(hueRad);
float i = yiq.y * cosA - yiq.z * sinA;
float q = yiq.y * sinA + yiq.z * cosA;
yiq.y = i;
yiq.z = q;
return yiq2rgb(yiq);
}
vec3 hash33(vec3 p3) {
p3 = fract(p3 * vec3(0.1031, 0.11369, 0.13787));
p3 += dot(p3, p3.yxz + 19.19);
return -1.0 + 2.0 * fract(vec3(
p3.x + p3.y,
p3.x + p3.z,
p3.y + p3.z
) * p3.zyx);
}
float snoise3(vec3 p) {
const float K1 = 0.333333333;
const float K2 = 0.166666667;
vec3 i = floor(p + (p.x + p.y + p.z) * K1);
vec3 d0 = p - (i - (i.x + i.y + i.z) * K2);
vec3 e = step(vec3(0.0), d0 - d0.yzx);
vec3 i1 = e * (1.0 - e.zxy);
vec3 i2 = 1.0 - e.zxy * (1.0 - e);
vec3 d1 = d0 - (i1 - K2);
vec3 d2 = d0 - (i2 - K1);
vec3 d3 = d0 - 0.5;
vec4 h = max(0.6 - vec4(
dot(d0, d0),
dot(d1, d1),
dot(d2, d2),
dot(d3, d3)
), 0.0);
vec4 n = h * h * h * h * vec4(
dot(d0, hash33(i)),
dot(d1, hash33(i + i1)),
dot(d2, hash33(i + i2)),
dot(d3, hash33(i + 1.0))
);
return dot(vec4(31.316), n);
}
vec4 extractAlpha(vec3 colorIn) {
float a = max(max(colorIn.r, colorIn.g), colorIn.b);
return vec4(colorIn.rgb / (a + 1e-5), a);
}
const vec3 baseColor1 = vec3(0.611765, 0.262745, 0.996078);
const vec3 baseColor2 = vec3(0.298039, 0.760784, 0.913725);
const vec3 baseColor3 = vec3(0.062745, 0.078431, 0.600000);
const float innerRadius = 0.6;
const float noiseScale = 0.65;
float light1(float intensity, float attenuation, float dist) {
return intensity / (1.0 + dist * attenuation);
}
float light2(float intensity, float attenuation, float dist) {
return intensity / (1.0 + dist * dist * attenuation);
}
vec4 draw(vec2 uv) {
vec3 color1 = adjustHue(baseColor1, hue);
vec3 color2 = adjustHue(baseColor2, hue);
vec3 color3 = adjustHue(baseColor3, hue);
float ang = atan(uv.y, uv.x);
float len = length(uv);
float invLen = len > 0.0 ? 1.0 / len : 0.0;
float n0 = snoise3(vec3(uv * noiseScale, iTime * 0.5)) * 0.5 + 0.5;
float r0 = mix(mix(innerRadius, 1.0, 0.4), mix(innerRadius, 1.0, 0.6), n0);
float d0 = distance(uv, (r0 * invLen) * uv);
float v0 = light1(1.0, 10.0, d0);
v0 *= smoothstep(r0 * 1.05, r0, len);
float cl = cos(ang + iTime * 2.0) * 0.5 + 0.5;
float a = iTime * -1.0;
vec2 pos = vec2(cos(a), sin(a)) * r0;
float d = distance(uv, pos);
float v1 = light2(1.5, 5.0, d);
v1 *= light1(1.0, 50.0, d0);
float v2 = smoothstep(1.0, mix(innerRadius, 1.0, n0 * 0.5), len);
float v3 = smoothstep(innerRadius, mix(innerRadius, 1.0, 0.5), len);
vec3 col = mix(color1, color2, cl);
col = mix(color3, col, v0);
col = (col + v1) * v2 * v3;
col = clamp(col, 0.0, 1.0);
return extractAlpha(col);
}
vec4 mainImage(vec2 fragCoord) {
vec2 center = iResolution.xy * 0.5;
float size = min(iResolution.x, iResolution.y);
vec2 uv = (fragCoord - center) / size * 2.0;
float angle = rot;
float s = sin(angle);
float c = cos(angle);
uv = vec2(c * uv.x - s * uv.y, s * uv.x + c * uv.y);
uv.x += hover * hoverIntensity * 0.1 * sin(uv.y * 10.0 + iTime);
uv.y += hover * hoverIntensity * 0.1 * sin(uv.x * 10.0 + iTime);
return draw(uv);
}
void main() {
vec2 fragCoord = vUv * iResolution.xy;
vec4 col = mainImage(fragCoord);
gl_FragColor = vec4(col.rgb * col.a, col.a);
}
`;
// Voice analysis function
const analyzeAudio = () => {
if (!analyserRef.current || !dataArrayRef.current) return 0;
// To avoid type incompatibilities between different ArrayBuffer-like types
// (Uint8Array<ArrayBufferLike> vs Uint8Array<ArrayBuffer>), create a
// standard Uint8Array copy with an ArrayBuffer backing it. This satisfies
// the Web Audio API typing and is safe (small cost to copy).
const src = dataArrayRef.current as Uint8Array;
const buffer = Uint8Array.from(src);
analyserRef.current.getByteFrequencyData(buffer);
// Calculate RMS (Root Mean Square) for better voice detection
let sum = 0;
for (let i = 0; i < buffer.length; i++) {
const value = buffer[i] / 255;
sum += value * value;
}
const rms = Math.sqrt(sum / buffer.length);
// Apply sensitivity and boost the signal
const level = Math.min(rms * voiceSensitivity * 3.0, 1);
return level;
};
// Stop microphone and cleanup
const stopMicrophone = () => {
try {
// Stop all tracks in the media stream
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach(track => {
track.stop();
});
mediaStreamRef.current = null;
}
// Disconnect and cleanup audio nodes
if (microphoneRef.current) {
microphoneRef.current.disconnect();
microphoneRef.current = null;
}
if (analyserRef.current) {
analyserRef.current.disconnect();
analyserRef.current = null;
}
// Close audio context
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
audioContextRef.current.close();
audioContextRef.current = null;
}
dataArrayRef.current = null;
console.log('Microphone stopped and cleaned up');
} catch (error) {
console.warn('Error stopping microphone:', error);
}
};
// Initialize microphone access
const initMicrophone = async () => {
try {
// Clean up any existing microphone first
stopMicrophone();
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
sampleRate: 44100,
},
});
mediaStreamRef.current = stream;
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
if (audioContextRef.current.state === 'suspended') {
await audioContextRef.current.resume();
}
analyserRef.current = audioContextRef.current.createAnalyser();
microphoneRef.current = audioContextRef.current.createMediaStreamSource(stream);
analyserRef.current.fftSize = 512;
analyserRef.current.smoothingTimeConstant = 0.3;
analyserRef.current.minDecibels = -90;
analyserRef.current.maxDecibels = -10;
microphoneRef.current.connect(analyserRef.current);
dataArrayRef.current = new Uint8Array(analyserRef.current.frequencyBinCount);
console.log('Microphone initialized successfully');
return true;
} catch (error) {
console.warn("Microphone access denied or not available:", error);
return false;
}
};
useEffect(() => {
const container = ctnDom.current;
if (!container) return;
let rendererInstance: any = null;
let glContext: WebGLRenderingContext | WebGL2RenderingContext | null = null;
let rafId: number;
let program: any = null;
try {
rendererInstance = new Renderer({
alpha: true,
premultipliedAlpha: false,
antialias: true,
dpr: window.devicePixelRatio || 1
});
glContext = rendererInstance.gl as WebGLRenderingContext;
glContext.clearColor(0, 0, 0, 0);
glContext.enable((glContext as any).BLEND);
glContext.blendFunc((glContext as any).SRC_ALPHA, (glContext as any).ONE_MINUS_SRC_ALPHA);
while (container.firstChild) {
container.removeChild(container.firstChild);
}
container.appendChild((glContext as any).canvas);
const geometry = new Triangle(glContext as any);
program = new Program(glContext as any, {
vertex: vert,
fragment: frag,
uniforms: {
iTime: { value: 0 },
iResolution: {
value: new Vec3(
(glContext as any).canvas.width,
(glContext as any).canvas.height,
(glContext as any).canvas.width / (glContext as any).canvas.height
),
},
hue: { value: hue },
hover: { value: 0 },
rot: { value: 0 },
hoverIntensity: { value: 0 },
},
});
const mesh = new Mesh(glContext as any, { geometry, program });
const resize = () => {
if (!container || !rendererInstance || !glContext) return;
const dpr = window.devicePixelRatio || 1;
const width = container.clientWidth;
const height = container.clientHeight;
if (width === 0 || height === 0) return;
rendererInstance.setSize(width * dpr, height * dpr);
(glContext as any).canvas.style.width = width + "px";
(glContext as any).canvas.style.height = height + "px";
if (program) {
program.uniforms.iResolution.value.set(
(glContext as any).canvas.width,
(glContext as any).canvas.height,
(glContext as any).canvas.width / (glContext as any).canvas.height
);
}
};
window.addEventListener("resize", resize);
resize();
let lastTime = 0;
let currentRot = 0;
let voiceLevel = 0;
const baseRotationSpeed = 0.3;
let isMicrophoneInitialized = false;
if (enableVoiceControl) {
initMicrophone().then((success) => {
isMicrophoneInitialized = success;
});
} else {
stopMicrophone();
isMicrophoneInitialized = false;
}
const update = (t: number) => {
rafId = requestAnimationFrame(update);
if (!program) return;
const dt = (t - lastTime) * 0.001;
lastTime = t;
program.uniforms.iTime.value = t * 0.001;
program.uniforms.hue.value = hue;
if (enableVoiceControl && isMicrophoneInitialized) {
voiceLevel = analyzeAudio();
if (onVoiceDetected) {
onVoiceDetected(voiceLevel > 0.1);
}
const voiceRotationSpeed = baseRotationSpeed + (voiceLevel * maxRotationSpeed * 2.0);
if (voiceLevel > 0.05) {
currentRot += dt * voiceRotationSpeed;
}
program.uniforms.hover.value = Math.min(voiceLevel * 2.0, 1.0);
program.uniforms.hoverIntensity.value = Math.min(voiceLevel * maxHoverIntensity * 0.8, maxHoverIntensity);
} else {
program.uniforms.hover.value = 0;
program.uniforms.hoverIntensity.value = 0;
if (onVoiceDetected) {
onVoiceDetected(false);
}
}
program.uniforms.rot.value = currentRot;
if (rendererInstance && glContext) {
glContext.clear((glContext as any).COLOR_BUFFER_BIT | (glContext as any).DEPTH_BUFFER_BIT);
rendererInstance.render({ scene: mesh });
}
};
rafId = requestAnimationFrame(update);
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener("resize", resize);
try {
if (container && glContext && (glContext as any).canvas) {
if (container.contains((glContext as any).canvas)) {
container.removeChild((glContext as any).canvas);
}
}
} catch (error) {
console.warn("Canvas cleanup error:", error);
}
stopMicrophone();
if (glContext) {
(glContext as any).getExtension("WEBGL_lose_context")?.loseContext();
}
};
} catch (error) {
console.error("Error initializing Voice Powered Orb:", error);
if (container && container.firstChild) {
container.removeChild(container.firstChild);
}
return () => {
window.removeEventListener("resize", () => {});
};
}
}, [
hue,
enableVoiceControl,
voiceSensitivity,
maxRotationSpeed,
maxHoverIntensity,
vert,
frag,
]);
useEffect(() => {
let isMounted = true;
const handleMicrophoneState = async () => {
if (enableVoiceControl) {
const success = await initMicrophone();
if (!isMounted) return;
} else {
stopMicrophone();
}
};
handleMicrophoneState();
return () => {
isMounted = false;
};
}, [enableVoiceControl]);
return (
<div
ref={ctnDom}
className={cn(
"w-full h-full relative",
className
)}
>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,118 @@
import React, { useState, useCallback, useMemo } from "react";
import { EventCard } from "./EventCard";
import { Card } from "@/components/ui/card";
// Types
import { Event } from "@/components/features/general/event-manager";
// Week View Component
export function WeekView({
currentDate,
events,
onEventClick,
onDragStart,
onDragEnd,
onDrop,
getColorClasses,
}: {
currentDate: Date;
events: Event[];
onEventClick: (event: Event) => void;
onDragStart: (event: Event) => void;
onDragEnd: () => void;
onDrop: (date: Date, hour: number) => void;
getColorClasses: (color: string) => { bg: string; text: string };
}) {
const startOfWeek = new Date(currentDate);
startOfWeek.setDate(currentDate.getDay());
const weekDays = Array.from({ length: 7 }, (_, i) => {
const day = new Date(startOfWeek);
day.setDate(startOfWeek.getDate() + i);
return day;
});
const hours = Array.from({ length: 24 }, (_, i) => i);
const getEventsForDayAndHour = (date: Date, hour: number) => {
return events.filter((event) => {
const eventDate = new Date(event.startTime);
const eventHour = eventDate.getHours();
return (
eventDate.getDate() === date.getDate() &&
eventDate.getMonth() === date.getMonth() &&
eventDate.getFullYear() === date.getFullYear() &&
eventHour === hour
);
});
};
// dias da semana em pt-BR (abreviações)
const weekDayNames = ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"];
return (
<Card className="overflow-auto">
<div className="grid grid-cols-8 border-b">
<div className="border-r p-2 text-center text-xs font-medium sm:text-sm">
Hora
</div>
{weekDays.map((day) => (
<div
key={day.toISOString()}
className="border-r p-2 text-center text-xs font-medium last:border-r-0 sm:text-sm"
>
<div className="hidden sm:block">
{day.toLocaleDateString("pt-BR", { weekday: "short" })}
</div>
<div className="sm:hidden">
{day.toLocaleDateString("pt-BR", { weekday: "narrow" })}
</div>
<div className="text-[10px] text-muted-foreground sm:text-xs">
{day.toLocaleDateString("pt-BR", {
month: "short",
day: "numeric",
})}
</div>
</div>
))}
</div>
<div className="grid grid-cols-8">
{hours.map((hour) => (
<React.Fragment key={`hour-${hour}`}>
<div
key={`time-${hour}`}
className="border-b border-r p-1 text-[10px] text-muted-foreground sm:p-2 sm:text-xs"
>
{hour.toString().padStart(2, "0")}:00
</div>
{weekDays.map((day) => {
const dayEvents = getEventsForDayAndHour(day, hour);
return (
<div
key={`${day.toISOString()}-${hour}`}
className="min-h-12 border-b border-r p-0.5 transition-colors hover:bg-accent/50 last:border-r-0 sm:min-h-16 sm:p-1"
onDragOver={(e) => e.preventDefault()}
onDrop={() => onDrop(day, hour)}
>
<div className="space-y-1">
{dayEvents.map((event) => (
<EventCard
key={event.id}
event={event}
onEventClick={onEventClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
getColorClasses={getColorClasses}
variant="default"
/>
))}
</div>
</div>
);
})}
</React.Fragment>
))}
</div>
</Card>
);
}

View File

@ -0,0 +1,103 @@
import React, { useState } from "react";
import { Event } from "@/components/features/general/event-manager";
import { cn } from "@/lib/utils";
/*
Componente leve para representar um evento no calendário.
Compatível com o uso em Calendar.tsx (WeekView / DayView).
*/
export function EventCard({
event,
onEventClick,
onDragStart,
onDragEnd,
getColorClasses,
variant = "default",
}: {
event: Event;
onEventClick: (e: Event) => void;
onDragStart: (e: Event) => void;
onDragEnd: () => void;
getColorClasses: (color: string) => { bg: string; text: string };
variant?: "default" | "compact" | "detailed";
}) {
const [hover, setHover] = useState(false);
const color = getColorClasses?.(event.color) ?? { bg: "bg-slate-400", text: "text-white" };
const handleDragStart = (e: React.DragEvent) => {
e.dataTransfer.setData("text/plain", event.id);
onDragStart && onDragStart(event);
};
const handleClick = () => {
onEventClick && onEventClick(event);
};
if (variant === "compact") {
return (
<div
draggable
onDragStart={handleDragStart}
onDragEnd={() => onDragEnd && onDragEnd()}
onClick={handleClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
className={cn(
"rounded px-2 py-0.5 text-xs font-medium truncate",
color.bg,
color.text,
"cursor-pointer transition-all",
hover && "shadow-md scale-105"
)}
>
{event.title}
</div>
);
}
if (variant === "detailed") {
return (
<div
draggable
onDragStart={handleDragStart}
onDragEnd={() => onDragEnd && onDragEnd()}
onClick={handleClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
className={cn(
"rounded-lg p-2 text-sm cursor-pointer transition-all",
color.bg,
color.text,
hover && "shadow-lg scale-[1.02]"
)}
>
<div className="font-semibold">{event.title}</div>
{event.description && <div className="text-xs opacity-90 mt-1 line-clamp-2">{event.description}</div>}
<div className="mt-1 text-[11px] opacity-80">
{event.startTime?.toLocaleTimeString?.("pt-BR", { hour: "2-digit", minute: "2-digit" }) ?? ""} - {event.endTime?.toLocaleTimeString?.("pt-BR", { hour: "2-digit", minute: "2-digit" }) ?? ""}
</div>
</div>
);
}
// default
return (
<div
draggable
onDragStart={handleDragStart}
onDragEnd={() => onDragEnd && onDragEnd()}
onClick={handleClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
className={cn(
"relative rounded px-2 py-1 text-xs font-medium cursor-pointer transition-all",
color.bg,
color.text,
hover && "shadow-md scale-105"
)}
>
<div className="truncate">{event.title}</div>
</div>
);
}

View File

@ -0,0 +1,122 @@
"use client";
import { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import { assignRoleToUser, listAssignmentsForPatient, PatientAssignmentRole } from "@/lib/assignment";
import { useAuth } from '@/hooks/useAuth';
import { listarProfissionais } from "@/lib/api";
type Props = {
patientId: string;
open: boolean;
onClose: () => void;
onSaved?: () => void;
};
export default function AssignmentForm({ patientId, open, onClose, onSaved }: Props) {
const { toast } = useToast();
const { user } = useAuth();
const [professionals, setProfessionals] = useState<any[]>([]);
const [selectedProfessional, setSelectedProfessional] = useState<string | null>(null);
// default to Portuguese role values expected by the backend
const [role, setRole] = useState<PatientAssignmentRole>("medico");
const [loading, setLoading] = useState(false);
const [existing, setExisting] = useState<any[]>([]);
useEffect(() => {
async function load() {
try {
const pros = await listarProfissionais();
setProfessionals(pros || []);
} catch (e) {
console.warn('Erro ao carregar profissionais', e);
setProfessionals([]);
}
try {
const a = await listAssignmentsForPatient(patientId);
setExisting(a || []);
} catch (e) {
setExisting([]);
}
}
if (open) load();
}, [open, patientId]);
// Resolve a display name for a professional id (user_id or id)
function resolveProfessionalName(userId: string | number | null | undefined) {
if (!userId) return String(userId ?? '')
const uid = String(userId)
const found = professionals.find(p => String(p.user_id ?? p.id) === uid)
if (found) return found.full_name || found.name || found.email || String(found.user_id ?? found.id)
return uid
}
async function handleSave() {
if (!selectedProfessional) return toast({ title: 'Selecione um profissional', variant: 'default' });
setLoading(true);
try {
await assignRoleToUser({ patient_id: patientId, user_id: String(selectedProfessional), role, created_by: user?.id ?? null });
toast({ title: 'Atribuição criada', variant: 'default' });
onSaved && onSaved();
onClose();
} catch (err: any) {
console.error(err);
toast({ title: 'Erro ao criar atribuição', description: err?.message });
} finally {
setLoading(false);
}
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Atribuir profissional ao paciente</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div>
<Label>Profissional</Label>
<Select onValueChange={(v) => setSelectedProfessional(v)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Selecione um profissional" />
</SelectTrigger>
<SelectContent>
{professionals.map((p) => (
// prefer the auth user id (p.user_id) when available; fallback to p.id
<SelectItem key={p.id} value={String(p.user_id ?? p.id)}>{p.full_name || p.name || p.email || p.user_id || p.id}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* role input removed - only professional select remains; role defaults to 'medico' on submit */}
{existing && existing.length > 0 && (
<div>
<Label>Atribuições existentes</Label>
<ul className="pl-4 list-disc text-sm text-muted-foreground">
{existing.map((it) => (
<li key={it.id}>{resolveProfessionalName(it.user_id)} {it.role}</li>
))}
</ul>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>Cancelar</Button>
<Button onClick={handleSave} disabled={loading}>{loading ? 'Salvando...' : 'Salvar'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,31 @@
"use client";
import { Save } from "lucide-react";
import { Button } from "../../ui/button";
import { Label } from "../../ui/label";
import { Switch } from "../../ui/switch";
import { useState } from "react";
interface FooterAgendaProps {
onSave: () => void;
onCancel: () => void;
}
export default function FooterAgenda({ onSave, onCancel }: FooterAgendaProps) {
const [bloqueio, setBloqueio] = useState(false);
return (
<div className="sticky bottom-0 left-0 right-0 border-t border-border bg-background">
<div className="mx-auto w-full max-w-7xl px-8 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Switch checked={bloqueio} onCheckedChange={setBloqueio} />
<Label className="text-sm text-foreground">Bloqueio de Agenda</Label>
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={onCancel}>Cancelar</Button>
<Button onClick={onSave}>Salvar</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,11 @@
"use client";
export default function HeaderAgenda() {
return (
<header className="border-b bg-background border-border">
<div className="mx-auto w-full max-w-7xl px-8 py-3 flex items-center justify-between">
<h1 className="text-[18px] font-semibold text-foreground">Novo Agendamento</h1>
</div>
</header>
);
}

View File

@ -0,0 +1,312 @@
'use client';
import { useState } from 'react';
import { ChevronLeft, ChevronRight, Plus, Clock, User, Calendar as CalendarIcon } from 'lucide-react';
interface Appointment {
id: string;
patient: string;
time: string;
duration: number;
type: 'consulta' | 'exame' | 'retorno';
status: 'confirmed' | 'pending' | 'absent';
professional: string;
notes: string;
}
interface Professional {
id: string;
name: string;
specialty: string;
}
interface AgendaCalendarProps {
professionals: Professional[];
appointments: Appointment[];
onAddAppointment: () => void;
onEditAppointment: (appointment: Appointment) => void;
}
export default function AgendaCalendar({
professionals,
appointments,
onAddAppointment,
onEditAppointment
}: AgendaCalendarProps) {
const [view, setView] = useState<'day' | 'week' | 'month'>('week');
const [selectedProfessional, setSelectedProfessional] = useState('all');
const [currentDate, setCurrentDate] = useState(new Date());
const timeSlots = Array.from({ length: 11 }, (_, i) => {
const hour = i + 8; // Das 8h às 18h
return [`${hour.toString().padStart(2, '0')}:00`, `${hour.toString().padStart(2, '0')}:30`];
}).flat();
const getStatusColor = (status: string) => {
switch (status) {
case 'confirmed': return 'bg-green-100 border-green-500 text-green-800';
case 'pending': return 'bg-yellow-100 border-yellow-500 text-yellow-800';
case 'absent': return 'bg-red-100 border-red-500 text-red-800';
default: return 'bg-gray-100 border-gray-500 text-gray-800';
}
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'consulta': return '🩺';
case 'exame': return '📋';
case 'retorno': return '↩️';
default: return '📅';
}
};
const formatDate = (date: Date) => {
return date.toLocaleDateString('pt-BR', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'America/Sao_Paulo'
});
};
const navigateDate = (direction: 'prev' | 'next') => {
const newDate = new Date(currentDate);
if (view === 'day') {
newDate.setDate(newDate.getDate() + (direction === 'next' ? 1 : -1));
} else if (view === 'week') {
newDate.setDate(newDate.getDate() + (direction === 'next' ? 7 : -7));
} else {
newDate.setMonth(newDate.getMonth() + (direction === 'next' ? 1 : -1));
}
setCurrentDate(newDate);
};
const goToToday = () => {
setCurrentDate(new Date());
};
const filteredAppointments = selectedProfessional === 'all'
? appointments
: appointments.filter(app => app.professional === selectedProfessional);
return (
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b border-gray-200">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-xl font-semibold text-gray-900 mb-4 sm:mb-0">Agenda</h2>
<div className="flex flex-wrap gap-2">
<select
value={selectedProfessional}
onChange={(e) => setSelectedProfessional(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="all">Todos os profissionais</option>
{professionals.map(prof => (
<option key={prof.id} value={prof.id}>{prof.name}</option>
))}
</select>
<div className="inline-flex rounded-md shadow-sm">
<button
type="button"
onClick={() => setView('day')}
className={`px-3 py-2 text-sm font-medium rounded-l-md ${
view === 'day'
? 'bg-blue-100 text-blue-700 border border-blue-300'
: 'bg-white text-gray-700 border border-gray-300'
}`}
>
Dia
</button>
<button
type="button"
onClick={() => setView('week')}
className={`px-3 py-2 text-sm font-medium -ml-px ${
view === 'week'
? 'bg-blue-100 text-blue-700 border border-blue-300'
: 'bg-white text-gray-700 border border-gray-300'
}`}
>
Semana
</button>
<button
type="button"
onClick={() => setView('month')}
className={`px-3 py-2 text-sm font-medium -ml-px rounded-r-md ${
view === 'month'
? 'bg-blue-100 text-blue-700 border border-blue-300'
: 'bg-white text-gray-700 border border-gray-300'
}`}
>
Mês
</button>
</div>
<button
onClick={onAddAppointment}
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<Plus className="h-4 w-4 mr-2" />
Novo Agendamento
</button>
</div>
</div>
</div>
<div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<button
onClick={() => navigateDate('prev')}
className="p-1 rounded-md hover:bg-gray-100"
>
<ChevronLeft className="h-5 w-5 text-gray-600" />
</button>
<h3 className="text-lg font-medium text-gray-900">
{formatDate(currentDate)}
</h3>
<button
onClick={() => navigateDate('next')}
className="p-1 rounded-md hover:bg-gray-100"
>
<ChevronRight className="h-5 w-5 text-gray-600" />
</button>
<button
onClick={goToToday}
className="ml-4 px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-100"
>
Hoje
</button>
</div>
<div className="text-sm text-gray-500">
Atalhos: 'C' para calendário, 'F' para fila de espera
</div>
</div>
</div>
{}
{view !== 'month' && (
<div className="overflow-auto">
<div className="min-w-full">
<div className="flex">
<div className="w-20 flex-shrink-0 border-r border-gray-200">
<div className="h-12 border-b border-gray-200 flex items-center justify-center text-sm font-medium text-gray-500">
Hora
</div>
{timeSlots.map(time => (
<div key={time} className="h-16 border-b border-gray-200 flex items-center justify-center text-sm text-gray-500">
{time}
</div>
))}
</div>
<div className="flex-1">
<div className="h-12 border-b border-gray-200 flex items-center justify-center text-sm font-medium text-gray-500">
{currentDate.toLocaleDateString('pt-BR', { weekday: 'long', timeZone: 'America/Sao_Paulo' })}
</div>
<div className="relative">
{timeSlots.map(time => (
<div key={time} className="h-16 border-b border-gray-200"></div>
))}
{filteredAppointments.map(app => {
// parse appointment time in Brazil timezone
const d = new Date(app.time);
// extract hour/minute in America/Sao_Paulo using Intl.DateTimeFormat
const parts = new Intl.DateTimeFormat('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', timeZone: 'America/Sao_Paulo' }).formatToParts(d);
const hourPart = parts.find(p => p.type === 'hour')?.value ?? '00';
const minutePart = parts.find(p => p.type === 'minute')?.value ?? '00';
const hour = parseInt(hourPart, 10);
const minute = parseInt(minutePart, 10);
return (
<div
key={app.id}
className={`absolute left-1 right-1 border-l-4 rounded p-2 shadow-sm cursor-pointer ${getStatusColor(app.status)}`}
style={{
top: `${((hour - 8) * 64 + (minute / 60) * 64) + 48}px`,
height: `${(app.duration / 60) * 64}px`,
}}
onClick={() => onEditAppointment(app)}
>
<div className="flex justify-between items-start">
<div>
<div className="font-medium flex items-center">
<User className="h-3 w-3 mr-1" />
{app.patient}
</div>
<div className="text-xs flex items-center mt-1">
<Clock className="h-3 w-3 mr-1" />
{String(hour).padStart(2,'0')}:{String(minute).padStart(2,'0')} - {app.type} {getTypeIcon(app.type)}
</div>
<div className="text-xs mt-1">
{professionals.find(p => p.id === app.professional)?.name}
</div>
</div>
<div className="text-xs capitalize">
{app.status === 'confirmed' ? 'confirmado' : app.status === 'pending' ? 'pendente' : 'ausente'}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
)}
{}
{view === 'month' && (
<div className="p-4">
<div className="space-y-4">
{filteredAppointments.map(app => {
const d = new Date(app.time);
const parts = new Intl.DateTimeFormat('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', timeZone: 'America/Sao_Paulo' }).formatToParts(d);
const hourPart = parts.find(p => p.type === 'hour')?.value ?? '00';
const minutePart = parts.find(p => p.type === 'minute')?.value ?? '00';
const hours = String(hourPart).padStart(2,'0');
const minutes = String(minutePart).padStart(2,'0');
return (
<div key={app.id} className={`border-l-4 p-4 rounded-lg shadow-sm ${getStatusColor(app.status)}`}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
<div className="flex items-center">
<User className="h-4 w-4 mr-2" />
<span className="font-medium">{app.patient}</span>
</div>
<div className="flex items-center">
<Clock className="h-4 w-4 mr-2" />
<span>{hours}:{minutes} - {app.type} {getTypeIcon(app.type)}</span>
</div>
<div className="flex items-center">
<span className="text-sm">{professionals.find(p => p.id === app.professional)?.name}</span>
</div>
</div>
{app.notes && (
<div className="mt-2 text-sm text-gray-600">
{app.notes}
</div>
)}
<div className="mt-2 flex justify-end">
<button
onClick={() => onEditAppointment(app)}
className="text-blue-600 hover:text-blue-800 text-sm"
>
Editar
</button>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,227 @@
'use client';
import { useState, useEffect } from 'react';
import { X } from 'lucide-react';
interface Appointment {
id?: string;
patient: string;
time: string;
duration: number;
type: 'consulta' | 'exame' | 'retorno';
status: 'confirmed' | 'pending' | 'absent';
professional: string;
notes?: string;
}
interface Professional {
id: string;
name: string;
specialty: string;
}
interface AppointmentModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (appointment: Appointment) => void;
professionals: Professional[];
appointment?: Appointment | null;
}
export default function AppointmentModal({
isOpen,
onClose,
onSave,
professionals,
appointment
}: AppointmentModalProps) {
const [formData, setFormData] = useState<Appointment>({
patient: '',
time: '',
duration: 30,
type: 'consulta',
status: 'pending',
professional: '',
notes: ''
});
useEffect(() => {
if (appointment) {
setFormData(appointment);
} else {
setFormData({
patient: '',
time: '',
duration: 30,
type: 'consulta',
status: 'pending',
professional: professionals[0]?.id || '',
notes: ''
});
}
}, [appointment, professionals]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
onClose();
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-xl font-semibold">
{appointment ? 'Editar Agendamento' : 'Novo Agendamento'}
</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-4">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Paciente
</label>
<input
type="text"
name="patient"
value={formData.patient}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Profissional
</label>
<select
name="professional"
value={formData.professional}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
required
>
<option value="">Selecione um profissional</option>
{professionals.map(prof => (
<option key={prof.id} value={prof.id}>
{prof.name} - {prof.specialty}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Data e Hora
</label>
<input
type="datetime-local"
name="time"
value={formData.time}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Duração (min)
</label>
<input
type="number"
name="duration"
value={formData.duration}
onChange={handleChange}
min="15"
step="15"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tipo
</label>
<select
name="type"
value={formData.type}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="consulta">Consulta</option>
<option value="exame">Exame</option>
<option value="retorno">Retorno</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
name="status"
value={formData.status}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="pending">Pendente</option>
<option value="confirmed">Confirmado</option>
<option value="absent">Ausente</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Observações
</label>
<textarea
name="notes"
value={formData.notes || ''}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Salvar
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,144 @@
'use client';
import { useState } from 'react';
import { Bell, Plus } from 'lucide-react';
interface WaitingPatient {
id: string;
name: string;
specialty: string;
preferredDate: string;
priority: 'high' | 'medium' | 'low';
contact: string;
}
interface ListaEsperaProps {
patients: WaitingPatient[];
onNotify: (patientId: string) => void;
onAddToWaitlist: () => void;
}
export default function ListaEspera({ patients, onNotify, onAddToWaitlist }: ListaEsperaProps) {
const [searchTerm, setSearchTerm] = useState('');
const filteredPatients = patients.filter(patient =>
patient.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
patient.specialty.toLowerCase().includes(searchTerm.toLowerCase())
);
const getPriorityLabel = (priority: string) => {
switch (priority) {
case 'high': return 'Alta';
case 'medium': return 'Média';
case 'low': return 'Baixa';
default: return priority;
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high': return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300';
case 'medium': return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300';
case 'low': return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300';
default: return 'bg-muted text-muted-foreground';
}
};
return (
<div className="bg-card border border-border rounded-lg shadow">
<div className="p-4 border-b border-border">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-xl font-semibold text-foreground mb-4 sm:mb-0">Lista de Espera Inteligente</h2>
<button
onClick={onAddToWaitlist}
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-primary-foreground bg-primary hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
>
<Plus className="h-4 w-4 mr-2" />
Adicionar à Lista
</button>
</div>
</div>
<div className="p-4 border-b border-border">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<span className="text-gray-500">🔍</span>
</div>
<input
type="text"
placeholder="Buscar paciente..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-input rounded-md leading-5 bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted/50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Paciente
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Especialidade
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Data Preferencial
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Prioridade
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Contato
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Ações
</th>
</tr>
</thead>
<tbody className="bg-card divide-y divide-border">
{filteredPatients.map((patient) => (
<tr key={patient.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-foreground">
{patient.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{patient.specialty}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{new Date(patient.preferredDate).toLocaleDateString('pt-BR')}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getPriorityColor(patient.priority)}`}>
{getPriorityLabel(patient.priority)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{patient.contact}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => onNotify(patient.id)}
className="text-primary hover:text-primary/80 mr-3"
title="Notificar paciente"
>
<Bell className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredPatients.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
Nenhum paciente encontrado na lista de espera
</div>
)}
</div>
);
}

View File

@ -0,0 +1,4 @@
// components/agendamento/index.ts
export { default as AgendaCalendar } from './AgendaCalendar';
export { default as AppointmentModal } from './AppointmentModal';
export { default as ListaEspera } from './ListaEspera';

View File

@ -0,0 +1,127 @@
"use client"
import { Bell, ChevronDown } from "lucide-react"
import { useAuth } from "@/hooks/useAuth"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { useState, useEffect, useRef } from "react"
import { useRouter } from "next/navigation"
import { SidebarTrigger } from "../../ui/sidebar"
import { SimpleThemeToggle } from "@/components/ui/simple-theme-toggle";
export function PagesHeader({ title = "", subtitle = "" }: { title?: string, subtitle?: string }) {
const { logout, user } = useAuth();
const router = useRouter();
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Fechar dropdown quando clicar fora
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setDropdownOpen(false);
}
}
if (dropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [dropdownOpen]);
return (
<header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-3 sm:px-6 py-2 flex flex-wrap items-center gap-3">
<div className="flex items-center gap-3 min-w-0">
<SidebarTrigger />
<div className="flex flex-col justify-center leading-tight min-w-0">
<h1 className="text-sm sm:text-lg font-semibold text-foreground truncate max-w-[55vw] sm:max-w-none">{title}</h1>
{subtitle && (
<p className="text-[11px] sm:text-xs text-muted-foreground truncate max-w-[55vw] sm:max-w-none">{subtitle}</p>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-auto">
<Button variant="ghost" size="icon" className="hover-primary-blue hidden xs:flex">
<Bell className="h-4 w-4" />
</Button>
<SimpleThemeToggle />
<div className="relative" ref={dropdownRef}>
<Button
variant="ghost"
className="relative h-8 w-8 rounded-full border border-border hover:border-primary"
onClick={() => setDropdownOpen(!dropdownOpen)}
aria-label="Abrir menu do perfil"
>
<Avatar className="h-8 w-8">
{(() => {
const userPhoto = (user as any)?.profile?.foto_url || (user as any)?.profile?.fotoUrl || (user as any)?.profile?.avatar_url
const alt = user?.name || user?.email || 'Usuário'
const getInitials = (name?: string, email?: string) => {
if (name) {
const parts = name.trim().split(/\s+/)
const first = parts[0]?.charAt(0) ?? ''
const second = parts[1]?.charAt(0) ?? ''
return (first + second).toUpperCase() || (email?.charAt(0) ?? 'U').toUpperCase()
}
if (email) return email.charAt(0).toUpperCase()
return 'U'
}
return (
<>
<AvatarImage src={userPhoto || undefined} alt={alt} />
<AvatarFallback className="bg-primary text-primary-foreground font-semibold">{getInitials(user?.name, user?.email)}</AvatarFallback>
</>
)
})()}
</Avatar>
</Button>
{dropdownOpen && (
<div className="absolute right-0 mt-2 w-64 sm:w-80 bg-popover border border-border rounded-md shadow-lg z-[100] text-popover-foreground animate-in fade-in slide-in-from-top-2">
<div className="p-3 sm:p-4 border-b border-border">
<div className="flex flex-col space-y-1">
<p className="text-xs sm:text-sm font-semibold leading-none">
{user?.userType === 'administrador' ? 'Administrador da Clínica' : 'Usuário do Sistema'}
</p>
{user?.email ? (
<p className="text-[10px] sm:text-xs leading-none text-muted-foreground truncate">{user.email}</p>
) : (
<p className="text-[10px] sm:text-xs leading-none text-muted-foreground">Email não disponível</p>
)}
<p className="text-[10px] sm:text-xs leading-none text-primary font-medium">
Tipo: {user?.userType === 'administrador' ? 'Administrador' : user?.userType || 'Não definido'}
</p>
</div>
</div>
<div className="py-1">
<button
onClick={(e) => {
e.preventDefault();
setDropdownOpen(false);
router.push('/perfil');
}}
className="w-full text-left px-3 sm:px-4 py-2 text-xs sm:text-sm hover:bg-accent cursor-pointer"
>
Perfil
</button>
<div className="border-t border-border my-1" />
<button
onClick={(e) => {
e.preventDefault();
setDropdownOpen(false);
logout();
}}
className="w-full text-left px-3 sm:px-4 py-2 text-xs sm:text-sm text-destructive hover:bg-destructive/10 cursor-pointer"
>
Sair
</button>
</div>
</div>
)}
</div>
</div>
</header>
)
}

View File

@ -0,0 +1,309 @@
"use client"
import { useState, useEffect, useMemo } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogFooter, AlertDialogAction, AlertDialogCancel } from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { criarDisponibilidade, atualizarDisponibilidade, listarExcecoes, DoctorAvailabilityCreate, DoctorAvailability, DoctorAvailabilityUpdate, DoctorException } from '@/lib/api'
import { useToast } from '@/hooks/use-toast'
export interface AvailabilityFormProps {
open: boolean
onOpenChange: (open: boolean) => void
doctorId?: string | null
onSaved?: (saved: any) => void
// when editing, pass the existing availability and set mode to 'edit'
availability?: DoctorAvailability | null
mode?: 'create' | 'edit'
// existing availabilities to prevent duplicate weekday selection
existingAvailabilities?: DoctorAvailability[]
}
export function AvailabilityForm({ open, onOpenChange, doctorId = null, onSaved, availability = null, mode = 'create', existingAvailabilities = [] }: AvailabilityFormProps) {
const [weekday, setWeekday] = useState<string>('segunda')
const [startTime, setStartTime] = useState<string>('09:00')
const [endTime, setEndTime] = useState<string>('17:00')
const [slotMinutes, setSlotMinutes] = useState<number>(30)
const [appointmentType, setAppointmentType] = useState<'presencial'|'telemedicina'>('presencial')
const [active, setActive] = useState<boolean>(true)
const [submitting, setSubmitting] = useState(false)
const { toast } = useToast()
const [blockedException, setBlockedException] = useState<null | { date: string; reason?: string; times?: string }>(null)
// Normalize weekday to standard format for comparison
const normalizeWeekdayForComparison = (w?: string) => {
if (!w) return w;
const k = String(w).toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').replace(/[^a-z0-9]/g, '');
const map: Record<string,string> = {
'segunda':'segunda','terca':'terca','quarta':'quarta','quinta':'quinta','sexta':'sexta','sabado':'sabado','domingo':'domingo',
'monday':'segunda','tuesday':'terca','wednesday':'quarta','thursday':'quinta','friday':'sexta','saturday':'sabado','sunday':'domingo',
'1':'segunda','2':'terca','3':'quarta','4':'quinta','5':'sexta','6':'sabado','0':'domingo','7':'domingo'
};
return map[k] ?? k;
};
// Get list of already used weekdays (excluding current one in edit mode)
const usedWeekdays = useMemo(() => {
return new Set(
(existingAvailabilities || [])
.filter(a => mode === 'edit' ? a.id !== availability?.id : true)
.map(a => normalizeWeekdayForComparison(a.weekday))
.filter(Boolean)
);
}, [existingAvailabilities, mode, availability?.id]);
// When editing, populate state from availability prop
useEffect(() => {
if (mode === 'edit' && availability) {
// weekday may be 'monday' or 'segunda' — keep original string
setWeekday(String(availability.weekday ?? 'segunda'))
// strip seconds for time inputs (HH:MM)
const st = String(availability.start_time ?? '09:00:00').replace(/:00$/,'')
const et = String(availability.end_time ?? '17:00:00').replace(/:00$/,'')
setStartTime(st)
setEndTime(et)
setSlotMinutes(Number(availability.slot_minutes ?? 30))
setAppointmentType((availability.appointment_type ?? 'presencial') as any)
setActive(!!availability.active)
}
}, [mode, availability])
// When creating and modal opens, set the first available weekday
useEffect(() => {
if (mode === 'create' && open) {
const allWeekdays = ['segunda', 'terca', 'quarta', 'quinta', 'sexta', 'sabado', 'domingo'];
const firstAvailable = allWeekdays.find(day => !usedWeekdays.has(day));
if (firstAvailable) {
setWeekday(firstAvailable);
}
}
}, [mode, open, usedWeekdays])
async function handleSubmit(e?: React.FormEvent) {
e?.preventDefault()
if (!doctorId) {
toast({ title: 'Erro', description: 'ID do médico não informado', variant: 'destructive' })
return
}
setSubmitting(true)
try {
// Pre-check exceptions for this doctor to avoid creating an availability
// that is blocked by an existing exception. If a blocking exception is
// found we show a specific toast and abort the creation request.
try {
const exceptions: DoctorException[] = await listarExcecoes({ doctorId: String(doctorId) });
const today = new Date();
const oneYearAhead = new Date();
oneYearAhead.setFullYear(oneYearAhead.getFullYear() + 1);
const parseTimeToMinutes = (t?: string | null) => {
if (!t) return null;
const parts = String(t).split(':').map((p) => Number(p));
if (parts.length >= 2 && !Number.isNaN(parts[0]) && !Number.isNaN(parts[1])) {
return parts[0] * 60 + parts[1];
}
return null;
};
const reqStart = parseTimeToMinutes(`${startTime}:00`);
const reqEnd = parseTimeToMinutes(`${endTime}:00`);
const normalizeWeekday = (w?: string) => {
if (!w) return w;
const k = String(w).toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').replace(/[^a-z0-9]/g, '');
const map: Record<string,string> = {
'segunda':'monday','terca':'tuesday','quarta':'wednesday','quinta':'thursday','sexta':'friday','sabado':'saturday','domingo':'sunday',
'monday':'monday','tuesday':'tuesday','wednesday':'wednesday','thursday':'thursday','friday':'friday','saturday':'saturday','sunday':'sunday'
};
return map[k] ?? k;
};
const reqWeekday = normalizeWeekday(weekday);
for (const ex of exceptions || []) {
if (!ex || !ex.date) continue;
const exDate = new Date(ex.date + 'T00:00:00');
if (isNaN(exDate.getTime())) continue;
if (exDate < today || exDate > oneYearAhead) continue;
if (ex.kind !== 'bloqueio') continue;
const exWeekday = normalizeWeekday(exDate.toLocaleDateString('en-US', { weekday: 'long' }));
if (exWeekday !== reqWeekday) continue;
// whole-day block
if (!ex.start_time && !ex.end_time) {
setBlockedException({ date: ex.date, reason: ex.reason ?? undefined, times: undefined })
setSubmitting(false);
return;
}
const exStart = parseTimeToMinutes(ex.start_time ?? undefined);
const exEnd = parseTimeToMinutes(ex.end_time ?? undefined);
if (reqStart != null && reqEnd != null && exStart != null && exEnd != null) {
if (reqStart < exEnd && exStart < reqEnd) {
setBlockedException({ date: ex.date, reason: ex.reason ?? undefined, times: `${ex.start_time}${ex.end_time}` })
setSubmitting(false);
return;
}
}
}
} catch (e) {
// If checking exceptions fails, continue and let the API handle it. We
// intentionally do not block the flow here because failure to fetch
// exceptions shouldn't completely prevent admins from creating slots.
console.warn('Falha ao verificar exceções antes da criação:', e);
}
if (mode === 'create') {
const payload: DoctorAvailabilityCreate = {
doctor_id: String(doctorId),
weekday: weekday as any,
start_time: `${startTime}:00`,
end_time: `${endTime}:00`,
slot_minutes: slotMinutes,
appointment_type: appointmentType,
active,
}
const saved = await criarDisponibilidade(payload)
const labelMap: Record<string,string> = {
'segunda':'Segunda','terca':'Terça','quarta':'Quarta','quinta':'Quinta','sexta':'Sexta','sabado':'Sábado','domingo':'Domingo',
'monday':'Segunda','tuesday':'Terça','wednesday':'Quarta','thursday':'Quinta','friday':'Sexta','saturday':'Sábado','sunday':'Domingo'
}
const label = labelMap[weekday as string] ?? String(weekday)
toast({ title: 'Disponibilidade criada', description: `${label} ${startTime}${endTime}`, variant: 'default' })
onSaved?.(saved)
onOpenChange(false)
} else {
// edit mode: update existing availability
if (!availability || !availability.id) {
throw new Error('Disponibilidade inválida para edição')
}
const payload: DoctorAvailabilityUpdate = {
weekday: weekday as any,
start_time: `${startTime}:00`,
end_time: `${endTime}:00`,
slot_minutes: slotMinutes,
appointment_type: appointmentType,
active,
}
const updated = await atualizarDisponibilidade(String(availability.id), payload)
const labelMap: Record<string,string> = {
'segunda':'Segunda','terca':'Terça','quarta':'Quarta','quinta':'Quinta','sexta':'Sexta','sabado':'Sábado','domingo':'Domingo',
'monday':'Segunda','tuesday':'Terça','wednesday':'Quarta','thursday':'Quinta','friday':'Sexta','saturday':'Sábado','sunday':'Domingo'
}
const label = labelMap[weekday as string] ?? String(weekday)
toast({ title: 'Disponibilidade atualizada', description: `${label} ${startTime}${endTime}`, variant: 'default' })
onSaved?.(updated)
onOpenChange(false)
}
} catch (err: any) {
console.error('Erro ao criar disponibilidade:', err)
toast({ title: 'Erro', description: err?.message || String(err), variant: 'destructive' })
} finally {
setSubmitting(false)
}
}
const be = blockedException
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{mode === 'edit' ? 'Editar disponibilidade' : 'Criar disponibilidade'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Dia da semana</Label>
<Select value={weekday} onValueChange={(v) => setWeekday(v)} disabled={mode === 'edit'}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="segunda" disabled={usedWeekdays.has('segunda')}>Segunda</SelectItem>
<SelectItem value="terca" disabled={usedWeekdays.has('terca')}>Terça</SelectItem>
<SelectItem value="quarta" disabled={usedWeekdays.has('quarta')}>Quarta</SelectItem>
<SelectItem value="quinta" disabled={usedWeekdays.has('quinta')}>Quinta</SelectItem>
<SelectItem value="sexta" disabled={usedWeekdays.has('sexta')}>Sexta</SelectItem>
<SelectItem value="sabado" disabled={usedWeekdays.has('sabado')}>Sábado</SelectItem>
<SelectItem value="domingo" disabled={usedWeekdays.has('domingo')}>Domingo</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Tipo</Label>
<Select value={appointmentType} onValueChange={(v) => setAppointmentType(v as any)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="presencial">Presencial</SelectItem>
<SelectItem value="telemedicina">Telemedicina</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<Label>Início</Label>
<Input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} />
</div>
<div>
<Label>Fim</Label>
<Input type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} />
</div>
<div>
<Label>Minutos por slot</Label>
<Input type="number" value={String(slotMinutes)} onChange={(e) => setSlotMinutes(Number(e.target.value || 30))} />
</div>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
<span>Ativo</span>
</label>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={submitting}>Cancelar</Button>
<Button type="submit" disabled={submitting}>{submitting ? 'Salvando...' : (mode === 'edit' ? 'Salvar alterações' : 'Criar disponibilidade')}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={!!be} onOpenChange={(open) => { if (!open) setBlockedException(null) }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Data bloqueada</AlertDialogTitle>
</AlertDialogHeader>
<div className="px-6 pb-6 pt-2">
{be ? (
<div className="space-y-2">
<p>Não é possível criar disponibilidade para o dia <strong>{be!.date}</strong>.</p>
{be!.times ? <p>Horário bloqueado: <strong>{be!.times}</strong></p> : null}
{be!.reason ? <p>Motivo: <strong>{be!.reason}</strong></p> : null}
</div>
) : null}
</div>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setBlockedException(null)}>OK</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default AvailabilityForm

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,186 @@
"use client"
import { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Calendar as CalendarComponent } from '@/components/ui/calendar'
import { Calendar } from 'lucide-react'
import { criarExcecao, DoctorExceptionCreate } from '@/lib/api'
import { useToast } from '@/hooks/use-toast'
export interface ExceptionFormProps {
open: boolean
onOpenChange: (open: boolean) => void
doctorId?: string | null
onSaved?: (saved: any) => void
}
export default function ExceptionForm({ open, onOpenChange, doctorId = null, onSaved }: ExceptionFormProps) {
const [date, setDate] = useState<string>('')
const [startTime, setStartTime] = useState<string>('')
const [endTime, setEndTime] = useState<string>('')
const [kind, setKind] = useState<'bloqueio'|'liberacao'>('bloqueio')
const [reason, setReason] = useState<string>('')
const [submitting, setSubmitting] = useState(false)
const [showDatePicker, setShowDatePicker] = useState(false)
const { toast } = useToast()
// Resetar form quando dialog fecha
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
setDate('')
setStartTime('')
setEndTime('')
setKind('bloqueio')
setReason('')
setShowDatePicker(false)
}
onOpenChange(newOpen)
}
async function handleSubmit(e?: React.FormEvent) {
e?.preventDefault()
if (!doctorId) {
toast({ title: 'Erro', description: 'ID do médico não informado', variant: 'destructive' })
return
}
if (!date) {
toast({ title: 'Erro', description: 'Data obrigatória', variant: 'destructive' })
return
}
setSubmitting(true)
try {
const payload: DoctorExceptionCreate = {
doctor_id: String(doctorId),
date: String(date),
start_time: startTime ? `${startTime}:00` : undefined,
end_time: endTime ? `${endTime}:00` : undefined,
kind,
reason: reason || undefined,
}
const saved = await criarExcecao(payload)
toast({ title: 'Exceção criada', description: `${payload.date}${kind}`, variant: 'default' })
onSaved?.(saved)
handleOpenChange(false)
} catch (err: any) {
console.error('Erro ao criar exceção:', err)
toast({ title: 'Erro', description: err?.message || String(err), variant: 'destructive' })
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Criar exceção</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 py-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-[13px]">Data *</Label>
<button
type="button"
aria-label="Abrir seletor de data"
onClick={() => setShowDatePicker(!showDatePicker)}
className="h-6 w-6 flex items-center justify-center text-muted-foreground hover:text-foreground cursor-pointer"
>
<Calendar className="h-4 w-4" />
</button>
</div>
<div className="relative">
<Input
type="text"
placeholder="DD/MM/AAAA"
className="h-11 w-full rounded-md pl-3 pr-3 text-[13px] transition-colors hover:bg-muted/30"
value={date ? (() => {
try {
const [y, m, d] = String(date).split('-');
return `${d}/${m}/${y}`;
} catch (e) {
return '';
}
})() : ''}
readOnly
/>
{showDatePicker && (
<div className="absolute top-full left-0 mt-1 z-50 bg-card border border-border rounded-md shadow-lg p-3">
<CalendarComponent
mode="single"
selected={date ? (() => {
try {
// Parse como local date para compatibilidade com Calendar
const [y, m, d] = String(date).split('-').map(Number);
return new Date(y, m - 1, d);
} catch (e) {
return undefined;
}
})() : undefined}
onSelect={(selectedDate) => {
if (selectedDate) {
// Extrair data como local para evitar problemas de timezone
const y = selectedDate.getFullYear();
const m = String(selectedDate.getMonth() + 1).padStart(2, '0');
const d = String(selectedDate.getDate()).padStart(2, '0');
const dateStr = `${y}-${m}-${d}`;
console.log('[ExceptionForm] Data selecionada:', dateStr, 'de', selectedDate);
setDate(dateStr);
setShowDatePicker(false);
}
}}
disabled={(checkDate) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
return checkDate < today;
}}
/>
</div>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Início (opcional)</Label>
<Input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} />
</div>
<div>
<Label>Fim (opcional)</Label>
<Input type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} />
</div>
</div>
<div>
<Label>Tipo</Label>
<Select value={kind} onValueChange={(v) => setKind(v as any)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bloqueio">Bloqueio</SelectItem>
<SelectItem value="liberacao">Liberação</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Motivo (opcional)</Label>
<Input value={reason} onChange={(e) => setReason(e.target.value)} />
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => handleOpenChange(false)} disabled={submitting}>Cancelar</Button>
<Button type="submit" disabled={submitting}>{submitting ? 'Salvando...' : 'Criar exceção'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,671 @@
"use client";
import { useEffect, useMemo, useState, useRef } from "react";
import { format, parseISO, parse } from "date-fns";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { cn } from "@/lib/utils";
import { AlertCircle, ChevronDown, ChevronUp, FileImage, Loader2, Save, Upload, User, X, XCircle, Trash2, CalendarIcon } from "lucide-react";
import {
Paciente,
PacienteInput,
buscarCepAPI,
atualizarPaciente,
uploadFotoPaciente,
removerFotoPaciente,
adicionarAnexo,
listarAnexos,
removerAnexo,
buscarPacientePorId,
criarPaciente,
} from "@/lib/api";
import { getAvatarPublicUrl } from '@/lib/api';
import { useAvatarUrl } from '@/hooks/useAvatarUrl';
import { validarCPFLocal } from "@/lib/utils";
import { verificarCpfDuplicado } from "@/lib/api";
import { CredentialsDialog } from "@/components/features/general/credentials-dialog";
type Mode = "create" | "edit";
export interface PatientRegistrationFormProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
patientId?: string | number | null;
inline?: boolean;
mode?: Mode;
onSaved?: (paciente: Paciente) => void;
onClose?: () => void;
}
type FormData = {
photo: File | null;
nome: string;
nome_social: string;
cpf: string;
rg: string;
sexo: string;
birth_date: Date | null;
email: string;
telefone: string;
cep: string;
logradouro: string;
numero: string;
complemento: string;
bairro: string;
cidade: string;
estado: string;
observacoes: string;
anexos: File[];
};
const initial: FormData = {
photo: null,
nome: "",
nome_social: "",
cpf: "",
rg: "",
sexo: "",
birth_date: null,
email: "",
telefone: "",
cep: "",
logradouro: "",
numero: "",
complemento: "",
bairro: "",
cidade: "",
estado: "",
observacoes: "",
anexos: [],
};
export function PatientRegistrationForm({
open = true,
onOpenChange,
patientId = null,
inline = false,
mode = "create",
onSaved,
onClose,
}: PatientRegistrationFormProps) {
const [form, setForm] = useState<FormData>(initial);
const [errors, setErrors] = useState<Record<string, string>>({});
const [expanded, setExpanded] = useState({ dados: true, contato: false, endereco: false, obs: false });
// Funções de formatação
const formatRG = (value: string) => {
const cleaned = value.replace(/\D/g, '');
if (cleaned.length <= 9) {
return cleaned.replace(/(\d{2})(\d{3})(\d{3})(\d{1})/, '$1.$2.$3-$4');
}
return cleaned.slice(0, 9);
};
const formatTelefone = (value: string) => {
const cleaned = value.replace(/\D/g, '');
if (cleaned.length <= 10) {
return cleaned.replace(/(\d{2})(\d{4})(\d{4})/, '($1) $2-$3');
}
return cleaned.replace(/(\d{2})(\d{5})(\d{4})/, '($1) $2-$3');
};
const formatDataNascimento = (value: string) => {
const cleaned = value.replace(/\D/g, '');
if (cleaned.length <= 2) return cleaned;
if (cleaned.length <= 4) return `${cleaned.slice(0, 2)}/${cleaned.slice(2)}`;
return `${cleaned.slice(0, 2)}/${cleaned.slice(2, 4)}/${cleaned.slice(4, 8)}`;
};
const [isSubmitting, setSubmitting] = useState(false);
const [isUploadingPhoto, setUploadingPhoto] = useState(false);
const [isSearchingCEP, setSearchingCEP] = useState(false);
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
const [serverAnexos, setServerAnexos] = useState<any[]>([]);
// Hook para carregar automaticamente o avatar do paciente
const { avatarUrl: retrievedAvatarUrl } = useAvatarUrl(mode === "edit" ? patientId : null);
const [showCredentialsDialog, setShowCredentialsDialog] = useState(false);
const [credentials, setCredentials] = useState<{
email: string;
password: string;
userName: string;
userType: 'médico' | 'paciente';
} | null>(null);
// Ref para guardar o paciente salvo para chamar onSaved quando o dialog fechar
const savedPatientRef = useRef<any>(null);
const title = useMemo(() => (mode === "create" ? "Cadastro de Paciente" : "Editar Paciente"), [mode]);
useEffect(() => {
async function load() {
if (mode !== "edit" || patientId == null) return;
try {
const p = await buscarPacientePorId(String(patientId));
setForm((s) => ({
...s,
nome: p.full_name || "",
nome_social: p.social_name || "",
cpf: p.cpf || "",
rg: p.rg || "",
sexo: p.sex || "",
birth_date: p.birth_date ? parseISO(String(p.birth_date)) : null,
telefone: p.phone_mobile || "",
email: p.email || "",
cep: p.cep || "",
logradouro: p.street || "",
numero: p.number || "",
complemento: p.complement || "",
bairro: p.neighborhood || "",
cidade: p.city || "",
estado: p.state || "",
observacoes: p.notes || "",
}));
const ax = await listarAnexos(String(patientId)).catch(() => []);
setServerAnexos(Array.isArray(ax) ? ax : []);
try {
const url = getAvatarPublicUrl(String(patientId));
try {
const head = await fetch(url, { method: 'HEAD' });
if (head.ok) { setPhotoPreview(url); }
else {
const get = await fetch(url, { method: 'GET' });
if (get.ok) { setPhotoPreview(url); }
}
} catch (inner) { /* ignore */ }
} catch (detectErr) { /* ignore */ }
} catch (err) {
console.error('[PatientForm] Erro ao carregar paciente:', err);
}
}
load();
}, [mode, patientId]);
function setField<T extends keyof FormData>(k: T, v: FormData[T]) {
setForm((s) => ({ ...s, [k]: v }));
if (errors[k as string]) setErrors((e) => ({ ...e, [k]: "" }));
}
function formatCPF(v: string) {
const n = v.replace(/\D/g, "").slice(0, 11);
return n.replace(/(\d{3})(\d{3})(\d{3})(\d{0,2})/, (_, a, b, c, d) => `${a}.${b}.${c}${d ? "-" + d : ""}`);
}
function handleCPFChange(v: string) { setField("cpf", formatCPF(v)); }
function formatCEP(v: string) { const n = v.replace(/\D/g, "").slice(0, 8); return n.replace(/(\d{5})(\d{0,3})/, (_, a, b) => `${a}${b ? "-" + b : ""}`); }
async function fillFromCEP(cep: string) {
const clean = cep.replace(/\D/g, ""); if (clean.length !== 8) return; setSearchingCEP(true);
try { const res = await buscarCepAPI(clean); if (res?.erro) setErrors((e) => ({ ...e, cep: "CEP não encontrado" })); else { setField("logradouro", res.logradouro ?? ""); setField("bairro", res.bairro ?? ""); setField("cidade", res.localidade ?? ""); setField("estado", res.uf ?? ""); } }
catch { setErrors((e) => ({ ...e, cep: "Erro ao buscar CEP" })); } finally { setSearchingCEP(false); }
}
function validateLocal(): boolean {
const e: Record<string, string> = {};
if (!form.nome.trim()) e.nome = "Nome é obrigatório";
if (!form.cpf.trim()) e.cpf = "CPF é obrigatório";
if (mode === 'create' && !form.email.trim()) e.email = "Email é obrigatório para criar um usuário";
setErrors(e);
return Object.keys(e).length === 0;
}
function toPayload(): PacienteInput {
return {
full_name: form.nome,
social_name: form.nome_social || null,
cpf: form.cpf,
rg: form.rg || null,
sex: form.sexo || null,
birth_date: form.birth_date ? form.birth_date.toISOString().slice(0, 10) : null,
phone_mobile: form.telefone || null,
email: form.email || null,
cep: form.cep || null,
street: form.logradouro || null,
number: form.numero || null,
complement: form.complemento || null,
neighborhood: form.bairro || null,
city: form.cidade || null,
state: form.estado || null,
notes: form.observacoes || null,
};
}
async function handleSubmit(ev: React.FormEvent) {
ev.preventDefault();
if (!validateLocal()) return;
// Debug: verificar se token está disponível
const tokenCheck = typeof window !== 'undefined' ? (localStorage.getItem('auth_token') || sessionStorage.getItem('auth_token')) : null;
console.debug('[PatientForm] Token disponível?', !!tokenCheck ? 'SIM' : 'NÃO - Possível causa do erro!');
if (!tokenCheck) {
setErrors({ submit: 'Sessão expirada. Por favor, faça login novamente.' });
return;
}
try {
if (!validarCPFLocal(form.cpf)) { setErrors((e) => ({ ...e, cpf: "CPF inválido" })); return; }
if (mode === "create") { const existe = await verificarCpfDuplicado(form.cpf); if (existe) { setErrors((e) => ({ ...e, cpf: "CPF já cadastrado no sistema" })); return; } }
} catch (err) { console.error("Erro ao validar CPF", err); setErrors({ submit: "Erro ao validar CPF." }); return; }
setSubmitting(true);
try {
if (mode === "edit") {
if (patientId == null) throw new Error("Paciente inexistente para edição");
const payload = toPayload(); const saved = await atualizarPaciente(String(patientId), payload);
if (form.photo) {
try {
setUploadingPhoto(true);
try { await removerFotoPaciente(String(patientId)); setPhotoPreview(null); } catch (remErr) { console.warn('[PatientForm] aviso: falha ao remover avatar antes do upload:', remErr); }
const uploadResult = await uploadFotoPaciente(String(patientId), form.photo);
// Upload realizado com sucesso - a foto está armazenada no Supabase Storage
// Não é necessário fazer PATCH para persistir a URL no banco
console.debug('[PatientForm] foto_url obtida do upload:', uploadResult.foto_url);
}
catch (upErr) { console.warn('[PatientForm] Falha ao enviar foto do paciente:', upErr); alert('Paciente atualizado, mas falha ao enviar a foto. Tente novamente.'); }
finally { setUploadingPhoto(false); }
}
onSaved?.(saved); alert("Paciente atualizado com sucesso!"); setForm(initial); setPhotoPreview(null); setServerAnexos([]); if (inline) onClose?.(); else onOpenChange?.(false);
} else {
// create
const patientPayload = toPayload();
// Debug helper: log the exact payload being sent to criarPaciente so
// we can inspect whether `sex`, `birth_date` and `cep` are present
// before the network request. This helps diagnose backends that
// ignore alternate field names or strip optional fields.
console.debug('[PatientForm] payload before criarPaciente:', patientPayload);
// require phone when email present for single-call function
if (form.email && form.email.includes('@') && (!form.telefone || !String(form.telefone).trim())) {
setErrors((e) => ({ ...e, telefone: 'Telefone é obrigatório quando email é informado (fluxo de criação único).' })); setSubmitting(false); return;
}
let savedPatientProfile: any = await criarPaciente(patientPayload);
console.log('🎯 Paciente criado! Resposta completa:', savedPatientProfile);
console.log('🔑 Senha no objeto:', savedPatientProfile?.password);
// Guardar a senha ANTES de qualquer operação que possa sobrescrever o objeto
const senhaGerada = savedPatientProfile?.password;
// Fallback: some backend create flows (create-user-with-password) do not
// persist optional patient fields like sex/cep/birth_date. The edit flow
// (atualizarPaciente) writes directly to the patients table and works.
// To make create behave like edit, attempt a PATCH right after create
// when any of those fields are missing from the returned object.
try {
const pacienteId = savedPatientProfile?.id || savedPatientProfile?.patient_id || savedPatientProfile?.user_id;
const missing: string[] = [];
if (!savedPatientProfile?.sex && patientPayload.sex) missing.push('sex');
if (!savedPatientProfile?.cep && patientPayload.cep) missing.push('cep');
if (!savedPatientProfile?.birth_date && patientPayload.birth_date) missing.push('birth_date');
if (pacienteId && missing.length) {
console.debug('[PatientForm] criando paciente: campos faltando no retorno do create, tentando PATCH fallback:', missing);
const patched = await atualizarPaciente(String(pacienteId), patientPayload).catch((e) => { console.warn('[PatientForm] fallback PATCH falhou:', e); return null; });
if (patched) {
console.debug('[PatientForm] fallback PATCH result:', patched);
// Preserva a senha ao fazer merge do patch
savedPatientProfile = { ...patched, password: senhaGerada };
}
}
} catch (e) {
console.warn('[PatientForm] erro ao tentar fallback PATCH:', e);
}
// Usar a senha que foi guardada ANTES do PATCH
const emailToDisplay = savedPatientProfile?.email || form.email;
console.log('📧 Email para exibir:', emailToDisplay);
console.log('🔐 Senha para exibir:', senhaGerada);
if (senhaGerada && emailToDisplay) {
console.log('✅ Abrindo modal de credenciais...');
const credentialsToShow = {
email: emailToDisplay,
password: String(senhaGerada),
userName: form.nome,
userType: 'paciente' as const
};
console.log('📝 Credenciais a serem definidas:', credentialsToShow);
// Guardar o paciente salvo no ref para usar quando o dialog fechar
savedPatientRef.current = savedPatientProfile;
// Definir credenciais e abrir dialog
setCredentials(credentialsToShow);
setShowCredentialsDialog(true);
// NÃO limpar o formulário ou fechar ainda - aguardar o usuário fechar o dialog de credenciais
// O dialog de credenciais vai chamar onSaved e fechar quando o usuário clicar em "Fechar"
// Verificar se foi setado
setTimeout(() => {
console.log('🔍 Verificando estados após 100ms:');
console.log(' - showCredentialsDialog:', showCredentialsDialog);
console.log(' - credentials:', credentials);
}, 100);
} else {
console.error('❌ Não foi possível exibir credenciais:', { senhaGerada, emailToDisplay });
alert(`Paciente criado!\n\nEmail: ${emailToDisplay}\n\nAVISO: A senha não pôde ser recuperada. Entre em contato com o suporte.`);
// Se não há senha, limpar e fechar normalmente
onSaved?.(savedPatientProfile);
setForm(initial);
setPhotoPreview(null);
setServerAnexos([]);
if (inline) onClose?.();
else onOpenChange?.(false);
}
if (form.photo) {
try {
setUploadingPhoto(true);
const pacienteId = savedPatientProfile?.id || (savedPatientProfile && (savedPatientProfile as any).id);
if (pacienteId) {
const uploadResult = await uploadFotoPaciente(String(pacienteId), form.photo);
// Upload realizado com sucesso - a foto está armazenada no Supabase Storage
console.debug('[PatientForm] foto_url obtida do upload após criação:', uploadResult.foto_url);
}
}
catch (upErr) { console.warn('[PatientForm] Falha ao enviar foto do paciente após criação:', upErr); alert('Paciente criado, mas falha ao enviar a foto. Você pode tentar novamente no perfil.'); }
finally { setUploadingPhoto(false); }
}
}
} catch (err: any) { console.error("❌ Erro no handleSubmit:", err); const userMessage = err?.message?.includes("toPayload") || err?.message?.includes("is not defined") ? "Erro ao processar os dados do formulário. Por favor, verifique os campos e tente novamente." : err?.message || "Erro ao salvar paciente. Por favor, tente novamente."; setErrors({ submit: userMessage }); }
finally { setSubmitting(false); }
}
function handlePhoto(e: React.ChangeEvent<HTMLInputElement>) { const f = e.target.files?.[0]; if (!f) return; if (f.size > 5 * 1024 * 1024) { setErrors((e) => ({ ...e, photo: "Arquivo muito grande. Máx 5MB." })); return; } setField("photo", f); const fr = new FileReader(); fr.onload = (ev) => setPhotoPreview(String(ev.target?.result || "")); fr.readAsDataURL(f); }
function addLocalAnexos(e: React.ChangeEvent<HTMLInputElement>) { const fs = Array.from(e.target.files || []); setField("anexos", [...form.anexos, ...fs]); }
function removeLocalAnexo(idx: number) { const clone = [...form.anexos]; clone.splice(idx, 1); setField("anexos", clone); }
async function handleRemoverFotoServidor() { if (mode !== "edit" || !patientId) return; try { setUploadingPhoto(true); await removerFotoPaciente(String(patientId)); setPhotoPreview(null); alert('Foto removida com sucesso.'); } catch (e: any) { console.warn('[PatientForm] erro ao remover foto do servidor', e); if (String(e?.message || '').includes('401')) { alert('Falha ao remover a foto: não autenticado. Faça login novamente e tente novamente.\nDetalhe: ' + (e?.message || '')); } else if (String(e?.message || '').includes('403')) { alert('Falha ao remover a foto: sem permissão. Verifique as permissões do token e se o storage aceita esse usuário.\nDetalhe: ' + (e?.message || '')); } else { alert(e?.message || 'Não foi possível remover a foto do storage. Veja console para detalhes.'); } } finally { setUploadingPhoto(false); } }
async function handleRemoverAnexoServidor(anexoId: string | number) { if (mode !== "edit" || !patientId) return; try { await removerAnexo(String(patientId), anexoId); setServerAnexos((prev) => prev.filter((a) => String(a.id ?? a.anexo_id) !== String(anexoId))); } catch (e: any) { alert(e?.message || "Não foi possível remover o anexo."); } }
const content = (
<>
{errors.submit && (
<Alert variant="destructive"><AlertCircle className="h-4 w-4" /><AlertDescription>{errors.submit}</AlertDescription></Alert>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Personal data, contact, address, attachments... keep markup concise */}
<Collapsible open={expanded.dados} onOpenChange={() => setExpanded((s) => ({ ...s, dados: !s.dados }))}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors">
<CardTitle className="flex items-center justify-between"><span className="flex items-center gap-2"><User className="h-4 w-4" /> Dados Pessoais</span>{expanded.dados ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</CardTitle>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<div className="w-24 h-24 border-2 border-dashed border-muted-foreground rounded-lg flex items-center justify-center overflow-hidden">
{photoPreview ? <img src={photoPreview || ""} alt="Preview" className="w-full h-full object-cover" /> : <FileImage className="h-8 w-8 text-muted-foreground" />}
</div>
<div className="space-y-2">
<Label htmlFor="photo" className="cursor-pointer rounded-md transition-colors">
<Button type="button" variant="ghost" asChild className="bg-primary text-primary-foreground border-transparent hover:bg-primary"><span><Upload className="mr-2 h-4 w-4 text-primary-foreground" /> Carregar Foto</span></Button>
</Label>
<Input id="photo" type="file" accept="image/*" className="hidden" onChange={handlePhoto} />
{mode === "edit" && (<Button type="button" variant="ghost" onClick={handleRemoverFotoServidor}><Trash2 className="mr-2 h-4 w-4" /> Remover foto</Button>)}
{errors.photo && <p className="text-sm text-destructive">{errors.photo}</p>}
<p className="text-xs text-muted-foreground">Máximo 5MB</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"><Label>Nome *</Label><Input value={form.nome} onChange={(e) => setField("nome", e.target.value)} className={errors.nome ? "border-destructive" : ""} />{errors.nome && <p className="text-sm text-destructive">{errors.nome}</p>}</div>
<div className="space-y-2"><Label>Nome Social</Label><Input value={form.nome_social} onChange={(e) => setField("nome_social", e.target.value)} /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"><Label>CPF *</Label><Input value={form.cpf} onChange={(e) => handleCPFChange(e.target.value)} placeholder="000.000.000-00" maxLength={14} className={errors.cpf ? "border-destructive" : ""} />{errors.cpf && <p className="text-sm text-destructive">{errors.cpf}</p>}</div>
<div className="space-y-2"><Label>RG</Label><Input value={form.rg} onChange={(e) => setField("rg", formatRG(e.target.value))} placeholder="00.000.000-0" maxLength={12} /></div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label>Sexo</Label>
<Select value={form.sexo} onValueChange={(v) => setField("sexo", v)}>
<SelectTrigger><SelectValue placeholder="Selecione o sexo" /></SelectTrigger>
<SelectContent>
<SelectItem value="masculino">Masculino</SelectItem>
<SelectItem value="feminino">Feminino</SelectItem>
<SelectItem value="outro">Outro</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="birth_date_input">Data de Nascimento</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"w-full justify-start text-left font-normal hover:bg-muted hover:text-foreground",
!form.birth_date && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{form.birth_date ? format(form.birth_date as Date, "dd/MM/yyyy") : <span>Selecione uma data</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={form.birth_date ?? undefined}
onSelect={(date) => setField("birth_date", date || null)}
initialFocus
captionLayout="dropdown"
fromYear={1900}
toYear={new Date().getFullYear()}
disabled={(date) => date > new Date()}
/>
</PopoverContent>
</Popover>
</div>
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
<Collapsible open={expanded.contato} onOpenChange={() => setExpanded((s) => ({ ...s, contato: !s.contato }))}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors"><CardTitle className="flex items-center justify-between"><span>Contato</span>{expanded.contato ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</CardTitle></CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"><Label>E-mail</Label><Input value={form.email} onChange={(e) => setField("email", e.target.value)} />{errors.email && <p className="text-sm text-destructive">{errors.email}</p>}</div>
<div className="space-y-2"><Label>Telefone</Label><Input value={form.telefone} onChange={(e) => setField("telefone", formatTelefone(e.target.value))} placeholder="(00) 00000-0000" maxLength={15} />{errors.telefone && <p className="text-sm text-destructive">{errors.telefone}</p>}</div>
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
<Collapsible open={expanded.endereco} onOpenChange={() => setExpanded((s) => ({ ...s, endereco: !s.endereco }))}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors"><CardTitle className="flex items-center justify-between"><span>Endereço</span>{expanded.endereco ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</CardTitle></CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2"><Label>CEP</Label><div className="relative"><Input value={form.cep} onChange={(e) => { const v = formatCEP(e.target.value); setField("cep", v); if (v.replace(/\D/g, "").length === 8) fillFromCEP(v); }} placeholder="00000-000" maxLength={9} disabled={isSearchingCEP} className={errors.cep ? "border-destructive" : ""} />{isSearchingCEP && <Loader2 className="absolute right-3 top-3 h-4 w-4 animate-spin" />}</div>{errors.cep && <p className="text-sm text-destructive">{errors.cep}</p>}</div>
<div className="space-y-2"><Label>Logradouro</Label><Input value={form.logradouro} onChange={(e) => setField("logradouro", e.target.value)} /></div>
<div className="space-y-2"><Label>Número</Label><Input value={form.numero} onChange={(e) => setField("numero", e.target.value)} /></div>
</div>
<div className="grid grid-cols-2 gap-4"><div className="space-y-2"><Label>Complemento</Label><Input value={form.complemento} onChange={(e) => setField("complemento", e.target.value)} /></div><div className="space-y-2"><Label>Bairro</Label><Input value={form.bairro} onChange={(e) => setField("bairro", e.target.value)} /></div></div>
<div className="grid grid-cols-2 gap-4"><div className="space-y-2"><Label>Cidade</Label><Input value={form.cidade} onChange={(e) => setField("cidade", e.target.value)} /></div><div className="space-y-2"><Label>Estado</Label><Input value={form.estado} onChange={(e) => setField("estado", e.target.value)} placeholder="UF" /></div></div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
<Collapsible open={expanded.obs} onOpenChange={() => setExpanded((s) => ({ ...s, obs: !s.obs }))}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors"><CardTitle className="flex items-center justify-between"><span>Observações e Anexos</span>{expanded.obs ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</CardTitle></CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4">
<div className="space-y-2"><Label>Observações</Label><Textarea rows={4} value={form.observacoes} onChange={(e) => setField("observacoes", e.target.value)} /></div>
<div className="space-y-2">
<Label>Adicionar anexos</Label>
<div className="border-2 border-dashed rounded-lg p-4">
<Label htmlFor="anexos" className="cursor-pointer block w-full rounded-md p-4 bg-primary text-primary-foreground">
<div className="flex flex-col items-center justify-center text-center">
<Upload className="h-7 w-7 mb-2 text-primary-foreground" />
<p className="text-sm text-primary-foreground">Clique para adicionar documentos (PDF, imagens, etc.)</p>
</div>
</Label>
<Input id="anexos" type="file" multiple className="hidden" onChange={addLocalAnexos} />
</div>
{form.anexos.length > 0 && (
<div className="space-y-2">
{form.anexos.map((f, i) => (
<div key={`${f.name}-${i}`} className="flex items-center justify-between p-2 border rounded">
<span className="text-sm">{f.name}</span>
<Button type="button" variant="ghost" size="sm" onClick={() => removeLocalAnexo(i)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{mode === "edit" && serverAnexos.length > 0 && (
<div className="space-y-2">
<Label>Anexos enviados</Label>
<div className="space-y-2">
{serverAnexos.map((ax) => {
const id = ax.id ?? ax.anexo_id ?? ax.uuid ?? "";
return (
<div key={String(id)} className="flex items-center justify-between p-2 border rounded">
<span className="text-sm">{ax.nome || ax.filename || `Anexo ${id}`}</span>
<Button type="button" variant="ghost" size="sm" onClick={() => handleRemoverAnexoServidor(String(id))}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
</div>
)}
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
<div className="flex justify-end gap-4 pt-6 border-t">
<Button type="button" variant="outline" className="hover:bg-primary/10 hover:text-primary dark:hover:bg-accent dark:hover:text-accent-foreground" onClick={() => (inline ? onClose?.() : onOpenChange?.(false))} disabled={isSubmitting}><XCircle className="mr-2 h-4 w-4" /> Cancelar</Button>
<Button type="submit" disabled={isSubmitting}>{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}{isSubmitting ? "Salvando..." : mode === "create" ? "Salvar Paciente" : "Atualizar Paciente"}</Button>
</div>
</form>
</>
);
if (inline) {
return (
<>
<div className="space-y-6">{content}</div>
<CredentialsDialog
open={showCredentialsDialog}
onOpenChange={(open) => {
console.log('🔄 CredentialsDialog onOpenChange chamado com:', open);
setShowCredentialsDialog(open);
if (!open) {
// Dialog foi fechado - limpar estados e fechar formulário
console.log('✅ Dialog fechado - limpando formulário...');
setCredentials(null);
// Chamar onSaved se houver paciente salvo
if (savedPatientRef.current) {
onSaved?.(savedPatientRef.current);
savedPatientRef.current = null;
}
// Limpar formulário
setForm(initial);
setPhotoPreview(null);
setServerAnexos([]);
// Fechar formulário
if (inline) onClose?.();
else onOpenChange?.(false);
}
}}
email={credentials?.email || ''}
password={credentials?.password || ''}
userName={credentials?.userName || ''}
userType={credentials?.userType || 'paciente'}
/>
</>
);
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<User className="h-5 w-5" /> {title}
</DialogTitle>
</DialogHeader>
{content}
</DialogContent>
</Dialog>
<CredentialsDialog
open={showCredentialsDialog}
onOpenChange={(open) => {
console.log('🔄 CredentialsDialog onOpenChange chamado com:', open);
setShowCredentialsDialog(open);
if (!open) {
// Dialog foi fechado - limpar estados e fechar formulário
console.log('✅ Dialog fechado - limpando formulário...');
setCredentials(null);
// Chamar onSaved se houver paciente salvo
if (savedPatientRef.current) {
onSaved?.(savedPatientRef.current);
savedPatientRef.current = null;
}
// Limpar formulário
setForm(initial);
setPhotoPreview(null);
setServerAnexos([]);
// Fechar formulário principal
onOpenChange?.(false);
}
}}
email={credentials?.email || ''}
password={credentials?.password || ''}
userName={credentials?.userName || ''}
userType={credentials?.userType || 'paciente'}
/>
</>
);
}

View File

@ -0,0 +1,79 @@
import { Card } from "@/components/ui/card"
import { Lightbulb, CheckCircle } from "lucide-react"
export function AboutSection() {
const values = ["Inovação", "Segurança", "Discrição", "Transparência", "Agilidade"]
return (
<section className="py-16 lg:py-24 bg-muted/30">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid lg:grid-cols-2 gap-12 items-center">
{}
<div className="space-y-8">
{}
<div className="relative">
<img
src="/Screenshot 2025-09-11 121911.png"
alt="Profissional trabalhando em laptop"
className="w-full h-auto rounded-2xl"
/>
</div>
{}
<Card className="bg-primary text-primary-foreground p-8 rounded-2xl">
<div className="flex items-start space-x-4">
<div className="flex-shrink-0 w-12 h-12 bg-primary-foreground/20 rounded-full flex items-center justify-center">
<Lightbulb className="w-6 h-6 text-primary-foreground" />
</div>
<div className="space-y-2">
<h3 className="text-sm font-semibold uppercase tracking-wide opacity-90">NOSSO OBJETIVO</h3>
<p className="text-lg leading-relaxed">
Nosso compromisso é garantir qualidade, segurança e sigilo em cada atendimento, unindo tecnologia à
responsabilidade médica.
</p>
</div>
</div>
</Card>
</div>
{}
<div className="space-y-8">
<div className="space-y-4">
<div className="inline-block px-4 py-2 bg-primary/10 text-primary rounded-full text-sm font-medium uppercase tracking-wide">
SOBRE NÓS
</div>
<h2 className="text-3xl lg:text-4xl font-bold text-foreground leading-tight text-balance">
Experimente o futuro do gerenciamento dos seus atendimentos médicos
</h2>
</div>
<div className="space-y-6 text-muted-foreground leading-relaxed">
<p>
Somos uma plataforma inovadora que conecta pacientes e médicos de forma prática, segura e humanizada.
Nosso objetivo é simplificar o processo de emissão e acompanhamento de laudos médicos, oferecendo um
ambiente online confiável e acessível.
</p>
<p>
Aqui, os pacientes podem registrar suas informações de saúde e solicitar laudos de forma rápida,
enquanto os médicos têm acesso a ferramentas que facilitam a análise, validação e emissão dos
documentos.
</p>
</div>
<div className="space-y-4">
<h3 className="text-xl font-semibold text-foreground">Nossos valores</h3>
<div className="grid grid-cols-2 gap-3">
{values.map((value, index) => (
<div key={index} className="flex items-center space-x-2">
<CheckCircle className="w-5 h-5 text-primary flex-shrink-0" />
<span className="text-foreground font-medium">{value}</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</section>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,146 @@
"use client";
import { useState } from "react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { CheckCircle2, Copy, Eye, EyeOff } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
export interface CredentialsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
email: string;
password: string;
userName: string;
userType: "médico" | "paciente";
}
export function CredentialsDialog({
open,
onOpenChange,
email,
password,
userName,
userType,
}: CredentialsDialogProps) {
const [showPassword, setShowPassword] = useState(false);
const [copiedEmail, setCopiedEmail] = useState(false);
const [copiedPassword, setCopiedPassword] = useState(false);
function handleCopyEmail() {
navigator.clipboard.writeText(email);
setCopiedEmail(true);
setTimeout(() => setCopiedEmail(false), 2000);
}
function handleCopyPassword() {
navigator.clipboard.writeText(password);
setCopiedPassword(true);
setTimeout(() => setCopiedPassword(false), 2000);
}
function handleCopyBoth() {
const text = `Email: ${email}\nSenha: ${password}`;
navigator.clipboard.writeText(text);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-500" />
{userType === "médico" ? "Médico" : "Paciente"} Cadastrado com Sucesso!
</DialogTitle>
<DialogDescription>
O {userType} <strong>{userName}</strong> foi cadastrado e pode fazer login com as credenciais abaixo.
</DialogDescription>
</DialogHeader>
<Alert className="bg-amber-50 border-amber-200">
<AlertDescription className="text-amber-900">
<strong>Importante:</strong> Anote ou copie estas credenciais agora. Por segurança, essa senha não será exibida novamente.
</AlertDescription>
</Alert>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="email">Email de Acesso</Label>
<div className="flex gap-2">
<Input
id="email"
value={email}
readOnly
className="bg-muted"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopyEmail}
title="Copiar email"
>
{copiedEmail ? <CheckCircle2 className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Senha Temporária</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={password}
readOnly
className="bg-muted pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full"
onClick={() => setShowPassword(!showPassword)}
title={showPassword ? "Ocultar senha" : "Mostrar senha"}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopyPassword}
title="Copiar senha"
>
{copiedPassword ? <CheckCircle2 className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button
type="button"
variant="outline"
onClick={handleCopyBoth}
className="w-full sm:w-auto"
>
<Copy className="mr-2 h-4 w-4" />
Copiar Tudo
</Button>
<Button
type="button"
onClick={() => onOpenChange(false)}
className="w-full sm:w-auto"
>
Fechar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,73 @@
import { Button } from "@/components/ui/button"
import { Shield, Clock, Users } from "lucide-react"
import Link from "next/link"
export function HeroSection() {
return (
<section className="py-8 lg:py-12 bg-background">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid lg:grid-cols-2 gap-8 items-center">
{}
<div className="space-y-6">
<div className="space-y-3">
<div className="inline-block px-4 py-2 bg-accent/10 text-accent rounded-full text-sm font-medium">
APROXIMANDO MÉDICOS E PACIENTES
</div>
<h1 className="text-3xl lg:text-4xl font-bold text-foreground leading-tight text-balance">
Segurança, <span className="text-primary">Confiabilidade</span> e{" "}
<span className="text-primary">Rapidez</span>
</h1>
<div className="space-y-1 text-base text-muted-foreground">
<p>Experimente o futuro dos agendamentos.</p>
<p>Encontre profissionais capacitados e marque sua consulta.</p>
</div>
</div>
</div>
{}
<div className="relative">
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-accent/20 to-primary/20 p-6">
<img
src="/medico-sorridente-de-tiro-medio-vestindo-casaco.jpg"
alt="Médico profissional sorrindo"
className="w-full h-auto rounded-lg min-h-80 max-h-[500px] object-cover object-center"
/>
</div>
</div>
</div>
{}
<div className="mt-10 grid md:grid-cols-3 gap-6">
<div className="flex items-start space-x-3">
<div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
<Shield className="w-4 h-4 text-primary" />
</div>
<div>
<h3 className="font-semibold text-foreground">Laudos digitais e padronizados</h3>
</div>
</div>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0 w-8 h-8 bg-accent/10 rounded-full flex items-center justify-center">
<Clock className="w-4 h-4 text-accent" />
</div>
<div>
<h3 className="font-semibold text-foreground">Notificações automáticas ao paciente</h3>
</div>
</div>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
<Users className="w-4 h-4 text-primary" />
</div>
<div>
<h3 className="font-semibold text-foreground">LGPD: controle de acesso e consentimento</h3>
</div>
</div>
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,114 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useTheme } from "next-themes";
import { ArrowLeft, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import FileUploadChat from "@/components/ui/file-upload-and-chat";
// 👉 AQUI você importa o fluxo correto de voz (já testado e funcionando)
import AIVoiceFlow from "@/components/ZoeIA/ai-voice-flow";
export function ChatWidget() {
const [assistantOpen, setAssistantOpen] = useState(false);
const [realtimeOpen, setRealtimeOpen] = useState(false);
const { theme } = useTheme();
const isDark = theme === "dark";
useEffect(() => {
if (!assistantOpen && !realtimeOpen) return;
const original = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = original;
};
}, [assistantOpen, realtimeOpen]);
const gradientRing = useMemo(
() => (
<span
aria-hidden
className="absolute inset-0 rounded-full bg-linear-to-br from-primary via-sky-500 to-emerald-400 opacity-90 blur-sm transition group-hover:blur group-hover:opacity-100"
/>
),
[]
);
const openAssistant = () => setAssistantOpen(true);
const closeAssistant = () => setAssistantOpen(false);
const openRealtime = () => setRealtimeOpen(true);
const closeRealtime = () => {
setRealtimeOpen(false);
setAssistantOpen(true);
};
return (
<>
{/* ----------------- ASSISTANT PANEL ----------------- */}
{assistantOpen && (
<div
id="ai-assistant-overlay"
className={`fixed inset-0 z-100 flex flex-col transition-colors ${isDark ? "bg-slate-950" : "bg-white"}`}
>
<div className={`flex items-center justify-between border-b px-4 py-3 shadow-sm transition-colors ${isDark ? "bg-slate-900 border-slate-700" : "bg-white border-gray-200"}`}>
<Button
type="button"
variant="ghost"
className={`flex items-center gap-2 ${isDark ? "text-slate-300 hover:bg-slate-800" : "text-slate-700 hover:bg-slate-100"}`}
onClick={closeAssistant}
>
<ArrowLeft className="h-4 w-4" aria-hidden />
<span className="text-sm font-semibold">Voltar</span>
</Button>
</div>
<div className="flex-1 overflow-auto">
<FileUploadChat onOpenVoice={openRealtime} />
</div>
</div>
)}
{/* ----------------- REALTIME VOICE PANEL ----------------- */}
{realtimeOpen && (
<div
id="ai-realtime-overlay"
className={`fixed inset-0 z-110 flex flex-col transition-colors ${isDark ? "bg-slate-950" : "bg-white"}`}
>
<div className={`flex items-center justify-between border-b px-4 py-3 transition-colors ${isDark ? "bg-slate-900 border-slate-700" : "bg-white border-gray-200"}`}>
<Button
type="button"
variant="ghost"
className={`flex items-center gap-2 ${isDark ? "text-slate-300 hover:bg-slate-800" : "text-slate-700 hover:bg-slate-100"}`}
onClick={closeRealtime}
>
<ArrowLeft className="h-4 w-4" aria-hidden />
<span className="text-sm">Voltar para a Zoe</span>
</Button>
</div>
{/* 🔥 Aqui entra o AIVoiceFlow COMPLETO */}
<div className="flex-1 overflow-auto flex items-center justify-center">
<AIVoiceFlow />
</div>
</div>
)}
{/* ----------------- FLOATING BUTTON ----------------- */}
<div className="fixed bottom-6 right-6 z-50 sm:bottom-8 sm:right-8">
<button
type="button"
onClick={openAssistant}
className="group relative flex h-16 w-16 items-center justify-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
>
{gradientRing}
<span className="relative flex h-16 w-16 items-center justify-center rounded-full bg-background text-primary shadow-[0_12px_30px_rgba(37,99,235,0.25)] ring-1 ring-primary/10 transition group-hover:scale-[1.03] group-active:scale-95">
<Sparkles className="h-7 w-7" aria-hidden />
</span>
</button>
</div>
</>
);
}

View File

@ -0,0 +1,47 @@
"use client"
import { ChevronUp } from "lucide-react"
import { Button } from "@/components/ui/button"
export function Footer() {
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" })
}
return (
<footer className="bg-background border-t border-border">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col md:flex-row items-center justify-between space-y-4 md:space-y-0">
{}
<div className="text-muted-foreground text-sm">© 2025 MEDI Connect</div>
{}
<nav className="flex items-center space-x-8">
<a href="#" className="text-muted-foreground hover:text-primary transition-colors text-sm">
Termos
</a>
<a href="#" className="text-muted-foreground hover:text-primary transition-colors text-sm">
Privacidade (LGPD)
</a>
<a href="#" className="text-muted-foreground hover:text-primary transition-colors text-sm">
Ajuda
</a>
</nav>
{}
<Button
variant="outline"
size="sm"
onClick={scrollToTop}
className="rounded-full w-10 h-10 p-0 border-primary text-primary hover:bg-primary hover:text-primary-foreground bg-transparent"
aria-label="Voltar ao topo"
>
<ChevronUp className="w-4 h-4" />
</Button>
</div>
</div>
</footer>
)
}

View File

@ -0,0 +1,101 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Menu, X } from "lucide-react";
import { usePathname } from "next/navigation";
import { SimpleThemeToggle } from "@/components/ui/simple-theme-toggle";
export function Header() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const pathname = usePathname();
return (
<header className="bg-background border-b border-border sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link href="/" className="flex items-center space-x-2">
<span className="text-xl font-bold text-foreground">
<span className="text-primary">MEDI</span>Connect
</span>
</Link>
{}
<nav className="hidden md:flex items-center gap-10">
<Link
href="/"
className={`text-foreground hover:text-primary transition-colors border-b-2 border-b-[transparent] ${
pathname === "/" ? "border-b-blue-500" : ""
}`}
>
Início
</Link>
<Link
href="/sobre"
className={`text-foreground hover:text-primary transition-colors border-b-2 border-b-[transparent] ${
pathname === "/sobre" ? "border-b-blue-500" : ""
}`}
>
Sobre
</Link>
</nav>
{}
<div className="hidden md:flex items-center space-x-3">
<SimpleThemeToggle />
<Button
variant="outline"
className="text-primary border-primary bg-transparent shadow-sm shadow-blue-500/10 border border-blue-200 hover:bg-blue-50 dark:shadow-none dark:border-primary dark:hover:bg-primary dark:hover:text-primary-foreground"
asChild
>
<Link href="/login">Entrar</Link>
</Button>
</div>
{}
<button
className="md:hidden p-2"
onClick={() => setIsMenuOpen(!isMenuOpen)}
aria-label="Toggle menu"
>
{isMenuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
{}
{isMenuOpen && (
<div className="md:hidden py-4 border-t border-border">
<nav className="flex flex-col space-y-4">
<Link
href="/"
className="text-foreground hover:text-primary transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Início
</Link>
<Link
href="/sobre"
className="text-muted-foreground hover:text-primary transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Sobre
</Link>
<div className="flex flex-col space-y-2 pt-4">
<SimpleThemeToggle />
<Button
variant="outline"
className="text-primary border-primary bg-transparent shadow-sm shadow-blue-500/10 border border-blue-200 hover:bg-blue-50 dark:shadow-none dark:border-primary dark:hover:bg-primary dark:hover:text-primary-foreground"
asChild
>
<Link href="/login">Entrar</Link>
</Button>
</div>
</nav>
</div>
)}
</div>
</header>
);
}

View File

@ -0,0 +1,102 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import {
Sidebar as ShadSidebar,
SidebarHeader,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarGroupContent,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarRail,
} from "@/components/ui/sidebar"
import {
Home,
Calendar,
Users,
UserCheck,
FileText,
BarChart3,
Stethoscope,
} from "lucide-react"
const navigation = [
{ name: "Dashboard", href: "/dashboard", icon: Home },
{ name: "Calendario", href: "/calendar", icon: Calendar },
{ name: "Pacientes", href: "/pacientes", icon: Users },
{ name: "Médicos", href: "/doutores", icon: Stethoscope },
{ name: "Consultas", href: "/consultas", icon: UserCheck },
{ name: "Relatórios", href: "/dashboard/relatorios", icon: BarChart3 },
]
export function Sidebar() {
const pathname = usePathname()
return (
<ShadSidebar
/* mude para side="right" se preferir */
side="left"
/* isso faz colapsar para ícones */
collapsible="icon"
className="border-r border-sidebar-border"
>
<SidebarHeader>
<Link
href="/"
className="flex items-center gap-2 hover:opacity-80 transition-opacity pt-2"
>
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center shrink-0">
<Stethoscope className="w-4 h-4 text-primary-foreground" />
</div>
{/* este span some no modo ícone */}
<span className="text-lg font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden">
MEDIConnect
</span>
</Link>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">
Menu
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{navigation.map((item) => {
const isActive = pathname === item.href ||
(pathname.startsWith(item.href + "/") && item.href !== "/dashboard")
return (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.href} className="flex items-center">
<item.icon className="mr-3 h-4 w-4 shrink-0" />
<span className="truncate group-data-[collapsible=icon]:hidden">
{item.name}
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>{/* espaço para perfil/logout, se quiser */}</SidebarFooter>
{/* rail clicável/hover que ajuda a reabrir/fechar */}
<SidebarRail />
</ShadSidebar>
)
}

View File

@ -0,0 +1,11 @@
'use client'
import * as React from 'react'
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@ -0,0 +1,136 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useAuth } from '@/hooks/useAuth'
import type { UserType } from '@/types/auth'
import { USER_TYPE_ROUTES, LOGIN_ROUTES, AUTH_STORAGE_KEYS } from '@/types/auth'
interface ProtectedRouteProps {
children: React.ReactNode
requiredUserType?: UserType[]
}
export default function ProtectedRoute({
children,
requiredUserType
}: ProtectedRouteProps) {
const { authStatus, user } = useAuth()
const router = useRouter()
const isRedirecting = useRef(false)
const [mounted, setMounted] = useState(false)
useEffect(() => {
// marca que o componente já montou no cliente
setMounted(true)
// Evitar múltiplos redirects
if (isRedirecting.current) return
// Durante loading, não fazer nada
if (authStatus === 'loading') return
// Se não autenticado, redirecionar para login
if (authStatus === 'unauthenticated') {
isRedirecting.current = true
console.log('[PROTECTED-ROUTE] Usuário NÃO autenticado - redirecionando...')
// Determinar página de login baseada no histórico
let userType: UserType = 'profissional'
if (typeof window !== 'undefined') {
try {
const storedUserType = localStorage.getItem(AUTH_STORAGE_KEYS.USER_TYPE)
if (storedUserType && ['profissional', 'paciente', 'administrador'].includes(storedUserType)) {
userType = storedUserType as UserType
}
} catch (error) {
console.warn('[PROTECTED-ROUTE] Erro ao ler localStorage:', error)
}
}
const loginRoute = LOGIN_ROUTES[userType]
console.log('[PROTECTED-ROUTE] Redirecionando para login:', {
userType,
loginRoute,
timestamp: new Date().toLocaleTimeString()
})
router.push(loginRoute)
return
}
// Se autenticado mas não tem permissão para esta página
if (authStatus === 'authenticated' && user && requiredUserType && !requiredUserType.includes(user.userType)) {
isRedirecting.current = true
console.log('[PROTECTED-ROUTE] Usuário SEM permissão para esta página', {
userType: user.userType,
requiredTypes: requiredUserType
})
const correctRoute = USER_TYPE_ROUTES[user.userType]
console.log('[PROTECTED-ROUTE] Redirecionando para área correta:', correctRoute)
router.push(correctRoute)
return
}
// Se chegou aqui, acesso está autorizado
if (authStatus === 'authenticated') {
console.log('[PROTECTED-ROUTE] ACESSO AUTORIZADO!', {
userType: user?.userType,
email: user?.email,
timestamp: new Date().toLocaleTimeString()
})
isRedirecting.current = false
}
}, [authStatus, user, requiredUserType, router])
// Durante loading, mostrar spinner
if (authStatus === 'loading') {
// evitar render no servidor para não causar mismatch de hidratação
if (!mounted) return null
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Verificando autenticação...</p>
</div>
</div>
)
}
// Se não autenticado ou redirecionando, mostrar spinner
if (authStatus === 'unauthenticated' || isRedirecting.current) {
// evitar render no servidor para não causar mismatch de hidratação
if (!mounted) return null
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Redirecionando...</p>
</div>
</div>
)
}
// Se usuário não tem permissão, mostrar fallback (não deveria chegar aqui devido ao useEffect)
if (requiredUserType && user && !requiredUserType.includes(user.userType)) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Acesso Negado</h2>
<p className="text-gray-600 mb-4">
Você não tem permissão para acessar esta página.
</p>
</div>
</div>
)
}
// Finalmente, renderizar conteúdo protegido
return <>{children}</>
}

View File

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,11 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }

View File

@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-blue-500 hover:text-white dark:bg-input/30 dark:border-input dark:hover:bg-blue-600 dark:hover:text-white",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,213 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short", timeZone: 'America/Sao_Paulo' }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(undefined, { timeZone: 'America/Sao_Paulo' })}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,241 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@ -0,0 +1,361 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-sector]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-none",
className
)}
ref={ref}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
payload?: any[]
label?: any
}
>(({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey
}, ref) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
let value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter && value) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item: any, index: number) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter &&
item.value !== undefined &&
item.name !== undefined ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
})
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
hideIcon?: boolean
payload?: any[]
verticalAlign?: RechartsPrimitive.LegendProps["verticalAlign"]
nameKey?: string
}
>(({ className, hideIcon = false, payload, verticalAlign, nameKey }, ref) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item: any) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
})
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: any,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -0,0 +1,252 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-primary focus:text-primary-foreground hover:bg-primary hover:text-primary-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 transition-colors",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@ -0,0 +1,586 @@
"use client";
import React, { useState, useRef, useCallback, useEffect } from "react";
import { useTheme } from "next-themes";
import {
Upload,
Paperclip,
Send,
Moon,
Sun,
X,
FileText,
ImageIcon,
Video,
Music,
Archive,
MessageCircle,
Bot,
User,
Info,
Lock,
Mic,
AudioLines,
Plus,
} from "lucide-react";
const API_ENDPOINT = "https://n8n.jonasbomfim.store/webhook/zoe2";
const FALLBACK_RESPONSE =
"Tive um problema para responder agora. Tente novamente em alguns instantes.";
const FileUploadChat = ({ onOpenVoice }: { onOpenVoice?: () => void }) => {
// Usa tema global fornecido por next-themes
const { theme, setTheme } = useTheme();
const isDarkMode = theme === "dark";
const [messages, setMessages] = useState([
{
id: 1,
type: "ai",
content:
"Compartilhe uma dúvida, exame ou orientação que deseja revisar. A Zoe registra o pedido e te retorna com um resumo organizado para a equipe de saúde.",
timestamp: new Date(),
},
]);
const [inputValue, setInputValue] = useState("");
const [uploadedFiles, setUploadedFiles] = useState<any[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const [isTyping, setIsTyping] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const chatEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Placeholder responsivo (não quebra, adapta o texto)
const [responsivePlaceholder, setResponsivePlaceholder] = useState("Pergunte qualquer coisa para a Zoe");
const computePlaceholder = (w: number) => {
if (w < 340) return "Pergunte à Zoe"; // ultra pequeno
if (w < 400) return "Pergunte algo à Zoe"; // pequeno
if (w < 520) return "Pergunte algo para a Zoe"; // médio estreito
return "Pergunte qualquer coisa para a Zoe"; // normal
};
useEffect(() => {
const update = () => setResponsivePlaceholder(computePlaceholder(window.innerWidth));
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, []);
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = textareaRef.current.scrollHeight + "px";
}
}, [inputValue]);
const getFileIcon = (fileName: string) => {
const ext = fileName.split(".").pop()?.toLowerCase();
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext || ""))
return <ImageIcon className="w-4 h-4" aria-hidden="true" />;
if (["mp4", "avi", "mkv", "mov", "webm"].includes(ext || ""))
return <Video className="w-4 h-4" aria-hidden="true" />;
if (["mp3", "wav", "flac", "ogg", "aac"].includes(ext || ""))
return <Music className="w-4 h-4" aria-hidden="true" />;
if (["zip", "rar", "7z", "tar", "gz"].includes(ext || ""))
return <Archive className="w-4 h-4" aria-hidden="true" />;
return <FileText className="w-4 h-4" aria-hidden="true" />;
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
const handleFileSelect = (files: FileList | null) => {
if (!files) return;
const newFiles = Array.from(files).map((file) => ({
id: Date.now() + Math.random(),
name: file.name,
size: file.size,
type: file.type,
file: file,
}));
setUploadedFiles((prev) => [...prev, ...newFiles]);
// Removido: mensagem de sistema de arquivos adicionados (não desejada na UI)
};
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFileSelect(files);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
}, []);
const removeFile = (fileId: number) => {
setUploadedFiles((prev) => prev.filter((file) => file.id !== fileId));
};
const generateAIResponse = useCallback(
async (userMessage: string, files: any[]) => {
try {
const pdfFile = files.find((file) => file.name.toLowerCase().endsWith(".pdf"));
let response: Response;
if (pdfFile) {
const formData = new FormData();
formData.append("pdf", pdfFile.file); // campo 'pdf'
formData.append("message", userMessage); // campo 'message'
response = await fetch(API_ENDPOINT, {
method: "POST",
body: formData, // multipart/form-data automático
});
} else {
response = await fetch(API_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: userMessage }),
});
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
let replyText = "";
try {
const parsed = await response.json(); // ← já trata como JSON direto
if (typeof parsed.message === "string") {
replyText = parsed.message.trim();
} else if (typeof parsed.reply === "string") {
replyText = parsed.reply.trim();
} else {
console.warn(
"[Zoe] Nenhum campo 'message' ou 'reply' na resposta:",
parsed
);
}
} catch (err) {
console.error("[Zoe] Erro ao processar resposta JSON:", err);
}
return replyText || FALLBACK_RESPONSE;
} catch (error) {
console.error("[FileUploadChat] Failed to get API response", error);
return FALLBACK_RESPONSE;
}
},
[]
);
const sendMessage = useCallback(async () => {
if (inputValue.trim() || uploadedFiles.length > 0) {
const newMessage = {
id: Date.now(),
type: "user",
content: inputValue.trim(),
files: [...uploadedFiles],
timestamp: new Date(),
};
setMessages((prev) => [...prev, newMessage]);
const messageContent = inputValue.trim();
const attachedFiles = [...uploadedFiles];
setInputValue("");
setUploadedFiles([]);
setIsTyping(true);
// Get AI response from API
const aiResponseContent = await generateAIResponse(
messageContent,
attachedFiles
);
const aiResponse = {
id: Date.now() + 1,
type: "ai",
content: aiResponseContent,
timestamp: new Date(),
};
setMessages((prev) => [...prev, aiResponse]);
setIsTyping(false);
}
}, [inputValue, uploadedFiles, generateAIResponse]);
const handleKeyPress = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
const themeClasses = {
background: isDarkMode ? "bg-gray-900" : "bg-gray-50",
cardBg: isDarkMode ? "bg-gray-800" : "bg-white",
text: isDarkMode ? "text-white" : "text-gray-900",
textSecondary: isDarkMode ? "text-gray-300" : "text-gray-600",
border: isDarkMode ? "border-gray-700" : "border-gray-200",
inputBg: isDarkMode ? "bg-gray-700" : "bg-gray-100",
uploadArea: isDragOver
? isDarkMode
? "bg-blue-900/50 border-blue-500"
: "bg-blue-50 border-blue-400"
: isDarkMode
? "bg-gray-700 border-gray-600"
: "bg-gray-50 border-gray-300",
userMessage: isDarkMode ? "bg-blue-600" : "bg-blue-500",
aiMessage: isDarkMode ? "bg-gray-700" : "bg-gray-200",
systemMessage: isDarkMode
? "bg-yellow-900/30 text-yellow-200"
: "bg-yellow-100 text-yellow-800",
};
return (
<div
className={`w-full min-h-screen transition-colors duration-300 ${themeClasses.background}`}
>
<div className="max-w-6xl mx-auto p-3 sm:p-6">
{/* Main Card - Zoe Assistant Section */}
<div
className={`rounded-2xl sm:rounded-3xl shadow-xl border bg-linear-to-br ${
isDarkMode
? "from-primary/15 via-gray-800 to-gray-900"
: "from-blue-50 via-white to-indigo-50"
} p-4 sm:p-8 ${
isDarkMode ? "border-gray-700" : "border-blue-200"
} mb-4 sm:mb-6 backdrop-blur-sm`}
>
<div className="flex flex-col gap-4 sm:gap-8">
{/* Header */}
<div className="flex flex-col gap-3 sm:gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-center gap-2 sm:gap-4">
<span className="flex h-10 w-10 sm:h-12 sm:w-12 shrink-0 items-center justify-center rounded-2xl sm:rounded-3xl bg-linear-to-br from-primary via-indigo-500 to-sky-500 text-sm sm:text-base font-semibold text-white shadow-lg">
Zoe
</span>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.18em] sm:tracking-[0.24em] text-primary/80">
Assistente Clínica Zoe
</p>
<h1 className="text-lg sm:text-3xl font-semibold tracking-tight text-foreground">
<span className="bg-linear-to-r from-sky-400 via-primary to-indigo-500 bg-clip-text text-transparent">
Olá, eu sou Zoe.
</span>
<span className="text-foreground"> Como posso ajudar?</span>
</h1>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-1 sm:gap-2">
<button
type="button"
className={`rounded-full px-2 sm:px-4 py-1 sm:py-2 text-xs font-semibold uppercase tracking-[0.12em] sm:tracking-[0.18em] whitespace-nowrap transition shadow-sm border ${isDarkMode ? "border-primary/40 text-primary hover:bg-primary/10" : "bg-primary border-primary text-white hover:bg-primary/90"}`}
>
Novo atendimento
</button>
<button
onClick={() => setTheme(isDarkMode ? "light" : "dark")}
className={`p-1.5 sm:p-2 rounded-lg sm:rounded-lg border transition-all duration-200 hover:scale-105 hover:shadow-lg ${themeClasses.border} ${themeClasses.inputBg} ${themeClasses.text}`}
aria-label="Alternar tema"
>
<Moon className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
</div>
</div>
{/* Description */}
<p
className={`max-w-3xl text-xs sm:text-sm leading-relaxed ${
isDarkMode ? "text-muted-foreground" : "text-gray-700"
}`}
>
Organizamos exames, orientações e tarefas assistenciais em um
painel único para acelerar decisões clínicas. Utilize a Zoe para
revisar resultados, registrar percepções e alinhar próximos passos
com a equipe de saúde.
</p>
{/* Security Info */}
<div className="flex items-center gap-2 rounded-full border border-primary/20 bg-primary/5 px-3 sm:px-4 py-1 sm:py-2 text-xs text-primary shadow-sm">
<Lock className="h-3 w-3 sm:h-4 sm:w-4 shrink-0" />
<span className="text-xs sm:text-sm">
Suas informações permanecem criptografadas e seguras com a
equipe Zoe.
</span>
</div>
{/* Info Section */}
<div
className={`rounded-2xl sm:rounded-3xl border bg-linear-to-br ${
isDarkMode
? "border-primary/25 from-primary/10 via-background/50 to-background text-muted-foreground"
: "border-blue-200 from-blue-50 via-white to-indigo-50 text-gray-700"
} p-4 sm:p-6 text-xs sm:text-sm leading-relaxed`}
>
<div
className={`mb-3 sm:mb-4 flex items-center gap-2 sm:gap-3 ${
isDarkMode ? "text-primary" : "text-blue-600"
}`}
>
<Info className="h-4 w-4 sm:h-5 sm:w-5 shrink-0" />
<span className="text-sm sm:text-base font-semibold">
Informativo importante
</span>
</div>
<p
className={`mb-3 sm:mb-4 text-xs sm:text-sm ${
isDarkMode ? "text-muted-foreground" : "text-gray-700"
}`}
>
A Zoe acompanha toda a jornada clínica, consolida exames e
registra orientações para que você tenha clareza em cada etapa
do cuidado. As respostas são informativas e complementam a
avaliação de um profissional de saúde qualificado.
</p>
<p
className={`font-medium text-xs sm:text-sm ${
isDarkMode ? "text-foreground" : "text-gray-900"
}`}
>
Em situações de urgência, entre em contato com a equipe médica
presencial ou acione os serviços de emergência da sua região.
</p>
</div>
{/* (Removido) Lista de arquivos antiga agora exibida sobre o input */}
</div>
</div>
{/* Chat Area */}
<div
className={`rounded-2xl shadow-xl border ${themeClasses.cardBg} ${themeClasses.border}`}
>
{/* Chat Header */}
<div
className={`px-4 sm:px-6 py-3 sm:py-4 border-b ${themeClasses.border}`}
>
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-2 h-2 sm:w-3 sm:h-3 bg-green-500 rounded-full animate-pulse"></div>
<h3
className={`font-semibold text-sm sm:text-base ${themeClasses.text}`}
>
Chat with AI Assistant
</h3>
<span
className={`text-xs sm:text-sm ${themeClasses.textSecondary}`}
>
Online
</span>
</div>
</div>
{/* Chat Messages */}
<div className="h-64 sm:h-96 overflow-y-auto p-4 sm:p-6 space-y-3 sm:space-y-4">
{messages.map((message: any) => (
<div
key={message.id}
className={`flex ${
message.type === "user"
? "justify-end"
: message.type === "system"
? "justify-center"
: "justify-start"
}`}
>
{message.type !== "system" && message.type === "ai" && (
<span className="flex h-7 w-7 sm:h-8 sm:w-8 shrink-0 items-center justify-center rounded-full bg-linear-to-br from-primary via-indigo-500 to-sky-500 text-xs font-semibold text-white shadow-lg mr-2 sm:mr-3">
Z
</span>
)}
<div
className={`max-w-xs sm:max-w-sm lg:max-w-md ${
message.type === "user"
? `${themeClasses.userMessage} text-white ml-3`
: message.type === "ai"
? `${themeClasses.aiMessage} ${themeClasses.text}`
: `${themeClasses.systemMessage} text-xs`
} px-4 py-3 rounded-2xl ${
message.type === "user"
? "rounded-br-md"
: message.type === "ai"
? "rounded-bl-md"
: "rounded-lg"
}`}
>
{message.content && (
<p className="wrap-break-word text-xs sm:text-sm">
{message.content}
</p>
)}
{message.files && message.files.length > 0 && (
<div className="mt-1 sm:mt-2 space-y-1">
{message.files.map((file: any) => (
<div
key={file.id}
className="flex items-center gap-1 sm:gap-2 text-xs opacity-90 bg-black/10 rounded px-2 py-1"
>
{getFileIcon(file.name)}
<span className="truncate text-xs">{file.name}</span>
<span className="text-xs">
({formatFileSize(file.size)})
</span>
</div>
))}
</div>
)}
<p className="text-xs opacity-70 mt-1 sm:mt-2">
{message.timestamp.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
{message.type === "user" && (
<div
className={`w-8 h-8 rounded-full ml-3 flex items-center justify-center ${themeClasses.userMessage}`}
>
<User className="w-5 h-5 text-white" />
</div>
)}
</div>
))}
{/* Typing Indicator */}
{isTyping && (
<div className="flex justify-start">
<span className="flex h-7 w-7 sm:h-8 sm:w-8 shrink-0 items-center justify-center rounded-full bg-linear-to-br from-primary via-indigo-500 to-sky-500 text-xs font-semibold text-white shadow-lg mr-2 sm:mr-3">
Z
</span>
<div
className={`px-4 py-3 rounded-2xl rounded-bl-md ${themeClasses.aiMessage}`}
>
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
style={{ animationDelay: "0.1s" }}
></div>
<div
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
style={{ animationDelay: "0.2s" }}
></div>
</div>
</div>
</div>
)}
<div ref={chatEndRef} />
</div>
{/* Chat Input */}
<div className={`border-t p-3 sm:p-4 ${themeClasses.border}`}>
<div className="flex flex-col gap-2">
{/* Anexos selecionados (chips) */}
{uploadedFiles.length > 0 && (
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto pb-1">
{uploadedFiles.map((file) => (
<div
key={file.id}
className={`group flex items-center gap-2 px-3 py-2 rounded-lg border ${themeClasses.border} ${themeClasses.inputBg} relative`}
>
{getFileIcon(file.name)}
<div className="min-w-0 max-w-[160px]">
<p className={`text-xs font-medium truncate ${themeClasses.text}`}>
{file.name}
</p>
<p className={`text-[10px] leading-tight ${themeClasses.textSecondary}`}>
{formatFileSize(file.size)}
</p>
</div>
<button
onClick={() => removeFile(file.id)}
className={`p-1 rounded-full transition-colors ${themeClasses.textSecondary} hover:text-red-500 hover:bg-red-500/20`}
aria-label="Remover arquivo"
>
<X className="w-3 h-3" />
</button>
</div>
))}
<button
onClick={() => setUploadedFiles([])}
className={`ml-auto text-[11px] px-2 py-1 rounded-md ${themeClasses.textSecondary} hover:text-red-500 transition-colors`}
>
Limpar tudo
</button>
</div>
)}
{/* Input unificado com ícones embutidos */}
<div className="flex w-full">
<div className={`flex items-center w-full rounded-full border ${themeClasses.border} ${themeClasses.inputBg} h-11 px-2 gap-2`}>
<button
onClick={() => fileInputRef.current?.click()}
type="button"
className={`flex items-center justify-center h-7 w-7 rounded-full transition-colors hover:bg-primary/20 flex-shrink-0 ${themeClasses.text}`}
aria-label="Anexar arquivos"
>
<Plus className="w-4 h-4" />
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => handleFileSelect(e.target.files)}
/>
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder={responsivePlaceholder}
rows={1}
className={`flex-1 bg-transparent resize-none focus:outline-none leading-snug py-3 pr-2 ${themeClasses.text} placeholder-gray-400 text-[13px] sm:text-sm placeholder:text-[12px] sm:placeholder:text-sm whitespace-nowrap overflow-hidden text-ellipsis placeholder:overflow-hidden placeholder:text-ellipsis`}
style={{ minHeight: 'auto', overflow: 'hidden' }}
/>
<button
onClick={() => onOpenVoice?.()}
type="button"
className={`flex items-center justify-center h-8 w-8 rounded-full border ${themeClasses.border} transition-colors hover:bg-primary/20 flex-shrink-0 ${themeClasses.text}`}
aria-label="Entrada de voz"
>
<AudioLines className="w-4 h-4" />
</button>
<button
onClick={sendMessage}
disabled={!inputValue.trim() && uploadedFiles.length === 0}
type="button"
className="flex items-center justify-center h-8 w-8 rounded-full bg-linear-to-r from-blue-500 to-purple-600 text-white hover:from-blue-600 hover:to-purple-700 disabled:from-gray-400 disabled:to-gray-500 disabled:cursor-not-allowed transition-colors shadow-md flex-shrink-0"
aria-label="Enviar mensagem"
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Quick Actions */}
</div>
</div>
</div>
</div>
);
};
export default FileUploadChat;

View File

@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@ -0,0 +1,77 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 flex h-9 w-full min-w-0 rounded-md bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"border border-gray-300 dark:border-input",
"focus-visible:border-primary focus-visible:ring-primary/20 focus-visible:ring-2",
"hover:border-gray-400 dark:hover:border-gray-500",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,276 @@
"use client"
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className
)}
{...props}
/>
)
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
)
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</MenubarPortal>
)
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
)
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}

View File

@ -0,0 +1,168 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}

Some files were not shown because too many files have changed in this diff Show More