Compare commits
30 Commits
913fd6ad64
...
c56cd9ff63
| Author | SHA1 | Date | |
|---|---|---|---|
| c56cd9ff63 | |||
| 84cb4c36eb | |||
| 56dd05c963 | |||
|
|
e4afaa5743 | ||
|
|
a6ae27876e | ||
| e389b0894e | |||
| 8bd4344670 | |||
| 956a8ff016 | |||
| 92b598b14a | |||
| 9cd35a0cc5 | |||
| ca7ab7a0fa | |||
| 67e52aa21f | |||
| 5030ae38d0 | |||
| 3c9bb1de4d | |||
| de0d5b41a9 | |||
| 23fad33ef9 | |||
| e17e709c01 | |||
| f14643fa6a | |||
| 2399fdfac9 | |||
| 31b02fdf2d | |||
| f8f32a9db7 | |||
| 19a9905b0c | |||
| 72a23cba69 | |||
| d69e8408fe | |||
| b50b429d16 | |||
| 616853220b | |||
| ab422746c8 | |||
| ba8b7881a4 | |||
|
|
af7de1dd0c | ||
| c36a16be06 |
45
et --hard 23fad33
Normal file
45
et --hard 23fad33
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
[33ma0d527c[m[33m ([m[1;36mHEAD[m[33m -> [m[1;32mfeature/settings[m[33m)[m ajuste no package.json da raiz
|
||||||
|
[33m23fad33[m[33m ([m[1;31morigin/feature/settings[m[33m)[m feat: implement settings module
|
||||||
|
[33mc36a16b[m[33m ([m[1;32mdevelop[m[33m)[m feat: ajustes na seção de laudos, cpf, imagem e assinatura digital
|
||||||
|
[33m913fd6a[m[33m ([m[1;31morigin/feature/doctor-laudo[m[33m, [m[1;32mfeature/api-medic[m[33m)[m Merge pull request 'feat(api): implementação e integração das APIs de médicos' (#12) from feature/api-medicos into develop
|
||||||
|
[33m791d31a[m[33m ([m[1;31morigin/feature/api-medicos[m[33m, [m[1;32mfeature/api-medicos[m[33m)[m feat(api): implementação e integração das APIs de médicos
|
||||||
|
[33me53d7fb[m[33m ([m[1;32mfeature/crud-medi-api[m[33m)[m Merge pull request 'feature/scheduling' (#11) from feature/scheduling into develop
|
||||||
|
[33m7aadcef[m Fix: folder organization
|
||||||
|
[33mc6b18b7[m Merge branch 'develop' of https://git.popcode.com.br/RiseUP/riseup-squad20 into feature/scheduling
|
||||||
|
[33m945c6ea[m fix: Calendar and sidebar
|
||||||
|
[33mdfb70c6[m Merge pull request 'feature/doctor-register' (#10) from feature/doctor-register into develop
|
||||||
|
[33m30b5609[m feat: adds new fields and cards to the physician registry
|
||||||
|
[33m9dfba10[m Merge branch 'develop' into feature/scheduling
|
||||||
|
[33mf435ade[m Ajuste no .gitignore
|
||||||
|
[33m9c7ce7d[m Finalizando merge da branch develop com origin/develop
|
||||||
|
[33m76feb4b[m feat:implements CRUD for doctors
|
||||||
|
[33m70c67e4[m Merge pull request 'change doctors page' (#8) from feature/changes-doctors-painel into develop
|
||||||
|
[33mba64fde[m add: new doctor page
|
||||||
|
[33ma7c9c90[m chore: update components config
|
||||||
|
[33ma5d89b3[m Merge pull request 'feature/image-doctor' (#7) from feature/image-doctor into develop
|
||||||
|
[33m0d416ca[m[33m ([m[1;31morigin/feature/image-doctor[m[33m)[m resolvendo erro de imagens
|
||||||
|
[33me405cc5[m WIP: alterações locais
|
||||||
|
[33mbb4cc38[m Ajustes no .gitignore
|
||||||
|
[33m953a4e7[m WIP: alterações locais
|
||||||
|
[33mdebc92d[m chore(calendar): adjust naming for calendar component consistency
|
||||||
|
[33mae637c4[m fix/errors-medical-page
|
||||||
|
[33mdf530f7[m Merge pull request 'Adicionando calendario interativo do medico' (#6) from feature/crud-medico into develop
|
||||||
|
[33m94839cc[m[33m ([m[1;31morigin/feature/crud-medico[m[33m, [m[1;32mfeature/crud-medico[m[33m)[m Adicionando calendario interativo do medico
|
||||||
|
[33m93a4389[m fix(merge): prefer feature versions (layout.tsx, package-lock.json)
|
||||||
|
[33mf2db866[m[33m ([m[1;32mfeature/patient-register[m[33m)[m fix(merge): resolve conflicts between develop and feature/patient-register
|
||||||
|
[33mcdd44da[m chore: save changes before switching branch
|
||||||
|
[33mb2a9ea0[m[33m ([m[1;31morigin/feature/patient-register[m[33m)[m feat(api): add and wire all mock endpoints
|
||||||
|
[33ma1ba4e5[m Merge pull request 'feature/scheduling' (#5) from feature/scheduling into develop
|
||||||
|
[33m40f05ca[m[33m ([m[1;31morigin/feature/scheduling[m[33m)[m ajeitando erro dos botões
|
||||||
|
[33ma9d093e[m adicionando agendamento-incompleto
|
||||||
|
[33m6ca8524[m Merge pull request 'feat: add medical page' (#4) from feature/crud-medico into develop
|
||||||
|
[33m7385e64[m feat: add medical page
|
||||||
|
[33ma44e9bc[m Merge branch 'feature/patient-register' of https://git.popcode.com.br/RiseUP/riseup-squad20 into feature/patient-register
|
||||||
|
[33m372383f[m feat: connect patient registration form to create patient API
|
||||||
|
[33m3cce8a9[m fix: fix ref error in actions menu
|
||||||
|
[33m91c84b6[m fix: secure setting of onOpenChange on the patient form
|
||||||
|
[33m8258fac[m feat: implement patient recorder
|
||||||
|
[33m20d070e[m[33m ([m[1;31morigin/feature/patient-list[m[33m, [m[1;32mfeature/patient-list[m[33m)[m chore: remove Website folderfrom repository
|
||||||
|
[33m0ba1590[m feat: add initial project files and patient list
|
||||||
|
[33m631f7f2[m[33m ([m[1;31morigin/feature/cadastro-pacientes[m[33m, [m[1;31morigin/developer[m[33m, [m[1;32mfeature/cadastro-pacientes[m[33m)[m feat: add initial structure
|
||||||
|
[33m6414f69[m[33m ([m[1;31morigin/main[m[33m, [m[1;31morigin/HEAD[m[33m)[m Initial commit
|
||||||
51
package-lock.json
generated
51
package-lock.json
generated
@ -8,7 +8,8 @@
|
|||||||
"@headlessui/react": "^2.2.7",
|
"@headlessui/react": "^2.2.7",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"react-big-calendar": "^1.19.4"
|
"react-big-calendar": "^1.19.4",
|
||||||
|
"react-signature-canvas": "^1.1.0-alpha.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/runtime": {
|
"node_modules/@babel/runtime": {
|
||||||
@ -266,6 +267,12 @@
|
|||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/signature_pad": {
|
||||||
|
"version": "2.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/signature_pad/-/signature_pad-2.3.6.tgz",
|
||||||
|
"integrity": "sha512-v3j92gCQJoxomHhd+yaG4Vsf8tRS/XbzWKqDv85UsqjMGy4zhokuwKe4b6vhbgncKkh+thF+gpz6+fypTtnFqQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/warning": {
|
"node_modules/@types/warning": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz",
|
||||||
@ -520,6 +527,36 @@
|
|||||||
"react-dom": ">=16.3.0"
|
"react-dom": ">=16.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-signature-canvas": {
|
||||||
|
"version": "1.1.0-alpha.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-signature-canvas/-/react-signature-canvas-1.1.0-alpha.2.tgz",
|
||||||
|
"integrity": "sha512-tKUNk3Gmh04Ug4K8p5g8Is08BFUKvbXxi0PyetQ/f8OgCBzcx4vqNf9+OArY/TdNdfHtswXQNRwZD6tyELjkjQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.17.9",
|
||||||
|
"@types/signature_pad": "^2.3.0",
|
||||||
|
"signature_pad": "^2.3.2",
|
||||||
|
"trim-canvas": "^0.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/agilgur5"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.26.0",
|
"version": "0.26.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
|
||||||
@ -527,12 +564,24 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/signature_pad": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-peYXLxOsIY6MES2TrRLDiNg2T++8gGbpP2yaC+6Ohtxr+a2dzoaqWosWDY9sWqTAAk6E/TyQO+LJw9zQwyu5kA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tabbable": {
|
"node_modules/tabbable": {
|
||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
|
||||||
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
|
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/trim-canvas": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/trim-canvas/-/trim-canvas-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-nd4Ga3iLFV94mdhW9JFMLpQbHUyCQuhFOD71PEAt1NjtMD5wbZctzhX8c3agHNybMR5zXD1XTGoIEWk995E6pQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
"@headlessui/react": "^2.2.7",
|
"@headlessui/react": "^2.2.7",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"react-big-calendar": "^1.19.4"
|
"react-big-calendar": "^1.19.4",
|
||||||
|
"react-signature-canvas": "^1.1.0-alpha.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
susconecta/.gitignore
vendored
4
susconecta/.gitignore
vendored
@ -24,4 +24,6 @@ yarn-error.log*
|
|||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.tsriseup-squad20/
|
||||||
|
susconecta/riseup-squad20/
|
||||||
|
riseup-squad20/
|
||||||
|
|||||||
34
susconecta/app/(main-routes)/configuracao/agenda/page.tsx
Normal file
34
susconecta/app/(main-routes)/configuracao/agenda/page.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||||
|
|
||||||
|
export default function AgendaConfigPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">Configurações da Agenda</h1>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Tempo padrão de consulta</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<select className="border rounded p-2">
|
||||||
|
<option>15 minutos</option>
|
||||||
|
<option>30 minutos</option>
|
||||||
|
<option>1 hora</option>
|
||||||
|
</select>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Horário de funcionamento</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<input type="time" className="border rounded p-2 mr-2" /> até
|
||||||
|
<input type="time" className="border rounded p-2 ml-2" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||||
|
|
||||||
|
export default function ComunicacaoConfigPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">Configurações de Comunicação</h1>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Modelo de Lembrete</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<textarea
|
||||||
|
className="w-full border rounded p-2"
|
||||||
|
placeholder="Exemplo: Olá {nome}, sua consulta está marcada para {data} às {hora}."
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Frequência de Lembretes</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<select className="border rounded p-2">
|
||||||
|
<option>24 horas antes</option>
|
||||||
|
<option>4 horas antes</option>
|
||||||
|
<option>1 hora antes</option>
|
||||||
|
</select>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||||
|
|
||||||
|
export default function NotificacoesConfigPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">Configurações de Notificações</h1>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Alertas Internos</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input type="checkbox" className="h-4 w-4" /> <span>Notificar quando consulta for cancelada</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center space-x-2 mt-2">
|
||||||
|
<input type="checkbox" className="h-4 w-4" /> <span>Notificar quando novo paciente for cadastrado</span>
|
||||||
|
</label>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
susconecta/app/(main-routes)/configuracao/page.tsx
Normal file
77
susconecta/app/(main-routes)/configuracao/page.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
MessageSquare,
|
||||||
|
Bell,
|
||||||
|
Users,
|
||||||
|
ShieldCheck,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
export default function ConfiguracaoPage() {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
title: "Agenda",
|
||||||
|
desc: "Defina horários e bloqueios",
|
||||||
|
href: "/dashboard/configuracao/agenda",
|
||||||
|
icon: Calendar,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Comunicação",
|
||||||
|
desc: "Gerencie mensagens automáticas",
|
||||||
|
href: "/dashboard/configuracao/comunicacao",
|
||||||
|
icon: MessageSquare,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Notificações",
|
||||||
|
desc: "Configure alertas internos",
|
||||||
|
href: "/dashboard/configuracao/notificacoes",
|
||||||
|
icon: Bell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Usuários",
|
||||||
|
desc: "Controle acessos e permissões",
|
||||||
|
href: "/dashboard/configuracao/usuarios",
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Segurança",
|
||||||
|
desc: "Senhas, privacidade e LGPD",
|
||||||
|
href: "/dashboard/configuracao/seguranca",
|
||||||
|
icon: ShieldCheck,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* título */}
|
||||||
|
<h1 className="text-2xl font-bold">Configurações</h1>
|
||||||
|
|
||||||
|
{/* introdução */}
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Ajuste os principais parâmetros do sistema. Escolha uma das seções abaixo
|
||||||
|
para configurar horários, mensagens, notificações internas, permissões de usuários
|
||||||
|
e regras de segurança da clínica.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* grid de cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Link key={item.title} href={item.href}>
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition">
|
||||||
|
<CardHeader className="flex flex-row items-center gap-2">
|
||||||
|
<item.icon className="w-5 h-5 text-primary" />
|
||||||
|
<CardTitle>{item.title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-gray-600">{item.desc}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
susconecta/app/(main-routes)/configuracao/seguranca/page.tsx
Normal file
33
susconecta/app/(main-routes)/configuracao/seguranca/page.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||||
|
|
||||||
|
export default function SegurancaConfigPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">Configurações de Segurança</h1>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Alterar Senha</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<input type="password" placeholder="Senha atual" className="w-full border rounded p-2 mb-2" />
|
||||||
|
<input type="password" placeholder="Nova senha" className="w-full border rounded p-2 mb-2" />
|
||||||
|
<input type="password" placeholder="Confirmar nova senha" className="w-full border rounded p-2" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Política de Dados (LGPD)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input type="checkbox" className="h-4 w-4" /> <span>Solicitar consentimento do paciente no cadastro</span>
|
||||||
|
</label>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
susconecta/app/(main-routes)/configuracao/usuarios/page.tsx
Normal file
37
susconecta/app/(main-routes)/configuracao/usuarios/page.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||||
|
|
||||||
|
export default function UsuariosConfigPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">Gerenciamento de Usuários</h1>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Usuários da Clínica</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<table className="w-full border">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-100">
|
||||||
|
<th className="p-2 text-left">Nome</th>
|
||||||
|
<th className="p-2 text-left">Email</th>
|
||||||
|
<th className="p-2 text-left">Permissão</th>
|
||||||
|
<th className="p-2">Ações</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td className="p-2">Maria Silva</td>
|
||||||
|
<td className="p-2">maria@clinica.com</td>
|
||||||
|
<td className="p-2">Secretária</td>
|
||||||
|
<td className="p-2">[Editar] [Remover]</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
362
susconecta/app/(main-routes)/consultas/page.tsx
Normal file
362
susconecta/app/(main-routes)/consultas/page.tsx
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
MoreHorizontal,
|
||||||
|
PlusCircle,
|
||||||
|
Search,
|
||||||
|
Eye,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
ArrowLeft,
|
||||||
|
} 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 { mockAppointments, mockProfessionals } from "@/lib/mocks/appointment-mocks";
|
||||||
|
import { CalendarRegistrationForm } from "@/components/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);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ConsultasPage() {
|
||||||
|
const [appointments, setAppointments] = useState(mockAppointments);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingAppointment, setEditingAppointment] = useState<any | null>(null);
|
||||||
|
const [viewingAppointment, setViewingAppointment] = useState<any | null>(null);
|
||||||
|
|
||||||
|
const mapAppointmentToFormData = (appointment: any) => {
|
||||||
|
const professional = mockProfessionals.find(p => p.id === appointment.professional);
|
||||||
|
const appointmentDate = new Date(appointment.time);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: appointment.id,
|
||||||
|
patientName: appointment.patient,
|
||||||
|
professionalName: professional ? professional.name : '',
|
||||||
|
appointmentDate: appointmentDate.toISOString().split('T')[0],
|
||||||
|
startTime: appointmentDate.toTimeString().split(' ')[0].substring(0, 5),
|
||||||
|
endTime: new Date(appointmentDate.getTime() + appointment.duration * 60000).toTimeString().split(' ')[0].substring(0, 5),
|
||||||
|
status: appointment.status,
|
||||||
|
appointmentType: appointment.type,
|
||||||
|
notes: appointment.notes,
|
||||||
|
cpf: '',
|
||||||
|
rg: '',
|
||||||
|
birthDate: '',
|
||||||
|
phoneCode: '+55',
|
||||||
|
phoneNumber: '',
|
||||||
|
email: '',
|
||||||
|
unit: 'nei',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (appointmentId: string) => {
|
||||||
|
if (window.confirm("Tem certeza que deseja excluir esta consulta?")) {
|
||||||
|
setAppointments((prev) => prev.filter((a) => a.id !== appointmentId));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (appointment: any) => {
|
||||||
|
const formData = mapAppointmentToFormData(appointment);
|
||||||
|
setEditingAppointment(formData);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleView = (appointment: any) => {
|
||||||
|
setViewingAppointment(appointment);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setEditingAppointment(null);
|
||||||
|
setShowForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = (formData: any) => {
|
||||||
|
|
||||||
|
const updatedAppointment = {
|
||||||
|
id: formData.id,
|
||||||
|
patient: formData.patientName,
|
||||||
|
time: new Date(`${formData.appointmentDate}T${formData.startTime}`).toISOString(),
|
||||||
|
duration: 30,
|
||||||
|
type: formData.appointmentType as any,
|
||||||
|
status: formData.status as any,
|
||||||
|
professional: appointments.find(a => a.id === formData.id)?.professional || '',
|
||||||
|
notes: formData.notes,
|
||||||
|
};
|
||||||
|
|
||||||
|
setAppointments(prev =>
|
||||||
|
prev.map(a => a.id === updatedAppointment.id ? updatedAppointment : a)
|
||||||
|
);
|
||||||
|
handleCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showForm && editingAppointment) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<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
|
||||||
|
initialData={editingAppointment}
|
||||||
|
onSave={handleSave}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Gerenciamento de Consultas</h1>
|
||||||
|
<p className="text-muted-foreground">Visualize, filtre e gerencie todas as consultas da clínica.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link href="/agenda">
|
||||||
|
<Button size="sm" className="h-8 gap-1">
|
||||||
|
<PlusCircle className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only sm:not-sr-only sm:whitespace-nowrap">
|
||||||
|
Agendar Nova Consulta
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Consultas Agendadas</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Visualize, filtre e gerencie todas as consultas da clínica.
|
||||||
|
</CardDescription>
|
||||||
|
<div className="pt-4 flex flex-wrap items-center gap-4">
|
||||||
|
<div className="relative flex-1 min-w-[250px]">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Buscar por..."
|
||||||
|
className="pl-8 w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Filtrar por status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todos</SelectItem>
|
||||||
|
<SelectItem value="confirmed">Confirmada</SelectItem>
|
||||||
|
<SelectItem value="pending">Pendente</SelectItem>
|
||||||
|
<SelectItem value="cancelled">Cancelada</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input type="date" className="w-[180px]" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Paciente</TableHead>
|
||||||
|
<TableHead>Médico</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Data e Hora</TableHead>
|
||||||
|
<TableHead>Ações</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{appointments.map((appointment) => {
|
||||||
|
const professional = mockProfessionals.find(
|
||||||
|
(p) => p.id === appointment.professional
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<TableRow key={appointment.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{appointment.patient}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{professional ? professional.name : "Não encontrado"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
appointment.status === "confirmed"
|
||||||
|
? "default"
|
||||||
|
: appointment.status === "pending"
|
||||||
|
? "secondary"
|
||||||
|
: "destructive"
|
||||||
|
}
|
||||||
|
className={
|
||||||
|
appointment.status === "confirmed" ? "bg-green-600" : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{capitalize(appointment.status)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(appointment.time)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button className="h-8 w-8 p-0 flex items-center justify-center rounded-md hover:bg-accent">
|
||||||
|
<span className="sr-only">Abrir 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>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{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">
|
||||||
|
{mockProfessionals.find(p => p.id === viewingAppointment?.professional)?.name || "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?.time ? formatDate(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"
|
||||||
|
? "default"
|
||||||
|
: viewingAppointment?.status === "pending"
|
||||||
|
? "secondary"
|
||||||
|
: "destructive"
|
||||||
|
}
|
||||||
|
className={
|
||||||
|
viewingAppointment?.status === "confirmed" ? "bg-green-600" : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{capitalize(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?.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
susconecta/app/(main-routes)/dashboard/relatorios/page.tsx
Normal file
88
susconecta/app/(main-routes)/dashboard/relatorios/page.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FileDown } from "lucide-react";
|
||||||
|
import jsPDF from "jspdf";
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
|
||||||
|
|
||||||
|
export default function RelatoriosPage() {
|
||||||
|
// Dados fictícios para o gráfico financeiro
|
||||||
|
const financeiro = [
|
||||||
|
{ mes: "Jan", faturamento: 35000, despesas: 12000 },
|
||||||
|
{ mes: "Fev", faturamento: 29000, despesas: 15000 },
|
||||||
|
{ mes: "Mar", faturamento: 42000, despesas: 18000 },
|
||||||
|
{ mes: "Abr", faturamento: 38000, despesas: 14000 },
|
||||||
|
{ mes: "Mai", faturamento: 45000, despesas: 20000 },
|
||||||
|
{ mes: "Jun", faturamento: 41000, despesas: 17000 },
|
||||||
|
];
|
||||||
|
// ============================
|
||||||
|
// PASSO 3 - Funções de exportar
|
||||||
|
// ============================
|
||||||
|
const exportConsultasPDF = () => {
|
||||||
|
const doc = new jsPDF();
|
||||||
|
doc.text("Relatório de Consultas", 10, 10);
|
||||||
|
doc.text("Resumo das consultas realizadas.", 10, 20);
|
||||||
|
doc.save("relatorio-consultas.pdf");
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportPacientesPDF = () => {
|
||||||
|
const doc = new jsPDF();
|
||||||
|
doc.text("Relatório de Pacientes", 10, 10);
|
||||||
|
doc.text("Informações gerais dos pacientes cadastrados.", 10, 20);
|
||||||
|
doc.save("relatorio-pacientes.pdf");
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportFinanceiroPDF = () => {
|
||||||
|
const doc = new jsPDF();
|
||||||
|
doc.text("Relatório Financeiro", 10, 10);
|
||||||
|
doc.text("Receitas e despesas da clínica.", 10, 20);
|
||||||
|
doc.save("relatorio-financeiro.pdf");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Relatórios</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-6">
|
||||||
|
{/* Card Consultas */}
|
||||||
|
<div className="p-4 border rounded-lg shadow">
|
||||||
|
<h2 className="font-semibold text-lg">Relatório de Consultas</h2>
|
||||||
|
<p className="text-sm text-gray-500">Resumo das consultas realizadas.</p>
|
||||||
|
{/* PASSO 4 - Botão chama a função */}
|
||||||
|
<Button onClick={exportConsultasPDF} className="mt-4">
|
||||||
|
<FileDown className="mr-2 h-4 w-4" /> Exportar PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Pacientes */}
|
||||||
|
<div className="p-4 border rounded-lg shadow">
|
||||||
|
<h2 className="font-semibold text-lg">Relatório de Pacientes</h2>
|
||||||
|
<p className="text-sm text-gray-500">Informações gerais dos pacientes cadastrados.</p>
|
||||||
|
<Button onClick={exportPacientesPDF} className="mt-4">
|
||||||
|
<FileDown className="mr-2 h-4 w-4" /> Exportar PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Financeiro com gráfico */}
|
||||||
|
<div className="p-4 border rounded-lg shadow col-span-3 md:col-span-3">
|
||||||
|
<h2 className="font-semibold text-lg mb-2">Relatório Financeiro</h2>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={financeiro} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="mes" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="faturamento" fill="#10b981" name="Faturamento" />
|
||||||
|
<Bar dataKey="despesas" fill="#ef4444" name="Despesas" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<Button onClick={exportFinanceiroPDF} className="mt-4">
|
||||||
|
<FileDown className="mr-2 h-4 w-4" /> Exportar PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,26 +5,59 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
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, Edit, Trash2, ArrowLeft, Eye } from "lucide-react";
|
import { MoreHorizontal, Plus, Search, Edit, Trash2, ArrowLeft, Eye } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { DoctorRegistrationForm } from "@/components/forms/doctor-registration-form";
|
import { DoctorRegistrationForm } from "@/components/forms/doctor-registration-form";
|
||||||
|
|
||||||
// >>> IMPORTES DA API <<<
|
|
||||||
import { listarMedicos, excluirMedico, Medico } from "@/lib/api";
|
import { listarMedicos, excluirMedico, Medico } from "@/lib/api";
|
||||||
|
|
||||||
|
function normalizeMedico(m: any): Medico {
|
||||||
|
return {
|
||||||
|
id: String(m.id ?? m.uuid ?? ""),
|
||||||
|
nome: m.nome ?? m.full_name ?? "", // 👈 Supabase usa full_name
|
||||||
|
nome_social: m.nome_social ?? m.social_name ?? null,
|
||||||
|
cpf: m.cpf ?? "",
|
||||||
|
rg: m.rg ?? m.document_number ?? null,
|
||||||
|
sexo: m.sexo ?? m.sex ?? null,
|
||||||
|
data_nascimento: m.data_nascimento ?? m.birth_date ?? null,
|
||||||
|
telefone: m.telefone ?? m.phone_mobile ?? "",
|
||||||
|
celular: m.celular ?? m.phone2 ?? null,
|
||||||
|
contato_emergencia: m.contato_emergencia ?? null,
|
||||||
|
email: m.email ?? "",
|
||||||
|
crm: m.crm ?? "",
|
||||||
|
estado_crm: m.estado_crm ?? m.crm_state ?? null,
|
||||||
|
rqe: m.rqe ?? null,
|
||||||
|
formacao_academica: m.formacao_academica ?? [],
|
||||||
|
curriculo_url: m.curriculo_url ?? null,
|
||||||
|
especialidade: m.especialidade ?? m.specialty ?? "",
|
||||||
|
observacoes: m.observacoes ?? m.notes ?? null,
|
||||||
|
foto_url: m.foto_url ?? null,
|
||||||
|
tipo_vinculo: m.tipo_vinculo ?? null,
|
||||||
|
dados_bancarios: m.dados_bancarios ?? null,
|
||||||
|
agenda_horario: m.agenda_horario ?? null,
|
||||||
|
valor_consulta: m.valor_consulta ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function DoutoresPage() {
|
export default function DoutoresPage() {
|
||||||
const [doctors, setDoctors] = useState<Medico[]>([]);
|
const [doctors, setDoctors] = useState<Medico[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [viewingDoctor, setViewingDoctor] = useState<Medico | null>(null);
|
||||||
|
|
||||||
|
|
||||||
// Carrega da API
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const list = await listarMedicos({ limit: 50 });
|
const list = await listarMedicos({ limit: 50 });
|
||||||
setDoctors(list ?? []);
|
setDoctors((list ?? []).map(normalizeMedico));
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -50,27 +83,52 @@ export default function DoutoresPage() {
|
|||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function handleEdit(id: string) {
|
function handleEdit(id: string) {
|
||||||
setEditingId(id);
|
setEditingId(id);
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Excluir via API e recarregar
|
function handleView(doctor: Medico) {
|
||||||
|
setViewingDoctor(doctor);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
if (!confirm("Excluir este médico?")) return;
|
if (!confirm("Excluir este médico?")) return;
|
||||||
await excluirMedico(id);
|
await excluirMedico(id);
|
||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Após salvar/criar/editar no form, fecha e recarrega
|
|
||||||
async function handleSaved() {
|
function handleSaved(savedDoctor?: Medico) {
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
await load();
|
|
||||||
|
if (savedDoctor) {
|
||||||
|
const normalized = normalizeMedico(savedDoctor);
|
||||||
|
setDoctors((prev) => {
|
||||||
|
const i = prev.findIndex((d) => String(d.id) === String(normalized.id));
|
||||||
|
if (i < 0) {
|
||||||
|
// Novo médico → adiciona no topo
|
||||||
|
return [normalized, ...prev];
|
||||||
|
} else {
|
||||||
|
// Médico editado → substitui na lista
|
||||||
|
const clone = [...prev];
|
||||||
|
clone[i] = normalized;
|
||||||
|
return clone;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// fallback → recarrega tudo
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (showForm) {
|
if (showForm) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 p-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" size="icon" onClick={() => setShowForm(false)}>
|
<Button variant="ghost" size="icon" onClick={() => setShowForm(false)}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
@ -90,7 +148,7 @@ export default function DoutoresPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 p-6">
|
||||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Médicos</h1>
|
<h1 className="text-2xl font-bold">Médicos</h1>
|
||||||
@ -155,7 +213,7 @@ export default function DoutoresPage() {
|
|||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => alert(JSON.stringify(doctor, null, 2))}>
|
<DropdownMenuItem onClick={() => handleView(doctor)}>
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
Ver
|
Ver
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@ -182,6 +240,47 @@ export default function DoutoresPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{viewingDoctor && (
|
||||||
|
<Dialog open={!!viewingDoctor} onOpenChange={() => setViewingDoctor(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Detalhes do Médico</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Informações detalhadas de {viewingDoctor?.nome}.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">Nome</Label>
|
||||||
|
<span className="col-span-3 font-medium">{viewingDoctor?.nome}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">Especialidade</Label>
|
||||||
|
<span className="col-span-3">
|
||||||
|
<Badge variant="outline">{viewingDoctor?.especialidade}</Badge>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">CRM</Label>
|
||||||
|
<span className="col-span-3">{viewingDoctor?.crm}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">Email</Label>
|
||||||
|
<span className="col-span-3">{viewingDoctor?.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">Telefone</Label>
|
||||||
|
<span className="col-span-3">{viewingDoctor?.telefone}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setViewingDoctor(null)}>Fechar</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Mostrando {filtered.length} de {doctors.length}
|
Mostrando {filtered.length} de {doctors.length}
|
||||||
</div>
|
</div>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import ProtectedRoute from "@/components/ProtectedRoute";
|
||||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { PagesHeader } from "@/components/dashboard/header";
|
import { PagesHeader } from "@/components/dashboard/header";
|
||||||
@ -8,7 +9,10 @@ export default function MainRoutesLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
console.log('[MAIN-ROUTES-LAYOUT] Layout do administrador carregado')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ProtectedRoute requiredUserType={["administrador"]}>
|
||||||
<div className="min-h-screen bg-background flex">
|
<div className="min-h-screen bg-background flex">
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
@ -18,5 +22,6 @@ export default function MainRoutesLayout({
|
|||||||
</main>
|
</main>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</div>
|
</div>
|
||||||
|
</ProtectedRoute>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
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 { MoreHorizontal, Plus, Search, Eye, Edit, Trash2, ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
import { Paciente, Endereco, listarPacientes, buscarPacientePorId, excluirPaciente } from "@/lib/api";
|
import { Paciente, Endereco, listarPacientes, buscarPacientePorId, excluirPaciente } from "@/lib/api";
|
||||||
@ -15,30 +17,31 @@ import { PatientRegistrationForm } from "@/components/forms/patient-registration
|
|||||||
function normalizePaciente(p: any): Paciente {
|
function normalizePaciente(p: any): Paciente {
|
||||||
const endereco: Endereco = {
|
const endereco: Endereco = {
|
||||||
cep: p.endereco?.cep ?? p.cep ?? "",
|
cep: p.endereco?.cep ?? p.cep ?? "",
|
||||||
logradouro: p.endereco?.logradouro ?? p.logradouro ?? "",
|
logradouro: p.endereco?.logradouro ?? p.street ?? "",
|
||||||
numero: p.endereco?.numero ?? p.numero ?? "",
|
numero: p.endereco?.numero ?? p.number ?? "",
|
||||||
complemento: p.endereco?.complemento ?? p.complemento ?? "",
|
complemento: p.endereco?.complemento ?? p.complement ?? "",
|
||||||
bairro: p.endereco?.bairro ?? p.bairro ?? "",
|
bairro: p.endereco?.bairro ?? p.neighborhood ?? "",
|
||||||
cidade: p.endereco?.cidade ?? p.cidade ?? "",
|
cidade: p.endereco?.cidade ?? p.city ?? "",
|
||||||
estado: p.endereco?.estado ?? p.estado ?? "",
|
estado: p.endereco?.estado ?? p.state ?? "",
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(p.id ?? p.uuid ?? p.paciente_id ?? ""),
|
id: String(p.id ?? p.uuid ?? p.paciente_id ?? ""),
|
||||||
nome: p.nome ?? "",
|
nome: p.full_name ?? "", // 👈 troca nome → full_name
|
||||||
nome_social: p.nome_social ?? null,
|
nome_social: p.social_name ?? null, // 👈 Supabase usa social_name
|
||||||
cpf: p.cpf ?? "",
|
cpf: p.cpf ?? "",
|
||||||
rg: p.rg ?? null,
|
rg: p.rg ?? p.document_number ?? null, // 👈 às vezes vem como document_number
|
||||||
sexo: p.sexo ?? null,
|
sexo: p.sexo ?? p.sex ?? null, // 👈 Supabase usa sex
|
||||||
data_nascimento: p.data_nascimento ?? null,
|
data_nascimento: p.data_nascimento ?? p.birth_date ?? null,
|
||||||
telefone: p.telefone ?? "",
|
telefone: p.telefone ?? p.phone_mobile ?? "",
|
||||||
email: p.email ?? "",
|
email: p.email ?? "",
|
||||||
endereco,
|
endereco,
|
||||||
observacoes: p.observacoes ?? null,
|
observacoes: p.observacoes ?? p.notes ?? null,
|
||||||
foto_url: p.foto_url ?? null,
|
foto_url: p.foto_url ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function PacientesPage() {
|
export default function PacientesPage() {
|
||||||
const [patients, setPatients] = useState<Paciente[]>([]);
|
const [patients, setPatients] = useState<Paciente[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -47,6 +50,7 @@ export default function PacientesPage() {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [viewingPatient, setViewingPatient] = useState<Paciente | null>(null);
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
try {
|
try {
|
||||||
@ -88,6 +92,10 @@ export default function PacientesPage() {
|
|||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleView(patient: Paciente) {
|
||||||
|
setViewingPatient(patient);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
if (!confirm("Excluir este paciente?")) return;
|
if (!confirm("Excluir este paciente?")) return;
|
||||||
try {
|
try {
|
||||||
@ -161,7 +169,6 @@ export default function PacientesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
{}
|
|
||||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Pacientes</h1>
|
<h1 className="text-2xl font-bold">Pacientes</h1>
|
||||||
@ -217,7 +224,7 @@ export default function PacientesPage() {
|
|||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => alert(JSON.stringify(p, null, 2))}>
|
<DropdownMenuItem onClick={() => handleView(p)}>
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
Ver
|
Ver
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@ -245,6 +252,46 @@ export default function PacientesPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</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.nome}.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">Nome</Label>
|
||||||
|
<span className="col-span-3 font-medium">{viewingPatient.nome}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">CPF</Label>
|
||||||
|
<span className="col-span-3">{viewingPatient.cpf}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">Telefone</Label>
|
||||||
|
<span className="col-span-3">{viewingPatient.telefone}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">Endereço</Label>
|
||||||
|
<span className="col-span-3">
|
||||||
|
{`${viewingPatient.endereco?.logradouro || ''}, ${viewingPatient.endereco?.numero || ''} - ${viewingPatient.endereco?.bairro || ''}, ${viewingPatient.endereco?.cidade || ''} - ${viewingPatient.endereco?.estado || ''}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">Observações</Label>
|
||||||
|
<span className="col-span-3">{viewingPatient.observacoes || "Nenhuma"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setViewingPatient(null)}>Fechar</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground">Mostrando {filtered.length} de {patients.length}</div>
|
<div className="text-sm text-muted-foreground">Mostrando {filtered.length} de {patients.length}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
22
susconecta/app/agenda/appointment-form.tsx
Normal file
22
susconecta/app/agenda/appointment-form.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
export default function AppointmentForm() {
|
||||||
|
return (
|
||||||
|
<form className="p-4 border rounded space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">Paciente</label>
|
||||||
|
<input type="text" className="mt-1 w-full border p-2 rounded" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">Data</label>
|
||||||
|
<input type="date" className="mt-1 w-full border p-2 rounded" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Salvar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,372 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import { CalendarRegistrationForm } from "@/components/forms/calendar-registration-form";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
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 { Switch } from "@/components/ui/switch";
|
|
||||||
import { Calendar } from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
RotateCcw,
|
|
||||||
Accessibility,
|
|
||||||
Volume2,
|
|
||||||
Flame,
|
|
||||||
Settings,
|
|
||||||
Clipboard,
|
|
||||||
Search,
|
|
||||||
ChevronDown,
|
|
||||||
Upload,
|
|
||||||
FileDown,
|
|
||||||
Tag,
|
|
||||||
Save,
|
|
||||||
} from "lucide-react";
|
|
||||||
import HeaderAgenda from "@/components/agenda/HeaderAgenda";
|
import HeaderAgenda from "@/components/agenda/HeaderAgenda";
|
||||||
import FooterAgenda from "@/components/agenda/FooterAgenda";
|
import FooterAgenda from "@/components/agenda/FooterAgenda";
|
||||||
|
|
||||||
export default function NovoAgendamentoPage() {
|
export default function NovoAgendamentoPage() {
|
||||||
const [bloqueio, setBloqueio] = useState(false);
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSave = (data: any) => {
|
||||||
|
console.log("Salvando novo agendamento...", data);
|
||||||
|
alert("Novo agendamento salvo (simulado)!");
|
||||||
|
router.push("/consultas");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// ====== WRAPPER COM ESPAÇAMENTO GERAL ======
|
|
||||||
<div className="min-h-screen flex flex-col bg-white">
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
{/* HEADER fora do <main>, usando o MESMO container do footer */}
|
|
||||||
<HeaderAgenda />
|
<HeaderAgenda />
|
||||||
|
<main className="flex-1 mx-auto w-full max-w-7xl px-8 py-8">
|
||||||
{/* Conteúdo */}
|
<CalendarRegistrationForm
|
||||||
<main className="flex-1 mx-auto w-full max-w-7xl px-8 py-8 space-y-8">
|
onSave={handleSave}
|
||||||
{/* ==== INFORMAÇÕES DO PACIENTE — layout idêntico ao print ==== */}
|
onCancel={handleCancel}
|
||||||
<div className="border rounded-md p-6 space-y-4 bg-white">
|
initialData={{}}
|
||||||
<h2 className="font-medium">Informações do paciente</h2>
|
|
||||||
|
|
||||||
{/* grade principal: 12 colunas para controlar as larguras */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
|
|
||||||
{/* ===== Linha 1 ===== */}
|
|
||||||
<div className="md:col-span-6">
|
|
||||||
<Label>Nome *</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
placeholder="Digite o nome do paciente"
|
|
||||||
className="h-10 pl-8"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-3">
|
|
||||||
<Label>CPF do paciente</Label>
|
|
||||||
<Input placeholder="Número do CPF" className="h-10" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-3">
|
|
||||||
<Label>RG</Label>
|
|
||||||
<Input placeholder="Número do RG" className="h-10" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ===== Linha 2 ===== */}
|
|
||||||
{/* 1ª coluna (span 6) com sub-grid: Data (5 col) + Telefone (7 col) */}
|
|
||||||
<div className="md:col-span-6">
|
|
||||||
<div className="grid grid-cols-12 gap-3">
|
|
||||||
<div className="col-span-5">
|
|
||||||
<Label>Data de nascimento *</Label>
|
|
||||||
<Input type="date" className="h-10" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-span-7">
|
|
||||||
<Label>Telefone</Label>
|
|
||||||
<div className="grid grid-cols-[86px_1fr] gap-2">
|
|
||||||
<select className="h-10 rounded-md border border-input bg-background px-2 text-[13px]">
|
|
||||||
<option value="+55">+55</option>
|
|
||||||
<option value="+351">+351</option>
|
|
||||||
<option value="+1">+1</option>
|
|
||||||
</select>
|
|
||||||
<Input placeholder="(99) 99999-9999" className="h-10" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 2ª coluna da linha 2: E-mail (span 6) */}
|
|
||||||
<div className="md:col-span-6">
|
|
||||||
<Label>E-mail</Label>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
placeholder="email@exemplo.com"
|
|
||||||
className="h-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ===== Linha 3 ===== */}
|
|
||||||
<div className="md:col-span-6">
|
|
||||||
<Label>Convênio</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<select
|
|
||||||
defaultValue="particular"
|
|
||||||
className="h-10 w-full rounded-md border border-input bg-background pr-8 pl-3 text-[13px] appearance-none"
|
|
||||||
>
|
|
||||||
<option value="particular">Particular</option>
|
|
||||||
<option value="plano-a">Plano A</option>
|
|
||||||
<option value="plano-b">Plano B</option>
|
|
||||||
</select>
|
|
||||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-6 grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label>Matrícula</Label>
|
|
||||||
<Input defaultValue="000000000" className="h-10" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>Validade</Label>
|
|
||||||
<Input placeholder="00/0000" className="h-10" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* link Informações adicionais */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-sm text-blue-600 inline-flex items-center gap-1 hover:underline"
|
|
||||||
aria-label="Ver informações adicionais do paciente"
|
|
||||||
>
|
|
||||||
Informações adicionais
|
|
||||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* barra Documentos e anexos */}
|
|
||||||
<div className="flex items-center justify-between border rounded-md px-3 py-2">
|
|
||||||
<span className="text-sm">Documentos e anexos</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
className="h-8 w-8"
|
|
||||||
aria-label="Enviar documento"
|
|
||||||
>
|
|
||||||
<Upload className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
className="h-8 w-8"
|
|
||||||
aria-label="Baixar documento"
|
|
||||||
>
|
|
||||||
<FileDown className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
className="h-8 w-8"
|
|
||||||
aria-label="Gerenciar etiquetas"
|
|
||||||
>
|
|
||||||
<Tag className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ==== INFORMAÇÕES DO ATENDIMENTO ==== */}
|
|
||||||
<div className="border rounded-md p-6 space-y-4 bg-white">
|
|
||||||
<h2 className="font-medium">Informações do atendimento</h2>
|
|
||||||
|
|
||||||
{/* GRID PRINCIPAL: 12 colunas */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6">
|
|
||||||
{/* COLUNA ESQUERDA (span 6) */}
|
|
||||||
<div className="md:col-span-6 space-y-3">
|
|
||||||
{/* Nome do profissional */}
|
|
||||||
<div>
|
|
||||||
<Label className="text-[13px]">
|
|
||||||
Nome do profissional <span className="text-red-600">*</span>
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
defaultValue="Robson Alves dos Anjos Neto"
|
|
||||||
className="h-10 w-full rounded-full border border-input pl-8 pr-12 text-[13px] focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex h-6 min-w-[28px] items-center justify-center rounded-full bg-muted px-2 text-xs font-medium">
|
|
||||||
RA
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{/* Unidade */}
|
|
||||||
<div>
|
|
||||||
<Label className="text-[13px]">
|
|
||||||
Unidade <span className="text-red-600">*</span>
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
defaultValue="nei"
|
|
||||||
className="h-10 w-full rounded-md border border-input bg-background pr-8 pl-3 text-[13px] appearance-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
>
|
|
||||||
<option value="nei">
|
|
||||||
Núcleo de Especialidades Integradas
|
|
||||||
</option>
|
|
||||||
<option value="cc">Clínica Central</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Data com ícone */}
|
|
||||||
<div>
|
|
||||||
<Label className="text-[13px]">
|
|
||||||
Data <span className="text-red-600">*</span>
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Calendar className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
defaultValue="2025-07-29"
|
|
||||||
className="h-10 w-full rounded-md border border-input pl-8 pr-3 text-[13px] focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Início / Término / Profissional solicitante (na mesma linha) */}
|
|
||||||
<div className="grid grid-cols-12 gap-3 items-end">
|
|
||||||
{/* Início (maior) */}
|
|
||||||
<div className="col-span-12 md:col-span-3">
|
|
||||||
<Label className="text-[13px]">
|
|
||||||
Início <span className="text-red-600">*</span>
|
|
||||||
</Label>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
defaultValue="21:03"
|
|
||||||
className="h-10 w-full rounded-md border border-input px-3 text-[13px] focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Término (maior) */}
|
|
||||||
<div className="col-span-12 md:col-span-3">
|
|
||||||
<Label className="text-[13px]">
|
|
||||||
Término <span className="text-red-600">*</span>
|
|
||||||
</Label>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
defaultValue="21:03"
|
|
||||||
className="h-10 w-full rounded-md border border-input px-3 text-[13px] focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Profissional solicitante */}
|
|
||||||
<div className="col-span-12 md:col-span-6">
|
|
||||||
<Label className="text-[13px]">
|
|
||||||
Profissional solicitante
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
{/* ícone de busca à esquerda */}
|
|
||||||
<svg
|
|
||||||
className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<select
|
|
||||||
defaultValue=""
|
|
||||||
className="h-10 w-full rounded-md border border-input bg-background pl-8 pr-8 text-[13px] appearance-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
>
|
|
||||||
<option value="" disabled>
|
|
||||||
Selecione o solicitante
|
|
||||||
</option>
|
|
||||||
<option value="1">Dr. Carlos Silva</option>
|
|
||||||
<option value="2">Dra. Maria Santos</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<svg
|
|
||||||
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M6 9l6 6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* COLUNA DIREITA — altura/posição como a imagem 1 */}
|
|
||||||
<div className="md:col-span-6 relative -top-10">
|
|
||||||
{/* toolbar */}
|
|
||||||
<div className="mb-2 flex items-center justify-end gap-1">
|
|
||||||
<Button size="icon" variant="outline" className="h-8 w-8">
|
|
||||||
<Accessibility className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button size="icon" variant="outline" className="h-8 w-8">
|
|
||||||
<Volume2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button size="icon" variant="outline" className="h-8 w-8">
|
|
||||||
<Flame className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button size="icon" variant="outline" className="h-8 w-8">
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button size="icon" variant="outline" className="h-8 w-8">
|
|
||||||
<Clipboard className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tipo de atendimento + campo de busca */}
|
|
||||||
<div className="mb-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="text-[13px]">
|
|
||||||
Tipo de atendimento <span className="text-red-600">*</span>
|
|
||||||
</Label>
|
|
||||||
<label className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-3.5 w-3.5 accent-current"
|
|
||||||
/>{" "}
|
|
||||||
Pagamento via Reembolso
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative mt-1">
|
|
||||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Pesquisar"
|
|
||||||
className="h-10 w-full rounded-md border border-input pl-8 pr-8 text-[13px]"
|
|
||||||
/>
|
|
||||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Observações + imprimir */}
|
|
||||||
<div className="mb-0.2 flex items-center justify-between">
|
|
||||||
<Label className="text-[13px]">Observações</Label>
|
|
||||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-3.5 w-3.5 accent-current"
|
|
||||||
/>{" "}
|
|
||||||
Imprimir na Etiqueta / Pulseira
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Textarea mais baixo e compacto */}
|
|
||||||
<Textarea
|
|
||||||
rows={4}
|
|
||||||
placeholder=""
|
|
||||||
className="text-[13px] h-[110px] min-h-0 resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* ====== FOOTER FIXO ====== */}
|
|
||||||
<FooterAgenda />
|
<FooterAgenda />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import type React from "react"
|
import type React from "react"
|
||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
|
import { AuthProvider } from "@/hooks/useAuth"
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "SUSConecta - Conectando Pacientes e Profissionais de Saúde",
|
title: "MediConnect - Conectando Pacientes e Profissionais de Saúde",
|
||||||
description:
|
description:
|
||||||
"Plataforma inovadora que conecta pacientes e médicos de forma prática, segura e humanizada. Experimente o futuro dos agendamentos médicos.",
|
"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",
|
keywords: "saúde, médicos, pacientes, agendamento, telemedicina, SUS",
|
||||||
generator: 'v0.app'
|
generator: 'v0.app'
|
||||||
}
|
}
|
||||||
@ -17,7 +18,11 @@ export default function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="pt-BR" className="antialiased">
|
<html lang="pt-BR" className="antialiased">
|
||||||
<body style={{ fontFamily: "var(--font-geist-sans)" }}>{children}</body>
|
<body style={{ fontFamily: "var(--font-geist-sans)" }}>
|
||||||
|
<AuthProvider>
|
||||||
|
{children}
|
||||||
|
</AuthProvider>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
124
susconecta/app/login-admin/page.tsx
Normal file
124
susconecta/app/login-admin/page.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
'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 LoginAdminPage() {
|
||||||
|
const [credentials, setCredentials] = useState({ email: '', password: '' })
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
const { login } = useAuth()
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Tentar fazer login usando o contexto com tipo administrador
|
||||||
|
const success = await login(credentials.email, credentials.password, 'administrador')
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
console.log('[LOGIN-ADMIN] Login bem-sucedido, redirecionando...')
|
||||||
|
|
||||||
|
// Redirecionamento direto - solução que funcionou
|
||||||
|
window.location.href = '/dashboard'
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[LOGIN-ADMIN] Erro no login:', err)
|
||||||
|
|
||||||
|
if (err instanceof AuthenticationError) {
|
||||||
|
setError(err.message)
|
||||||
|
} else {
|
||||||
|
setError('Erro inesperado. Tente novamente.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 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-gray-900">
|
||||||
|
Login Administrador de Clínica
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
Entre com suas credenciais para acessar o sistema administrativo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-center">Acesso Administrativo</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleLogin} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||||
|
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-gray-700">
|
||||||
|
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 no Sistema Administrativo'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<Button variant="outline" asChild className="w-full">
|
||||||
|
<Link href="/">
|
||||||
|
Voltar ao Início
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
122
susconecta/app/login-paciente/page.tsx
Normal file
122
susconecta/app/login-paciente/page.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
'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 LoginPacientePage() {
|
||||||
|
const [credentials, setCredentials] = useState({ email: '', password: '' })
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
const { login } = useAuth()
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Tentar fazer login usando o contexto com tipo paciente
|
||||||
|
const success = await login(credentials.email, credentials.password, 'paciente')
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Redirecionar para a página do paciente
|
||||||
|
router.push('/paciente')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[LOGIN-PACIENTE] Erro no login:', err)
|
||||||
|
|
||||||
|
if (err instanceof AuthenticationError) {
|
||||||
|
setError(err.message)
|
||||||
|
} else {
|
||||||
|
setError('Erro inesperado. Tente novamente.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 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-gray-900">
|
||||||
|
Portal do Paciente
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
Acesse sua área pessoal e gerencie suas consultas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-center">Entrar como Paciente</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleLogin} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||||
|
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-gray-700">
|
||||||
|
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 na Minha Área'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<Button variant="outline" asChild className="w-full">
|
||||||
|
<Link href="/">
|
||||||
|
Voltar ao Início
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
susconecta/app/login/page.tsx
Normal file
124
susconecta/app/login/page.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
'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 } = useAuth()
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Tentar fazer login usando o contexto com tipo profissional
|
||||||
|
const success = await login(credentials.email, credentials.password, 'profissional')
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
console.log('[LOGIN-PROFISSIONAL] Login bem-sucedido, redirecionando...')
|
||||||
|
|
||||||
|
// Redirecionamento direto - solução que funcionou
|
||||||
|
window.location.href = '/profissional'
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[LOGIN-PROFISSIONAL] Erro no login:', err)
|
||||||
|
|
||||||
|
if (err instanceof AuthenticationError) {
|
||||||
|
setError(err.message)
|
||||||
|
} else {
|
||||||
|
setError('Erro inesperado. Tente novamente.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 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-gray-900">
|
||||||
|
Login Profissional de Saúde
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
Entre com suas credenciais para acessar o sistema
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-center">Acesso ao Sistema</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleLogin} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||||
|
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-gray-700">
|
||||||
|
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="outline" asChild className="w-full">
|
||||||
|
<Link href="/">
|
||||||
|
Voltar ao Início
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
95
susconecta/app/paciente/page.tsx
Normal file
95
susconecta/app/paciente/page.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
'use client'
|
||||||
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { User, LogOut, Home } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||||
|
|
||||||
|
export default function PacientePage() {
|
||||||
|
const { logout, user } = useAuth()
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
console.log('[PACIENTE] Iniciando logout...')
|
||||||
|
await logout()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProtectedRoute requiredUserType={["paciente"]}>
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-md shadow-lg">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<User className="h-8 w-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl font-bold text-gray-900">
|
||||||
|
Portal do Paciente
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Bem-vindo ao seu espaço pessoal
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Informações do Paciente */}
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-2">
|
||||||
|
Maria Silva Santos
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
CPF: 123.456.789-00
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Idade: 35 anos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Informações do Login */}
|
||||||
|
<div className="bg-gray-100 rounded-lg p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-gray-600 mb-1">
|
||||||
|
Conectado como:
|
||||||
|
</p>
|
||||||
|
<p className="font-medium text-gray-800">
|
||||||
|
{user?.email || 'paciente@example.com'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Tipo de usuário: Paciente
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botão Voltar ao Início */}
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
className="w-full flex items-center justify-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Link href="/">
|
||||||
|
<Home className="h-4 w-4" />
|
||||||
|
Voltar ao Início
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Botão de Logout */}
|
||||||
|
<Button
|
||||||
|
onClick={handleLogout}
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full flex items-center justify-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Sair
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Informação adicional */}
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Em breve, mais funcionalidades estarão disponíveis
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</ProtectedRoute>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
137
susconecta/components/ProtectedRoute.tsx
Normal file
137
susconecta/components/ProtectedRoute.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
'use client'
|
||||||
|
import { useEffect, useRef } 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)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 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') {
|
||||||
|
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) {
|
||||||
|
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>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
Tipo de acesso necessário: {requiredUserType.join(' ou ')}
|
||||||
|
<br />
|
||||||
|
Seu tipo de acesso: {user.userType}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(USER_TYPE_ROUTES[user.userType])}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 cursor-pointer"
|
||||||
|
>
|
||||||
|
Ir para minha área
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalmente, renderizar conteúdo protegido
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
@ -1,20 +1,34 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Bell, Search } from "lucide-react"
|
import { Bell, Search, ChevronDown } from "lucide-react"
|
||||||
|
import { useAuth } from "@/hooks/useAuth"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import {
|
import { useState, useEffect, useRef } from "react"
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu"
|
|
||||||
import { SidebarTrigger } from "../ui/sidebar"
|
import { SidebarTrigger } from "../ui/sidebar"
|
||||||
|
|
||||||
export function PagesHeader({ title = "", subtitle = "" }: { title?: string, subtitle?: string }) {
|
export function PagesHeader({ title = "", subtitle = "" }: { title?: string, subtitle?: string }) {
|
||||||
|
const { logout, user } = useAuth();
|
||||||
|
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 (
|
return (
|
||||||
<header className="h-16 border-b border-border bg-background px-6 flex items-center justify-between">
|
<header className="h-16 border-b border-border bg-background px-6 flex items-center justify-between">
|
||||||
<div className="flex flex-row items-center gap-4">
|
<div className="flex flex-row items-center gap-4">
|
||||||
@ -35,29 +49,63 @@ export function PagesHeader({ title = "", subtitle = "" }: { title?: string, sub
|
|||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
{/* Avatar Dropdown Simples */}
|
||||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="relative h-8 w-8 rounded-full border-2 border-gray-300 hover:border-blue-500"
|
||||||
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
|
>
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
<AvatarImage src="/avatars/01.png" alt="@usuario" />
|
<AvatarImage src="/avatars/01.png" alt="@usuario" />
|
||||||
<AvatarFallback>RA</AvatarFallback>
|
<AvatarFallback className="bg-blue-500 text-white font-semibold">RA</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
{/* Dropdown Content */}
|
||||||
<DropdownMenuLabel className="font-normal">
|
{dropdownOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-80 bg-white border border-gray-200 rounded-md shadow-lg z-50">
|
||||||
|
<div className="p-4 border-b border-gray-100">
|
||||||
<div className="flex flex-col space-y-1">
|
<div className="flex flex-col space-y-1">
|
||||||
<p className="text-sm font-medium leading-none">Dr. Roberto Alves</p>
|
<p className="text-sm font-semibold leading-none">
|
||||||
<p className="text-xs leading-none text-muted-foreground">roberto@clinica.com</p>
|
{user?.userType === 'administrador' ? 'Administrador da Clínica' : 'Usuário do Sistema'}
|
||||||
|
</p>
|
||||||
|
{user?.email ? (
|
||||||
|
<p className="text-xs leading-none text-gray-600">{user.email}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs leading-none text-gray-600">Email não disponível</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs leading-none text-blue-600 font-medium">
|
||||||
|
Tipo: {user?.userType === 'administrador' ? 'Administrador' : user?.userType || 'Não definido'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="py-1">
|
||||||
|
<button className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 cursor-pointer">
|
||||||
|
👤 Perfil
|
||||||
|
</button>
|
||||||
|
<button className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 cursor-pointer">
|
||||||
|
⚙️ Configurações
|
||||||
|
</button>
|
||||||
|
<div className="border-t border-gray-100 my-1"></div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDropdownOpen(false);
|
||||||
|
|
||||||
|
// Usar sempre o logout do hook useAuth (ele já redireciona corretamente)
|
||||||
|
logout();
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
🚪 Sair
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem>Perfil</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>Configurações</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem>Sair</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -32,12 +32,11 @@ import {
|
|||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: "Dashboard", href: "/dashboard", icon: Home },
|
{ name: "Dashboard", href: "/dashboard", icon: Home },
|
||||||
{ name: "Calendario", href: "/calendar", icon: Calendar },
|
{ name: "Calendario", href: "/calendar", icon: Calendar },
|
||||||
{ name: "Pacientes", href: "/dashboard/pacientes", icon: Users },
|
{ name: "Pacientes", href: "/pacientes", icon: Users },
|
||||||
{ name: "Médicos", href: "/dashboard/doutores", icon: User },
|
{ name: "Médicos", href: "/doutores", icon: User },
|
||||||
{ name: "Consultas", href: "/dashboard/consultas", icon: UserCheck },
|
{ name: "Consultas", href: "/consultas", icon: UserCheck },
|
||||||
{ name: "Prontuários", href: "/dashboard/prontuarios", icon: FileText },
|
|
||||||
{ name: "Relatórios", href: "/dashboard/relatorios", icon: BarChart3 },
|
{ name: "Relatórios", href: "/dashboard/relatorios", icon: BarChart3 },
|
||||||
{ name: "Configurações", href: "/dashboard/configuracoes", icon: Settings },
|
{ name: "Configurações", href: "/configuracao", icon: Settings },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
@ -62,7 +61,7 @@ export function Sidebar() {
|
|||||||
|
|
||||||
{/* este span some no modo ícone */}
|
{/* este span some no modo ícone */}
|
||||||
<span className="text-lg font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden">
|
<span className="text-lg font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden">
|
||||||
SUSConecta
|
MediConnect
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
@ -75,13 +74,14 @@ export function Sidebar() {
|
|||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{navigation.map((item) => {
|
{navigation.map((item) => {
|
||||||
const isActive = pathname === item.href
|
const isActive =
|
||||||
|
pathname === item.href || pathname.startsWith(item.href + "/")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={item.name}>
|
<SidebarMenuItem key={item.name}>
|
||||||
<SidebarMenuButton asChild isActive={isActive}>
|
<SidebarMenuButton asChild isActive={isActive}>
|
||||||
<Link href={item.href} className="flex items-center">
|
<Link href={item.href} className="flex items-center">
|
||||||
<item.icon className="mr-3 h-4 w-4 shrink-0" />
|
<item.icon className="mr-3 h-4 w-4 shrink-0" />
|
||||||
{/* o texto esconde quando colapsa */}
|
|
||||||
<span className="truncate group-data-[collapsible=icon]:hidden">
|
<span className="truncate group-data-[collapsible=icon]:hidden">
|
||||||
{item.name}
|
{item.name}
|
||||||
</span>
|
</span>
|
||||||
@ -90,6 +90,7 @@ export function Sidebar() {
|
|||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export function Footer() {
|
|||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<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="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 SUS Conecta</div>
|
<div className="text-muted-foreground text-sm">© 2025 Medi Conecta</div>
|
||||||
|
|
||||||
{}
|
{}
|
||||||
<nav className="flex items-center space-x-8">
|
<nav className="flex items-center space-x-8">
|
||||||
|
|||||||
BIN
susconecta/components/forms/appointment-form.tsx
Normal file
BIN
susconecta/components/forms/appointment-form.tsx
Normal file
Binary file not shown.
140
susconecta/components/forms/calendar-registration-form.tsx
Normal file
140
susconecta/components/forms/calendar-registration-form.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
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 { Calendar, Search, ChevronDown, Upload, FileDown, Tag } from "lucide-react";
|
||||||
|
|
||||||
|
export function CalendarRegistrationForm({ initialData, onSave, onCancel }: any) {
|
||||||
|
const [formData, setFormData] = useState(initialData || {});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFormData(initialData || {});
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData((prev: any) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
|
<div className="border rounded-md p-6 space-y-4 bg-white">
|
||||||
|
<h2 className="font-medium">Informações do paciente</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
|
||||||
|
<div className="md:col-span-6">
|
||||||
|
<Label>Nome *</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
name="patientName"
|
||||||
|
placeholder="Digite o nome do paciente"
|
||||||
|
className="h-10 pl-8"
|
||||||
|
value={formData.patientName || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-3">
|
||||||
|
<Label>CPF do paciente</Label>
|
||||||
|
<Input name="cpf" placeholder="Número do CPF" className="h-10" value={formData.cpf || ''} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-3">
|
||||||
|
<Label>RG</Label>
|
||||||
|
<Input name="rg" placeholder="Número do RG" className="h-10" value={formData.rg || ''} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-6">
|
||||||
|
<div className="grid grid-cols-12 gap-3">
|
||||||
|
<div className="col-span-5">
|
||||||
|
<Label>Data de nascimento *</Label>
|
||||||
|
<Input name="birthDate" type="date" className="h-10" value={formData.birthDate || ''} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-7">
|
||||||
|
<Label>Telefone</Label>
|
||||||
|
<div className="grid grid-cols-[86px_1fr] gap-2">
|
||||||
|
<select name="phoneCode" className="h-10 rounded-md border border-input bg-background px-2 text-[13px]" value={formData.phoneCode || '+55'} onChange={handleChange}>
|
||||||
|
<option value="+55">+55</option>
|
||||||
|
<option value="+351">+351</option>
|
||||||
|
<option value="+1">+1</option>
|
||||||
|
</select>
|
||||||
|
<Input name="phoneNumber" placeholder="(99) 99999-9999" className="h-10" value={formData.phoneNumber || ''} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-6">
|
||||||
|
<Label>E-mail</Label>
|
||||||
|
<Input name="email" type="email" placeholder="email@exemplo.com" className="h-10" value={formData.email || ''} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{}
|
||||||
|
<div className="border rounded-md p-6 space-y-4 bg-white">
|
||||||
|
<h2 className="font-medium">Informações do atendimento</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-6">
|
||||||
|
<div className="md:col-span-6 space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[13px]">Nome do profissional *</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input name="professionalName" className="h-10 w-full rounded-full border border-input pl-8 pr-12 text-[13px]" value={formData.professionalName || ''} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[13px]">Unidade *</Label>
|
||||||
|
<select name="unit" className="h-10 w-full rounded-md border border-input bg-background pr-8 pl-3 text-[13px] appearance-none" value={formData.unit || 'nei'} onChange={handleChange}>
|
||||||
|
<option value="nei">Núcleo de Especialidades Integradas</option>
|
||||||
|
<option value="cc">Clínica Central</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[13px]">Data *</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input name="appointmentDate" type="date" className="h-10 w-full rounded-md border border-input pl-8 pr-3 text-[13px]" value={formData.appointmentDate || ''} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-12 gap-3 items-end">
|
||||||
|
<div className="col-span-12 md:col-span-3">
|
||||||
|
<Label className="text-[13px]">Início *</Label>
|
||||||
|
<Input name="startTime" type="time" className="h-10 w-full rounded-md border border-input px-3 text-[13px]" value={formData.startTime || ''} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-12 md:col-span-3">
|
||||||
|
<Label className="text-[13px]">Término *</Label>
|
||||||
|
<Input name="endTime" type="time" className="h-10 w-full rounded-md border border-input px-3 text-[13px]" value={formData.endTime || ''} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-6">
|
||||||
|
<div className="mb-2">
|
||||||
|
<Label className="text-[13px]">Tipo de atendimento *</Label>
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input name="appointmentType" placeholder="Pesquisar" className="h-10 w-full rounded-md border border-input pl-8 pr-8 text-[13px]" value={formData.appointmentType || ''} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[13px]">Observações</Label>
|
||||||
|
<Textarea name="notes" rows={4} className="text-[13px] h-[110px] min-h-0 resize-none" value={formData.notes || ''} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={onCancel}>Cancelar</Button>
|
||||||
|
<Button type="submit">Salvar</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -24,9 +24,7 @@ import {
|
|||||||
MedicoInput,
|
MedicoInput,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
|
||||||
import { buscarCepAPI } from "@/lib/api"; // use o seu já existente
|
import { buscarCepAPI } from "@/lib/api";
|
||||||
|
|
||||||
// Mock data and types since API is not used for now
|
|
||||||
|
|
||||||
type FormacaoAcademica = {
|
type FormacaoAcademica = {
|
||||||
instituicao: string;
|
instituicao: string;
|
||||||
@ -179,7 +177,6 @@ export function DoctorRegistrationForm({
|
|||||||
if (mode === "edit" && doctorId) {
|
if (mode === "edit" && doctorId) {
|
||||||
const medico = await buscarMedicoPorId(doctorId);
|
const medico = await buscarMedicoPorId(doctorId);
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
// mapeia API -> estado do formulário
|
|
||||||
setForm({
|
setForm({
|
||||||
photo: null,
|
photo: null,
|
||||||
nome: medico.nome ?? "",
|
nome: medico.nome ?? "",
|
||||||
@ -188,7 +185,7 @@ export function DoctorRegistrationForm({
|
|||||||
estado_crm: medico.estado_crm ?? "",
|
estado_crm: medico.estado_crm ?? "",
|
||||||
rqe: medico.rqe ?? "",
|
rqe: medico.rqe ?? "",
|
||||||
formacao_academica: medico.formacao_academica ?? [],
|
formacao_academica: medico.formacao_academica ?? [],
|
||||||
curriculo: null, // se a API devolver URL, você pode exibir ao lado
|
curriculo: null,
|
||||||
especialidade: medico.especialidade ?? "",
|
especialidade: medico.especialidade ?? "",
|
||||||
cpf: medico.cpf ?? "",
|
cpf: medico.cpf ?? "",
|
||||||
rg: medico.rg ?? "",
|
rg: medico.rg ?? "",
|
||||||
@ -213,7 +210,7 @@ export function DoctorRegistrationForm({
|
|||||||
valor_consulta: medico.valor_consulta ? String(medico.valor_consulta) : "",
|
valor_consulta: medico.valor_consulta ? String(medico.valor_consulta) : "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// (Opcional) listar anexos que já existem no servidor
|
|
||||||
try {
|
try {
|
||||||
const list = await listarAnexosMedico(doctorId);
|
const list = await listarAnexosMedico(doctorId);
|
||||||
setServerAnexos(list ?? []);
|
setServerAnexos(list ?? []);
|
||||||
@ -320,7 +317,6 @@ export function DoctorRegistrationForm({
|
|||||||
setErrors((e) => ({ ...e, submit: "" }));
|
setErrors((e) => ({ ...e, submit: "" }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// monta o payload esperado pela API
|
|
||||||
const payload: MedicoInput = {
|
const payload: MedicoInput = {
|
||||||
nome: form.nome,
|
nome: form.nome,
|
||||||
nome_social: form.nome_social || null,
|
nome_social: form.nome_social || null,
|
||||||
@ -336,7 +332,7 @@ export function DoctorRegistrationForm({
|
|||||||
estado_crm: form.estado_crm || null,
|
estado_crm: form.estado_crm || null,
|
||||||
rqe: form.rqe || null,
|
rqe: form.rqe || null,
|
||||||
formacao_academica: form.formacao_academica ?? [],
|
formacao_academica: form.formacao_academica ?? [],
|
||||||
curriculo_url: null, // se quiser, suba arquivo do currículo num endpoint próprio e salve a URL aqui
|
curriculo_url: null,
|
||||||
especialidade: form.especialidade,
|
especialidade: form.especialidade,
|
||||||
observacoes: form.observacoes || null,
|
observacoes: form.observacoes || null,
|
||||||
tipo_vinculo: form.tipo_vinculo || null,
|
tipo_vinculo: form.tipo_vinculo || null,
|
||||||
@ -345,14 +341,12 @@ export function DoctorRegistrationForm({
|
|||||||
valor_consulta: form.valor_consulta || null,
|
valor_consulta: form.valor_consulta || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// cria ou atualiza
|
|
||||||
const saved = mode === "create"
|
const saved = mode === "create"
|
||||||
? await criarMedico(payload)
|
? await criarMedico(payload)
|
||||||
: await atualizarMedico(doctorId as number, payload);
|
: await atualizarMedico(doctorId as number, payload);
|
||||||
|
|
||||||
const medicoId = saved.id;
|
const medicoId = saved.id;
|
||||||
|
|
||||||
// foto (opcional)
|
|
||||||
if (form.photo) {
|
if (form.photo) {
|
||||||
try {
|
try {
|
||||||
await uploadFotoMedico(medicoId, form.photo);
|
await uploadFotoMedico(medicoId, form.photo);
|
||||||
@ -361,7 +355,6 @@ export function DoctorRegistrationForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// anexos locais (opcional)
|
|
||||||
if (form.anexos?.length) {
|
if (form.anexos?.length) {
|
||||||
for (const f of form.anexos) {
|
for (const f of form.anexos) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -17,7 +17,6 @@ import {
|
|||||||
Paciente,
|
Paciente,
|
||||||
PacienteInput,
|
PacienteInput,
|
||||||
buscarCepAPI,
|
buscarCepAPI,
|
||||||
validarCPF,
|
|
||||||
criarPaciente,
|
criarPaciente,
|
||||||
atualizarPaciente,
|
atualizarPaciente,
|
||||||
uploadFotoPaciente,
|
uploadFotoPaciente,
|
||||||
@ -28,6 +27,11 @@ import {
|
|||||||
buscarPacientePorId,
|
buscarPacientePorId,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
import { validarCPFLocal } from "@/lib/utils";
|
||||||
|
import { verificarCpfDuplicado } from "@/lib/api";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
type Mode = "create" | "edit";
|
type Mode = "create" | "edit";
|
||||||
|
|
||||||
export interface PatientRegistrationFormProps {
|
export interface PatientRegistrationFormProps {
|
||||||
@ -192,13 +196,13 @@ export function PatientRegistrationForm({
|
|||||||
telefone: form.telefone || null,
|
telefone: form.telefone || null,
|
||||||
email: form.email || null,
|
email: form.email || null,
|
||||||
endereco: {
|
endereco: {
|
||||||
cep: form.cep || null,
|
cep: form.cep || undefined,
|
||||||
logradouro: form.logradouro || null,
|
logradouro: form.logradouro || undefined,
|
||||||
numero: form.numero || null,
|
numero: form.numero || undefined,
|
||||||
complemento: form.complemento || null,
|
complemento: form.complemento || undefined,
|
||||||
bairro: form.bairro || null,
|
bairro: form.bairro || undefined,
|
||||||
cidade: form.cidade || null,
|
cidade: form.cidade || undefined,
|
||||||
estado: form.estado || null,
|
estado: form.estado || undefined,
|
||||||
},
|
},
|
||||||
observacoes: form.observacoes || null,
|
observacoes: form.observacoes || null,
|
||||||
};
|
};
|
||||||
@ -210,18 +214,24 @@ export function PatientRegistrationForm({
|
|||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { valido, existe } = await validarCPF(form.cpf);
|
// 1) validação local
|
||||||
if (!valido) {
|
if (!validarCPFLocal(form.cpf)) {
|
||||||
setErrors((e) => ({ ...e, cpf: "CPF inválido (validação externa)" }));
|
setErrors((e) => ({ ...e, cpf: "CPF inválido" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (existe && mode === "create") {
|
|
||||||
|
// 2) checar duplicidade no banco (apenas se criando novo paciente)
|
||||||
|
if (mode === "create") {
|
||||||
|
const existe = await verificarCpfDuplicado(form.cpf);
|
||||||
|
if (existe) {
|
||||||
setErrors((e) => ({ ...e, cpf: "CPF já cadastrado no sistema" }));
|
setErrors((e) => ({ ...e, cpf: "CPF já cadastrado no sistema" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erro ao validar CPF", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export function Header() {
|
|||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link href="/" className="flex items-center space-x-2">
|
<Link href="/" className="flex items-center space-x-2">
|
||||||
<span className="text-xl font-bold text-foreground">
|
<span className="text-xl font-bold text-foreground">
|
||||||
<span className="text-primary">SUS</span>Conecta
|
<span className="text-primary">MEDI</span>Conecta
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@ -46,13 +46,14 @@ export function Header() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-primary border-primary hover:bg-primary hover:text-primary-foreground bg-transparent"
|
className="text-primary border-primary hover:bg-primary hover:text-primary-foreground bg-transparent"
|
||||||
|
asChild
|
||||||
>
|
>
|
||||||
Sou Paciente
|
<Link href="/login-paciente">Sou Paciente</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="bg-primary hover:bg-primary/90 text-primary-foreground">
|
<Button className="bg-primary hover:bg-primary/90 text-primary-foreground">
|
||||||
<Link href="/profissional">Sou Profissional de Saúde</Link>
|
<Link href="/login">Sou Profissional de Saúde</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Link href="/dashboard">
|
<Link href="/login-admin">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-slate-700 border-slate-600 hover:bg-slate-700 hover:text-white bg-transparent"
|
className="text-slate-700 border-slate-600 hover:bg-slate-700 hover:text-white bg-transparent"
|
||||||
@ -94,13 +95,14 @@ export function Header() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-primary border-primary hover:bg-primary hover:text-primary-foreground bg-transparent"
|
className="text-primary border-primary hover:bg-primary hover:text-primary-foreground bg-transparent"
|
||||||
|
asChild
|
||||||
>
|
>
|
||||||
Sou Paciente
|
<Link href="/login-paciente">Sou Paciente</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="bg-primary hover:bg-primary/90 text-primary-foreground w-full">
|
<Button className="bg-primary hover:bg-primary/90 text-primary-foreground w-full">
|
||||||
Sou Profissional de Saúde
|
<Link href="/login">Sou Profissional de Saúde</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Link href="/dashboard">
|
<Link href="/login-admin">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-slate-700 border-slate-600 hover:bg-slate-700 hover:text-white bg-transparent w-full"
|
className="text-slate-700 border-slate-600 hover:bg-slate-700 hover:text-white bg-transparent w-full"
|
||||||
|
|||||||
@ -4,20 +4,20 @@ import Link from "next/link"
|
|||||||
|
|
||||||
export function HeroSection() {
|
export function HeroSection() {
|
||||||
return (
|
return (
|
||||||
<section className="py-16 lg:py-24 bg-background">
|
<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="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="grid lg:grid-cols-2 gap-8 items-center">
|
||||||
{}
|
{}
|
||||||
<div className="space-y-8">
|
<div className="space-y-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div className="inline-block px-4 py-2 bg-accent/10 text-accent rounded-full text-sm font-medium">
|
<div className="inline-block px-4 py-2 bg-accent/10 text-accent rounded-full text-sm font-medium">
|
||||||
APROXIMANDO MÉDICOS E PACIENTES
|
APROXIMANDO MÉDICOS E PACIENTES
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl lg:text-5xl font-bold text-foreground leading-tight text-balance">
|
<h1 className="text-3xl lg:text-4xl font-bold text-foreground leading-tight text-balance">
|
||||||
Segurança, <span className="text-primary">Confiabilidade</span> e{" "}
|
Segurança, <span className="text-primary">Confiabilidade</span> e{" "}
|
||||||
<span className="text-primary">Rapidez</span>
|
<span className="text-primary">Rapidez</span>
|
||||||
</h1>
|
</h1>
|
||||||
<div className="space-y-2 text-lg text-muted-foreground">
|
<div className="space-y-1 text-base text-muted-foreground">
|
||||||
<p>Experimente o futuro dos agendamentos.</p>
|
<p>Experimente o futuro dos agendamentos.</p>
|
||||||
<p>Encontre profissionais capacitados e marque já sua consulta.</p>
|
<p>Encontre profissionais capacitados e marque já sua consulta.</p>
|
||||||
</div>
|
</div>
|
||||||
@ -25,33 +25,38 @@ export function HeroSection() {
|
|||||||
|
|
||||||
{}
|
{}
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<Button size="lg" className="bg-primary hover:bg-primary/90 text-primary-foreground">
|
<Button
|
||||||
Sou Paciente
|
size="lg"
|
||||||
|
className="bg-primary hover:bg-primary/90 text-primary-foreground cursor-pointer"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href="/login-paciente">Portal do Paciente</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-primary border-primary hover:bg-primary hover:text-primary-foreground bg-transparent"
|
className="text-primary border-primary hover:bg-primary hover:text-primary-foreground bg-transparent cursor-pointer"
|
||||||
|
asChild
|
||||||
>
|
>
|
||||||
<Link href="/profissional">Sou Profissional de Saúde</Link>
|
<Link href="/login">Sou Profissional de Saúde</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{}
|
{}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-accent/20 to-primary/20 p-8">
|
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-accent/20 to-primary/20 p-6">
|
||||||
<img
|
<img
|
||||||
src="/medico-sorridente-de-tiro-medio-vestindo-casaco.jpg"
|
src="/medico-sorridente-de-tiro-medio-vestindo-casaco.jpg"
|
||||||
alt="Médico profissional sorrindo"
|
alt="Médico profissional sorrindo"
|
||||||
className="w-full h-auto rounded-lg"
|
className="w-full h-auto rounded-lg min-h-80 max-h-[500px] object-cover object-center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{}
|
{}
|
||||||
<div className="mt-16 grid md:grid-cols-3 gap-8">
|
<div className="mt-10 grid md:grid-cols-3 gap-6">
|
||||||
<div className="flex items-start space-x-3">
|
<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">
|
<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" />
|
<Shield className="w-4 h-4 text-primary" />
|
||||||
|
|||||||
252
susconecta/hooks/useAuth.tsx
Normal file
252
susconecta/hooks/useAuth.tsx
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
'use client'
|
||||||
|
import { createContext, useContext, useEffect, useState, ReactNode, useCallback, useMemo, useRef } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { loginUser, logoutUser, AuthenticationError } from '@/lib/auth'
|
||||||
|
import { isExpired, parseJwt } from '@/lib/jwt'
|
||||||
|
import { httpClient } from '@/lib/http'
|
||||||
|
import type {
|
||||||
|
AuthContextType,
|
||||||
|
UserData,
|
||||||
|
AuthStatus,
|
||||||
|
UserType
|
||||||
|
} from '@/types/auth'
|
||||||
|
import { AUTH_STORAGE_KEYS, LOGIN_ROUTES } from '@/types/auth'
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [authStatus, setAuthStatus] = useState<AuthStatus>('loading')
|
||||||
|
const [user, setUser] = useState<UserData | null>(null)
|
||||||
|
const [token, setToken] = useState<string | null>(null)
|
||||||
|
const router = useRouter()
|
||||||
|
const hasInitialized = useRef(false)
|
||||||
|
|
||||||
|
// Utilitários de armazenamento memorizados
|
||||||
|
const clearAuthData = useCallback(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem(AUTH_STORAGE_KEYS.TOKEN)
|
||||||
|
localStorage.removeItem(AUTH_STORAGE_KEYS.USER)
|
||||||
|
localStorage.removeItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN)
|
||||||
|
// Manter USER_TYPE para redirecionamento correto
|
||||||
|
}
|
||||||
|
setUser(null)
|
||||||
|
setToken(null)
|
||||||
|
setAuthStatus('unauthenticated')
|
||||||
|
console.log('[AUTH] Dados de autenticação limpos - logout realizado')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const saveAuthData = useCallback((
|
||||||
|
accessToken: string,
|
||||||
|
userData: UserData,
|
||||||
|
refreshToken?: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// Persistir dados de forma atômica
|
||||||
|
localStorage.setItem(AUTH_STORAGE_KEYS.TOKEN, accessToken)
|
||||||
|
localStorage.setItem(AUTH_STORAGE_KEYS.USER, JSON.stringify(userData))
|
||||||
|
localStorage.setItem(AUTH_STORAGE_KEYS.USER_TYPE, userData.userType)
|
||||||
|
|
||||||
|
if (refreshToken) {
|
||||||
|
localStorage.setItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN, refreshToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setToken(accessToken)
|
||||||
|
setUser(userData)
|
||||||
|
setAuthStatus('authenticated')
|
||||||
|
|
||||||
|
console.log('[AUTH] LOGIN realizado - Dados salvos!', {
|
||||||
|
userType: userData.userType,
|
||||||
|
email: userData.email,
|
||||||
|
timestamp: new Date().toLocaleTimeString()
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AUTH] Erro ao salvar dados:', error)
|
||||||
|
clearAuthData()
|
||||||
|
}
|
||||||
|
}, [clearAuthData])
|
||||||
|
|
||||||
|
// Verificação inicial de autenticação
|
||||||
|
const checkAuth = useCallback(async (): Promise<void> => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
setAuthStatus('unauthenticated')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storedToken = localStorage.getItem(AUTH_STORAGE_KEYS.TOKEN)
|
||||||
|
const storedUser = localStorage.getItem(AUTH_STORAGE_KEYS.USER)
|
||||||
|
|
||||||
|
console.log('[AUTH] Verificando sessão...', {
|
||||||
|
hasToken: !!storedToken,
|
||||||
|
hasUser: !!storedUser,
|
||||||
|
timestamp: new Date().toLocaleTimeString()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pequeno delay para visualizar logs
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 800))
|
||||||
|
|
||||||
|
if (!storedToken || !storedUser) {
|
||||||
|
console.log('[AUTH] Dados ausentes - sessão inválida')
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
clearAuthData()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se token está expirado
|
||||||
|
if (isExpired(storedToken)) {
|
||||||
|
console.log('[AUTH] Token expirado - tentando renovar...')
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
|
const refreshToken = localStorage.getItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN)
|
||||||
|
if (refreshToken && !isExpired(refreshToken)) {
|
||||||
|
// Tentar renovar via HTTP client (que já tem a lógica)
|
||||||
|
try {
|
||||||
|
await httpClient.get('/auth/v1/me') // Trigger refresh se necessário
|
||||||
|
|
||||||
|
// Se chegou aqui, refresh foi bem-sucedido
|
||||||
|
const newToken = localStorage.getItem(AUTH_STORAGE_KEYS.TOKEN)
|
||||||
|
const userData = JSON.parse(storedUser) as UserData
|
||||||
|
|
||||||
|
if (newToken && newToken !== storedToken) {
|
||||||
|
setToken(newToken)
|
||||||
|
setUser(userData)
|
||||||
|
setAuthStatus('authenticated')
|
||||||
|
console.log('[AUTH] Token RENOVADO automaticamente!')
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 800))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (refreshError) {
|
||||||
|
console.log('❌ [AUTH] Falha no refresh automático')
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 400))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAuthData()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restaurar sessão válida
|
||||||
|
const userData = JSON.parse(storedUser) as UserData
|
||||||
|
setToken(storedToken)
|
||||||
|
setUser(userData)
|
||||||
|
setAuthStatus('authenticated')
|
||||||
|
|
||||||
|
console.log('[AUTH] Sessão RESTAURADA com sucesso!', {
|
||||||
|
userId: userData.id,
|
||||||
|
userType: userData.userType,
|
||||||
|
email: userData.email,
|
||||||
|
timestamp: new Date().toLocaleTimeString()
|
||||||
|
})
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AUTH] Erro na verificação:', error)
|
||||||
|
clearAuthData()
|
||||||
|
}
|
||||||
|
}, [clearAuthData])
|
||||||
|
|
||||||
|
// Login memoizado
|
||||||
|
const login = useCallback(async (
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
userType: UserType
|
||||||
|
): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
console.log('[AUTH] Iniciando login:', { email, userType })
|
||||||
|
|
||||||
|
const response = await loginUser(email, password, userType)
|
||||||
|
|
||||||
|
saveAuthData(
|
||||||
|
response.access_token,
|
||||||
|
response.user,
|
||||||
|
response.refresh_token
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('[AUTH] Login realizado com sucesso')
|
||||||
|
return true
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AUTH] Erro no login:', error)
|
||||||
|
|
||||||
|
if (error instanceof AuthenticationError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AuthenticationError(
|
||||||
|
'Erro inesperado durante o login',
|
||||||
|
'UNKNOWN_ERROR',
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [saveAuthData])
|
||||||
|
|
||||||
|
// Logout memoizado
|
||||||
|
const logout = useCallback(async (): Promise<void> => {
|
||||||
|
console.log('[AUTH] Iniciando logout')
|
||||||
|
|
||||||
|
const currentUserType = user?.userType ||
|
||||||
|
(typeof window !== 'undefined' ? localStorage.getItem(AUTH_STORAGE_KEYS.USER_TYPE) : null) ||
|
||||||
|
'profissional'
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (token) {
|
||||||
|
await logoutUser(token)
|
||||||
|
console.log('[AUTH] Logout realizado na API')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AUTH] Erro no logout da API:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAuthData()
|
||||||
|
|
||||||
|
// Redirecionamento baseado no tipo de usuário
|
||||||
|
const loginRoute = LOGIN_ROUTES[currentUserType as UserType] || '/login'
|
||||||
|
|
||||||
|
console.log('[AUTH] Redirecionando para:', loginRoute)
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = loginRoute
|
||||||
|
}
|
||||||
|
}, [user?.userType, token, clearAuthData])
|
||||||
|
|
||||||
|
// Refresh token memoizado (usado pelo HTTP client)
|
||||||
|
const refreshToken = useCallback(async (): Promise<boolean> => {
|
||||||
|
// Esta função é principalmente para compatibilidade
|
||||||
|
// O refresh real é feito pelo HTTP client
|
||||||
|
return false
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Getters memorizados
|
||||||
|
const contextValue = useMemo(() => ({
|
||||||
|
authStatus,
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
refreshToken
|
||||||
|
}), [authStatus, user, token, login, logout, refreshToken])
|
||||||
|
|
||||||
|
// Inicialização única
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasInitialized.current && typeof window !== 'undefined') {
|
||||||
|
hasInitialized.current = true
|
||||||
|
checkAuth()
|
||||||
|
}
|
||||||
|
}, [checkAuth])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext)
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth deve ser usado dentro de AuthProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
|
// lib/api.ts
|
||||||
|
|
||||||
export type ApiOk<T = any> = {
|
export type ApiOk<T = any> = {
|
||||||
success: boolean;
|
success?: boolean;
|
||||||
data: T;
|
data: T;
|
||||||
message?: string;
|
message?: string;
|
||||||
pagination?: {
|
pagination?: {
|
||||||
@ -12,6 +12,7 @@ export type ApiOk<T = any> = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ===== TIPOS COMUNS =====
|
||||||
export type Endereco = {
|
export type Endereco = {
|
||||||
cep?: string;
|
cep?: string;
|
||||||
logradouro?: string;
|
logradouro?: string;
|
||||||
@ -22,6 +23,7 @@ export type Endereco = {
|
|||||||
estado?: string;
|
estado?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ===== PACIENTES =====
|
||||||
export type Paciente = {
|
export type Paciente = {
|
||||||
id: string;
|
id: string;
|
||||||
nome?: string;
|
nome?: string;
|
||||||
@ -46,227 +48,11 @@ export type PacienteInput = {
|
|||||||
data_nascimento?: string | null;
|
data_nascimento?: string | null;
|
||||||
telefone?: string | null;
|
telefone?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
endereco?: {
|
endereco?: Endereco;
|
||||||
cep?: string | null;
|
|
||||||
logradouro?: string | null;
|
|
||||||
numero?: string | null;
|
|
||||||
complemento?: string | null;
|
|
||||||
bairro?: string | null;
|
|
||||||
cidade?: string | null;
|
|
||||||
estado?: string | null;
|
|
||||||
};
|
|
||||||
observacoes?: string | null;
|
observacoes?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ===== MÉDICOS =====
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "https://mock.apidog.com/m1/1053378-0-default";
|
|
||||||
const MEDICOS_BASE = process.env.NEXT_PUBLIC_MEDICOS_BASE_PATH ?? "/medicos";
|
|
||||||
|
|
||||||
export const PATHS = {
|
|
||||||
// Pacientes (já existia)
|
|
||||||
pacientes: "/pacientes",
|
|
||||||
pacienteId: (id: string | number) => `/pacientes/${id}`,
|
|
||||||
foto: (id: string | number) => `/pacientes/${id}/foto`,
|
|
||||||
anexos: (id: string | number) => `/pacientes/${id}/anexos`,
|
|
||||||
anexoId: (id: string | number, anexoId: string | number) => `/pacientes/${id}/anexos/${anexoId}`,
|
|
||||||
validarCPF: "/pacientes/validar-cpf",
|
|
||||||
cep: (cep: string) => `/utils/cep/${cep}`,
|
|
||||||
|
|
||||||
// Médicos (APONTANDO PARA PACIENTES por enquanto)
|
|
||||||
medicos: MEDICOS_BASE,
|
|
||||||
medicoId: (id: string | number) => `${MEDICOS_BASE}/${id}`,
|
|
||||||
medicoFoto: (id: string | number) => `${MEDICOS_BASE}/${id}/foto`,
|
|
||||||
medicoAnexos: (id: string | number) => `${MEDICOS_BASE}/${id}/anexos`,
|
|
||||||
medicoAnexoId: (id: string | number, anexoId: string | number) => `${MEDICOS_BASE}/${id}/anexos/${anexoId}`,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
|
|
||||||
function headers(kind: "json" | "form" = "json"): Record<string, string> {
|
|
||||||
const h: Record<string, string> = {};
|
|
||||||
const token = process.env.NEXT_PUBLIC_API_TOKEN?.trim();
|
|
||||||
if (token) h.Authorization = `Bearer ${token}`;
|
|
||||||
if (kind === "json") h["Content-Type"] = "application/json";
|
|
||||||
return h;
|
|
||||||
}
|
|
||||||
|
|
||||||
function logAPI(title: string, info: { url?: string; payload?: any; result?: any } = {}) {
|
|
||||||
try {
|
|
||||||
console.group(`[API] ${title}`);
|
|
||||||
if (info.url) console.log("url:", info.url);
|
|
||||||
if (info.payload !== undefined) console.log("payload:", info.payload);
|
|
||||||
if (info.result !== undefined) console.log("API result:", info.result);
|
|
||||||
console.groupEnd();
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function parse<T>(res: Response): Promise<T> {
|
|
||||||
let json: any = null;
|
|
||||||
try {
|
|
||||||
json = await res.json();
|
|
||||||
} catch {
|
|
||||||
// ignora erro de parse vazio
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
// 🔴 ADICIONE ESSA LINHA AQUI:
|
|
||||||
console.error("[API ERROR]", res.url, res.status, json);
|
|
||||||
|
|
||||||
const code = json?.apidogError?.code ?? res.status;
|
|
||||||
const msg = json?.apidogError?.message ?? res.statusText;
|
|
||||||
throw new Error(`${code}: ${msg}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (json?.data ?? json) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
//
|
|
||||||
// Pacientes (CRUD)
|
|
||||||
//
|
|
||||||
export async function listarPacientes(params?: { page?: number; limit?: number; q?: string }): Promise<Paciente[]> {
|
|
||||||
const query = new URLSearchParams();
|
|
||||||
if (params?.page) query.set("page", String(params.page));
|
|
||||||
if (params?.limit) query.set("limit", String(params.limit));
|
|
||||||
if (params?.q) query.set("q", params.q);
|
|
||||||
const url = `${API_BASE}${PATHS.pacientes}${query.toString() ? `?${query.toString()}` : ""}`;
|
|
||||||
|
|
||||||
const res = await fetch(url, { method: "GET", headers: headers("json") });
|
|
||||||
const data = await parse<ApiOk<Paciente[]>>(res);
|
|
||||||
logAPI("listarPacientes", { url, result: data });
|
|
||||||
return data?.data ?? (data as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function buscarPacientePorId(id: string | number): Promise<Paciente> {
|
|
||||||
const url = `${API_BASE}${PATHS.pacienteId(id)}`;
|
|
||||||
const res = await fetch(url, { method: "GET", headers: headers("json") });
|
|
||||||
const data = await parse<ApiOk<Paciente>>(res);
|
|
||||||
logAPI("buscarPacientePorId", { url, result: data });
|
|
||||||
return data?.data ?? (data as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function criarPaciente(input: PacienteInput): Promise<Paciente> {
|
|
||||||
const url = `${API_BASE}${PATHS.pacientes}`;
|
|
||||||
const res = await fetch(url, { method: "POST", headers: headers("json"), body: JSON.stringify(input) });
|
|
||||||
const data = await parse<ApiOk<Paciente>>(res);
|
|
||||||
logAPI("criarPaciente", { url, payload: input, result: data });
|
|
||||||
return data?.data ?? (data as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function atualizarPaciente(id: string | number, input: PacienteInput): Promise<Paciente> {
|
|
||||||
const url = `${API_BASE}${PATHS.pacienteId(id)}`;
|
|
||||||
const res = await fetch(url, { method: "PUT", headers: headers("json"), body: JSON.stringify(input) });
|
|
||||||
const data = await parse<ApiOk<Paciente>>(res);
|
|
||||||
logAPI("atualizarPaciente", { url, payload: input, result: data });
|
|
||||||
return data?.data ?? (data as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function excluirPaciente(id: string | number): Promise<void> {
|
|
||||||
const url = `${API_BASE}${PATHS.pacienteId(id)}`;
|
|
||||||
const res = await fetch(url, { method: "DELETE", headers: headers("json") });
|
|
||||||
await parse<any>(res);
|
|
||||||
logAPI("excluirPaciente", { url, result: { ok: true } });
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Foto
|
|
||||||
//
|
|
||||||
|
|
||||||
export async function uploadFotoPaciente(id: string | number, file: File): Promise<{ foto_url?: string; thumbnail_url?: string }> {
|
|
||||||
const url = `${API_BASE}${PATHS.foto(id)}`;
|
|
||||||
const fd = new FormData();
|
|
||||||
// nome de campo mais comum no mock
|
|
||||||
fd.append("foto", file);
|
|
||||||
const res = await fetch(url, { method: "POST", headers: headers("form"), body: fd });
|
|
||||||
const data = await parse<ApiOk<{ foto_url?: string; thumbnail_url?: string }>>(res);
|
|
||||||
logAPI("uploadFotoPaciente", { url, payload: { file: file.name }, result: data });
|
|
||||||
return data?.data ?? (data as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removerFotoPaciente(id: string | number): Promise<void> {
|
|
||||||
const url = `${API_BASE}${PATHS.foto(id)}`;
|
|
||||||
const res = await fetch(url, { method: "DELETE", headers: headers("json") });
|
|
||||||
await parse<any>(res);
|
|
||||||
logAPI("removerFotoPaciente", { url, result: { ok: true } });
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Anexos
|
|
||||||
//
|
|
||||||
|
|
||||||
export async function listarAnexos(id: string | number): Promise<any[]> {
|
|
||||||
const url = `${API_BASE}${PATHS.anexos(id)}`;
|
|
||||||
const res = await fetch(url, { method: "GET", headers: headers("json") });
|
|
||||||
const data = await parse<ApiOk<any[]>>(res);
|
|
||||||
logAPI("listarAnexos", { url, result: data });
|
|
||||||
return data?.data ?? (data as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function adicionarAnexo(id: string | number, file: File): Promise<any> {
|
|
||||||
const url = `${API_BASE}${PATHS.anexos(id)}`;
|
|
||||||
const fd = new FormData();
|
|
||||||
|
|
||||||
fd.append("arquivo", file);
|
|
||||||
const res = await fetch(url, { method: "POST", body: fd, headers: headers("form") });
|
|
||||||
const data = await parse<ApiOk<any>>(res);
|
|
||||||
logAPI("adicionarAnexo", { url, payload: { file: file.name }, result: data });
|
|
||||||
return data?.data ?? (data as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removerAnexo(id: string | number, anexoId: string | number): Promise<void> {
|
|
||||||
const url = `${API_BASE}${PATHS.anexoId(id, anexoId)}`;
|
|
||||||
const res = await fetch(url, { method: "DELETE", headers: headers("json") });
|
|
||||||
await parse<any>(res);
|
|
||||||
logAPI("removerAnexo", { url, result: { ok: true } });
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Validações
|
|
||||||
//
|
|
||||||
|
|
||||||
export async function validarCPF(cpf: string): Promise<{ valido: boolean; existe: boolean; paciente_id: string | null }> {
|
|
||||||
const url = `${API_BASE}${PATHS.validarCPF}`;
|
|
||||||
const payload = { cpf };
|
|
||||||
const res = await fetch(url, { method: "POST", headers: headers("json"), body: JSON.stringify(payload) });
|
|
||||||
const data = await parse<ApiOk<{ valido: boolean; existe: boolean; paciente_id: string | null }>>(res);
|
|
||||||
logAPI("validarCPF", { url, payload, result: data });
|
|
||||||
return data?.data ?? (data as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function buscarCepAPI(cep: string): Promise<{ logradouro?: string; bairro?: string; localidade?: string; uf?: string; erro?: boolean }> {
|
|
||||||
const clean = (cep || "").replace(/\D/g, "");
|
|
||||||
const urlMock = `${API_BASE}${PATHS.cep(clean)}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(urlMock, { method: "GET", headers: headers("json") });
|
|
||||||
const data = await parse<any>(res); // pode vir direto ou dentro de {data}
|
|
||||||
logAPI("buscarCEP (mock)", { url: urlMock, payload: { cep: clean }, result: data });
|
|
||||||
const d = data?.data ?? data ?? {};
|
|
||||||
return {
|
|
||||||
logradouro: d.logradouro ?? d.street ?? "",
|
|
||||||
bairro: d.bairro ?? d.neighborhood ?? "",
|
|
||||||
localidade: d.localidade ?? d.city ?? "",
|
|
||||||
uf: d.uf ?? d.state ?? "",
|
|
||||||
erro: false,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
// fallback ViaCEP
|
|
||||||
const urlVia = `https://viacep.com.br/ws/${clean}/json/`;
|
|
||||||
const resV = await fetch(urlVia);
|
|
||||||
const jsonV = await resV.json().catch(() => ({}));
|
|
||||||
logAPI("buscarCEP (ViaCEP/fallback)", { url: urlVia, payload: { cep: clean }, result: jsonV });
|
|
||||||
if (jsonV?.erro) return { erro: true };
|
|
||||||
return {
|
|
||||||
logradouro: jsonV.logradouro ?? "",
|
|
||||||
bairro: jsonV.bairro ?? "",
|
|
||||||
localidade: jsonV.localidade ?? "",
|
|
||||||
uf: jsonV.uf ?? "",
|
|
||||||
erro: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// >>> ADICIONE (ou mova) ESTES TIPOS <<<
|
|
||||||
export type FormacaoAcademica = {
|
export type FormacaoAcademica = {
|
||||||
instituicao: string;
|
instituicao: string;
|
||||||
curso: string;
|
curso: string;
|
||||||
@ -330,85 +116,221 @@ export type MedicoInput = {
|
|||||||
valor_consulta?: number | string | null;
|
valor_consulta?: number | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
//
|
// ===== CONFIG =====
|
||||||
// MÉDICOS (CRUD)
|
const API_BASE =
|
||||||
//
|
process.env.NEXT_PUBLIC_API_BASE ?? "https://yuanqfswhberkoevtmfr.supabase.co";
|
||||||
// ======= MÉDICOS (forçando usar rotas de PACIENTES no mock) =======
|
const REST = `${API_BASE}/rest/v1`;
|
||||||
|
|
||||||
export async function listarMedicos(params?: { page?: number; limit?: number; q?: string }): Promise<Medico[]> {
|
// Token salvo no browser (aceita auth_token ou token)
|
||||||
const query = new URLSearchParams();
|
function getAuthToken(): string | null {
|
||||||
if (params?.page) query.set("page", String(params.page));
|
if (typeof window === "undefined") return null;
|
||||||
if (params?.limit) query.set("limit", String(params.limit));
|
return (
|
||||||
if (params?.q) query.set("q", params.q);
|
localStorage.getItem("auth_token") ||
|
||||||
|
localStorage.getItem("token") ||
|
||||||
|
sessionStorage.getItem("auth_token") ||
|
||||||
|
sessionStorage.getItem("token")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// FORÇA /pacientes
|
// Cabeçalhos base
|
||||||
const url = `${API_BASE}/pacientes${query.toString() ? `?${query.toString()}` : ""}`;
|
function baseHeaders(): Record<string, string> {
|
||||||
const res = await fetch(url, { method: "GET", headers: headers("json") });
|
const h: Record<string, string> = {
|
||||||
const data = await parse<ApiOk<Medico[]>>(res);
|
apikey:
|
||||||
return (data as any)?.data ?? (data as any);
|
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ",
|
||||||
|
Accept: "application/json",
|
||||||
|
};
|
||||||
|
const jwt = getAuthToken();
|
||||||
|
if (jwt) h.Authorization = `Bearer ${jwt}`;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Para POST/PATCH/DELETE e para GET com count
|
||||||
|
function withPrefer(h: Record<string, string>, prefer: string) {
|
||||||
|
return { ...h, Prefer: prefer };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse genérico
|
||||||
|
async function parse<T>(res: Response): Promise<T> {
|
||||||
|
let json: any = null;
|
||||||
|
try {
|
||||||
|
json = await res.json();
|
||||||
|
} catch {}
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error("[API ERROR]", res.url, res.status, json);
|
||||||
|
const code = (json && (json.error?.code || json.code)) ?? res.status;
|
||||||
|
const msg = (json && (json.error?.message || json.message)) ?? res.statusText;
|
||||||
|
throw new Error(`${code}: ${msg}`);
|
||||||
|
}
|
||||||
|
return (json?.data ?? json) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper de paginação (Range/Range-Unit)
|
||||||
|
function rangeHeaders(page?: number, limit?: number): Record<string, string> {
|
||||||
|
if (!page || !limit) return {};
|
||||||
|
const start = (page - 1) * limit;
|
||||||
|
const end = start + limit - 1;
|
||||||
|
return { Range: `${start}-${end}`, "Range-Unit": "items" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== PACIENTES (CRUD) =====
|
||||||
|
export async function listarPacientes(params?: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
q?: string;
|
||||||
|
}): Promise<Paciente[]> {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.q) qs.set("q", params.q);
|
||||||
|
|
||||||
|
const url = `${REST}/patients${qs.toString() ? `?${qs.toString()}` : ""}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
...baseHeaders(),
|
||||||
|
...rangeHeaders(params?.page, params?.limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return await parse<Paciente[]>(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buscarPacientePorId(id: string | number): Promise<Paciente> {
|
||||||
|
const url = `${REST}/patients?id=eq.${id}`;
|
||||||
|
const res = await fetch(url, { method: "GET", headers: baseHeaders() });
|
||||||
|
const arr = await parse<Paciente[]>(res);
|
||||||
|
if (!arr?.length) throw new Error("404: Paciente não encontrado");
|
||||||
|
return arr[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function criarPaciente(input: PacienteInput): Promise<Paciente> {
|
||||||
|
const url = `${REST}/patients`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: withPrefer({ ...baseHeaders(), "Content-Type": "application/json" }, "return=representation"),
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
const arr = await parse<Paciente[] | Paciente>(res);
|
||||||
|
return Array.isArray(arr) ? arr[0] : (arr as Paciente);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function atualizarPaciente(id: string | number, input: PacienteInput): Promise<Paciente> {
|
||||||
|
const url = `${REST}/patients?id=eq.${id}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: withPrefer({ ...baseHeaders(), "Content-Type": "application/json" }, "return=representation"),
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
const arr = await parse<Paciente[] | Paciente>(res);
|
||||||
|
return Array.isArray(arr) ? arr[0] : (arr as Paciente);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function excluirPaciente(id: string | number): Promise<void> {
|
||||||
|
const url = `${REST}/patients?id=eq.${id}`;
|
||||||
|
const res = await fetch(url, { method: "DELETE", headers: baseHeaders() });
|
||||||
|
await parse<any>(res);
|
||||||
|
}
|
||||||
|
// ===== PACIENTES (Extra: verificação de CPF duplicado) =====
|
||||||
|
export async function verificarCpfDuplicado(cpf: string): Promise<boolean> {
|
||||||
|
const clean = (cpf || "").replace(/\D/g, "");
|
||||||
|
const url = `${API_BASE}/rest/v1/patients?cpf=eq.${clean}&select=id`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: baseHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => []);
|
||||||
|
return Array.isArray(data) && data.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ===== MÉDICOS (CRUD) =====
|
||||||
|
export async function listarMedicos(params?: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
q?: string;
|
||||||
|
}): Promise<Medico[]> {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.q) qs.set("q", params.q);
|
||||||
|
|
||||||
|
const url = `${REST}/doctors${qs.toString() ? `?${qs.toString()}` : ""}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
...baseHeaders(),
|
||||||
|
...rangeHeaders(params?.page, params?.limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return await parse<Medico[]>(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buscarMedicoPorId(id: string | number): Promise<Medico> {
|
export async function buscarMedicoPorId(id: string | number): Promise<Medico> {
|
||||||
const url = `${API_BASE}/pacientes/${id}`; // FORÇA /pacientes
|
const url = `${REST}/doctors?id=eq.${id}`;
|
||||||
const res = await fetch(url, { method: "GET", headers: headers("json") });
|
const res = await fetch(url, { method: "GET", headers: baseHeaders() });
|
||||||
const data = await parse<ApiOk<Medico>>(res);
|
const arr = await parse<Medico[]>(res);
|
||||||
return (data as any)?.data ?? (data as any);
|
if (!arr?.length) throw new Error("404: Médico não encontrado");
|
||||||
|
return arr[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function criarMedico(input: MedicoInput): Promise<Medico> {
|
export async function criarMedico(input: MedicoInput): Promise<Medico> {
|
||||||
const url = `${API_BASE}/pacientes`; // FORÇA /pacientes
|
const url = `${REST}/doctors`;
|
||||||
const res = await fetch(url, { method: "POST", headers: headers("json"), body: JSON.stringify(input) });
|
const res = await fetch(url, {
|
||||||
const data = await parse<ApiOk<Medico>>(res);
|
method: "POST",
|
||||||
return (data as any)?.data ?? (data as any);
|
headers: withPrefer({ ...baseHeaders(), "Content-Type": "application/json" }, "return=representation"),
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
const arr = await parse<Medico[] | Medico>(res);
|
||||||
|
return Array.isArray(arr) ? arr[0] : (arr as Medico);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function atualizarMedico(id: string | number, input: MedicoInput): Promise<Medico> {
|
export async function atualizarMedico(id: string | number, input: MedicoInput): Promise<Medico> {
|
||||||
const url = `${API_BASE}/pacientes/${id}`; // FORÇA /pacientes
|
const url = `${REST}/doctors?id=eq.${id}`;
|
||||||
const res = await fetch(url, { method: "PUT", headers: headers("json"), body: JSON.stringify(input) });
|
const res = await fetch(url, {
|
||||||
const data = await parse<ApiOk<Medico>>(res);
|
method: "PATCH",
|
||||||
return (data as any)?.data ?? (data as any);
|
headers: withPrefer({ ...baseHeaders(), "Content-Type": "application/json" }, "return=representation"),
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
const arr = await parse<Medico[] | Medico>(res);
|
||||||
|
return Array.isArray(arr) ? arr[0] : (arr as Medico);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function excluirMedico(id: string | number): Promise<void> {
|
export async function excluirMedico(id: string | number): Promise<void> {
|
||||||
const url = `${API_BASE}/pacientes/${id}`; // FORÇA /pacientes
|
const url = `${REST}/doctors?id=eq.${id}`;
|
||||||
const res = await fetch(url, { method: "DELETE", headers: headers("json") });
|
const res = await fetch(url, { method: "DELETE", headers: baseHeaders() });
|
||||||
await parse<any>(res);
|
await parse<any>(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadFotoMedico(id: string | number, file: File): Promise<{ foto_url?: string; thumbnail_url?: string }> {
|
// ===== CEP (usado nos formulários) =====
|
||||||
const url = `${API_BASE}/pacientes/${id}/foto`; // FORÇA /pacientes
|
export async function buscarCepAPI(cep: string): Promise<{
|
||||||
const fd = new FormData();
|
logradouro?: string;
|
||||||
fd.append("foto", file);
|
bairro?: string;
|
||||||
const res = await fetch(url, { method: "POST", headers: headers("form"), body: fd });
|
localidade?: string;
|
||||||
const data = await parse<ApiOk<{ foto_url?: string; thumbnail_url?: string }>>(res);
|
uf?: string;
|
||||||
return (data as any)?.data ?? (data as any);
|
erro?: boolean;
|
||||||
|
}> {
|
||||||
|
const clean = (cep || "").replace(/\D/g, "");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://viacep.com.br/ws/${clean}/json/`);
|
||||||
|
const json = await res.json();
|
||||||
|
if (json?.erro) return { erro: true };
|
||||||
|
return {
|
||||||
|
logradouro: json.logradouro ?? "",
|
||||||
|
bairro: json.bairro ?? "",
|
||||||
|
localidade: json.localidade ?? "",
|
||||||
|
uf: json.uf ?? "",
|
||||||
|
erro: false,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { erro: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removerFotoMedico(id: string | number): Promise<void> {
|
// ===== Stubs pra não quebrar imports dos forms (sem rotas de storage na doc) =====
|
||||||
const url = `${API_BASE}/pacientes/${id}/foto`; // FORÇA /pacientes
|
export async function listarAnexos(_id: string | number): Promise<any[]> { return []; }
|
||||||
const res = await fetch(url, { method: "DELETE", headers: headers("json") });
|
export async function adicionarAnexo(_id: string | number, _file: File): Promise<any> { return {}; }
|
||||||
await parse<any>(res);
|
export async function removerAnexo(_id: string | number, _anexoId: string | number): Promise<void> {}
|
||||||
}
|
export async function uploadFotoPaciente(_id: string | number, _file: File): Promise<{ foto_url?: string; thumbnail_url?: string }> { return {}; }
|
||||||
|
export async function removerFotoPaciente(_id: string | number): Promise<void> {}
|
||||||
export async function listarAnexosMedico(id: string | number): Promise<any[]> {
|
export async function listarAnexosMedico(_id: string | number): Promise<any[]> { return []; }
|
||||||
const url = `${API_BASE}/pacientes/${id}/anexos`; // FORÇA /pacientes
|
export async function adicionarAnexoMedico(_id: string | number, _file: File): Promise<any> { return {}; }
|
||||||
const res = await fetch(url, { method: "GET", headers: headers("json") });
|
export async function removerAnexoMedico(_id: string | number, _anexoId: string | number): Promise<void> {}
|
||||||
const data = await parse<ApiOk<any[]>>(res);
|
export async function uploadFotoMedico(_id: string | number, _file: File): Promise<{ foto_url?: string; thumbnail_url?: string }> { return {}; }
|
||||||
return (data as any)?.data ?? (data as any);
|
export async function removerFotoMedico(_id: string | number): Promise<void> {}
|
||||||
}
|
|
||||||
|
|
||||||
export async function adicionarAnexoMedico(id: string | number, file: File): Promise<any> {
|
|
||||||
const url = `${API_BASE}/pacientes/${id}/anexos`; // FORÇA /pacientes
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("arquivo", file);
|
|
||||||
const res = await fetch(url, { method: "POST", headers: headers("form"), body: fd });
|
|
||||||
const data = await parse<ApiOk<any>>(res);
|
|
||||||
return (data as any)?.data ?? (data as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removerAnexoMedico(id: string | number, anexoId: string | number): Promise<void> {
|
|
||||||
const url = `${API_BASE}/pacientes/${id}/anexos/${anexoId}`; // FORÇA /pacientes
|
|
||||||
const res = await fetch(url, { method: "DELETE", headers: headers("json") });
|
|
||||||
await parse<any>(res);
|
|
||||||
}
|
|
||||||
// ======= FIM: médicos usando rotas de pacientes =======
|
|
||||||
|
|||||||
388
susconecta/lib/auth.ts
Normal file
388
susconecta/lib/auth.ts
Normal file
@ -0,0 +1,388 @@
|
|||||||
|
import type {
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
RefreshTokenResponse,
|
||||||
|
AuthError,
|
||||||
|
UserData
|
||||||
|
} from '@/types/auth';
|
||||||
|
|
||||||
|
import { API_CONFIG, AUTH_ENDPOINTS, DEFAULT_HEADERS, API_KEY, buildApiUrl } from '@/lib/config';
|
||||||
|
import { debugRequest } from '@/lib/debug-utils';
|
||||||
|
import { ENV_CONFIG } from '@/lib/env-config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classe de erro customizada para autenticação
|
||||||
|
*/
|
||||||
|
export class AuthenticationError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public code: string,
|
||||||
|
public details?: any
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AuthenticationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headers para requisições autenticadas (COM Bearer token)
|
||||||
|
*/
|
||||||
|
function getAuthHeaders(token: string): Record<string, string> {
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"apikey": API_KEY,
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headers APENAS para login (SEM Authorization Bearer)
|
||||||
|
*/
|
||||||
|
function getLoginHeaders(): Record<string, string> {
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"apikey": API_KEY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utilitário para processar resposta da API
|
||||||
|
*/
|
||||||
|
async function processResponse<T>(response: Response): Promise<T> {
|
||||||
|
console.log(`[AUTH] Response status: ${response.status} ${response.statusText}`);
|
||||||
|
|
||||||
|
let data: any = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await response.text();
|
||||||
|
if (text) {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[AUTH] Response sem JSON ou vazia (normal para alguns endpoints)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = data?.message || data?.error || response.statusText || 'Erro na autenticação';
|
||||||
|
const errorCode = data?.code || String(response.status);
|
||||||
|
|
||||||
|
console.error('[AUTH ERROR]', {
|
||||||
|
url: response.url,
|
||||||
|
status: response.status,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new AuthenticationError(errorMessage, errorCode, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[AUTH] Response data:', data);
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serviço para fazer login e obter token JWT
|
||||||
|
*/
|
||||||
|
export async function loginUser(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
userType: 'profissional' | 'paciente' | 'administrador'
|
||||||
|
): Promise<LoginResponse> {
|
||||||
|
let url = AUTH_ENDPOINTS.LOGIN;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[AUTH-API] Iniciando login...', {
|
||||||
|
email,
|
||||||
|
userType,
|
||||||
|
url,
|
||||||
|
payload,
|
||||||
|
timestamp: new Date().toLocaleTimeString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delay para visualizar na aba Network
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[AUTH-API] Enviando requisição de login...');
|
||||||
|
|
||||||
|
// Debug: Log request sem credenciais sensíveis
|
||||||
|
debugRequest('POST', url, getLoginHeaders(), payload);
|
||||||
|
|
||||||
|
let response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getLoginHeaders(),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Se login falhar com 400, tentar criar usuário automaticamente
|
||||||
|
if (!response.ok && response.status === 400) {
|
||||||
|
console.log('[AUTH-API] Login falhou (400), tentando criar usuário...');
|
||||||
|
|
||||||
|
const signupUrl = `${ENV_CONFIG.SUPABASE_URL}/auth/v1/signup`;
|
||||||
|
const signupPayload = {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
data: {
|
||||||
|
userType: userType,
|
||||||
|
name: email.split('@')[0],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
debugRequest('POST', signupUrl, getLoginHeaders(), signupPayload);
|
||||||
|
|
||||||
|
const signupResponse = await fetch(signupUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getLoginHeaders(),
|
||||||
|
body: JSON.stringify(signupPayload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signupResponse.ok) {
|
||||||
|
console.log('[AUTH-API] Usuário criado, tentando login novamente...');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getLoginHeaders(),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[AUTH-API] Login response: ${response.status} ${response.statusText}`, {
|
||||||
|
url: response.url,
|
||||||
|
status: response.status,
|
||||||
|
timestamp: new Date().toLocaleTimeString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Se ainda for 400, mostrar detalhes do erro
|
||||||
|
if (!response.ok) {
|
||||||
|
try {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('[AUTH-API] Erro detalhado:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
body: errorText,
|
||||||
|
headers: Object.fromEntries(response.headers.entries())
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[AUTH-API] Não foi possível ler erro da resposta');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay adicional para ver status code
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const data = await processResponse<any>(response);
|
||||||
|
|
||||||
|
console.log('[AUTH] Dados recebidos da API:', data);
|
||||||
|
|
||||||
|
// Verificar se recebemos os dados necessários
|
||||||
|
if (!data || (!data.access_token && !data.token)) {
|
||||||
|
console.error('[AUTH] API não retornou token válido:', data);
|
||||||
|
throw new AuthenticationError(
|
||||||
|
'API não retornou token de acesso',
|
||||||
|
'NO_TOKEN_RECEIVED',
|
||||||
|
data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adaptar resposta da sua API para o formato esperado
|
||||||
|
const adaptedResponse: LoginResponse = {
|
||||||
|
access_token: data.access_token || data.token,
|
||||||
|
token_type: data.token_type || "Bearer",
|
||||||
|
expires_in: data.expires_in || 3600,
|
||||||
|
user: {
|
||||||
|
id: data.user?.id || data.id || "1",
|
||||||
|
email: email,
|
||||||
|
name: data.user?.name || data.name || email.split('@')[0],
|
||||||
|
userType: userType,
|
||||||
|
profile: data.user?.profile || data.profile || {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[AUTH-API] LOGIN REALIZADO COM SUCESSO!', {
|
||||||
|
token: adaptedResponse.access_token?.substring(0, 20) + '...',
|
||||||
|
user: {
|
||||||
|
email: adaptedResponse.user.email,
|
||||||
|
userType: adaptedResponse.user.userType
|
||||||
|
},
|
||||||
|
timestamp: new Date().toLocaleTimeString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delay final para visualizar sucesso
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
return adaptedResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AUTH] Erro no login:', error);
|
||||||
|
|
||||||
|
if (error instanceof AuthenticationError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AuthenticationError(
|
||||||
|
'Email ou senha incorretos',
|
||||||
|
'INVALID_CREDENTIALS',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serviço para fazer logout do usuário
|
||||||
|
*/
|
||||||
|
export async function logoutUser(token: string): Promise<void> {
|
||||||
|
const url = AUTH_ENDPOINTS.LOGOUT;
|
||||||
|
|
||||||
|
console.log('[AUTH-API] Fazendo logout na API...', {
|
||||||
|
url,
|
||||||
|
hasToken: !!token,
|
||||||
|
timestamp: new Date().toLocaleTimeString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delay para visualizar na aba Network
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 400));
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[AUTH-API] Enviando requisição de logout...');
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(token),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[AUTH-API] Logout response: ${response.status} ${response.statusText}`, {
|
||||||
|
timestamp: new Date().toLocaleTimeString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delay para ver status code
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 600));
|
||||||
|
|
||||||
|
// Logout pode retornar 200, 204 ou até 401 (se token já expirou)
|
||||||
|
// Todos são considerados "sucesso" para logout
|
||||||
|
if (response.ok || response.status === 401) {
|
||||||
|
console.log('[AUTH] Logout realizado com sucesso na API');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se chegou aqui, algo deu errado mas não é crítico para logout
|
||||||
|
console.warn('[AUTH] API retornou status inesperado:', response.status);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AUTH] Erro ao chamar API de logout:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Para logout, sempre continuamos mesmo com erro na API
|
||||||
|
// Isso evita que o usuário fique "preso" se a API estiver indisponível
|
||||||
|
console.log('[AUTH] Logout concluído (local sempre executado)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serviço para renovar token JWT
|
||||||
|
*/
|
||||||
|
export async function refreshAuthToken(refreshToken: string): Promise<RefreshTokenResponse> {
|
||||||
|
const url = AUTH_ENDPOINTS.REFRESH;
|
||||||
|
|
||||||
|
console.log('[AUTH] Renovando token');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"apikey": API_KEY,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await processResponse<RefreshTokenResponse>(response);
|
||||||
|
|
||||||
|
console.log('[AUTH] Token renovado com sucesso');
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AUTH] Erro ao renovar token:', error);
|
||||||
|
|
||||||
|
if (error instanceof AuthenticationError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AuthenticationError(
|
||||||
|
'Não foi possível renovar a sessão',
|
||||||
|
'REFRESH_ERROR',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serviço para obter dados do usuário atual
|
||||||
|
*/
|
||||||
|
export async function getCurrentUser(token: string): Promise<UserData> {
|
||||||
|
const url = AUTH_ENDPOINTS.USER;
|
||||||
|
|
||||||
|
console.log('[AUTH] Obtendo dados do usuário atual');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getAuthHeaders(token),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await processResponse<UserData>(response);
|
||||||
|
|
||||||
|
console.log('[AUTH] Dados do usuário obtidos:', { id: data.id, email: data.email });
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AUTH] Erro ao obter usuário atual:', error);
|
||||||
|
|
||||||
|
if (error instanceof AuthenticationError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AuthenticationError(
|
||||||
|
'Não foi possível obter dados do usuário',
|
||||||
|
'USER_DATA_ERROR',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utilitário para validar se um token está expirado
|
||||||
|
*/
|
||||||
|
export function isTokenExpired(expiryTimestamp: number): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const expiry = expiryTimestamp * 1000; // Converter para milliseconds
|
||||||
|
const buffer = 5 * 60 * 1000; // Buffer de 5 minutos
|
||||||
|
|
||||||
|
return now >= (expiry - buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utilitário para interceptar requests e adicionar token automaticamente
|
||||||
|
*/
|
||||||
|
export function createAuthenticatedFetch(getToken: () => string | null) {
|
||||||
|
return async (url: string, options: RequestInit = {}): Promise<Response> => {
|
||||||
|
const token = getToken();
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const headers = {
|
||||||
|
...options.headers,
|
||||||
|
...getAuthHeaders(token),
|
||||||
|
};
|
||||||
|
|
||||||
|
options = {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(url, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
22
susconecta/lib/config.ts
Normal file
22
susconecta/lib/config.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { ENV_CONFIG } from './env-config';
|
||||||
|
|
||||||
|
export const API_CONFIG = {
|
||||||
|
BASE_URL: ENV_CONFIG.SUPABASE_URL + "/rest/v1",
|
||||||
|
TIMEOUT: 30000,
|
||||||
|
VERSION: "v1",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const AUTH_ENDPOINTS = ENV_CONFIG.AUTH_ENDPOINTS;
|
||||||
|
|
||||||
|
export const API_KEY = ENV_CONFIG.SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
export const DEFAULT_HEADERS = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function buildApiUrl(endpoint: string): string {
|
||||||
|
const baseUrl = API_CONFIG.BASE_URL.replace(/\/$/, '');
|
||||||
|
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||||||
|
return `${baseUrl}${cleanEndpoint}`;
|
||||||
|
}
|
||||||
34
susconecta/lib/debug-utils.ts
Normal file
34
susconecta/lib/debug-utils.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Utilitário de debug para requisições HTTP (apenas em desenvolvimento)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function debugRequest(
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
body?: any
|
||||||
|
) {
|
||||||
|
if (process.env.NODE_ENV !== 'development') return;
|
||||||
|
|
||||||
|
const headersWithoutSensitive = Object.keys(headers).reduce((acc, key) => {
|
||||||
|
// Não logar valores sensíveis, apenas nomes
|
||||||
|
if (key.toLowerCase().includes('apikey') || key.toLowerCase().includes('authorization')) {
|
||||||
|
acc[key] = '[REDACTED]';
|
||||||
|
} else {
|
||||||
|
acc[key] = headers[key];
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, string>);
|
||||||
|
|
||||||
|
const bodyShape = body ? Object.keys(typeof body === 'string' ? JSON.parse(body) : body) : [];
|
||||||
|
|
||||||
|
console.log('[DEBUG] Request Preview:', {
|
||||||
|
method,
|
||||||
|
path: new URL(url).pathname,
|
||||||
|
query: new URL(url).search,
|
||||||
|
headerNames: Object.keys(headers),
|
||||||
|
headers: headersWithoutSensitive,
|
||||||
|
bodyShape,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
83
susconecta/lib/env-config.ts
Normal file
83
susconecta/lib/env-config.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Configuração segura das variáveis de ambiente
|
||||||
|
* Valida se URL e API Key pertencem ao mesmo projeto Supabase
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL || "https://yuanqfswhberkoevtmfr.supabase.co";
|
||||||
|
const SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrai o REF do projeto da URL da Supabase
|
||||||
|
*/
|
||||||
|
function extractProjectRef(url: string): string | null {
|
||||||
|
const match = url.match(/https:\/\/([^.]+)\.supabase\.co/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrai o REF do projeto da API Key JWT
|
||||||
|
*/
|
||||||
|
function extractProjectRefFromKey(apiKey: string): string | null {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(apiKey.split('.')[1]));
|
||||||
|
return payload.ref || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida se URL e API Key pertencem ao mesmo projeto
|
||||||
|
*/
|
||||||
|
function validateProjectConsistency(): boolean {
|
||||||
|
const urlRef = extractProjectRef(SUPABASE_URL);
|
||||||
|
const keyRef = extractProjectRefFromKey(SUPABASE_ANON_KEY);
|
||||||
|
|
||||||
|
if (!urlRef || !keyRef) {
|
||||||
|
console.warn('[ENV] Não foi possível extrair REF do projeto');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlRef !== keyRef) {
|
||||||
|
console.error('[ENV] ERRO: URL e API Key são de projetos diferentes!', {
|
||||||
|
urlRef,
|
||||||
|
keyRef
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[ENV] Projeto validado:', urlRef);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar na inicialização
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
// Server-side
|
||||||
|
validateProjectConsistency();
|
||||||
|
} else {
|
||||||
|
// Client-side
|
||||||
|
setTimeout(() => validateProjectConsistency(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ENV_CONFIG = {
|
||||||
|
SUPABASE_URL,
|
||||||
|
SUPABASE_ANON_KEY,
|
||||||
|
PROJECT_REF: extractProjectRef(SUPABASE_URL),
|
||||||
|
|
||||||
|
// URLs dos endpoints de autenticação
|
||||||
|
AUTH_ENDPOINTS: {
|
||||||
|
LOGIN: `${SUPABASE_URL}/auth/v1/token?grant_type=password`,
|
||||||
|
LOGOUT: `${SUPABASE_URL}/auth/v1/logout`,
|
||||||
|
REFRESH: `${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`,
|
||||||
|
USER: `${SUPABASE_URL}/auth/v1/user`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Headers padrão
|
||||||
|
DEFAULT_HEADERS: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"apikey": SUPABASE_ANON_KEY,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Validação
|
||||||
|
isValid: validateProjectConsistency(),
|
||||||
|
} as const;
|
||||||
260
susconecta/lib/http.ts
Normal file
260
susconecta/lib/http.ts
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* Cliente HTTP com refresh automático de token e fila de requisições
|
||||||
|
* Implementa lock para evitar múltiplas chamadas de refresh simultaneamente
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AUTH_STORAGE_KEYS } from '@/types/auth'
|
||||||
|
import { isExpired } from '@/lib/jwt'
|
||||||
|
import { API_KEY } from '@/lib/config'
|
||||||
|
|
||||||
|
interface QueuedRequest {
|
||||||
|
resolve: (value: any) => void
|
||||||
|
reject: (error: any) => void
|
||||||
|
config: RequestInit & { url: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpClient {
|
||||||
|
private isRefreshing = false
|
||||||
|
private requestQueue: QueuedRequest[] = []
|
||||||
|
private baseURL: string
|
||||||
|
|
||||||
|
constructor(baseURL: string) {
|
||||||
|
this.baseURL = baseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processa fila de requisições após refresh bem-sucedido
|
||||||
|
*/
|
||||||
|
private processQueue(error: Error | null, token: string | null = null) {
|
||||||
|
console.log(`[HTTP] Processando fila de ${this.requestQueue.length} requisições`)
|
||||||
|
|
||||||
|
this.requestQueue.forEach(({ resolve, reject, config }) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error)
|
||||||
|
} else {
|
||||||
|
// Reexecutar requisição com novo token
|
||||||
|
const headers = {
|
||||||
|
...config.headers,
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
resolve(this.executeRequest({ ...config, headers }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.requestQueue = []
|
||||||
|
console.log('[HTTP] Fila de requisições processada')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executa refresh de token uma única vez usando lock
|
||||||
|
*/
|
||||||
|
private async refreshToken(): Promise<string | null> {
|
||||||
|
const refreshToken = localStorage.getItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN)
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error('No refresh token available')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[HTTP] Iniciando refresh de token...', {
|
||||||
|
timestamp: new Date().toLocaleTimeString()
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetch('https://yuanqfswhberkoevtmfr.supabase.co/auth/v1/token?grant_type=refresh_token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'apikey': API_KEY // API Key sempre necessária
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log('[HTTP] Refresh falhou:', response.status)
|
||||||
|
throw new Error(`Refresh failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
// Atualizar tokens de forma atômica
|
||||||
|
localStorage.setItem(AUTH_STORAGE_KEYS.TOKEN, data.access_token)
|
||||||
|
if (data.refresh_token) {
|
||||||
|
localStorage.setItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN, data.refresh_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[HTTP] Token renovado com sucesso!', {
|
||||||
|
timestamp: new Date().toLocaleTimeString()
|
||||||
|
})
|
||||||
|
return data.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executa requisição HTTP com tratamento de erros
|
||||||
|
*/
|
||||||
|
private async executeRequest(config: RequestInit & { url: string }): Promise<Response> {
|
||||||
|
try {
|
||||||
|
console.log(`[HTTP] Fazendo requisição: ${config.method || 'GET'} ${config.url}`)
|
||||||
|
|
||||||
|
// Delay para visualizar na aba Network
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 800))
|
||||||
|
|
||||||
|
const response = await fetch(config.url, config)
|
||||||
|
|
||||||
|
console.log(`[HTTP] Resposta recebida: ${response.status} ${response.statusText}`, {
|
||||||
|
url: config.url,
|
||||||
|
status: response.status,
|
||||||
|
timestamp: new Date().toLocaleTimeString()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Se for 401 e não for uma tentativa de refresh, tentar renovar token
|
||||||
|
if (response.status === 401 && !config.url.includes('/refresh')) {
|
||||||
|
console.log('[HTTP] Status 401 - Verificando possibilidade de refresh token...')
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
|
const token = localStorage.getItem(AUTH_STORAGE_KEYS.TOKEN)
|
||||||
|
if (token && !isExpired(token)) {
|
||||||
|
// Token ainda é válido, erro pode ser temporário
|
||||||
|
console.log('[HTTP] Token ainda válido - erro pode ser temporário')
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 600))
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token expirado, tentar refresh
|
||||||
|
if (this.isRefreshing) {
|
||||||
|
// Adicionar à fila se já está fazendo refresh
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.requestQueue.push({
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
config
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRefreshing = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newToken = await this.refreshToken()
|
||||||
|
this.isRefreshing = false
|
||||||
|
|
||||||
|
// Processar fila com sucesso
|
||||||
|
this.processQueue(null, newToken)
|
||||||
|
|
||||||
|
// Reexecutar requisição original
|
||||||
|
const newHeaders = {
|
||||||
|
...config.headers,
|
||||||
|
'apikey': API_KEY, // Garantir API Key
|
||||||
|
Authorization: `Bearer ${newToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[HTTP] Reexecutando requisição com novo token...')
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 800))
|
||||||
|
|
||||||
|
return await fetch(config.url, { ...config, headers: newHeaders })
|
||||||
|
} catch (refreshError) {
|
||||||
|
this.isRefreshing = false
|
||||||
|
this.processQueue(refreshError as Error)
|
||||||
|
|
||||||
|
// Logout único em caso de falha no refresh
|
||||||
|
console.error('[HTTP] Refresh FALHOU - fazendo logout automático:', refreshError)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
this.performLogout()
|
||||||
|
|
||||||
|
throw refreshError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[HTTP] Erro na requisição:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout único com limpeza de estado
|
||||||
|
*/
|
||||||
|
private performLogout() {
|
||||||
|
// Limpar dados de autenticação
|
||||||
|
localStorage.removeItem(AUTH_STORAGE_KEYS.TOKEN)
|
||||||
|
localStorage.removeItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN)
|
||||||
|
localStorage.removeItem(AUTH_STORAGE_KEYS.USER)
|
||||||
|
|
||||||
|
// Redirecionar para login
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const userType = localStorage.getItem(AUTH_STORAGE_KEYS.USER_TYPE) || 'profissional'
|
||||||
|
const loginRoutes = {
|
||||||
|
profissional: '/login',
|
||||||
|
paciente: '/login-paciente',
|
||||||
|
administrador: '/login-admin'
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginRoute = loginRoutes[userType as keyof typeof loginRoutes] || '/login'
|
||||||
|
window.location.href = loginRoute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Método público para fazer requisições autenticadas
|
||||||
|
*/
|
||||||
|
async request(url: string, options: RequestInit = {}): Promise<Response> {
|
||||||
|
const token = localStorage.getItem(AUTH_STORAGE_KEYS.TOKEN)
|
||||||
|
|
||||||
|
console.log(`[HTTP] Preparando requisição: ${options.method || 'GET'} ${url}`, {
|
||||||
|
hasToken: !!token,
|
||||||
|
timestamp: new Date().toLocaleTimeString()
|
||||||
|
})
|
||||||
|
|
||||||
|
const config: RequestInit & { url: string } = {
|
||||||
|
url: url.startsWith('http') ? url : `${this.baseURL}${url}`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'apikey': API_KEY, // API Key da Supabase sempre presente
|
||||||
|
...(token && { Authorization: `Bearer ${token}` }), // Bearer Token quando usuário logado
|
||||||
|
...options.headers
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.executeRequest(config)
|
||||||
|
|
||||||
|
console.log(`[HTTP] Requisição finalizada: ${response.status}`, {
|
||||||
|
url: config.url,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Métodos de conveniência
|
||||||
|
*/
|
||||||
|
async get(url: string, options?: RequestInit): Promise<Response> {
|
||||||
|
return this.request(url, { ...options, method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async post(url: string, data?: any, options?: RequestInit): Promise<Response> {
|
||||||
|
return this.request(url, {
|
||||||
|
...options,
|
||||||
|
method: 'POST',
|
||||||
|
body: data ? JSON.stringify(data) : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(url: string, data?: any, options?: RequestInit): Promise<Response> {
|
||||||
|
return this.request(url, {
|
||||||
|
...options,
|
||||||
|
method: 'PUT',
|
||||||
|
body: data ? JSON.stringify(data) : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(url: string, options?: RequestInit): Promise<Response> {
|
||||||
|
return this.request(url, { ...options, method: 'DELETE' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instância única do cliente HTTP
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'https://mock.apidog.com/m1/1053378-0-default'
|
||||||
|
export const httpClient = new HttpClient(API_BASE_URL)
|
||||||
|
|
||||||
|
export default httpClient
|
||||||
133
susconecta/lib/jwt.ts
Normal file
133
susconecta/lib/jwt.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* Utilitários JWT com verificação de expiração padronizada
|
||||||
|
* Clock skew tolerance de 60 segundos para compensar diferenças de tempo
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface JWTPayload {
|
||||||
|
exp?: number
|
||||||
|
iat?: number
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLOCK_SKEW_SECONDS = 60
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse JWT token payload sem validação de assinatura
|
||||||
|
* @param token JWT token
|
||||||
|
* @returns Payload decodificado ou null se inválido
|
||||||
|
*/
|
||||||
|
export function parseJwt(token: string): JWTPayload | null {
|
||||||
|
try {
|
||||||
|
const base64Url = token.split('.')[1]
|
||||||
|
if (!base64Url) return null
|
||||||
|
|
||||||
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const jsonPayload = decodeURIComponent(
|
||||||
|
atob(base64)
|
||||||
|
.split('')
|
||||||
|
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||||
|
.join('')
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSON.parse(jsonPayload)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[JWT] Erro ao fazer parse do token:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se token está expirado com tolerância de clock skew
|
||||||
|
* @param token JWT token ou timestamp de expiração em segundos
|
||||||
|
* @returns true se expirado
|
||||||
|
*/
|
||||||
|
export function isExpired(token: string | number): boolean {
|
||||||
|
try {
|
||||||
|
let expTimestamp: number
|
||||||
|
|
||||||
|
if (typeof token === 'string') {
|
||||||
|
const payload = parseJwt(token)
|
||||||
|
if (!payload?.exp) {
|
||||||
|
console.warn('[JWT] Token sem claim exp, considerando válido')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
expTimestamp = payload.exp
|
||||||
|
} else {
|
||||||
|
expTimestamp = token
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||||
|
const isExpiredValue = nowSeconds >= (expTimestamp + CLOCK_SKEW_SECONDS)
|
||||||
|
|
||||||
|
console.log('[JWT] Verificação de expiração:', {
|
||||||
|
nowSeconds,
|
||||||
|
expTimestamp,
|
||||||
|
clockSkew: CLOCK_SKEW_SECONDS,
|
||||||
|
isExpired: isExpiredValue,
|
||||||
|
timeUntilExpiry: expTimestamp - nowSeconds
|
||||||
|
})
|
||||||
|
|
||||||
|
return isExpiredValue
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[JWT] Erro na verificação de expiração:', error)
|
||||||
|
return true // Assumir expirado em caso de erro
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se token deve ser renovado (expira em menos de 5 minutos)
|
||||||
|
* @param token JWT token ou timestamp de expiração em segundos
|
||||||
|
* @returns true se deve renovar
|
||||||
|
*/
|
||||||
|
export function shouldRefresh(token: string | number): boolean {
|
||||||
|
try {
|
||||||
|
let expTimestamp: number
|
||||||
|
|
||||||
|
if (typeof token === 'string') {
|
||||||
|
const payload = parseJwt(token)
|
||||||
|
if (!payload?.exp) return false
|
||||||
|
expTimestamp = payload.exp
|
||||||
|
} else {
|
||||||
|
expTimestamp = token
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||||
|
const refreshThreshold = 5 * 60 // 5 minutos
|
||||||
|
const shouldRefreshValue = nowSeconds >= (expTimestamp - refreshThreshold)
|
||||||
|
|
||||||
|
console.log('[JWT] Verificação de renovação:', {
|
||||||
|
nowSeconds,
|
||||||
|
expTimestamp,
|
||||||
|
refreshThreshold,
|
||||||
|
shouldRefresh: shouldRefreshValue,
|
||||||
|
timeUntilRefresh: expTimestamp - refreshThreshold - nowSeconds
|
||||||
|
})
|
||||||
|
|
||||||
|
return shouldRefreshValue
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[JWT] Erro na verificação de renovação:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrai informações úteis do token
|
||||||
|
* @param token JWT token
|
||||||
|
* @returns Informações do token ou null
|
||||||
|
*/
|
||||||
|
export function getTokenInfo(token: string): {
|
||||||
|
payload: JWTPayload
|
||||||
|
isExpired: boolean
|
||||||
|
shouldRefresh: boolean
|
||||||
|
expiresAt: Date | null
|
||||||
|
} | null {
|
||||||
|
const payload = parseJwt(token)
|
||||||
|
if (!payload) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
payload,
|
||||||
|
isExpired: isExpired(token),
|
||||||
|
shouldRefresh: shouldRefresh(token),
|
||||||
|
expiresAt: payload.exp ? new Date(payload.exp * 1000) : null
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,3 +4,22 @@ import { twMerge } from "tailwind-merge"
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validarCPFLocal(cpf: string): boolean {
|
||||||
|
if (!cpf) return false;
|
||||||
|
cpf = cpf.replace(/[^\d]+/g, "");
|
||||||
|
if (cpf.length !== 11) return false;
|
||||||
|
if (/^(\d)\1{10}$/.test(cpf)) return false;
|
||||||
|
|
||||||
|
let soma = 0, resto = 0;
|
||||||
|
for (let i = 1; i <= 9; i++) soma += parseInt(cpf.substring(i - 1, i)) * (11 - i);
|
||||||
|
resto = (soma * 10) % 11; if (resto === 10 || resto === 11) resto = 0;
|
||||||
|
if (resto !== parseInt(cpf.substring(9, 10))) return false;
|
||||||
|
|
||||||
|
soma = 0;
|
||||||
|
for (let i = 1; i <= 10; i++) soma += parseInt(cpf.substring(i - 1, i)) * (12 - i);
|
||||||
|
resto = (soma * 10) % 11; if (resto === 10 || resto === 11) resto = 0;
|
||||||
|
if (resto !== parseInt(cpf.substring(10, 11))) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
5
susconecta/next-env.d.ts
vendored
Normal file
5
susconecta/next-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
416
susconecta/package-lock.json
generated
416
susconecta/package-lock.json
generated
@ -50,6 +50,7 @@
|
|||||||
"embla-carousel-react": "latest",
|
"embla-carousel-react": "latest",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"input-otp": "latest",
|
"input-otp": "latest",
|
||||||
|
"jspdf": "^3.0.3",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"next": "14.2.16",
|
"next": "14.2.16",
|
||||||
"next-themes": "latest",
|
"next-themes": "latest",
|
||||||
@ -57,7 +58,9 @@
|
|||||||
"react-day-picker": "latest",
|
"react-day-picker": "latest",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-hook-form": "latest",
|
"react-hook-form": "latest",
|
||||||
|
"react-quill": "^2.0.0",
|
||||||
"react-resizable-panels": "latest",
|
"react-resizable-panels": "latest",
|
||||||
|
"react-signature-canvas": "^1.1.0-alpha.2",
|
||||||
"recharts": "latest",
|
"recharts": "latest",
|
||||||
"sonner": "latest",
|
"sonner": "latest",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
@ -89,6 +92,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.28.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||||
|
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@date-fns/tz": {
|
"node_modules/@date-fns/tz": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
|
||||||
@ -2161,6 +2173,12 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pako": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@ -2168,6 +2186,22 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/quill": {
|
||||||
|
"version": "1.3.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/quill/-/quill-1.3.10.tgz",
|
||||||
|
"integrity": "sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"parchment": "^1.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/raf": {
|
||||||
|
"version": "3.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||||
|
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.24",
|
"version": "18.3.24",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz",
|
||||||
@ -2189,6 +2223,19 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/signature_pad": {
|
||||||
|
"version": "2.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/signature_pad/-/signature_pad-2.3.6.tgz",
|
||||||
|
"integrity": "sha512-v3j92gCQJoxomHhd+yaG4Vsf8tRS/XbzWKqDv85UsqjMGy4zhokuwKe4b6vhbgncKkh+thF+gpz6+fypTtnFqQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/trusted-types": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@types/use-sync-external-store": {
|
"node_modules/@types/use-sync-external-store": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
@ -2265,6 +2312,16 @@
|
|||||||
"postcss": "^8.1.0"
|
"postcss": "^8.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-arraybuffer": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.25.4",
|
"version": "4.25.4",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz",
|
||||||
@ -2328,6 +2385,26 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/canvg": {
|
||||||
|
"version": "3.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
||||||
|
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@types/raf": "^3.4.0",
|
||||||
|
"core-js": "^3.8.3",
|
||||||
|
"raf": "^3.4.1",
|
||||||
|
"regenerator-runtime": "^0.13.7",
|
||||||
|
"rgbcolor": "^1.0.1",
|
||||||
|
"stackblur-canvas": "^2.0.0",
|
||||||
|
"svg-pathdata": "^6.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chownr": {
|
"node_modules/chownr": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||||
@ -2356,6 +2433,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/clone": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@ -2381,6 +2467,28 @@
|
|||||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/core-js": {
|
||||||
|
"version": "3.45.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.45.1.tgz",
|
||||||
|
"integrity": "sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/core-js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/css-line-break": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
@ -2531,6 +2639,26 @@
|
|||||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/deep-equal": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-arguments": "^1.1.1",
|
||||||
|
"is-date-object": "^1.0.5",
|
||||||
|
"is-regex": "^1.1.4",
|
||||||
|
"object-is": "^1.1.5",
|
||||||
|
"object-keys": "^1.1.1",
|
||||||
|
"regexp.prototype.flags": "^1.5.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
||||||
@ -2547,6 +2675,16 @@
|
|||||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||||
|
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optional": true,
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.213",
|
"version": "1.5.213",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.213.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.213.tgz",
|
||||||
@ -2620,6 +2758,35 @@
|
|||||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/extend": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fast-diff": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/fast-png": {
|
||||||
|
"version": "6.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
|
||||||
|
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/pako": "^2.0.3",
|
||||||
|
"iobuffer": "^5.3.2",
|
||||||
|
"pako": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||||
|
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fraction.js": {
|
"node_modules/fraction.js": {
|
||||||
"version": "4.3.7",
|
"version": "4.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||||
@ -2657,6 +2824,20 @@
|
|||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/html2canvas": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"css-line-break": "^2.1.0",
|
||||||
|
"text-segmentation": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/immer": {
|
"node_modules/immer": {
|
||||||
"version": "10.1.3",
|
"version": "10.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
|
||||||
@ -2686,6 +2867,12 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iobuffer": {
|
||||||
|
"version": "5.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||||
|
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
|
||||||
@ -2702,6 +2889,23 @@
|
|||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/jspdf": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.26.9",
|
||||||
|
"fast-png": "^6.2.0",
|
||||||
|
"fflate": "^0.8.1"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"canvg": "^3.0.11",
|
||||||
|
"core-js": "^3.6.0",
|
||||||
|
"dompurify": "^3.2.4",
|
||||||
|
"html2canvas": "^1.0.0-rc.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.30.1",
|
"version": "1.30.1",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
||||||
@ -2941,6 +3145,12 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.17.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@ -3132,6 +3342,35 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-assign": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
|
"node_modules/parchment": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/performance-now": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@ -3182,6 +3421,69 @@
|
|||||||
"url": "https://opencollective.com/preact"
|
"url": "https://opencollective.com/preact"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prop-types": {
|
||||||
|
"version": "15.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.4.0",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"react-is": "^16.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prop-types/node_modules/react-is": {
|
||||||
|
"version": "16.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/quill": {
|
||||||
|
"version": "1.3.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
|
||||||
|
"integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"clone": "^2.1.1",
|
||||||
|
"deep-equal": "^1.0.1",
|
||||||
|
"eventemitter3": "^2.0.3",
|
||||||
|
"extend": "^3.0.2",
|
||||||
|
"parchment": "^1.1.4",
|
||||||
|
"quill-delta": "^3.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/quill-delta": {
|
||||||
|
"version": "3.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
|
||||||
|
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"deep-equal": "^1.0.1",
|
||||||
|
"extend": "^3.0.2",
|
||||||
|
"fast-diff": "1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/quill/node_modules/eventemitter3": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/raf": {
|
||||||
|
"version": "3.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||||
|
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"performance-now": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
@ -3251,6 +3553,21 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/react-quill": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-quill/-/react-quill-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-4qQtv1FtCfLgoD3PXAur5RyxuUbPXQGOHgTlFie3jtxp43mXDtzCKaOgQ3mLyZfi1PUlyjycfivKelFhy13QUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/quill": "^1.3.10",
|
||||||
|
"lodash": "^4.17.4",
|
||||||
|
"quill": "^1.3.7"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16 || ^17 || ^18",
|
||||||
|
"react-dom": "^16 || ^17 || ^18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-redux": {
|
"node_modules/react-redux": {
|
||||||
"version": "9.2.0",
|
"version": "9.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
@ -3331,6 +3648,36 @@
|
|||||||
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-signature-canvas": {
|
||||||
|
"version": "1.1.0-alpha.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-signature-canvas/-/react-signature-canvas-1.1.0-alpha.2.tgz",
|
||||||
|
"integrity": "sha512-tKUNk3Gmh04Ug4K8p5g8Is08BFUKvbXxi0PyetQ/f8OgCBzcx4vqNf9+OArY/TdNdfHtswXQNRwZD6tyELjkjQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.17.9",
|
||||||
|
"@types/signature_pad": "^2.3.0",
|
||||||
|
"signature_pad": "^2.3.2",
|
||||||
|
"trim-canvas": "^0.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/agilgur5"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-style-singleton": {
|
"node_modules/react-style-singleton": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||||
@ -3395,12 +3742,29 @@
|
|||||||
"redux": "^5.0.0"
|
"redux": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/regenerator-runtime": {
|
||||||
|
"version": "0.13.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||||
|
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/reselect": {
|
"node_modules/reselect": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/rgbcolor": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||||
|
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
@ -3416,6 +3780,12 @@
|
|||||||
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
|
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/signature_pad": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-peYXLxOsIY6MES2TrRLDiNg2T++8gGbpP2yaC+6Ohtxr+a2dzoaqWosWDY9sWqTAAk6E/TyQO+LJw9zQwyu5kA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/sonner": {
|
"node_modules/sonner": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||||
@ -3435,6 +3805,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackblur-canvas": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.1.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/streamsearch": {
|
"node_modules/streamsearch": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||||
@ -3466,6 +3846,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svg-pathdata": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwind-merge": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
|
||||||
@ -3523,12 +3913,28 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/text-segmentation": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tiny-invariant": {
|
"node_modules/tiny-invariant": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/trim-canvas": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/trim-canvas/-/trim-canvas-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-nd4Ga3iLFV94mdhW9JFMLpQbHUyCQuhFOD71PEAt1NjtMD5wbZctzhX8c3agHNybMR5zXD1XTGoIEWk995E6pQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
@ -3648,6 +4054,16 @@
|
|||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/utrie": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"base64-arraybuffer": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vaul": {
|
"node_modules/vaul": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
||||||
|
|||||||
@ -51,6 +51,7 @@
|
|||||||
"embla-carousel-react": "latest",
|
"embla-carousel-react": "latest",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"input-otp": "latest",
|
"input-otp": "latest",
|
||||||
|
"jspdf": "^3.0.3",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"next": "14.2.16",
|
"next": "14.2.16",
|
||||||
"next-themes": "latest",
|
"next-themes": "latest",
|
||||||
@ -58,7 +59,9 @@
|
|||||||
"react-day-picker": "latest",
|
"react-day-picker": "latest",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-hook-form": "latest",
|
"react-hook-form": "latest",
|
||||||
|
"react-quill": "^2.0.0",
|
||||||
"react-resizable-panels": "latest",
|
"react-resizable-panels": "latest",
|
||||||
|
"react-signature-canvas": "^1.1.0-alpha.2",
|
||||||
"recharts": "latest",
|
"recharts": "latest",
|
||||||
"sonner": "latest",
|
"sonner": "latest",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
|
|||||||
3595
susconecta/pnpm-lock.yaml
generated
3595
susconecta/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
90
susconecta/types/auth.ts
Normal file
90
susconecta/types/auth.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Tipos estritos para autenticação sem any
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated'
|
||||||
|
|
||||||
|
export type UserType = 'profissional' | 'paciente' | 'administrador'
|
||||||
|
|
||||||
|
export interface UserData {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
userType: UserType
|
||||||
|
profile?: {
|
||||||
|
cpf?: string
|
||||||
|
crm?: string // Para profissionais
|
||||||
|
telefone?: string
|
||||||
|
foto_url?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
access_token: string
|
||||||
|
refresh_token?: string
|
||||||
|
token_type: string
|
||||||
|
expires_in: number
|
||||||
|
user: UserData
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshTokenResponse {
|
||||||
|
access_token: string
|
||||||
|
token_type: string
|
||||||
|
expires_in: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthError {
|
||||||
|
message: string
|
||||||
|
code: string
|
||||||
|
details?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthContextType {
|
||||||
|
authStatus: AuthStatus
|
||||||
|
user: UserData | null
|
||||||
|
token: string | null
|
||||||
|
login: (email: string, password: string, userType: UserType) => Promise<boolean>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
refreshToken: () => Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthStorageKeys {
|
||||||
|
readonly TOKEN: string
|
||||||
|
readonly REFRESH_TOKEN: string
|
||||||
|
readonly USER: string
|
||||||
|
readonly USER_TYPE: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserTypeRoutes = {
|
||||||
|
readonly [K in UserType]: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoginRoutes = {
|
||||||
|
readonly [K in UserType]: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constantes para localStorage
|
||||||
|
export const AUTH_STORAGE_KEYS: AuthStorageKeys = {
|
||||||
|
TOKEN: 'auth_token',
|
||||||
|
REFRESH_TOKEN: 'auth_refresh_token',
|
||||||
|
USER: 'auth_user',
|
||||||
|
USER_TYPE: 'auth_user_type',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// Rotas baseadas no tipo de usuário
|
||||||
|
export const USER_TYPE_ROUTES: UserTypeRoutes = {
|
||||||
|
profissional: '/profissional',
|
||||||
|
paciente: '/paciente',
|
||||||
|
administrador: '/dashboard',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const LOGIN_ROUTES: LoginRoutes = {
|
||||||
|
profissional: '/login',
|
||||||
|
paciente: '/login-paciente',
|
||||||
|
administrador: '/login-admin',
|
||||||
|
} as const
|
||||||
1
susconecta/types/react-signature-canvas.d.ts
vendored
Normal file
1
susconecta/types/react-signature-canvas.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
declare module 'react-signature-canvas';
|
||||||
Loading…
x
Reference in New Issue
Block a user