Compare commits
12 Commits
b0ab1e86ca
...
107bba89d8
| Author | SHA1 | Date | |
|---|---|---|---|
| 107bba89d8 | |||
|
|
0a7d3f3ae4 | ||
|
|
a43fdcc655 | ||
|
|
d7fbcab6a6 | ||
|
|
40bf3e0e6f | ||
|
|
a994a70d90 | ||
| 8955446bc7 | |||
| 11c8b790ba | |||
| 0510ef8a36 | |||
| 8284ccbadd | |||
| 63e5a2ca9d | |||
| 20f7d79474 |
29
.gitignore
vendored
29
.gitignore
vendored
@ -1,29 +1,2 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
node_modules/
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.tsriseup-squad20/
|
||||
susconecta/riseup-squad20/
|
||||
riseup-squad20/
|
||||
|
||||
3
.next/app-build-manifest.json
Normal file
3
.next/app-build-manifest.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"pages": {}
|
||||
}
|
||||
17
.next/build-manifest.json
Normal file
17
.next/build-manifest.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"polyfillFiles": [
|
||||
"static/chunks/polyfills.js"
|
||||
],
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"lowPriorityFiles": [
|
||||
"static/development/_buildManifest.js",
|
||||
"static/development/_ssgManifest.js"
|
||||
],
|
||||
"rootMainFiles": [],
|
||||
"rootMainFilesTree": {},
|
||||
"pages": {
|
||||
"/_app": []
|
||||
},
|
||||
"ampFirstPages": []
|
||||
}
|
||||
1
.next/cache/.previewinfo
vendored
Normal file
1
.next/cache/.previewinfo
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"previewModeId":"ef42988a66c7facb8cacc41264c816a3","previewModeSigningKey":"a79be007b8fc9844687acde60b0aaa42535a074e1b398db709f1ba36bb394a70","previewModeEncryptionKey":"581405036cabc30bb42611700f8f34afae8b4345617d5f849f74468b1d1b38d5","expireAt":1761141052491}
|
||||
1
.next/cache/.rscinfo
vendored
Normal file
1
.next/cache/.rscinfo
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"encryption.key":"1+xyFz3ckt+qN0qpo15cccDdyvzpv2iYyAOjplkpmUQ=","encryption.expire_at":1761141052419}
|
||||
1
.next/cache/next-devtools-config.json
vendored
Normal file
1
.next/cache/next-devtools-config.json
vendored
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
BIN
.next/cache/webpack/client-development/0.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/client-development/0.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/client-development/1.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/client-development/1.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/client-development/2.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/client-development/2.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/client-development/index.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/client-development/index.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/client-development/index.pack.gz.old
vendored
Normal file
BIN
.next/cache/webpack/client-development/index.pack.gz.old
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/server-development/0.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/server-development/0.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/server-development/1.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/server-development/1.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/server-development/index.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/server-development/index.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/server-development/index.pack.gz.old
vendored
Normal file
BIN
.next/cache/webpack/server-development/index.pack.gz.old
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/server-production/0.pack
vendored
Normal file
BIN
.next/cache/webpack/server-production/0.pack
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/server-production/index.pack
vendored
Normal file
BIN
.next/cache/webpack/server-production/index.pack
vendored
Normal file
Binary file not shown.
1
.next/package.json
Normal file
1
.next/package.json
Normal file
@ -0,0 +1 @@
|
||||
{"type": "commonjs"}
|
||||
11
.next/prerender-manifest.json
Normal file
11
.next/prerender-manifest.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": 4,
|
||||
"routes": {},
|
||||
"dynamicRoutes": {},
|
||||
"notFoundRoutes": [],
|
||||
"preview": {
|
||||
"previewModeId": "3a416f4d36dff8983ece0b1d649076fb",
|
||||
"previewModeSigningKey": "5d2b597ca7226d41745e16f10542be9669d399be31d9ddb1d7d8740428afe5a2",
|
||||
"previewModeEncryptionKey": "77da2f1b974f25583b0b427b5d5575be062291801610be0e6467ad97df7aaba3"
|
||||
}
|
||||
}
|
||||
1
.next/react-loadable-manifest.json
Normal file
1
.next/react-loadable-manifest.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
1
.next/routes-manifest.json
Normal file
1
.next/routes-manifest.json
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"caseSensitive":false,"basePath":"","rewrites":{"beforeFiles":[],"afterFiles":[],"fallback":[]},"redirects":[{"source":"/:path+/","destination":"/:path+","permanent":true,"internal":true,"regex":"^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$"}],"headers":[]}
|
||||
1
.next/server/app-paths-manifest.json
Normal file
1
.next/server/app-paths-manifest.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
1
.next/server/interception-route-rewrite-manifest.js
Normal file
1
.next/server/interception-route-rewrite-manifest.js
Normal file
@ -0,0 +1 @@
|
||||
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"
|
||||
19
.next/server/middleware-build-manifest.js
Normal file
19
.next/server/middleware-build-manifest.js
Normal file
@ -0,0 +1,19 @@
|
||||
globalThis.__BUILD_MANIFEST = {
|
||||
"polyfillFiles": [
|
||||
"static/chunks/polyfills.js"
|
||||
],
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"lowPriorityFiles": [],
|
||||
"rootMainFiles": [],
|
||||
"rootMainFilesTree": {},
|
||||
"pages": {
|
||||
"/_app": []
|
||||
},
|
||||
"ampFirstPages": []
|
||||
};
|
||||
globalThis.__BUILD_MANIFEST.lowPriorityFiles = [
|
||||
"/static/" + process.env.__NEXT_BUILD_ID + "/_buildManifest.js",
|
||||
,"/static/" + process.env.__NEXT_BUILD_ID + "/_ssgManifest.js",
|
||||
|
||||
];
|
||||
6
.next/server/middleware-manifest.json
Normal file
6
.next/server/middleware-manifest.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": 3,
|
||||
"middleware": {},
|
||||
"functions": {},
|
||||
"sortedMiddleware": []
|
||||
}
|
||||
1
.next/server/middleware-react-loadable-manifest.js
Normal file
1
.next/server/middleware-react-loadable-manifest.js
Normal file
@ -0,0 +1 @@
|
||||
self.__REACT_LOADABLE_MANIFEST="{}"
|
||||
1
.next/server/next-font-manifest.js
Normal file
1
.next/server/next-font-manifest.js
Normal file
@ -0,0 +1 @@
|
||||
self.__NEXT_FONT_MANIFEST="{\"pages\":{},\"app\":{},\"appUsingSizeAdjust\":false,\"pagesUsingSizeAdjust\":false}"
|
||||
1
.next/server/next-font-manifest.json
Normal file
1
.next/server/next-font-manifest.json
Normal file
@ -0,0 +1 @@
|
||||
{"pages":{},"app":{},"appUsingSizeAdjust":false,"pagesUsingSizeAdjust":false}
|
||||
1
.next/server/pages-manifest.json
Normal file
1
.next/server/pages-manifest.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
1
.next/server/server-reference-manifest.js
Normal file
1
.next/server/server-reference-manifest.js
Normal file
@ -0,0 +1 @@
|
||||
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY\"\n}"
|
||||
5
.next/server/server-reference-manifest.json
Normal file
5
.next/server/server-reference-manifest.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"node": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "1+xyFz3ckt+qN0qpo15cccDdyvzpv2iYyAOjplkpmUQ="
|
||||
}
|
||||
1
.next/static/chunks/polyfills.js
Normal file
1
.next/static/chunks/polyfills.js
Normal file
File diff suppressed because one or more lines are too long
1
.next/static/development/_buildManifest.js
Normal file
1
.next/static/development/_buildManifest.js
Normal file
@ -0,0 +1 @@
|
||||
self.__BUILD_MANIFEST = (function(a){return {__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},__routerFilterStatic:a,__routerFilterDynamic:a,sortedPages:["\u002F_app"]}}(void 0));self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
|
||||
1
.next/static/development/_ssgManifest.js
Normal file
1
.next/static/development/_ssgManifest.js
Normal file
@ -0,0 +1 @@
|
||||
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
|
||||
4
.next/trace
Normal file
4
.next/trace
Normal file
File diff suppressed because one or more lines are too long
141
.next/types/cache-life.d.ts
vendored
Normal file
141
.next/types/cache-life.d.ts
vendored
Normal file
@ -0,0 +1,141 @@
|
||||
// Type definitions for Next.js cacheLife configs
|
||||
|
||||
declare module 'next/cache' {
|
||||
export { unstable_cache } from 'next/dist/server/web/spec-extension/unstable-cache'
|
||||
export {
|
||||
revalidateTag,
|
||||
revalidatePath,
|
||||
unstable_expireTag,
|
||||
unstable_expirePath,
|
||||
} from 'next/dist/server/web/spec-extension/revalidate'
|
||||
export { unstable_noStore } from 'next/dist/server/web/spec-extension/unstable-no-store'
|
||||
|
||||
|
||||
/**
|
||||
* Cache this `"use cache"` for a timespan defined by the `"default"` profile.
|
||||
* ```
|
||||
* stale: 300 seconds (5 minutes)
|
||||
* revalidate: 900 seconds (15 minutes)
|
||||
* expire: never
|
||||
* ```
|
||||
*
|
||||
* This cache may be stale on clients for 5 minutes before checking with the server.
|
||||
* If the server receives a new request after 15 minutes, start revalidating new values in the background.
|
||||
* It lives for the maximum age of the server cache. If this entry has no traffic for a while, it may serve an old value the next request.
|
||||
*/
|
||||
export function unstable_cacheLife(profile: "default"): void
|
||||
|
||||
/**
|
||||
* Cache this `"use cache"` for a timespan defined by the `"seconds"` profile.
|
||||
* ```
|
||||
* stale: 30 seconds
|
||||
* revalidate: 1 seconds
|
||||
* expire: 60 seconds (1 minute)
|
||||
* ```
|
||||
*
|
||||
* This cache may be stale on clients for 30 seconds before checking with the server.
|
||||
* If the server receives a new request after 1 seconds, start revalidating new values in the background.
|
||||
* If this entry has no traffic for 1 minute it will expire. The next request will recompute it.
|
||||
*/
|
||||
export function unstable_cacheLife(profile: "seconds"): void
|
||||
|
||||
/**
|
||||
* Cache this `"use cache"` for a timespan defined by the `"minutes"` profile.
|
||||
* ```
|
||||
* stale: 300 seconds (5 minutes)
|
||||
* revalidate: 60 seconds (1 minute)
|
||||
* expire: 3600 seconds (1 hour)
|
||||
* ```
|
||||
*
|
||||
* This cache may be stale on clients for 5 minutes before checking with the server.
|
||||
* If the server receives a new request after 1 minute, start revalidating new values in the background.
|
||||
* If this entry has no traffic for 1 hour it will expire. The next request will recompute it.
|
||||
*/
|
||||
export function unstable_cacheLife(profile: "minutes"): void
|
||||
|
||||
/**
|
||||
* Cache this `"use cache"` for a timespan defined by the `"hours"` profile.
|
||||
* ```
|
||||
* stale: 300 seconds (5 minutes)
|
||||
* revalidate: 3600 seconds (1 hour)
|
||||
* expire: 86400 seconds (1 day)
|
||||
* ```
|
||||
*
|
||||
* This cache may be stale on clients for 5 minutes before checking with the server.
|
||||
* If the server receives a new request after 1 hour, start revalidating new values in the background.
|
||||
* If this entry has no traffic for 1 day it will expire. The next request will recompute it.
|
||||
*/
|
||||
export function unstable_cacheLife(profile: "hours"): void
|
||||
|
||||
/**
|
||||
* Cache this `"use cache"` for a timespan defined by the `"days"` profile.
|
||||
* ```
|
||||
* stale: 300 seconds (5 minutes)
|
||||
* revalidate: 86400 seconds (1 day)
|
||||
* expire: 604800 seconds (1 week)
|
||||
* ```
|
||||
*
|
||||
* This cache may be stale on clients for 5 minutes before checking with the server.
|
||||
* If the server receives a new request after 1 day, start revalidating new values in the background.
|
||||
* If this entry has no traffic for 1 week it will expire. The next request will recompute it.
|
||||
*/
|
||||
export function unstable_cacheLife(profile: "days"): void
|
||||
|
||||
/**
|
||||
* Cache this `"use cache"` for a timespan defined by the `"weeks"` profile.
|
||||
* ```
|
||||
* stale: 300 seconds (5 minutes)
|
||||
* revalidate: 604800 seconds (1 week)
|
||||
* expire: 2592000 seconds (30 days)
|
||||
* ```
|
||||
*
|
||||
* This cache may be stale on clients for 5 minutes before checking with the server.
|
||||
* If the server receives a new request after 1 week, start revalidating new values in the background.
|
||||
* If this entry has no traffic for 30 days it will expire. The next request will recompute it.
|
||||
*/
|
||||
export function unstable_cacheLife(profile: "weeks"): void
|
||||
|
||||
/**
|
||||
* Cache this `"use cache"` for a timespan defined by the `"max"` profile.
|
||||
* ```
|
||||
* stale: 300 seconds (5 minutes)
|
||||
* revalidate: 2592000 seconds (30 days)
|
||||
* expire: never
|
||||
* ```
|
||||
*
|
||||
* This cache may be stale on clients for 5 minutes before checking with the server.
|
||||
* If the server receives a new request after 30 days, start revalidating new values in the background.
|
||||
* It lives for the maximum age of the server cache. If this entry has no traffic for a while, it may serve an old value the next request.
|
||||
*/
|
||||
export function unstable_cacheLife(profile: "max"): void
|
||||
|
||||
/**
|
||||
* Cache this `"use cache"` using a custom timespan.
|
||||
* ```
|
||||
* stale: ... // seconds
|
||||
* revalidate: ... // seconds
|
||||
* expire: ... // seconds
|
||||
* ```
|
||||
*
|
||||
* This is similar to Cache-Control: max-age=`stale`,s-max-age=`revalidate`,stale-while-revalidate=`expire-revalidate`
|
||||
*
|
||||
* If a value is left out, the lowest of other cacheLife() calls or the default, is used instead.
|
||||
*/
|
||||
export function unstable_cacheLife(profile: {
|
||||
/**
|
||||
* This cache may be stale on clients for ... seconds before checking with the server.
|
||||
*/
|
||||
stale?: number,
|
||||
/**
|
||||
* If the server receives a new request after ... seconds, start revalidating new values in the background.
|
||||
*/
|
||||
revalidate?: number,
|
||||
/**
|
||||
* If this entry has no traffic for ... seconds it will expire. The next request will recompute it.
|
||||
*/
|
||||
expire?: number
|
||||
}): void
|
||||
|
||||
|
||||
export { cacheTag as unstable_cacheTag } from 'next/dist/server/use-cache/cache-tag'
|
||||
}
|
||||
1
.next/types/package.json
Normal file
1
.next/types/package.json
Normal file
@ -0,0 +1 @@
|
||||
{"type": "module"}
|
||||
55
.next/types/routes.d.ts
vendored
Normal file
55
.next/types/routes.d.ts
vendored
Normal file
@ -0,0 +1,55 @@
|
||||
// This file is generated automatically by Next.js
|
||||
// Do not edit this file manually
|
||||
|
||||
type AppRoutes = never
|
||||
type PageRoutes = never
|
||||
type LayoutRoutes = never
|
||||
type RedirectRoutes = never
|
||||
type RewriteRoutes = never
|
||||
type Routes = AppRoutes | PageRoutes | LayoutRoutes | RedirectRoutes | RewriteRoutes
|
||||
|
||||
|
||||
interface ParamMap {
|
||||
}
|
||||
|
||||
|
||||
export type ParamsOf<Route extends Routes> = ParamMap[Route]
|
||||
|
||||
interface LayoutSlotMap {
|
||||
}
|
||||
|
||||
|
||||
export type { AppRoutes, PageRoutes, LayoutRoutes, RedirectRoutes, RewriteRoutes, ParamMap }
|
||||
|
||||
declare global {
|
||||
/**
|
||||
* Props for Next.js App Router page components
|
||||
* @example
|
||||
* ```tsx
|
||||
* export default function Page(props: PageProps<'/blog/[slug]'>) {
|
||||
* const { slug } = await props.params
|
||||
* return <div>Blog post: {slug}</div>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
interface PageProps<AppRoute extends AppRoutes> {
|
||||
params: Promise<ParamMap[AppRoute]>
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for Next.js App Router layout components
|
||||
* @example
|
||||
* ```tsx
|
||||
* export default function Layout(props: LayoutProps<'/dashboard'>) {
|
||||
* return <div>{props.children}</div>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
type LayoutProps<LayoutRoute extends LayoutRoutes> = {
|
||||
params: Promise<ParamMap[LayoutRoute]>
|
||||
children: React.ReactNode
|
||||
} & {
|
||||
[K in LayoutSlotMap[LayoutRoute]]: React.ReactNode
|
||||
}
|
||||
}
|
||||
16
.next/types/validator.ts
Normal file
16
.next/types/validator.ts
Normal file
@ -0,0 +1,16 @@
|
||||
// This file is generated automatically by Next.js
|
||||
// Do not edit this file manually
|
||||
// This file validates that all pages and layouts export the correct types
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,370 +0,0 @@
|
||||
# ✅ Implementação: Opção 2 - Vínculo por Email
|
||||
|
||||
## 🎯 Solução Implementada
|
||||
|
||||
**Vínculo entre Supabase Auth e API Mock através do EMAIL**
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ Supabase Auth │
|
||||
│ (Login/Autenticação) │
|
||||
│ │
|
||||
│ email: "user@email.com"│ ◄─┐
|
||||
│ password: "senha123!" │ │
|
||||
│ userType: "paciente" │ │
|
||||
└──────────────────────────┘ │
|
||||
│ VÍNCULO
|
||||
┌──────────────────────────┐ │ POR EMAIL
|
||||
│ API Mock (Apidog) │ │
|
||||
│ (Dados do Sistema) │ │
|
||||
│ │ │
|
||||
│ email: "user@email.com"│ ◄─┘
|
||||
│ full_name: "João Silva"│
|
||||
│ cpf: "123.456.789-00" │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Código Implementado
|
||||
|
||||
### `lib/api.ts` - Funções de Criação de Usuários
|
||||
|
||||
```typescript
|
||||
import { ENV_CONFIG } from '@/lib/env-config';
|
||||
import { API_KEY } from '@/lib/config';
|
||||
|
||||
// Gera senha aleatória (formato: senhaXXX!)
|
||||
export function gerarSenhaAleatoria(): string {
|
||||
const num1 = Math.floor(Math.random() * 10);
|
||||
const num2 = Math.floor(Math.random() * 10);
|
||||
const num3 = Math.floor(Math.random() * 10);
|
||||
return `senha${num1}${num2}${num3}!`;
|
||||
}
|
||||
|
||||
// Cria usuário MÉDICO no Supabase Auth
|
||||
export async function criarUsuarioMedico(medico: {
|
||||
email: string;
|
||||
full_name: string;
|
||||
phone_mobile: string;
|
||||
}): Promise<CreateUserWithPasswordResponse> {
|
||||
const senha = gerarSenhaAleatoria();
|
||||
|
||||
// Endpoint do Supabase Auth (mesmo que auth.ts usa)
|
||||
const signupUrl = `${ENV_CONFIG.SUPABASE_URL}/auth/v1/signup`;
|
||||
|
||||
const payload = {
|
||||
email: medico.email, // ◄── VÍNCULO!
|
||||
password: senha,
|
||||
data: {
|
||||
userType: 'profissional',
|
||||
full_name: medico.full_name,
|
||||
phone: medico.phone_mobile,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(signupUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
apikey: API_KEY,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Erro ao criar usuário: ${response.status}`);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: responseData.user,
|
||||
email: medico.email,
|
||||
password: senha,
|
||||
};
|
||||
}
|
||||
|
||||
// Cria usuário PACIENTE no Supabase Auth
|
||||
export async function criarUsuarioPaciente(paciente: {
|
||||
email: string;
|
||||
full_name: string;
|
||||
phone_mobile: string;
|
||||
}): Promise<CreateUserWithPasswordResponse> {
|
||||
const senha = gerarSenhaAleatoria();
|
||||
|
||||
const signupUrl = `${ENV_CONFIG.SUPABASE_URL}/auth/v1/signup`;
|
||||
|
||||
const payload = {
|
||||
email: paciente.email, // ◄── VÍNCULO!
|
||||
password: senha,
|
||||
data: {
|
||||
userType: 'paciente',
|
||||
full_name: paciente.full_name,
|
||||
phone: paciente.phone_mobile,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(signupUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
apikey: API_KEY,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Erro ao criar usuário: ${response.status}`);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: responseData.user,
|
||||
email: paciente.email,
|
||||
password: senha,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Fluxo Completo
|
||||
|
||||
### 1️⃣ **Admin Cadastra Paciente**
|
||||
|
||||
```typescript
|
||||
// components/forms/patient-registration-form.tsx
|
||||
|
||||
async function handleSubmit() {
|
||||
// 1. Salva paciente na API Mock
|
||||
const saved = await salvarPaciente({
|
||||
full_name: form.nome,
|
||||
email: form.email, // ◄── EMAIL usado como vínculo
|
||||
cpf: form.cpf,
|
||||
telefone: form.telefone,
|
||||
// ...outros dados
|
||||
});
|
||||
|
||||
// 2. Cria usuário no Supabase Auth com MESMO EMAIL
|
||||
if (mode === 'create' && form.email) {
|
||||
try {
|
||||
const credentials = await criarUsuarioPaciente({
|
||||
email: form.email, // ◄── MESMO EMAIL!
|
||||
full_name: form.nome,
|
||||
phone_mobile: form.telefone,
|
||||
});
|
||||
|
||||
// 3. Mostra popup com credenciais
|
||||
setCredentials(credentials);
|
||||
setShowCredentials(true);
|
||||
} catch (error) {
|
||||
alert('Paciente cadastrado, mas houve erro ao criar usuário de acesso');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2️⃣ **Paciente Faz Login**
|
||||
|
||||
```typescript
|
||||
// Paciente vai em /login-paciente
|
||||
// Digita: email = "jonas@email.com", password = "senha481!"
|
||||
|
||||
// hooks/useAuth.tsx
|
||||
const login = async (email, password, userType) => {
|
||||
// Autentica no Supabase Auth
|
||||
const response = await loginUser(email, password, userType);
|
||||
|
||||
// Token JWT contém o email
|
||||
const token = response.access_token;
|
||||
const decoded = decodeJWT(token);
|
||||
console.log(decoded.email); // "jonas@email.com"
|
||||
|
||||
// Salva sessão
|
||||
localStorage.setItem('token', token);
|
||||
|
||||
// Redireciona para /paciente
|
||||
router.push('/paciente');
|
||||
};
|
||||
```
|
||||
|
||||
### 3️⃣ **Buscar Dados do Paciente na Área Logada**
|
||||
|
||||
```typescript
|
||||
// app/paciente/page.tsx
|
||||
|
||||
export default function PacientePage() {
|
||||
const { user } = useAuth(); // user.email = "jonas@email.com"
|
||||
|
||||
useEffect(() => {
|
||||
async function carregarDados() {
|
||||
// Busca paciente pelo EMAIL (vínculo)
|
||||
const response = await fetch(
|
||||
`https://mock.apidog.com/pacientes?email=${user.email}`
|
||||
);
|
||||
const paciente = await response.json();
|
||||
|
||||
// Agora tem os dados completos do paciente
|
||||
console.log(paciente);
|
||||
// {
|
||||
// id: "123",
|
||||
// full_name: "Jonas Francisco",
|
||||
// email: "jonas@email.com", ◄── VÍNCULO!
|
||||
// cpf: "123.456.789-00",
|
||||
// ...
|
||||
// }
|
||||
}
|
||||
|
||||
carregarDados();
|
||||
}, [user.email]);
|
||||
|
||||
return <div>Bem-vindo, {user.email}!</div>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Estrutura dos Dados
|
||||
|
||||
### **Supabase Auth (`auth.users`)**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"email": "jonas@email.com",
|
||||
"encrypted_password": "$2a$10$...",
|
||||
"created_at": "2025-10-03T00:00:00",
|
||||
"user_metadata": {
|
||||
"userType": "paciente",
|
||||
"full_name": "Jonas Francisco",
|
||||
"phone": "(79) 99649-8907"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **API Mock - Tabela `pacientes`**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "123",
|
||||
"full_name": "Jonas Francisco Nascimento Bonfim",
|
||||
"email": "jonas@email.com",
|
||||
"cpf": "123.456.789-00",
|
||||
"telefone": "(79) 99649-8907",
|
||||
"data_nascimento": "1990-01-15",
|
||||
"endereco": {
|
||||
"cep": "49000-000",
|
||||
"logradouro": "Rua Principal",
|
||||
"cidade": "Aracaju"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Vínculo:** Campo `email` presente em ambos os sistemas!
|
||||
|
||||
---
|
||||
|
||||
## 📊 Vantagens da Opção 2
|
||||
|
||||
✅ **Simples:** Não precisa modificar estrutura da API Mock
|
||||
✅ **Natural:** Email já é único e obrigatório
|
||||
✅ **Sem duplicação:** Usa campo existente
|
||||
✅ **Funcional:** Supabase Auth retorna email no token JWT
|
||||
✅ **Escalável:** Fácil de manter e debugar
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Considerações Importantes
|
||||
|
||||
### **Email DEVE ser único**
|
||||
|
||||
- Cada email só pode ter um usuário no Supabase Auth
|
||||
- Cada email só pode ter um paciente/médico na API Mock
|
||||
- Se tentar cadastrar email duplicado, Supabase retorna erro
|
||||
|
||||
### **Email DEVE ser válido**
|
||||
|
||||
- Supabase valida formato (nome@dominio.com)
|
||||
- Use validação no formulário antes de enviar
|
||||
|
||||
### **Formato da senha**
|
||||
|
||||
- Supabase exige mínimo 6 caracteres
|
||||
- Geramos: `senhaXXX!` (10 caracteres) ✅
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Logs Esperados (Sucesso)
|
||||
|
||||
```
|
||||
🏥 [CRIAR PACIENTE] Iniciando criação no Supabase Auth...
|
||||
📧 Email: jonas@email.com
|
||||
👤 Nome: Jonas Francisco Nascimento Bonfim
|
||||
📱 Telefone: (79) 99649-8907
|
||||
🔑 Senha gerada: senha481!
|
||||
📤 [CRIAR PACIENTE] Enviando para: https://yuanqfswhberkoevtmfr.supabase.co/auth/v1/signup
|
||||
📋 [CRIAR PACIENTE] Status da resposta: 200 OK
|
||||
✅ [CRIAR PACIENTE] Usuário criado com sucesso no Supabase Auth!
|
||||
🆔 User ID: 550e8400-e29b-41d4-a716-446655440000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❌ Possíveis Erros
|
||||
|
||||
### **Erro: "Este email já está cadastrado no sistema"**
|
||||
|
||||
```
|
||||
❌ [CRIAR PACIENTE] Erro: User already registered
|
||||
```
|
||||
|
||||
**Solução:** Email já existe no Supabase. Use outro email ou delete o usuário existente.
|
||||
|
||||
### **Erro: "Formato de email inválido"**
|
||||
|
||||
```
|
||||
❌ [CRIAR PACIENTE] Erro: Invalid email format
|
||||
```
|
||||
|
||||
**Solução:** Valide o formato do email antes de enviar.
|
||||
|
||||
### **Erro: 429 - Too Many Requests**
|
||||
|
||||
```
|
||||
❌ [CRIAR PACIENTE] Erro: 429
|
||||
```
|
||||
|
||||
**Solução:** Aguarde 1 minuto. Supabase limita taxa de requisições.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Resultado Final
|
||||
|
||||
**Agora o sistema funciona assim:**
|
||||
|
||||
1. ✅ Admin cadastra paciente → Salva na API Mock
|
||||
2. ✅ Sistema cria usuário no Supabase Auth (mesmo email)
|
||||
3. ✅ Popup mostra credenciais (email + senha)
|
||||
4. ✅ Paciente faz login em `/login-paciente`
|
||||
5. ✅ Login funciona! Token JWT é gerado
|
||||
6. ✅ Sistema busca dados do paciente pelo email
|
||||
7. ✅ Paciente acessa área `/paciente` com todos os dados
|
||||
|
||||
---
|
||||
|
||||
## 📅 Data da Implementação
|
||||
|
||||
3 de outubro de 2025
|
||||
|
||||
## 🔗 Arquivos Modificados
|
||||
|
||||
- ✅ `susconecta/lib/api.ts` - Funções de criação (Supabase Auth)
|
||||
- ✅ `susconecta/components/forms/patient-registration-form.tsx` - Já integrado
|
||||
- ✅ `susconecta/components/forms/doctor-registration-form.tsx` - Já integrado
|
||||
- ✅ `susconecta/components/credentials-dialog.tsx` - Já implementado
|
||||
@ -1,74 +0,0 @@
|
||||
// eslint.config.js
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import eslint from '@eslint/js';
|
||||
import nextPlugin from '@next/eslint-plugin-next';
|
||||
import unicornPlugin from 'eslint-plugin-unicorn';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
|
||||
export default [
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'],
|
||||
plugins: {
|
||||
'@next/next': nextPlugin,
|
||||
unicorn: unicornPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...nextPlugin.configs.recommended.rules,
|
||||
...nextPlugin.configs['core-web-vitals'].rules,
|
||||
'unicorn/prevent-abbreviations': 'off',
|
||||
'unicorn/no-null': 'off',
|
||||
'unicorn/prefer-string-replace-all': 'off',
|
||||
'unicorn/prefer-string-slice': 'off',
|
||||
'unicorn/prefer-number-properties': 'off',
|
||||
'unicorn/no-array-reduce': 'off',
|
||||
'unicorn/no-array-for-each': 'off',
|
||||
'unicorn/prefer-global-this': 'off',
|
||||
'unicorn/no-useless-undefined': 'off',
|
||||
'unicorn/explicit-length-check': 'off',
|
||||
'unicorn/consistent-existence-index-check': 'off',
|
||||
'unicorn/prefer-ternary': 'off',
|
||||
'unicorn/numeric-separators-style': 'off',
|
||||
'unicorn/filename-case': [
|
||||
'error',
|
||||
{
|
||||
cases: {
|
||||
camelCase: true,
|
||||
pascalCase: true,
|
||||
kebabCase: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
'unicorn/prefer-add-event-listener': 'off',
|
||||
'unicorn/prefer-spread': 'off',
|
||||
'unicorn/consistent-function-scoping': 'off',
|
||||
'unicorn/no-document-cookie': 'off',
|
||||
'unicorn/no-negated-condition': 'off',
|
||||
'unicorn/prefer-code-point': 'off',
|
||||
'unicorn/prefer-single-call': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'prefer-const': 'off',
|
||||
},
|
||||
},
|
||||
prettierConfig,
|
||||
];
|
||||
9430
package-lock.json
generated
9430
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
94
package.json
94
package.json
@ -1,93 +1,9 @@
|
||||
{
|
||||
"name": "my-v0-project",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"dev": "next dev",
|
||||
"lint": "next lint",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fullcalendar/core": "^6.1.19",
|
||||
"@fullcalendar/daygrid": "^6.1.19",
|
||||
"@fullcalendar/interaction": "^6.1.19",
|
||||
"@fullcalendar/react": "^6.1.19",
|
||||
"@fullcalendar/timegrid": "^6.1.19",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@radix-ui/react-accordion": "latest",
|
||||
"@radix-ui/react-alert-dialog": "latest",
|
||||
"@radix-ui/react-aspect-ratio": "latest",
|
||||
"@radix-ui/react-avatar": "latest",
|
||||
"@radix-ui/react-checkbox": "latest",
|
||||
"@radix-ui/react-collapsible": "latest",
|
||||
"@radix-ui/react-context-menu": "latest",
|
||||
"@radix-ui/react-dialog": "latest",
|
||||
"@radix-ui/react-dropdown-menu": "latest",
|
||||
"@radix-ui/react-hover-card": "latest",
|
||||
"@radix-ui/react-label": "latest",
|
||||
"@radix-ui/react-menubar": "latest",
|
||||
"@radix-ui/react-navigation-menu": "latest",
|
||||
"@radix-ui/react-popover": "latest",
|
||||
"@radix-ui/react-progress": "latest",
|
||||
"@radix-ui/react-radio-group": "latest",
|
||||
"@radix-ui/react-scroll-area": "latest",
|
||||
"@radix-ui/react-select": "latest",
|
||||
"@radix-ui/react-separator": "latest",
|
||||
"@radix-ui/react-slider": "latest",
|
||||
"@radix-ui/react-slot": "latest",
|
||||
"@radix-ui/react-switch": "latest",
|
||||
"@radix-ui/react-tabs": "latest",
|
||||
"@radix-ui/react-toast": "latest",
|
||||
"@radix-ui/react-toggle": "latest",
|
||||
"@radix-ui/react-toggle-group": "latest",
|
||||
"@radix-ui/react-tooltip": "latest",
|
||||
"@vercel/analytics": "1.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "latest",
|
||||
"date-fns": "4.1.0",
|
||||
"embla-carousel-react": "latest",
|
||||
"geist": "^1.3.1",
|
||||
"input-otp": "latest",
|
||||
"jspdf": "^3.0.3",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next-themes": "latest",
|
||||
"react": "^18",
|
||||
"react-day-picker": "latest",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "latest",
|
||||
"react-quill": "^2.0.0",
|
||||
"react-resizable-panels": "latest",
|
||||
"react-signature-canvas": "^1.1.0-alpha.2",
|
||||
"recharts": "latest",
|
||||
"sonner": "latest",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "latest",
|
||||
"zod": "3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@next/eslint-plugin-next": "^15.5.4",
|
||||
"@tailwindcss/postcss": "^4.1.9",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@typescript-eslint/eslint-plugin": "^8.45.0",
|
||||
"@typescript-eslint/parser": "^8.45.0",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-config-next": "^15.5.4",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-unicorn": "^61.0.2",
|
||||
"globals": "^16.4.0",
|
||||
"next": "^15.5.4",
|
||||
"postcss": "^8.5",
|
||||
"tailwindcss": "^4.1.9",
|
||||
"tw-animate-css": "1.3.3",
|
||||
"typescript": "^5",
|
||||
"typescript-eslint": "^8.45.0"
|
||||
"@headlessui/react": "^2.2.7",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-signature-canvas": "^1.1.0-alpha.2"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,88 +0,0 @@
|
||||
"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 bg-background min-h-screen">
|
||||
<h1 className="text-2xl font-bold mb-6 text-foreground">Relatórios</h1>
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Card Consultas */}
|
||||
<div className="p-4 border border-border rounded-lg shadow bg-card">
|
||||
<h2 className="font-semibold text-lg text-foreground">Relatório de Consultas</h2>
|
||||
<p className="text-sm text-muted-foreground">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 border-border rounded-lg shadow bg-card">
|
||||
<h2 className="font-semibold text-lg text-foreground">Relatório de Pacientes</h2>
|
||||
<p className="text-sm text-muted-foreground">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 border-border rounded-lg shadow col-span-3 md:col-span-3 bg-card">
|
||||
<h2 className="font-semibold text-lg mb-2 text-foreground">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>
|
||||
);
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
'use client'
|
||||
import type { ReactNode } from 'react'
|
||||
import ProtectedRoute from '@/components/layout/ProtectedRoute'
|
||||
|
||||
export default function PacienteLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ProtectedRoute requiredUserType={['paciente']}>
|
||||
{children}
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
@ -1,92 +0,0 @@
|
||||
'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'
|
||||
|
||||
export default function PacientePage() {
|
||||
const { logout, user } = useAuth()
|
||||
|
||||
const handleLogout = async () => {
|
||||
console.log('[PACIENTE] Iniciando logout...')
|
||||
await logout()
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
'use client'
|
||||
import type { ReactNode } from 'react'
|
||||
import ProtectedRoute from '@/components/layout/ProtectedRoute'
|
||||
|
||||
export default function ProfissionalLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ProtectedRoute requiredUserType={['profissional']}>
|
||||
{children}
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,206 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState, type ChangeEvent, type FormEvent } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useAuth } from '@/features/autenticacao/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'
|
||||
import { AUTH_STORAGE_KEYS } from '@/features/autenticacao/types'
|
||||
|
||||
type UserRole = 'profissional' | 'paciente' | 'administrador'
|
||||
|
||||
const USER_ROLES: readonly UserRole[] = ['profissional', 'paciente', 'administrador'] as const
|
||||
|
||||
const roleConfig: Record<UserRole, {
|
||||
title: string
|
||||
subtitle: string
|
||||
redirect: string
|
||||
cta: string
|
||||
}> = {
|
||||
profissional: {
|
||||
title: 'Login Profissional de Saúde',
|
||||
subtitle: 'Entre com suas credenciais para acessar o sistema clínico',
|
||||
redirect: '/profissional',
|
||||
cta: 'Entrar como Profissional'
|
||||
},
|
||||
paciente: {
|
||||
title: 'Sou Paciente',
|
||||
subtitle: 'Acesse sua área pessoal e gerencie suas consultas',
|
||||
redirect: '/paciente',
|
||||
cta: 'Entrar na Minha Área'
|
||||
},
|
||||
administrador: {
|
||||
title: 'Login Administrativo',
|
||||
subtitle: 'Gerencie agendas, pacientes e finanças da clínica',
|
||||
redirect: '/dashboard',
|
||||
cta: 'Entrar no Painel Administrativo'
|
||||
}
|
||||
}
|
||||
|
||||
const isUserRole = (value: string | null): value is UserRole => {
|
||||
if (!value) return false
|
||||
return USER_ROLES.includes(value as UserRole)
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [credentials, setCredentials] = useState({ email: '', password: '' })
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedRole, setSelectedRole] = useState<UserRole>('profissional')
|
||||
const { login } = useAuth()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
const roleParam = searchParams.get('role')
|
||||
if (isUserRole(roleParam)) {
|
||||
setSelectedRole(roleParam)
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(AUTH_STORAGE_KEYS.USER_TYPE, roleParam)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const storedRole = localStorage.getItem(AUTH_STORAGE_KEYS.USER_TYPE)
|
||||
if (isUserRole(storedRole)) {
|
||||
setSelectedRole(storedRole)
|
||||
}
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const { title, subtitle, redirect, cta } = useMemo(() => roleConfig[selectedRole], [selectedRole])
|
||||
|
||||
const handleSelectRole = (role: UserRole) => {
|
||||
setSelectedRole(role)
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(AUTH_STORAGE_KEYS.USER_TYPE, role)
|
||||
}
|
||||
router.replace(`/login?role=${role}`, { scroll: false })
|
||||
}
|
||||
|
||||
const handleLogin = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const success = await login(credentials.email, credentials.password, selectedRole)
|
||||
if (success) {
|
||||
router.push(redirect)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
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-background py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center">Escolha como deseja entrar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-6">
|
||||
{USER_ROLES.map((role) => {
|
||||
const isActive = role === selectedRole
|
||||
return (
|
||||
<Button
|
||||
key={role}
|
||||
type="button"
|
||||
variant={isActive ? 'default' : 'outline'}
|
||||
className={`w-full text-sm h-auto py-3 px-4 transition-all ${isActive ? 'shadow-md' : 'hover:bg-primary/5'}`}
|
||||
onClick={() => handleSelectRole(role)}
|
||||
disabled={loading}
|
||||
>
|
||||
{roleConfig[role].title}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Digite seu email"
|
||||
value={credentials.email}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setCredentials({ ...credentials, email: event.target.value })
|
||||
}
|
||||
required
|
||||
className="mt-1"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
Senha
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Digite sua senha"
|
||||
value={credentials.password}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setCredentials({ ...credentials, password: event.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...' : cta}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
asChild
|
||||
className="w-full hover:!bg-primary hover:!text-white hover:!border-primary transition-all duration-200"
|
||||
>
|
||||
<Link href="/">
|
||||
Voltar ao Início
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,181 +0,0 @@
|
||||
import httpClient from "@/lib/http";
|
||||
import { API_KEY } from "@/lib/config"; // Necessário para algumas chamadas de auth
|
||||
import { ENV_CONFIG } from "@/lib/env-config"; // Configuração de ambiente completa
|
||||
import { API_KEY } from "@/lib/config"; // Necessário para algumas chamadas de auth
|
||||
|
||||
// ===== TIPOS DE AUTENTICAÇÃO E PERFIL =====
|
||||
export type Profile = {
|
||||
id: string;
|
||||
full_name: string;
|
||||
email: string;
|
||||
// Adicione outros campos do perfil aqui conforme necessário
|
||||
};
|
||||
|
||||
export type CreateUserWithPasswordResponse = {
|
||||
success: boolean;
|
||||
user: any; // Objeto do usuário retornado pelo Supabase
|
||||
email: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export type UserData = {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name?: string;
|
||||
phone_mobile?: string;
|
||||
// ... outros campos do usuário
|
||||
};
|
||||
|
||||
export type LoginResponse = {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
user: UserData;
|
||||
};
|
||||
|
||||
export type RefreshTokenResponse = {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
};
|
||||
|
||||
// ===== PERFIS DE USUÁRIOS =====
|
||||
export async function listarPerfis(): Promise<Profile[]> {
|
||||
// O endpoint original usava um mock.apidog.com. Vamos assumir que o endpoint real seria /rest/v1/profiles
|
||||
// ou que o httpClient já está configurado para o Supabase.
|
||||
const url = `/rest/v1/profiles`; // Usando caminho relativo para o httpClient
|
||||
const res = await httpClient.get(url);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Erro ao listar perfis: ${res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ===== CRIAÇÃO DE USUÁRIOS (PACIENTE/MÉDICO) =====
|
||||
// Assumindo que o Supabase Auth tem um endpoint de signup ou que a criação é feita via API de admin
|
||||
// Este é um placeholder. A implementação exata pode variar.
|
||||
export async function criarUsuarioPaciente(input: {
|
||||
email: string;
|
||||
full_name: string;
|
||||
phone_mobile?: string;
|
||||
}): Promise<CreateUserWithPasswordResponse> {
|
||||
// Endpoint de signup do Supabase Auth
|
||||
const url = AUTH_ENDPOINTS.SIGNUP || `/auth/v1/signup`; // Usar AUTH_ENDPOINTS se definido, senão um padrão
|
||||
const res = await httpClient.post(url, {
|
||||
email: input.email,
|
||||
password: Math.random().toString(36).slice(-8), // Gerar senha temporária
|
||||
data: { full_name: input.full_name, phone_mobile: input.phone_mobile, role: 'paciente' },
|
||||
}, {
|
||||
headers: {
|
||||
'apikey': API_KEY, // API Key é necessária para signup direto no Supabase Auth
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(`Erro ao criar usuário paciente: ${error.message}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return { user_id: data.user.id, email: data.user.email, password: data.password };
|
||||
}
|
||||
|
||||
export async function criarUsuarioMedico(input: {
|
||||
email: string;
|
||||
full_name: string;
|
||||
phone_mobile?: string;
|
||||
}): Promise<CreateUserWithPasswordResponse> {
|
||||
const url = AUTH_ENDPOINTS.SIGNUP || `/auth/v1/signup`;
|
||||
const res = await httpClient.post(url, {
|
||||
email: input.email,
|
||||
password: Math.random().toString(36).slice(-8), // Gerar senha temporária
|
||||
data: { full_name: input.full_name, phone_mobile: input.phone_mobile, role: 'medico' },
|
||||
}, {
|
||||
headers: {
|
||||
'apikey': API_KEY,
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(`Erro ao criar usuário médico: ${error.message}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return { user_id: data.user.id, email: data.user.email, password: data.password };
|
||||
}
|
||||
|
||||
// ===== FUNÇÕES DE AUTENTICAÇÃO PRINCIPAIS (MOVIDAS DE auth.ts) =====
|
||||
// Estas funções serão movidas de src/lib/auth.ts para cá
|
||||
|
||||
export async function loginUser(
|
||||
email: string,
|
||||
password: string,
|
||||
userType: 'profissional' | 'paciente' | 'administrador'
|
||||
): Promise<LoginResponse> {
|
||||
const url = AUTH_ENDPOINTS.LOGIN;
|
||||
const payload = { email, password };
|
||||
|
||||
const res = await httpClient.post(url, payload, {
|
||||
headers: {
|
||||
'apikey': API_KEY,
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(`Erro no login: ${error.message}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return { access_token: data.access_token, refresh_token: data.refresh_token, user: data.user };
|
||||
}
|
||||
|
||||
export async function logoutUser(token: string): Promise<void> {
|
||||
const url = AUTH_ENDPOINTS.LOGOUT;
|
||||
const res = await httpClient.post(url, null, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'apikey': API_KEY,
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(`Erro no logout: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshAuthToken(refreshToken: string): Promise<RefreshTokenResponse> {
|
||||
const url = AUTH_ENDPOINTS.REFRESH;
|
||||
const res = await httpClient.post(url, { refresh_token: refreshToken }, {
|
||||
headers: {
|
||||
'apikey': API_KEY,
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(`Erro ao renovar token: ${error.message}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return { access_token: data.access_token, refresh_token: data.refresh_token };
|
||||
}
|
||||
|
||||
export async function getCurrentUser(token: string): Promise<UserData> {
|
||||
const url = AUTH_ENDPOINTS.USER;
|
||||
const res = await httpClient.get(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'apikey': API_KEY,
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(`Erro ao obter usuário atual: ${error.message}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return data.user; // Assumindo que a resposta tem um objeto 'user'
|
||||
}
|
||||
@ -1,179 +0,0 @@
|
||||
// src/features/pacientes/api/index.ts
|
||||
import httpClient from "@/lib/http";
|
||||
import type { Paciente, PacienteInput } from "@/features/pacientes/types";
|
||||
|
||||
// A constante `REST` foi removida para usar a baseURL do httpClient.
|
||||
|
||||
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);
|
||||
|
||||
// Utiliza caminho relativo, o httpClient adicionará a base.
|
||||
const url = `/rest/v1/patients${qs.toString() ? `?${qs.toString()}` : ""}`;
|
||||
const res = await httpClient.get(url, {
|
||||
headers: {
|
||||
...rangeHeaders(params?.page, params?.limit),
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Erro ao listar pacientes: ${res.statusText}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function buscarPacientes(termo: string): Promise<Paciente[]> {
|
||||
if (!termo || termo.trim().length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchTerm = termo.toLowerCase().trim();
|
||||
const digitsOnly = searchTerm.replace(/\D/g, "");
|
||||
|
||||
const queries = [];
|
||||
if (searchTerm.includes("-") && searchTerm.length > 10) {
|
||||
queries.push(`id=eq.${searchTerm}`);
|
||||
}
|
||||
if (digitsOnly.length >= 11) {
|
||||
queries.push(`cpf=eq.${digitsOnly}`);
|
||||
} else if (digitsOnly.length >= 3) {
|
||||
queries.push(`cpf=ilike.*${digitsOnly}*`);
|
||||
}
|
||||
if (searchTerm.length >= 2) {
|
||||
queries.push(`full_name=ilike.*${searchTerm}*`);
|
||||
queries.push(`social_name=ilike.*${searchTerm}*`);
|
||||
}
|
||||
if (searchTerm.includes("@")) {
|
||||
queries.push(`email=ilike.*${searchTerm}*`);
|
||||
}
|
||||
|
||||
const results: Paciente[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const query of queries) {
|
||||
try {
|
||||
const url = `/rest/v1/patients?${query}&limit=10`;
|
||||
const res = await httpClient.get(url);
|
||||
const arr: Paciente[] = await res.json();
|
||||
|
||||
if (arr?.length > 0) {
|
||||
for (const paciente of arr) {
|
||||
if (!seenIds.has(paciente.id)) {
|
||||
seenIds.add(paciente.id);
|
||||
results.push(paciente);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Erro na busca com query: ${query}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return results.slice(0, 20);
|
||||
}
|
||||
|
||||
export async function buscarPacientePorId(id: string | number): Promise<Paciente> {
|
||||
const url = `/rest/v1/patients?id=eq.${id}&limit=1`;
|
||||
const res = await httpClient.get(url);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("404: Paciente não encontrado");
|
||||
}
|
||||
|
||||
const arr: Paciente[] = await res.json();
|
||||
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/v1/patients`;
|
||||
const res = await httpClient.post(url, input, {
|
||||
headers: {
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(`Erro ao criar paciente: ${error.message}`);
|
||||
}
|
||||
|
||||
const arr: Paciente[] = await res.json();
|
||||
return arr[0];
|
||||
}
|
||||
|
||||
export async function atualizarPaciente(
|
||||
id: string | number,
|
||||
input: Partial<PacienteInput>
|
||||
): Promise<Paciente> {
|
||||
const url = `/rest/v1/patients?id=eq.${id}`;
|
||||
const res = await httpClient.patch(url, input, {
|
||||
headers: {
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(`Erro ao atualizar paciente: ${error.message}`);
|
||||
}
|
||||
|
||||
const arr: Paciente[] = await res.json();
|
||||
return arr[0];
|
||||
}
|
||||
|
||||
export async function excluirPaciente(id: string | number): Promise<void> {
|
||||
const url = `/rest/v1/patients?id=eq.${id}`;
|
||||
const res = await httpClient.delete(url);
|
||||
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const error = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(`Erro ao excluir paciente: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verificarCpfDuplicado(cpf: string): Promise<boolean> {
|
||||
const clean = (cpf || "").replace(/\D/g, "");
|
||||
if (!clean) return false;
|
||||
|
||||
const url = `/rest/v1/patients?cpf=eq.${clean}&select=id`;
|
||||
const res = await httpClient.get(url);
|
||||
|
||||
if (!res.ok) {
|
||||
console.error("Erro ao verificar CPF:", res.statusText);
|
||||
return false;
|
||||
}
|
||||
|
||||
const data: any[] = await res.json().catch(() => []);
|
||||
return Array.isArray(data) && data.length > 0;
|
||||
}
|
||||
|
||||
// Funções "stub" para pacientes
|
||||
export const uploadFotoPacienteAPI = async (
|
||||
pacienteId: string,
|
||||
foto: File
|
||||
) => {
|
||||
console.log(`Upload da foto do paciente ${pacienteId}:`, foto.name);
|
||||
return { success: true, message: "Foto do paciente enviada com sucesso." };
|
||||
};
|
||||
|
||||
export const uploadAnexoPacienteAPI = async (
|
||||
pacienteId: string,
|
||||
anexo: File
|
||||
) => {
|
||||
console.log(`Upload do anexo do paciente ${pacienteId}:`, anexo.name);
|
||||
return { success: true, message: "Anexo do paciente enviado com sucesso." };
|
||||
};
|
||||
@ -1,41 +0,0 @@
|
||||
// src/features/pacientes/types/index.ts
|
||||
|
||||
// ===== PACIENTES =====
|
||||
export type Paciente = {
|
||||
id: string;
|
||||
full_name: string;
|
||||
social_name?: string | null;
|
||||
cpf?: string;
|
||||
rg?: string | null;
|
||||
sex?: string | null;
|
||||
birth_date?: string | null;
|
||||
phone_mobile?: string;
|
||||
email?: string;
|
||||
cep?: string | null;
|
||||
street?: string | null;
|
||||
number?: string | null;
|
||||
complement?: string | null;
|
||||
neighborhood?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export type PacienteInput = {
|
||||
full_name: string;
|
||||
social_name?: string | null;
|
||||
cpf: string;
|
||||
rg?: string | null;
|
||||
sex?: string | null;
|
||||
birth_date?: string | null;
|
||||
phone_mobile?: string | null;
|
||||
email?: string | null;
|
||||
cep?: string | null;
|
||||
street?: string | null;
|
||||
number?: string | null;
|
||||
complement?: string | null;
|
||||
neighborhood?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
@ -1,168 +0,0 @@
|
||||
// src/features/profissionais/api/index.ts
|
||||
import httpClient from "@/lib/http";
|
||||
import type { Medico, MedicoInput } from "@/features/profissionais/types";
|
||||
|
||||
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" };
|
||||
}
|
||||
|
||||
// ===== 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/v1/doctors${qs.toString() ? `?${qs.toString()}` : ""}`;
|
||||
const res = await httpClient.get(url, {
|
||||
headers: {
|
||||
...rangeHeaders(params?.page, params?.limit),
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Erro ao listar médicos: ${res.statusText}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function buscarMedicos(termo: string): Promise<Medico[]> {
|
||||
if (!termo || termo.trim().length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchTerm = termo.toLowerCase().trim();
|
||||
const digitsOnly = searchTerm.replace(/\D/g, '');
|
||||
|
||||
const queries = [];
|
||||
|
||||
if (searchTerm.includes('-') && searchTerm.length > 10) {
|
||||
queries.push(`id=eq.${searchTerm}`);
|
||||
}
|
||||
if (digitsOnly.length >= 3) {
|
||||
queries.push(`crm=ilike.*${digitsOnly}*`);
|
||||
}
|
||||
if (searchTerm.length >= 2) {
|
||||
queries.push(`full_name=ilike.*${searchTerm}*`);
|
||||
queries.push(`nome_social=ilike.*${searchTerm}*`);
|
||||
}
|
||||
if (searchTerm.includes('@')) {
|
||||
queries.push(`email=ilike.*${searchTerm}*`);
|
||||
}
|
||||
if (searchTerm.length >= 2) {
|
||||
queries.push(`specialty=ilike.*${searchTerm}*`);
|
||||
}
|
||||
|
||||
const results: Medico[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const query of queries) {
|
||||
try {
|
||||
const url = `/rest/v1/doctors?${query}&limit=10`;
|
||||
const res = await httpClient.get(url);
|
||||
const arr: Medico[] = await res.json();
|
||||
|
||||
if (arr?.length > 0) {
|
||||
for (const medico of arr) {
|
||||
if (!seenIds.has(medico.id)) {
|
||||
seenIds.add(medico.id);
|
||||
results.push(medico);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Erro na busca com query: ${query}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return results.slice(0, 20);
|
||||
}
|
||||
|
||||
export async function buscarMedicoPorId(id: string | number): Promise<Medico> {
|
||||
const url = `/rest/v1/doctors?id=eq.${id}&limit=1`;
|
||||
const res = await httpClient.get(url);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("404: Médico não encontrado");
|
||||
}
|
||||
|
||||
const arr: Medico[] = await res.json();
|
||||
if (!arr?.length) throw new Error("404: Médico não encontrado");
|
||||
return arr[0];
|
||||
}
|
||||
|
||||
export async function criarMedico(input: MedicoInput): Promise<Medico> {
|
||||
const url = `/rest/v1/doctors`;
|
||||
const res = await httpClient.post(url, input, {
|
||||
headers: {
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(`Erro ao criar médico: ${error.message}`);
|
||||
}
|
||||
|
||||
const arr: Medico[] = await res.json();
|
||||
return arr[0];
|
||||
}
|
||||
|
||||
export async function atualizarMedico(
|
||||
id: string | number,
|
||||
input: Partial<MedicoInput>
|
||||
): Promise<Medico> {
|
||||
const url = `/rest/v1/doctors?id=eq.${id}`;
|
||||
const res = await httpClient.patch(url, input, {
|
||||
headers: {
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(`Erro ao atualizar médico: ${error.message}`);
|
||||
}
|
||||
|
||||
const arr: Medico[] = await res.json();
|
||||
return arr[0];
|
||||
}
|
||||
|
||||
export async function excluirMedico(id: string | number): Promise<void> {
|
||||
const url = `/rest/v1/doctors?id=eq.${id}`;
|
||||
const res = await httpClient.delete(url);
|
||||
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const error = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(`Erro ao excluir médico: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Funções "stub" para profissionais
|
||||
export const uploadFotoProfissionalAPI = async (
|
||||
profissionalId: string,
|
||||
foto: File
|
||||
) => {
|
||||
console.log(`Upload da foto do profissional ${profissionalId}:`, foto.name);
|
||||
return {
|
||||
success: true,
|
||||
message: "Foto do profissional enviada com sucesso.",
|
||||
};
|
||||
};
|
||||
|
||||
export const uploadAnexoProfissionalAPI = async (
|
||||
profissionalId: string,
|
||||
anexo: File
|
||||
) => {
|
||||
console.log(`Upload do anexo do profissional ${profissionalId}:`, anexo.name);
|
||||
return {
|
||||
success: true,
|
||||
message: "Anexo do profissional enviado com sucesso.",
|
||||
};
|
||||
};
|
||||
@ -1,78 +0,0 @@
|
||||
// src/features/profissionais/types/index.ts
|
||||
|
||||
export type FormacaoAcademica = {
|
||||
instituicao: string;
|
||||
curso: string;
|
||||
ano_conclusao: string;
|
||||
};
|
||||
|
||||
export type DadosBancarios = {
|
||||
banco: string;
|
||||
agencia: string;
|
||||
conta: string;
|
||||
tipo_conta: string;
|
||||
};
|
||||
|
||||
export type Medico = {
|
||||
id: string;
|
||||
full_name: string;
|
||||
nome_social?: string | null;
|
||||
cpf?: string;
|
||||
rg?: string | null;
|
||||
sexo?: string | null;
|
||||
data_nascimento?: string | null;
|
||||
telefone?: string;
|
||||
celular?: string;
|
||||
contato_emergencia?: string;
|
||||
email?: string;
|
||||
crm?: string;
|
||||
estado_crm?: string;
|
||||
rqe?: string;
|
||||
formacao_academica?: FormacaoAcademica[];
|
||||
curriculo_url?: string | null;
|
||||
especialidade?: string;
|
||||
observacoes?: string | null;
|
||||
foto_url?: string | null;
|
||||
tipo_vinculo?: string;
|
||||
dados_bancarios?: DadosBancarios;
|
||||
agenda_horario?: string;
|
||||
valor_consulta?: number | string;
|
||||
active?: boolean;
|
||||
cep?: string;
|
||||
city?: string;
|
||||
complement?: string;
|
||||
neighborhood?: string;
|
||||
number?: string;
|
||||
phone2?: string;
|
||||
state?: string;
|
||||
street?: string;
|
||||
created_at?: string;
|
||||
created_by?: string;
|
||||
updated_at?: string;
|
||||
updated_by?: string;
|
||||
user_id?: string;
|
||||
};
|
||||
|
||||
export type MedicoInput = {
|
||||
user_id?: string | null;
|
||||
crm: string;
|
||||
crm_uf: string;
|
||||
specialty: string;
|
||||
full_name: string;
|
||||
cpf: string;
|
||||
email: string;
|
||||
phone_mobile: string;
|
||||
phone2?: string | null;
|
||||
cep: string;
|
||||
street: string;
|
||||
number: string;
|
||||
complement?: string;
|
||||
neighborhood?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
birth_date: string | null;
|
||||
rg?: string | null;
|
||||
active?: boolean;
|
||||
created_by?: string | null;
|
||||
updated_by?: string | null;
|
||||
};
|
||||
@ -1,24 +0,0 @@
|
||||
// src/types/api.ts
|
||||
|
||||
export type ApiOk<T = any> = {
|
||||
success?: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
pagination?: {
|
||||
current_page?: number;
|
||||
per_page?: number;
|
||||
total_pages?: number;
|
||||
total?: number;
|
||||
};
|
||||
};
|
||||
|
||||
// ===== TIPOS COMUNS =====
|
||||
export type Endereco = {
|
||||
cep?: string;
|
||||
logradouro?: string;
|
||||
numero?: string;
|
||||
complemento?: string;
|
||||
bairro?: string;
|
||||
cidade?: string;
|
||||
estado?: string;
|
||||
};
|
||||
29
susconecta/.gitignore
vendored
Normal file
29
susconecta/.gitignore
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.tsriseup-squad20/
|
||||
susconecta/riseup-squad20/
|
||||
riseup-squad20/
|
||||
@ -8,6 +8,8 @@ import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||
import { EventInput } from "@fullcalendar/core/index.js";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { PagesHeader } from "@/components/dashboard/header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
mockAppointments,
|
||||
@ -54,7 +54,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
|
||||
import { mockAppointments, mockProfessionals } from "@/lib/mocks/appointment-mocks";
|
||||
import { CalendarRegistrationForm } from "@/features/agendamento/components/forms/calendar-registration-form";
|
||||
import { CalendarRegistrationForm } from "@/components/forms/calendar-registration-form";
|
||||
|
||||
|
||||
const formatDate = (date: string | Date) => {
|
||||
265
susconecta/app/(main-routes)/dashboard/relatorios/page.tsx
Normal file
265
susconecta/app/(main-routes)/dashboard/relatorios/page.tsx
Normal file
@ -0,0 +1,265 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileDown, BarChart2, Users, DollarSign, TrendingUp, UserCheck, CalendarCheck, ThumbsUp, User, Briefcase } from "lucide-react";
|
||||
import jsPDF from "jspdf";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, LineChart, Line, PieChart, Pie, Cell } from "recharts";
|
||||
|
||||
// Dados fictícios para demonstração
|
||||
const metricas = [
|
||||
{ label: "Atendimentos", value: 1240, icon: <CalendarCheck className="w-6 h-6 text-blue-500" /> },
|
||||
{ label: "Absenteísmo", value: "7,2%", icon: <UserCheck className="w-6 h-6 text-red-500" /> },
|
||||
{ label: "Satisfação", value: "92%", icon: <ThumbsUp className="w-6 h-6 text-green-500" /> },
|
||||
{ label: "Faturamento (Mês)", value: "R$ 45.000", icon: <DollarSign className="w-6 h-6 text-emerald-500" /> },
|
||||
{ label: "No-show", value: "5,1%", icon: <User className="w-6 h-6 text-yellow-500" /> },
|
||||
];
|
||||
|
||||
const consultasPorPeriodo = [
|
||||
{ periodo: "Jan", consultas: 210 },
|
||||
{ periodo: "Fev", consultas: 180 },
|
||||
{ periodo: "Mar", consultas: 250 },
|
||||
{ periodo: "Abr", consultas: 230 },
|
||||
{ periodo: "Mai", consultas: 270 },
|
||||
{ periodo: "Jun", consultas: 220 },
|
||||
];
|
||||
|
||||
const faturamentoMensal = [
|
||||
{ mes: "Jan", valor: 35000 },
|
||||
{ mes: "Fev", valor: 29000 },
|
||||
{ mes: "Mar", valor: 42000 },
|
||||
{ mes: "Abr", valor: 38000 },
|
||||
{ mes: "Mai", valor: 45000 },
|
||||
{ mes: "Jun", valor: 41000 },
|
||||
];
|
||||
|
||||
const taxaNoShow = [
|
||||
{ mes: "Jan", noShow: 6.2 },
|
||||
{ mes: "Fev", noShow: 5.8 },
|
||||
{ mes: "Mar", noShow: 4.9 },
|
||||
{ mes: "Abr", noShow: 5.5 },
|
||||
{ mes: "Mai", noShow: 5.1 },
|
||||
{ mes: "Jun", noShow: 4.7 },
|
||||
];
|
||||
|
||||
const pacientesMaisAtendidos = [
|
||||
{ nome: "Ana Souza", consultas: 18 },
|
||||
{ nome: "Bruno Lima", consultas: 15 },
|
||||
{ nome: "Carla Menezes", consultas: 13 },
|
||||
{ nome: "Diego Alves", consultas: 12 },
|
||||
{ nome: "Fernanda Dias", consultas: 11 },
|
||||
];
|
||||
|
||||
const medicosMaisProdutivos = [
|
||||
{ nome: "Dr. Carlos Andrade", consultas: 62 },
|
||||
{ nome: "Dra. Paula Silva", consultas: 58 },
|
||||
{ nome: "Dr. João Pedro", consultas: 54 },
|
||||
{ nome: "Dra. Marina Costa", consultas: 51 },
|
||||
];
|
||||
|
||||
const convenios = [
|
||||
{ nome: "Unimed", valor: 18000 },
|
||||
{ nome: "Bradesco", valor: 12000 },
|
||||
{ nome: "SulAmérica", valor: 9000 },
|
||||
{ nome: "Particular", valor: 15000 },
|
||||
];
|
||||
|
||||
const performancePorMedico = [
|
||||
{ nome: "Dr. Carlos Andrade", consultas: 62, absenteismo: 4.8 },
|
||||
{ nome: "Dra. Paula Silva", consultas: 58, absenteismo: 6.1 },
|
||||
{ nome: "Dr. João Pedro", consultas: 54, absenteismo: 7.5 },
|
||||
{ nome: "Dra. Marina Costa", consultas: 51, absenteismo: 5.2 },
|
||||
];
|
||||
|
||||
const COLORS = ["#10b981", "#6366f1", "#f59e42", "#ef4444"];
|
||||
|
||||
function exportPDF(title: string, content: string) {
|
||||
const doc = new jsPDF();
|
||||
doc.text(title, 10, 10);
|
||||
doc.text(content, 10, 20);
|
||||
doc.save(`${title.toLowerCase().replace(/ /g, '-')}.pdf`);
|
||||
}
|
||||
|
||||
export default function RelatoriosPage() {
|
||||
return (
|
||||
<div className="p-6 bg-background min-h-screen">
|
||||
<h1 className="text-2xl font-bold mb-6 text-foreground">Dashboard Executivo de Relatórios</h1>
|
||||
|
||||
{/* Métricas principais */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-6 mb-8">
|
||||
{metricas.map((m) => (
|
||||
<div key={m.label} className="p-4 bg-card border border-border rounded-lg shadow flex flex-col items-center justify-center">
|
||||
{m.icon}
|
||||
<span className="text-2xl font-bold mt-2 text-foreground">{m.value}</span>
|
||||
<span className="text-sm text-muted-foreground mt-1 text-center">{m.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Gráficos e Relatórios */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Consultas realizadas por período */}
|
||||
<div className="bg-card border border-border rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><BarChart2 className="w-5 h-5" /> Consultas por Período</h2>
|
||||
<Button size="sm" variant="outline" onClick={() => exportPDF("Consultas por Período", "Resumo das consultas realizadas por período.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={consultasPorPeriodo}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="periodo" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="consultas" fill="#6366f1" name="Consultas" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Faturamento mensal/anual */}
|
||||
<div className="bg-card border border-border rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><DollarSign className="w-5 h-5" /> Faturamento Mensal</h2>
|
||||
<Button size="sm" variant="outline" onClick={() => exportPDF("Faturamento Mensal", "Resumo do faturamento mensal.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={faturamentoMensal}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="mes" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="valor" stroke="#10b981" name="Faturamento" strokeWidth={3} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Taxa de no-show */}
|
||||
<div className="bg-card border border-border rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><UserCheck className="w-5 h-5" /> Taxa de No-show</h2>
|
||||
<Button size="sm" variant="outline" onClick={() => exportPDF("Taxa de No-show", "Resumo da taxa de no-show.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={taxaNoShow}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="mes" />
|
||||
<YAxis unit="%" />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="noShow" stroke="#ef4444" name="No-show (%)" strokeWidth={3} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Indicadores de satisfação */}
|
||||
<div className="bg-card border border-border rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><ThumbsUp className="w-5 h-5" /> Satisfação dos Pacientes</h2>
|
||||
<Button size="sm" variant="outline" onClick={() => exportPDF("Satisfação dos Pacientes", "Resumo dos indicadores de satisfação.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center h-[220px]">
|
||||
<span className="text-5xl font-bold text-green-500">92%</span>
|
||||
<span className="text-muted-foreground mt-2">Índice de satisfação geral</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Pacientes mais atendidos */}
|
||||
<div className="bg-card border border-border rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><Users className="w-5 h-5" /> Pacientes Mais Atendidos</h2>
|
||||
<Button size="sm" variant="outline" onClick={() => exportPDF("Pacientes Mais Atendidos", "Lista dos pacientes mais atendidos.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
</div>
|
||||
<table className="w-full text-sm mt-4">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground">
|
||||
<th className="text-left font-medium">Paciente</th>
|
||||
<th className="text-left font-medium">Consultas</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pacientesMaisAtendidos.map((p) => (
|
||||
<tr key={p.nome}>
|
||||
<td className="py-1">{p.nome}</td>
|
||||
<td className="py-1">{p.consultas}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Médicos mais produtivos */}
|
||||
<div className="bg-card border border-border rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><Briefcase className="w-5 h-5" /> Médicos Mais Produtivos</h2>
|
||||
<Button size="sm" variant="outline" onClick={() => exportPDF("Médicos Mais Produtivos", "Lista dos médicos mais produtivos.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
</div>
|
||||
<table className="w-full text-sm mt-4">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground">
|
||||
<th className="text-left font-medium">Médico</th>
|
||||
<th className="text-left font-medium">Consultas</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{medicosMaisProdutivos.map((m) => (
|
||||
<tr key={m.nome}>
|
||||
<td className="py-1">{m.nome}</td>
|
||||
<td className="py-1">{m.consultas}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Análise de convênios */}
|
||||
<div className="bg-card border border-border rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><DollarSign className="w-5 h-5" /> Análise de Convênios</h2>
|
||||
<Button size="sm" variant="outline" onClick={() => exportPDF("Análise de Convênios", "Resumo da análise de convênios.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={convenios} dataKey="valor" nameKey="nome" cx="50%" cy="50%" outerRadius={80} label>
|
||||
{convenios.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Performance por médico */}
|
||||
<div className="bg-card border border-border rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><TrendingUp className="w-5 h-5" /> Performance por Médico</h2>
|
||||
<Button size="sm" variant="outline" onClick={() => exportPDF("Performance por Médico", "Resumo da performance por médico.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
</div>
|
||||
<table className="w-full text-sm mt-4">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground">
|
||||
<th className="text-left font-medium">Médico</th>
|
||||
<th className="text-left font-medium">Consultas</th>
|
||||
<th className="text-left font-medium">Absenteísmo (%)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{performancePorMedico.map((m) => (
|
||||
<tr key={m.nome}>
|
||||
<td className="py-1">{m.nome}</td>
|
||||
<td className="py-1">{m.consultas}</td>
|
||||
<td className="py-1">{m.absenteismo}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -9,7 +9,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { MoreHorizontal, Plus, Search, Edit, Trash2, ArrowLeft, Eye } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DoctorRegistrationForm } from "@/features/profissionais/components/forms/doctor-registration-form";
|
||||
import { DoctorRegistrationForm } from "@/components/forms/doctor-registration-form";
|
||||
|
||||
|
||||
import { listarMedicos, excluirMedico, buscarMedicos, buscarMedicoPorId, Medico } from "@/lib/api";
|
||||
@ -1,8 +1,8 @@
|
||||
import type React from "react";
|
||||
import ProtectedRoute from "@/components/layout/ProtectedRoute";
|
||||
import { Sidebar } from "@/components/layout/app/Sidebar";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { PagesHeader } from "@/components/layout/app/Header";
|
||||
import ProtectedRoute from "@/components/ProtectedRoute";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { PagesHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function MainRoutesLayout({
|
||||
children,
|
||||
@ -11,7 +11,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { MoreHorizontal, Plus, Search, Eye, Edit, Trash2, ArrowLeft } from "lucide-react";
|
||||
|
||||
import { Paciente, Endereco, listarPacientes, buscarPacientes, buscarPacientePorId, excluirPaciente } from "@/lib/api";
|
||||
import { PatientRegistrationForm } from "@/features/pacientes/components/forms/patient-registration-form";
|
||||
import { PatientRegistrationForm } from "@/components/forms/patient-registration-form";
|
||||
|
||||
|
||||
function normalizePaciente(p: any): Paciente {
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CalendarRegistrationForm } from "@/features/agendamento/components/forms/calendar-registration-form";
|
||||
import { CalendarRegistrationForm } from "@/components/forms/calendar-registration-form";
|
||||
import HeaderAgenda from "@/components/agenda/HeaderAgenda";
|
||||
import FooterAgenda from "@/components/agenda/FooterAgenda";
|
||||
import { useState } from "react";
|
||||
@ -56,7 +56,6 @@ export default function NovoAgendamentoPage() {
|
||||
/>
|
||||
</main>
|
||||
<FooterAgenda onSave={handleSave} onCancel={handleCancel} />
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import type React from "react"
|
||||
import type { Metadata } from "next"
|
||||
import { AuthProvider } from "@/features/autenticacao/hooks/useAuth"
|
||||
import { ThemeProvider } from "@/components/layout/ThemeProvider"
|
||||
import { AuthProvider } from "@/hooks/useAuth"
|
||||
import { ThemeProvider } from "@/components/theme-provider"
|
||||
import "./globals.css"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
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-background py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-foreground">
|
||||
Login Administrador de Clínica
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
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-foreground">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Digite seu email"
|
||||
value={credentials.email}
|
||||
onChange={(e) => setCredentials({...credentials, email: e.target.value})}
|
||||
required
|
||||
className="mt-1"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
Senha
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Digite sua senha"
|
||||
value={credentials.password}
|
||||
onChange={(e) => setCredentials({...credentials, password: e.target.value})}
|
||||
required
|
||||
className="mt-1"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full cursor-pointer"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Entrando...' : 'Entrar no Sistema Administrativo'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button variant="outline" asChild className="w-full hover:!bg-primary hover:!text-white hover:!border-primary transition-all duration-200">
|
||||
<Link href="/">
|
||||
Voltar ao Início
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
131
susconecta/app/login-paciente/page.tsx
Normal file
131
susconecta/app/login-paciente/page.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
'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) {
|
||||
// Verificar se é erro de credenciais inválidas (pode ser email não confirmado)
|
||||
if (err.code === '400' || err.details?.error_code === 'invalid_credentials') {
|
||||
setError(
|
||||
'⚠️ Email ou senha incorretos. Se você acabou de se cadastrar, ' +
|
||||
'verifique sua caixa de entrada e clique no link de confirmação ' +
|
||||
'que foi enviado para ' + credentials.email
|
||||
)
|
||||
} else {
|
||||
setError(err.message)
|
||||
}
|
||||
} else {
|
||||
setError('Erro inesperado. Tente novamente.')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-foreground">
|
||||
Sou Paciente
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
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-foreground">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Digite seu email"
|
||||
value={credentials.email}
|
||||
onChange={(e) => setCredentials({...credentials, email: e.target.value})}
|
||||
required
|
||||
className="mt-1"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
Senha
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Digite sua senha"
|
||||
value={credentials.password}
|
||||
onChange={(e) => setCredentials({...credentials, password: e.target.value})}
|
||||
required
|
||||
className="mt-1"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full cursor-pointer"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Entrando...' : 'Entrar na Minha Área'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button variant="outline" asChild className="w-full hover:!bg-primary hover:!text-white hover:!border-primary transition-all duration-200">
|
||||
<Link href="/">
|
||||
Voltar ao Início
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
133
susconecta/app/login/page.tsx
Normal file
133
susconecta/app/login/page.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
'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) {
|
||||
// Verificar se é erro de credenciais inválidas (pode ser email não confirmado)
|
||||
if (err.code === '400' || err.details?.error_code === 'invalid_credentials') {
|
||||
setError(
|
||||
'⚠️ Email ou senha incorretos. Se você acabou de se cadastrar, ' +
|
||||
'verifique sua caixa de entrada e clique no link de confirmação ' +
|
||||
'que foi enviado para ' + credentials.email
|
||||
)
|
||||
} else {
|
||||
setError(err.message)
|
||||
}
|
||||
} else {
|
||||
setError('Erro inesperado. Tente novamente.')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-foreground">
|
||||
Login Profissional de Saúde
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Entre com suas credenciais para acessar o sistema
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center">Acesso ao Sistema</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleLogin} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Digite seu email"
|
||||
value={credentials.email}
|
||||
onChange={(e) => setCredentials({...credentials, email: e.target.value})}
|
||||
required
|
||||
className="mt-1"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
Senha
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Digite sua senha"
|
||||
value={credentials.password}
|
||||
onChange={(e) => setCredentials({...credentials, password: e.target.value})}
|
||||
required
|
||||
className="mt-1"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full cursor-pointer"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Entrando...' : 'Entrar'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button variant="outline" asChild className="w-full hover:!bg-primary hover:!text-white hover:!border-primary transition-all duration-200">
|
||||
<Link href="/">
|
||||
Voltar ao Início
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
578
susconecta/app/paciente/page.tsx
Normal file
578
susconecta/app/paciente/page.tsx
Normal file
@ -0,0 +1,578 @@
|
||||
'use client'
|
||||
// import { useAuth } from '@/hooks/useAuth' // removido duplicado
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { User, LogOut, Calendar, FileText, MessageCircle, UserCog, Home, Clock, FolderOpen, ChevronLeft, ChevronRight, MapPin, Stethoscope } from 'lucide-react'
|
||||
import { SimpleThemeToggle } from '@/components/simple-theme-toggle'
|
||||
import Link from 'next/link'
|
||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
// Simulação de internacionalização básica
|
||||
const strings = {
|
||||
dashboard: 'Dashboard',
|
||||
consultas: 'Consultas',
|
||||
exames: 'Exames & Laudos',
|
||||
mensagens: 'Mensagens',
|
||||
perfil: 'Perfil',
|
||||
sair: 'Sair',
|
||||
proximaConsulta: 'Próxima Consulta',
|
||||
ultimosExames: 'Últimos Exames',
|
||||
mensagensNaoLidas: 'Mensagens Não Lidas',
|
||||
agendar: 'Agendar',
|
||||
reagendar: 'Reagendar',
|
||||
cancelar: 'Cancelar',
|
||||
detalhes: 'Detalhes',
|
||||
adicionarCalendario: 'Adicionar ao calendário',
|
||||
visualizarLaudo: 'Visualizar Laudo',
|
||||
download: 'Download',
|
||||
compartilhar: 'Compartilhar',
|
||||
inbox: 'Caixa de Entrada',
|
||||
enviarMensagem: 'Enviar Mensagem',
|
||||
salvar: 'Salvar',
|
||||
editarPerfil: 'Editar Perfil',
|
||||
consentimentos: 'Consentimentos',
|
||||
notificacoes: 'Preferências de Notificação',
|
||||
vazio: 'Nenhum dado encontrado.',
|
||||
erro: 'Ocorreu um erro. Tente novamente.',
|
||||
carregando: 'Carregando...',
|
||||
sucesso: 'Salvo com sucesso!',
|
||||
erroSalvar: 'Erro ao salvar.',
|
||||
}
|
||||
|
||||
export default function PacientePage() {
|
||||
const { logout, user } = useAuth()
|
||||
const [tab, setTab] = useState<'dashboard'|'consultas'|'exames'|'mensagens'|'perfil'>('dashboard')
|
||||
|
||||
// Simulação de loaders, empty states e erro
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [toast, setToast] = useState<{type: 'success'|'error', msg: string}|null>(null)
|
||||
|
||||
// Acessibilidade: foco visível e ordem de tabulação garantidos por padrão nos botões e inputs
|
||||
|
||||
const handleLogout = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
await logout()
|
||||
} catch {
|
||||
setError(strings.erro)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Estado para edição do perfil
|
||||
const [isEditingProfile, setIsEditingProfile] = useState(false)
|
||||
const [profileData, setProfileData] = useState({
|
||||
nome: "Maria Silva Santos",
|
||||
email: user?.email || "paciente@example.com",
|
||||
telefone: "(11) 99999-9999",
|
||||
endereco: "Rua das Flores, 123",
|
||||
cidade: "São Paulo",
|
||||
cep: "01234-567",
|
||||
biografia: "Paciente desde 2020. Histórico de consultas e exames regulares.",
|
||||
})
|
||||
|
||||
const handleProfileChange = (field: string, value: string) => {
|
||||
setProfileData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
const handleSaveProfile = () => {
|
||||
setIsEditingProfile(false)
|
||||
setToast({ type: 'success', msg: strings.sucesso })
|
||||
}
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditingProfile(false)
|
||||
}
|
||||
function DashboardCards() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<Card className="flex flex-col items-center justify-center p-4">
|
||||
<Calendar className="mb-2 text-primary" aria-hidden />
|
||||
<span className="font-semibold">{strings.proximaConsulta}</span>
|
||||
<span className="text-2xl">12/10/2025</span>
|
||||
</Card>
|
||||
<Card className="flex flex-col items-center justify-center p-4">
|
||||
<FileText className="mb-2 text-primary" aria-hidden />
|
||||
<span className="font-semibold">{strings.ultimosExames}</span>
|
||||
<span className="text-2xl">2</span>
|
||||
</Card>
|
||||
<Card className="flex flex-col items-center justify-center p-4">
|
||||
<MessageCircle className="mb-2 text-primary" aria-hidden />
|
||||
<span className="font-semibold">{strings.mensagensNaoLidas}</span>
|
||||
<span className="text-2xl">1</span>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Consultas fictícias
|
||||
const [currentDate, setCurrentDate] = useState(new Date())
|
||||
const consultasFicticias = [
|
||||
{
|
||||
id: 1,
|
||||
medico: "Dr. Carlos Andrade",
|
||||
especialidade: "Cardiologia",
|
||||
local: "Clínica Coração Feliz",
|
||||
data: new Date().toISOString().split('T')[0],
|
||||
hora: "09:00",
|
||||
status: "Confirmada"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
medico: "Dra. Fernanda Lima",
|
||||
especialidade: "Dermatologia",
|
||||
local: "Clínica Pele Viva",
|
||||
data: new Date().toISOString().split('T')[0],
|
||||
hora: "14:30",
|
||||
status: "Pendente"
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
medico: "Dr. João Silva",
|
||||
especialidade: "Ortopedia",
|
||||
local: "Hospital Ortopédico",
|
||||
data: (() => { let d = new Date(); d.setDate(d.getDate()+1); return d.toISOString().split('T')[0] })(),
|
||||
hora: "11:00",
|
||||
status: "Cancelada"
|
||||
},
|
||||
];
|
||||
|
||||
function formatDatePt(dateStr: string) {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
function navigateDate(direction: 'prev' | 'next') {
|
||||
const newDate = new Date(currentDate);
|
||||
newDate.setDate(newDate.getDate() + (direction === 'next' ? 1 : -1));
|
||||
setCurrentDate(newDate);
|
||||
}
|
||||
function goToToday() {
|
||||
setCurrentDate(new Date());
|
||||
}
|
||||
|
||||
const todayStr = currentDate.toISOString().split('T')[0];
|
||||
const consultasDoDia = consultasFicticias.filter(c => c.data === todayStr);
|
||||
|
||||
function Consultas() {
|
||||
return (
|
||||
<section className="bg-card shadow-md rounded-lg border border-border p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-2xl font-bold">Minhas Consultas</h2>
|
||||
</div>
|
||||
{/* Navegação de Data */}
|
||||
<div className="flex items-center justify-between mb-6 p-4 bg-blue-50 rounded-lg dark:bg-muted">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="outline" size="sm" onClick={() => navigateDate('prev')} className="p-2"><ChevronLeft className="h-4 w-4" /></Button>
|
||||
<h3 className="text-lg font-medium text-foreground">{formatDatePt(todayStr)}</h3>
|
||||
<Button variant="outline" size="sm" onClick={() => navigateDate('next')} className="p-2"><ChevronRight className="h-4 w-4" /></Button>
|
||||
<Button variant="outline" size="sm" onClick={goToToday} className="ml-4 px-3 py-1 text-sm">Hoje</Button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-muted-foreground">
|
||||
{consultasDoDia.length} consulta{consultasDoDia.length !== 1 ? 's' : ''} agendada{consultasDoDia.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
{/* Lista de Consultas do Dia */}
|
||||
<div className="space-y-4">
|
||||
{consultasDoDia.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-600 dark:text-muted-foreground">
|
||||
<Calendar className="h-12 w-12 mx-auto mb-4 text-gray-400 dark:text-muted-foreground/50" />
|
||||
<p className="text-lg mb-2">Nenhuma consulta agendada para este dia</p>
|
||||
<p className="text-sm">Você pode agendar uma nova consulta</p>
|
||||
<Button variant="default" className="mt-4">Agendar Consulta</Button>
|
||||
</div>
|
||||
) : (
|
||||
consultasDoDia.map(consulta => (
|
||||
<div key={consulta.id} className="border-l-4 border-t border-r border-b p-4 rounded-lg shadow-sm bg-card border-border">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 items-center">
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 rounded-full mr-3" style={{ backgroundColor: consulta.status === 'Confirmada' ? '#22c55e' : consulta.status === 'Pendente' ? '#fbbf24' : '#ef4444' }}></div>
|
||||
<div>
|
||||
<div className="font-medium flex items-center">
|
||||
<Stethoscope className="h-4 w-4 mr-2 text-gray-500 dark:text-muted-foreground" />
|
||||
{consulta.medico}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-muted-foreground">
|
||||
{consulta.especialidade} • {consulta.local}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Clock className="h-4 w-4 mr-2 text-gray-500 dark:text-muted-foreground" />
|
||||
<span className="font-medium">{consulta.hora}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className={`px-3 py-1 rounded-full text-sm font-medium text-white ${consulta.status === 'Confirmada' ? 'bg-green-600' : consulta.status === 'Pendente' ? 'bg-yellow-500' : 'bg-red-600'}`}>{consulta.status}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button variant="outline" size="sm">Detalhes</Button>
|
||||
{consulta.status !== 'Cancelada' && <Button variant="secondary" size="sm">Reagendar</Button>}
|
||||
{consulta.status !== 'Cancelada' && <Button variant="destructive" size="sm">Cancelar</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Exames e laudos fictícios
|
||||
const examesFicticios = [
|
||||
{
|
||||
id: 1,
|
||||
nome: "Hemograma Completo",
|
||||
data: "2025-09-20",
|
||||
status: "Disponível",
|
||||
prontuario: "Paciente apresenta hemograma dentro dos padrões de normalidade. Sem alterações significativas.",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
nome: "Raio-X de Tórax",
|
||||
data: "2025-08-10",
|
||||
status: "Disponível",
|
||||
prontuario: "Exame radiológico sem evidências de lesões pulmonares. Estruturas cardíacas normais.",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
nome: "Eletrocardiograma",
|
||||
data: "2025-07-05",
|
||||
status: "Disponível",
|
||||
prontuario: "Ritmo sinusal, sem arritmias. Exame dentro da normalidade.",
|
||||
},
|
||||
];
|
||||
|
||||
const laudosFicticios = [
|
||||
{
|
||||
id: 1,
|
||||
nome: "Laudo Hemograma Completo",
|
||||
data: "2025-09-21",
|
||||
status: "Assinado",
|
||||
laudo: "Hemoglobina, hematócrito, leucócitos e plaquetas dentro dos valores de referência. Sem anemias ou infecções detectadas.",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
nome: "Laudo Raio-X de Tórax",
|
||||
data: "2025-08-11",
|
||||
status: "Assinado",
|
||||
laudo: "Radiografia sem alterações. Parênquima pulmonar preservado. Ausência de derrame pleural.",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
nome: "Laudo Eletrocardiograma",
|
||||
data: "2025-07-06",
|
||||
status: "Assinado",
|
||||
laudo: "ECG normal. Não há sinais de isquemia ou sobrecarga.",
|
||||
},
|
||||
];
|
||||
|
||||
const [exameSelecionado, setExameSelecionado] = useState<null | typeof examesFicticios[0]>(null)
|
||||
const [laudoSelecionado, setLaudoSelecionado] = useState<null | typeof laudosFicticios[0]>(null)
|
||||
|
||||
function ExamesLaudos() {
|
||||
return (
|
||||
<section className="bg-card shadow-md rounded-lg border border-border p-6">
|
||||
<h2 className="text-2xl font-bold mb-6">Exames</h2>
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-semibold mb-2">Meus Exames</h3>
|
||||
<div className="space-y-3">
|
||||
{examesFicticios.map(exame => (
|
||||
<div key={exame.id} className="flex flex-col md:flex-row md:items-center md:justify-between bg-muted rounded p-4">
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{exame.nome}</div>
|
||||
<div className="text-sm text-muted-foreground">Data: {new Date(exame.data).toLocaleDateString('pt-BR')}</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2 md:mt-0">
|
||||
<Button variant="outline" onClick={() => setExameSelecionado(exame)}>Ver Prontuário</Button>
|
||||
<Button variant="secondary">Download</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-6">Laudos</h2>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Meus Laudos</h3>
|
||||
<div className="space-y-3">
|
||||
{laudosFicticios.map(laudo => (
|
||||
<div key={laudo.id} className="flex flex-col md:flex-row md:items-center md:justify-between bg-muted rounded p-4">
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{laudo.nome}</div>
|
||||
<div className="text-sm text-muted-foreground">Data: {new Date(laudo.data).toLocaleDateString('pt-BR')}</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2 md:mt-0">
|
||||
<Button variant="outline" onClick={() => setLaudoSelecionado(laudo)}>Visualizar</Button>
|
||||
<Button variant="secondary">Compartilhar</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Prontuário Exame */}
|
||||
<Dialog open={!!exameSelecionado} onOpenChange={open => !open && setExameSelecionado(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Prontuário do Exame</DialogTitle>
|
||||
<DialogDescription>
|
||||
{exameSelecionado && (
|
||||
<>
|
||||
<div className="font-semibold mb-2">{exameSelecionado.nome}</div>
|
||||
<div className="text-sm text-muted-foreground mb-4">Data: {new Date(exameSelecionado.data).toLocaleDateString('pt-BR')}</div>
|
||||
<div className="mb-4 whitespace-pre-line">{exameSelecionado.prontuario}</div>
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setExameSelecionado(null)}>Fechar</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Modal Visualizar Laudo */}
|
||||
<Dialog open={!!laudoSelecionado} onOpenChange={open => !open && setLaudoSelecionado(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Laudo Médico</DialogTitle>
|
||||
<DialogDescription>
|
||||
{laudoSelecionado && (
|
||||
<>
|
||||
<div className="font-semibold mb-2">{laudoSelecionado.nome}</div>
|
||||
<div className="text-sm text-muted-foreground mb-4">Data: {new Date(laudoSelecionado.data).toLocaleDateString('pt-BR')}</div>
|
||||
<div className="mb-4 whitespace-pre-line">{laudoSelecionado.laudo}</div>
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setLaudoSelecionado(null)}>Fechar</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Mensagens fictícias recebidas do médico
|
||||
const mensagensFicticias = [
|
||||
{
|
||||
id: 1,
|
||||
medico: "Dr. Carlos Andrade",
|
||||
data: "2025-10-06T15:30:00",
|
||||
conteudo: "Olá Maria, seu exame de hemograma está normal. Parabéns por manter seus exames em dia!",
|
||||
lida: false
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
medico: "Dra. Fernanda Lima",
|
||||
data: "2025-09-21T10:15:00",
|
||||
conteudo: "Maria, seu laudo de Raio-X já está disponível no sistema. Qualquer dúvida, estou à disposição.",
|
||||
lida: true
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
medico: "Dr. João Silva",
|
||||
data: "2025-08-12T09:00:00",
|
||||
conteudo: "Bom dia! Lembre-se de agendar seu retorno para acompanhamento da ortopedia.",
|
||||
lida: true
|
||||
},
|
||||
];
|
||||
|
||||
function Mensagens() {
|
||||
return (
|
||||
<section className="bg-card shadow-md rounded-lg border border-border p-6">
|
||||
<h2 className="text-2xl font-bold mb-6">Mensagens Recebidas</h2>
|
||||
<div className="space-y-3">
|
||||
{mensagensFicticias.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-600 dark:text-muted-foreground">
|
||||
<MessageCircle className="h-12 w-12 mx-auto mb-4 text-gray-400 dark:text-muted-foreground/50" />
|
||||
<p className="text-lg mb-2">Nenhuma mensagem recebida</p>
|
||||
<p className="text-sm">Você ainda não recebeu mensagens dos seus médicos.</p>
|
||||
</div>
|
||||
) : (
|
||||
mensagensFicticias.map(msg => (
|
||||
<div key={msg.id} className={`flex flex-col md:flex-row md:items-center md:justify-between bg-muted rounded p-4 border ${!msg.lida ? 'border-primary' : 'border-transparent'}`}>
|
||||
<div>
|
||||
<div className="font-medium text-foreground flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-primary" />
|
||||
{msg.medico}
|
||||
{!msg.lida && <span className="ml-2 px-2 py-0.5 rounded-full text-xs bg-primary text-white">Nova</span>}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mb-2">{new Date(msg.data).toLocaleString('pt-BR')}</div>
|
||||
<div className="text-foreground whitespace-pre-line">{msg.conteudo}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Perfil() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl mx-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-foreground">Meu Perfil</h2>
|
||||
{!isEditingProfile ? (
|
||||
<Button onClick={() => setIsEditingProfile(true)} className="flex items-center gap-2">
|
||||
Editar Perfil
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSaveProfile} className="flex items-center gap-2">Salvar</Button>
|
||||
<Button variant="outline" onClick={handleCancelEdit}>Cancelar</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Informações Pessoais */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold border-b border-border text-foreground pb-2">Informações Pessoais</h3>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nome">Nome Completo</Label>
|
||||
<p className="p-2 bg-muted rounded text-muted-foreground">{profileData.nome}</p>
|
||||
<span className="text-xs text-muted-foreground">Este campo não pode ser alterado</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
{isEditingProfile ? (
|
||||
<Input id="email" type="email" value={profileData.email} onChange={e => handleProfileChange('email', e.target.value)} />
|
||||
) : (
|
||||
<p className="p-2 bg-muted/50 rounded text-foreground">{profileData.email}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="telefone">Telefone</Label>
|
||||
{isEditingProfile ? (
|
||||
<Input id="telefone" value={profileData.telefone} onChange={e => handleProfileChange('telefone', e.target.value)} />
|
||||
) : (
|
||||
<p className="p-2 bg-muted/50 rounded text-foreground">{profileData.telefone}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Endereço e Contato */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold border-b border-border text-foreground pb-2">Endereço</h3>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endereco">Endereço</Label>
|
||||
{isEditingProfile ? (
|
||||
<Input id="endereco" value={profileData.endereco} onChange={e => handleProfileChange('endereco', e.target.value)} />
|
||||
) : (
|
||||
<p className="p-2 bg-muted/50 rounded text-foreground">{profileData.endereco}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cidade">Cidade</Label>
|
||||
{isEditingProfile ? (
|
||||
<Input id="cidade" value={profileData.cidade} onChange={e => handleProfileChange('cidade', e.target.value)} />
|
||||
) : (
|
||||
<p className="p-2 bg-muted/50 rounded text-foreground">{profileData.cidade}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cep">CEP</Label>
|
||||
{isEditingProfile ? (
|
||||
<Input id="cep" value={profileData.cep} onChange={e => handleProfileChange('cep', e.target.value)} />
|
||||
) : (
|
||||
<p className="p-2 bg-muted/50 rounded text-foreground">{profileData.cep}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="biografia">Biografia</Label>
|
||||
{isEditingProfile ? (
|
||||
<Textarea id="biografia" value={profileData.biografia} onChange={e => handleProfileChange('biografia', e.target.value)} rows={4} placeholder="Conte um pouco sobre você..." />
|
||||
) : (
|
||||
<p className="p-2 bg-muted/50 rounded min-h-[100px] text-foreground">{profileData.biografia}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Foto do Perfil */}
|
||||
<div className="border-t border-border pt-6">
|
||||
<h3 className="text-lg font-semibold mb-4 text-foreground">Foto do Perfil</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-20 w-20">
|
||||
<AvatarFallback className="text-lg">
|
||||
{profileData.nome.split(' ').map(n => n[0]).join('').toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{isEditingProfile && (
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" size="sm">Alterar Foto</Button>
|
||||
<p className="text-xs text-muted-foreground">Formatos aceitos: JPG, PNG (máx. 2MB)</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Renderização principal
|
||||
return (
|
||||
<ProtectedRoute requiredUserType={["paciente"]}>
|
||||
<div className="min-h-screen bg-background text-foreground flex flex-col">
|
||||
{/* Header só com título e botão de sair */}
|
||||
<header className="flex items-center justify-between px-4 py-2 border-b bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-6 w-6 text-primary" aria-hidden />
|
||||
<span className="font-bold">Portal do Paciente</span>
|
||||
<Button asChild variant="outline" className="ml-4">
|
||||
<Link href="/">
|
||||
<Home className="h-4 w-4 mr-1" /> Início
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SimpleThemeToggle />
|
||||
<Button onClick={handleLogout} variant="destructive" aria-label={strings.sair} disabled={loading} className="ml-2"><LogOut className="h-4 w-4" /> {strings.sair}</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Sidebar vertical */}
|
||||
<nav aria-label="Navegação do dashboard" className="w-56 bg-card border-r flex flex-col py-6 px-2 gap-2">
|
||||
<Button variant={tab==='dashboard'?'secondary':'ghost'} aria-current={tab==='dashboard'} onClick={()=>setTab('dashboard')} className="justify-start"><Calendar className="mr-2 h-5 w-5" />{strings.dashboard}</Button>
|
||||
<Button variant={tab==='consultas'?'secondary':'ghost'} aria-current={tab==='consultas'} onClick={()=>setTab('consultas')} className="justify-start"><Calendar className="mr-2 h-5 w-5" />{strings.consultas}</Button>
|
||||
<Button variant={tab==='exames'?'secondary':'ghost'} aria-current={tab==='exames'} onClick={()=>setTab('exames')} className="justify-start"><FileText className="mr-2 h-5 w-5" />{strings.exames}</Button>
|
||||
<Button variant={tab==='mensagens'?'secondary':'ghost'} aria-current={tab==='mensagens'} onClick={()=>setTab('mensagens')} className="justify-start"><MessageCircle className="mr-2 h-5 w-5" />{strings.mensagens}</Button>
|
||||
<Button variant={tab==='perfil'?'secondary':'ghost'} aria-current={tab==='perfil'} onClick={()=>setTab('perfil')} className="justify-start"><UserCog className="mr-2 h-5 w-5" />{strings.perfil}</Button>
|
||||
</nav>
|
||||
{/* Conteúdo principal */}
|
||||
<div className="flex-1 min-w-0 p-4 max-w-4xl mx-auto w-full">
|
||||
{/* Toasts de feedback */}
|
||||
{toast && (
|
||||
<div className={`fixed top-4 right-4 z-50 px-4 py-2 rounded shadow-lg ${toast.type==='success'?'bg-green-600 text-white':'bg-red-600 text-white'}`} role="alert">{toast.msg}</div>
|
||||
)}
|
||||
|
||||
{/* Loader global */}
|
||||
{loading && <div className="flex-1 flex items-center justify-center"><span>{strings.carregando}</span></div>}
|
||||
{error && <div className="flex-1 flex items-center justify-center text-red-600"><span>{error}</span></div>}
|
||||
|
||||
{/* Conteúdo principal */}
|
||||
{!loading && !error && (
|
||||
<main className="flex-1">
|
||||
{tab==='dashboard' && <DashboardCards />}
|
||||
{tab==='consultas' && <Consultas />}
|
||||
{tab==='exames' && <ExamesLaudos />}
|
||||
{tab==='mensagens' && <Mensagens />}
|
||||
{tab==='perfil' && <Perfil />}
|
||||
</main>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { Header } from "@/components/layout/marketing/Header"
|
||||
import { HeroSection } from "@/features/marketing/components/hero-section"
|
||||
import { Footer } from "@/components/layout/marketing/Footer"
|
||||
import { Header } from "@/components/header"
|
||||
import { HeroSection } from "@/components/hero-section"
|
||||
import { Footer } from "@/components/footer"
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
@ -3,12 +3,14 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import SignatureCanvas from "react-signature-canvas";
|
||||
import Link from "next/link";
|
||||
import ProtectedRoute from "@/components/ProtectedRoute";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { buscarPacientes } from "@/lib/api";
|
||||
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 { SimpleThemeToggle } from "@/components/simple-theme-toggle";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -2031,14 +2033,40 @@ Nevo melanocítico benigno. Seguimento clínico recomendado.
|
||||
}
|
||||
}, [laudo, isNewLaudo]);
|
||||
|
||||
const formatText = (type: string) => {
|
||||
// Histórico para desfazer/refazer
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
|
||||
// Atualiza histórico ao digitar
|
||||
useEffect(() => {
|
||||
if (history[historyIndex] !== content) {
|
||||
const newHistory = history.slice(0, historyIndex + 1);
|
||||
setHistory([...newHistory, content]);
|
||||
setHistoryIndex(newHistory.length);
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
}, [content]);
|
||||
|
||||
const handleUndo = () => {
|
||||
if (historyIndex > 0) {
|
||||
setContent(history[historyIndex - 1]);
|
||||
setHistoryIndex(historyIndex - 1);
|
||||
}
|
||||
};
|
||||
const handleRedo = () => {
|
||||
if (historyIndex < history.length - 1) {
|
||||
setContent(history[historyIndex + 1]);
|
||||
setHistoryIndex(historyIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Formatação avançada
|
||||
const formatText = (type: string, value?: any) => {
|
||||
const textarea = document.querySelector('textarea') as HTMLTextAreaElement;
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selectedText = textarea.value.substring(start, end);
|
||||
|
||||
let formattedText = "";
|
||||
switch(type) {
|
||||
case "bold":
|
||||
@ -2048,13 +2076,44 @@ Nevo melanocítico benigno. Seguimento clínico recomendado.
|
||||
formattedText = selectedText ? `*${selectedText}*` : "*texto em itálico*";
|
||||
break;
|
||||
case "underline":
|
||||
formattedText = selectedText ? `<u>${selectedText}</u>` : "<u>texto sublinhado</u>";
|
||||
formattedText = selectedText ? `__${selectedText}__` : "__texto sublinhado__";
|
||||
break;
|
||||
case "list":
|
||||
formattedText = selectedText ? `• ${selectedText}` : "• item da lista";
|
||||
case "list-ul":
|
||||
formattedText = selectedText ? selectedText.split('\n').map(l => `• ${l}`).join('\n') : "• item da lista";
|
||||
break;
|
||||
case "list-ol":
|
||||
formattedText = selectedText ? selectedText.split('\n').map((l,i) => `${i+1}. ${l}`).join('\n') : "1. item da lista";
|
||||
break;
|
||||
case "indent":
|
||||
formattedText = selectedText ? selectedText.split('\n').map(l => ` ${l}`).join('\n') : " ";
|
||||
break;
|
||||
case "outdent":
|
||||
formattedText = selectedText ? selectedText.split('\n').map(l => l.replace(/^\s{1,4}/, "")).join('\n') : "";
|
||||
break;
|
||||
case "align-left":
|
||||
formattedText = selectedText ? `[left]${selectedText}[/left]` : "[left]Texto à esquerda[/left]";
|
||||
break;
|
||||
case "align-center":
|
||||
formattedText = selectedText ? `[center]${selectedText}[/center]` : "[center]Texto centralizado[/center]";
|
||||
break;
|
||||
case "align-right":
|
||||
formattedText = selectedText ? `[right]${selectedText}[/right]` : "[right]Texto à direita[/right]";
|
||||
break;
|
||||
case "align-justify":
|
||||
formattedText = selectedText ? `[justify]${selectedText}[/justify]` : "[justify]Texto justificado[/justify]";
|
||||
break;
|
||||
case "font-size":
|
||||
formattedText = selectedText ? `[size=${value}]${selectedText}[/size]` : `[size=${value}]Texto tamanho ${value}[/size]`;
|
||||
break;
|
||||
case "font-family":
|
||||
formattedText = selectedText ? `[font=${value}]${selectedText}[/font]` : `[font=${value}]${value}[/font]`;
|
||||
break;
|
||||
case "font-color":
|
||||
formattedText = selectedText ? `[color=${value}]${selectedText}[/color]` : `[color=${value}]${value}[/color]`;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
const newText = textarea.value.substring(0, start) + formattedText + textarea.value.substring(end);
|
||||
setContent(newText);
|
||||
};
|
||||
@ -2083,7 +2142,14 @@ Nevo melanocítico benigno. Seguimento clínico recomendado.
|
||||
return content
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
.replace(/<u>(.*?)<\/u>/g, '<u>$1</u>')
|
||||
.replace(/__(.*?)__/g, '<u>$1</u>')
|
||||
.replace(/\[left\](.*?)\[\/left\]/gs, '<div style="text-align:left">$1</div>')
|
||||
.replace(/\[center\](.*?)\[\/center\]/gs, '<div style="text-align:center">$1</div>')
|
||||
.replace(/\[right\](.*?)\[\/right\]/gs, '<div style="text-align:right">$1</div>')
|
||||
.replace(/\[justify\](.*?)\[\/justify\]/gs, '<div style="text-align:justify">$1</div>')
|
||||
.replace(/\[size=(\d+)\](.*?)\[\/size\]/gs, '<span style="font-size:$1px">$2</span>')
|
||||
.replace(/\[font=([^\]]+)\](.*?)\[\/font\]/gs, '<span style="font-family:$1">$2</span>')
|
||||
.replace(/\[color=([^\]]+)\](.*?)\[\/color\]/gs, '<span style="color:$1">$2</span>')
|
||||
.replace(/{{sexo_paciente}}/g, pacienteSelecionado?.sexo || laudo?.paciente?.sexo || '[SEXO]')
|
||||
.replace(/{{diagnostico}}/g, campos.diagnostico || '[DIAGNÓSTICO]')
|
||||
.replace(/{{conclusao}}/g, campos.conclusao || '[CONCLUSÃO]')
|
||||
@ -2382,44 +2448,60 @@ Nevo melanocítico benigno. Seguimento clínico recomendado.
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => formatText("bold")}
|
||||
title="Negrito"
|
||||
className="hover:bg-blue-50 dark:hover:bg-accent"
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{/* Tamanho da fonte */}
|
||||
<label className="text-xs mr-1">Tamanho</label>
|
||||
<input
|
||||
type="number"
|
||||
min={8}
|
||||
max={32}
|
||||
defaultValue={14}
|
||||
onBlur={e => formatText('font-size', e.target.value)}
|
||||
className="w-14 border rounded px-1 py-0.5 text-xs mr-2"
|
||||
title="Tamanho da fonte"
|
||||
/>
|
||||
{/* Família da fonte */}
|
||||
<label className="text-xs mr-1">Fonte</label>
|
||||
<select
|
||||
defaultValue={'Arial'}
|
||||
onBlur={e => formatText('font-family', e.target.value)}
|
||||
className="border rounded px-1 py-0.5 text-xs mr-2"
|
||||
title="Família da fonte"
|
||||
>
|
||||
<strong>B</strong>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => formatText("italic")}
|
||||
title="Itálico"
|
||||
className="hover:bg-blue-50 dark:hover:bg-accent"
|
||||
>
|
||||
<em>I</em>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => formatText("underline")}
|
||||
title="Sublinhado"
|
||||
className="hover:bg-blue-50 dark:hover:bg-accent"
|
||||
>
|
||||
<u>U</u>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => formatText("list")}
|
||||
title="Lista"
|
||||
className="hover:bg-blue-50 dark:hover:bg-accent"
|
||||
>
|
||||
•
|
||||
</Button>
|
||||
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Helvetica">Helvetica</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
<option value="Verdana">Verdana</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
</select>
|
||||
{/* Cor da fonte */}
|
||||
<label className="text-xs mr-1">Cor</label>
|
||||
<input
|
||||
type="color"
|
||||
defaultValue="#222222"
|
||||
onBlur={e => formatText('font-color', e.target.value)}
|
||||
className="w-6 h-6 border rounded mr-2"
|
||||
title="Cor da fonte"
|
||||
/>
|
||||
{/* Alinhamento */}
|
||||
<Button variant="outline" size="sm" onClick={() => formatText('align-left')} title="Alinhar à esquerda" className="px-1"><svg width="16" height="16" fill="none"><rect x="2" y="4" width="12" height="2" rx="1" fill="currentColor"/><rect x="2" y="7" width="8" height="2" rx="1" fill="currentColor"/><rect x="2" y="10" width="10" height="2" rx="1" fill="currentColor"/></svg></Button>
|
||||
<Button variant="outline" size="sm" onClick={() => formatText('align-center')} title="Centralizar" className="px-1"><svg width="16" height="16" fill="none"><rect x="4" y="4" width="8" height="2" rx="1" fill="currentColor"/><rect x="2" y="7" width="12" height="2" rx="1" fill="currentColor"/><rect x="3" y="10" width="10" height="2" rx="1" fill="currentColor"/></svg></Button>
|
||||
<Button variant="outline" size="sm" onClick={() => formatText('align-right')} title="Alinhar à direita" className="px-1"><svg width="16" height="16" fill="none"><rect x="6" y="4" width="8" height="2" rx="1" fill="currentColor"/><rect x="2" y="7" width="12" height="2" rx="1" fill="currentColor"/><rect x="4" y="10" width="10" height="2" rx="1" fill="currentColor"/></svg></Button>
|
||||
<Button variant="outline" size="sm" onClick={() => formatText('align-justify')} title="Justificar" className="px-1"><svg width="16" height="16" fill="none"><rect x="2" y="4" width="12" height="2" rx="1" fill="currentColor"/><rect x="2" y="7" width="12" height="2" rx="1" fill="currentColor"/><rect x="2" y="10" width="12" height="2" rx="1" fill="currentColor"/></svg></Button>
|
||||
{/* Listas */}
|
||||
<Button variant="outline" size="sm" onClick={() => formatText('list-ol')} title="Lista numerada" className="px-1">1.</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => formatText('list-ul')} title="Lista com marcadores" className="px-1">•</Button>
|
||||
{/* Recuo */}
|
||||
<Button variant="outline" size="sm" onClick={() => formatText('indent')} title="Aumentar recuo" className="px-1">→</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => formatText('outdent')} title="Diminuir recuo" className="px-1">←</Button>
|
||||
{/* Desfazer/Refazer */}
|
||||
<Button variant="outline" size="sm" onClick={handleUndo} title="Desfazer" className="px-1">↺</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleRedo} title="Refazer" className="px-1">↻</Button>
|
||||
{/* Negrito, itálico, sublinhado */}
|
||||
<Button variant="outline" size="sm" onClick={() => formatText("bold") } title="Negrito" className="hover:bg-blue-50 dark:hover:bg-accent"><strong>B</strong></Button>
|
||||
<Button variant="outline" size="sm" onClick={() => formatText("italic") } title="Itálico" className="hover:bg-blue-50 dark:hover:bg-accent"><em>I</em></Button>
|
||||
<Button variant="outline" size="sm" onClick={() => formatText("underline") } title="Sublinhado" className="hover:bg-blue-50 dark:hover:bg-accent"><u>U</u></Button>
|
||||
</div>
|
||||
|
||||
{/* Templates */}
|
||||
@ -2442,12 +2524,13 @@ Nevo melanocítico benigno. Seguimento clínico recomendado.
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="flex-1 p-4">
|
||||
<div className="flex-1 p-4 overflow-auto max-h-[500px]">
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Digite o conteúdo do laudo aqui. Use ** para negrito, * para itálico, <u></u> para sublinhado."
|
||||
className="h-full min-h-[400px] resize-none"
|
||||
className="h-full min-h-[400px] resize-none scrollbar-thin scrollbar-thumb-blue-400 scrollbar-track-blue-100"
|
||||
style={{ maxHeight: 400, overflow: 'auto' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -3250,7 +3333,8 @@ Nevo melanocítico benigno. Seguimento clínico recomendado.
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<ProtectedRoute requiredUserType={["profissional"]}>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<header className="bg-card shadow-md rounded-lg border border-border p-4 mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-12 w-12">
|
||||
@ -3268,13 +3352,16 @@ Nevo melanocítico benigno. Seguimento clínico recomendado.
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={logout}
|
||||
className="text-red-600 border-red-600 hover:bg-red-50 cursor-pointer dark:hover:bg-red-600 dark:hover:text-white"
|
||||
>
|
||||
Sair
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<SimpleThemeToggle />
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={logout}
|
||||
className="text-red-600 border-red-600 hover:bg-red-50 cursor-pointer dark:hover:bg-red-600 dark:hover:text-white"
|
||||
>
|
||||
Sair
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-[220px_1fr] gap-6">
|
||||
@ -3508,6 +3595,7 @@ Nevo melanocítico benigno. Seguimento clínico recomendado.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Header } from "@/components/layout/marketing/Header"
|
||||
import { AboutSection } from "@/features/marketing/components/about-section"
|
||||
import { Footer } from "@/components/layout/marketing/Footer"
|
||||
import { Header } from "@/components/header"
|
||||
import { AboutSection } from "@/components/about-section"
|
||||
import { Footer } from "@/components/footer"
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
@ -2,8 +2,8 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import type { UserType } from '@/features/autenticacao/types'
|
||||
import { USER_TYPE_ROUTES, LOGIN_ROUTES, AUTH_STORAGE_KEYS } from '@/features/autenticacao/types'
|
||||
import type { UserType } from '@/types/auth'
|
||||
import { USER_TYPE_ROUTES, LOGIN_ROUTES, AUTH_STORAGE_KEYS } from '@/types/auth'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode
|
||||
177
susconecta/components/credentials-dialog.tsx
Normal file
177
susconecta/components/credentials-dialog.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { CheckCircle2, Copy, Eye, EyeOff } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
export interface CredentialsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
email: string;
|
||||
password: string;
|
||||
userName: string;
|
||||
userType: "médico" | "paciente";
|
||||
}
|
||||
|
||||
export function CredentialsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
email,
|
||||
password,
|
||||
userName,
|
||||
userType,
|
||||
}: CredentialsDialogProps) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [copiedEmail, setCopiedEmail] = useState(false);
|
||||
const [copiedPassword, setCopiedPassword] = useState(false);
|
||||
|
||||
function handleCopyEmail() {
|
||||
navigator.clipboard.writeText(email);
|
||||
setCopiedEmail(true);
|
||||
setTimeout(() => setCopiedEmail(false), 2000);
|
||||
}
|
||||
|
||||
function handleCopyPassword() {
|
||||
navigator.clipboard.writeText(password);
|
||||
setCopiedPassword(true);
|
||||
setTimeout(() => setCopiedPassword(false), 2000);
|
||||
}
|
||||
|
||||
function handleCopyBoth() {
|
||||
const text = `Email: ${email}\nSenha: ${password}`;
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
{userType === "médico" ? "Médico" : "Paciente"} Cadastrado com Sucesso!
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
O {userType} <strong>{userName}</strong> foi cadastrado e pode fazer login com as credenciais abaixo.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert className="bg-amber-50 border-amber-200">
|
||||
<AlertDescription className="text-amber-900">
|
||||
<strong>Importante:</strong> Anote ou copie estas credenciais agora. Por segurança, essa senha não será exibida novamente.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert className="bg-blue-50 border-blue-200">
|
||||
<AlertDescription className="text-blue-900">
|
||||
<strong>📧 Confirme o email:</strong> Um email de confirmação foi enviado para <strong>{email}</strong>.
|
||||
O {userType} deve clicar no link de confirmação antes de fazer o primeiro login.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email de Acesso</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="email"
|
||||
value={email}
|
||||
readOnly
|
||||
className="bg-muted"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopyEmail}
|
||||
title="Copiar email"
|
||||
>
|
||||
{copiedEmail ? <CheckCircle2 className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Senha Temporária</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
readOnly
|
||||
className="bg-muted pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
title={showPassword ? "Ocultar senha" : "Mostrar senha"}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopyPassword}
|
||||
title="Copiar senha"
|
||||
>
|
||||
{copiedPassword ? <CheckCircle2 className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-3 text-sm text-blue-900">
|
||||
<strong>Próximos passos:</strong>
|
||||
<ol className="list-decimal list-inside mt-2 space-y-1">
|
||||
<li>Compartilhe estas credenciais com o {userType}</li>
|
||||
<li>
|
||||
<strong className="text-blue-700">O {userType} deve confirmar o email</strong> clicando no link enviado para{" "}
|
||||
<strong>{email}</strong> (verifique também a pasta de spam)
|
||||
</li>
|
||||
<li>
|
||||
Após confirmar o email, o {userType} deve acessar:{" "}
|
||||
<code className="bg-blue-100 px-1 py-0.5 rounded text-xs font-mono">
|
||||
{userType === "médico" ? "/login" : "/login-paciente"}
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
Após o login, terá acesso à área:{" "}
|
||||
<code className="bg-blue-100 px-1 py-0.5 rounded text-xs font-mono">
|
||||
{userType === "médico" ? "/profissional" : "/paciente"}
|
||||
</code>
|
||||
</li>
|
||||
<li>Recomende trocar a senha no primeiro acesso</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCopyBoth}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copiar Tudo
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Fechar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -1,11 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { Bell } from "lucide-react"
|
||||
import { Bell, ChevronDown } from "lucide-react"
|
||||
import { useAuth } from "@/hooks/useAuth"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import { SidebarTrigger } from "../ui/sidebar"
|
||||
import { SimpleThemeToggle } from "@/components/simple-theme-toggle";
|
||||
|
||||
export function PagesHeader({ title = "", subtitle = "" }: { title?: string, subtitle?: string }) {
|
||||
const { logout, user } = useAuth();
|
||||
@ -43,7 +45,12 @@ export function PagesHeader({ title = "", subtitle = "" }: { title?: string, sub
|
||||
<Bell className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
|
||||
<SimpleThemeToggle />
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-primary border-primary bg-transparent shadow-sm shadow-blue-500/10 border border-blue-200 hover:bg-blue-50 dark:shadow-none dark:border-primary dark:hover:bg-primary dark:hover:text-primary-foreground"
|
||||
asChild
|
||||
></Button>
|
||||
{/* Avatar Dropdown Simples */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<Button
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Sidebar as ShadSidebar,
|
||||
SidebarHeader,
|
||||
@ -71,23 +72,22 @@ export function Sidebar() {
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{navigation.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
(pathname.startsWith(item.href + "/") && item.href !== "/dashboard")
|
||||
const isActive = pathname === item.href ||
|
||||
(pathname.startsWith(item.href + "/") && item.href !== "/dashboard")
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={item.name}>
|
||||
<SidebarMenuButton asChild isActive={isActive}>
|
||||
<Link href={item.href} className="flex items-center">
|
||||
<item.icon className="mr-3 h-4 w-4 shrink-0" />
|
||||
<span className="truncate group-data-[collapsible=icon]:hidden">
|
||||
{item.name}
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
return (
|
||||
<SidebarMenuItem key={item.name}>
|
||||
<SidebarMenuButton asChild isActive={isActive}>
|
||||
<Link href={item.href} className="flex items-center">
|
||||
<item.icon className="mr-3 h-4 w-4 shrink-0" />
|
||||
<span className="truncate group-data-[collapsible=icon]:hidden">
|
||||
{item.name}
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
@ -12,8 +12,10 @@ export function Footer() {
|
||||
<footer className="bg-background border-t border-border">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between space-y-4 md:space-y-0">
|
||||
{}
|
||||
<div className="text-muted-foreground text-sm">© 2025 MEDI Connect</div>
|
||||
|
||||
{}
|
||||
<nav className="flex items-center space-x-8">
|
||||
<a href="#" className="text-muted-foreground hover:text-primary transition-colors text-sm">
|
||||
Termos
|
||||
@ -26,6 +28,7 @@ export function Footer() {
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { buscarPacientePorId } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@ -23,10 +24,13 @@ import {
|
||||
removerAnexoMedico,
|
||||
MedicoInput, // 👈 importado do lib/api
|
||||
Medico, // 👈 adicionado import do tipo Medico
|
||||
criarUsuarioMedico,
|
||||
CreateUserWithPasswordResponse,
|
||||
} from "@/lib/api";
|
||||
;
|
||||
|
||||
import { buscarCepAPI } from "@/lib/api";
|
||||
import { buscarCepAPI } from "@/lib/api";
|
||||
import { CredentialsDialog } from "@/components/credentials-dialog";
|
||||
|
||||
type FormacaoAcademica = {
|
||||
instituicao: string;
|
||||
@ -149,6 +153,11 @@ export function DoctorRegistrationForm({
|
||||
const [isSearchingCEP, setSearchingCEP] = useState(false);
|
||||
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
||||
const [serverAnexos, setServerAnexos] = useState<any[]>([]);
|
||||
|
||||
// Estados para o dialog de credenciais
|
||||
const [showCredentials, setShowCredentials] = useState(false);
|
||||
const [credentials, setCredentials] = useState<CreateUserWithPasswordResponse | null>(null);
|
||||
const [savedDoctor, setSavedDoctor] = useState<Medico | null>(null);
|
||||
|
||||
const title = useMemo(() => (mode === "create" ? "Cadastro de Médico" : "Editar Médico"), [mode]);
|
||||
|
||||
@ -390,8 +399,69 @@ if (missingFields.length > 0) {
|
||||
|
||||
console.log("✅ Médico salvo com sucesso:", saved);
|
||||
|
||||
onSaved?.(saved);
|
||||
setSubmitting(false);
|
||||
// Se for criação de novo médico e tiver email válido, cria usuário
|
||||
if (mode === "create" && form.email && form.email.includes('@')) {
|
||||
console.log("🔐 Iniciando criação de usuário para o médico...");
|
||||
console.log("📧 Email:", form.email);
|
||||
console.log("👤 Nome:", form.full_name);
|
||||
console.log("📱 Telefone:", form.celular);
|
||||
|
||||
try {
|
||||
const userCredentials = await criarUsuarioMedico({
|
||||
email: form.email,
|
||||
full_name: form.full_name,
|
||||
phone_mobile: form.celular,
|
||||
});
|
||||
|
||||
console.log("✅ Usuário criado com sucesso!", userCredentials);
|
||||
console.log("🔑 Senha gerada:", userCredentials.password);
|
||||
|
||||
// Armazena as credenciais e mostra o dialog
|
||||
setCredentials(userCredentials);
|
||||
setShowCredentials(true);
|
||||
setSavedDoctor(saved); // Salva médico para chamar onSaved depois
|
||||
|
||||
console.log("📋 Credenciais definidas, dialog deve aparecer!");
|
||||
|
||||
// NÃO chama onSaved aqui! Isso fecha o formulário.
|
||||
// O dialog vai chamar onSaved quando o usuário fechar
|
||||
setSubmitting(false);
|
||||
return; // ← IMPORTANTE: Impede que o código abaixo seja executado
|
||||
|
||||
} catch (userError: any) {
|
||||
console.error("❌ ERRO ao criar usuário:", userError);
|
||||
console.error("📋 Stack trace:", userError?.stack);
|
||||
const errorMessage = userError?.message || "Erro desconhecido";
|
||||
console.error("💬 Mensagem:", errorMessage);
|
||||
|
||||
// Mostra erro mas fecha o formulário normalmente
|
||||
alert(`Médico cadastrado com sucesso!\n\n⚠️ Porém, houve erro ao criar usuário de acesso:\n${errorMessage}\n\nVerifique os logs do console (F12) para mais detalhes.`);
|
||||
|
||||
// Fecha o formulário mesmo com erro na criação de usuário
|
||||
setForm(initial);
|
||||
setPhotoPreview(null);
|
||||
setServerAnexos([]);
|
||||
onSaved?.(saved);
|
||||
if (inline) onClose?.();
|
||||
else onOpenChange?.(false);
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.log("⚠️ Não criará usuário. Motivo:");
|
||||
console.log(" - Mode:", mode);
|
||||
console.log(" - Email:", form.email);
|
||||
console.log(" - Tem @:", form.email?.includes('@'));
|
||||
|
||||
// Se não for criar usuário, fecha normalmente
|
||||
setForm(initial);
|
||||
setPhotoPreview(null);
|
||||
setServerAnexos([]);
|
||||
onSaved?.(saved);
|
||||
if (inline) onClose?.();
|
||||
else onOpenChange?.(false);
|
||||
setSubmitting(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("❌ Erro ao salvar médico:", err);
|
||||
console.error("❌ Detalhes do erro:", {
|
||||
@ -934,18 +1004,90 @@ if (missingFields.length > 0) {
|
||||
</>
|
||||
);
|
||||
|
||||
if (inline) return <div className="space-y-6">{content}</div>;
|
||||
if (inline) {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">{content}</div>
|
||||
|
||||
{/* Dialog de credenciais */}
|
||||
{credentials && (
|
||||
<CredentialsDialog
|
||||
open={showCredentials}
|
||||
onOpenChange={(open) => {
|
||||
console.log("🔄 CredentialsDialog (inline) onOpenChange:", open);
|
||||
setShowCredentials(open);
|
||||
if (!open) {
|
||||
console.log("🔄 Dialog fechando - chamando onSaved e limpando formulário");
|
||||
|
||||
// Chama onSaved com o médico salvo
|
||||
if (savedDoctor) {
|
||||
console.log("✅ Chamando onSaved com médico:", savedDoctor.id);
|
||||
onSaved?.(savedDoctor);
|
||||
}
|
||||
|
||||
// Limpa o formulário e fecha
|
||||
setForm(initial);
|
||||
setPhotoPreview(null);
|
||||
setServerAnexos([]);
|
||||
setCredentials(null);
|
||||
setSavedDoctor(null);
|
||||
onClose?.();
|
||||
}
|
||||
}}
|
||||
email={credentials.email}
|
||||
password={credentials.password}
|
||||
userName={form.full_name}
|
||||
userType="médico"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" /> {title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{content}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" /> {title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{content}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog de credenciais */}
|
||||
{credentials && (
|
||||
<CredentialsDialog
|
||||
open={showCredentials}
|
||||
onOpenChange={(open) => {
|
||||
console.log("🔄 CredentialsDialog (dialog mode) onOpenChange:", open);
|
||||
setShowCredentials(open);
|
||||
if (!open) {
|
||||
console.log("🔄 Dialog fechando - chamando onSaved e fechando modal principal");
|
||||
|
||||
// Chama onSaved com o médico salvo
|
||||
if (savedDoctor) {
|
||||
console.log("✅ Chamando onSaved com médico:", savedDoctor.id);
|
||||
onSaved?.(savedDoctor);
|
||||
}
|
||||
|
||||
// Limpa o formulário e fecha o modal principal
|
||||
setForm(initial);
|
||||
setPhotoPreview(null);
|
||||
setServerAnexos([]);
|
||||
setCredentials(null);
|
||||
setSavedDoctor(null);
|
||||
onOpenChange?.(false);
|
||||
}
|
||||
}}
|
||||
email={credentials.email}
|
||||
password={credentials.password}
|
||||
userName={form.full_name}
|
||||
userType="médico"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -16,6 +16,7 @@ import { AlertCircle, ChevronDown, ChevronUp, FileImage, Loader2, Save, Upload,
|
||||
import {
|
||||
Paciente,
|
||||
PacienteInput,
|
||||
buscarCepAPI,
|
||||
criarPaciente,
|
||||
atualizarPaciente,
|
||||
uploadFotoPaciente,
|
||||
@ -24,14 +25,13 @@ import {
|
||||
listarAnexos,
|
||||
removerAnexo,
|
||||
buscarPacientePorId,
|
||||
verificarCpfDuplicado,
|
||||
} from "@/features/pacientes/api";
|
||||
import {
|
||||
criarUsuarioPaciente,
|
||||
CreateUserWithPasswordResponse,
|
||||
} from "@/features/autenticacao/api";
|
||||
} from "@/lib/api";
|
||||
|
||||
import { validarCPFLocal, buscarCepAPI } from "@/lib/utils";
|
||||
import { validarCPFLocal } from "@/lib/utils";
|
||||
import { verificarCpfDuplicado } from "@/lib/api";
|
||||
import { CredentialsDialog } from "@/components/credentials-dialog";
|
||||
|
||||
|
||||
|
||||
@ -107,6 +107,11 @@ export function PatientRegistrationForm({
|
||||
const [isSearchingCEP, setSearchingCEP] = useState(false);
|
||||
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
||||
const [serverAnexos, setServerAnexos] = useState<any[]>([]);
|
||||
|
||||
// Estados para o dialog de credenciais
|
||||
const [showCredentials, setShowCredentials] = useState(false);
|
||||
const [credentials, setCredentials] = useState<CreateUserWithPasswordResponse | null>(null);
|
||||
const [savedPatient, setSavedPatient] = useState<Paciente | null>(null);
|
||||
|
||||
const title = useMemo(() => (mode === "create" ? "Cadastro de Paciente" : "Editar Paciente"), [mode]);
|
||||
|
||||
@ -236,11 +241,9 @@ export function PatientRegistrationForm({
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Log the validation error for debugging.
|
||||
// Keeping the catch block intentionally minimal to avoid empty-block lint errors.
|
||||
console.error("Erro ao validar CPF", err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Erro ao validar CPF", err);
|
||||
}
|
||||
|
||||
|
||||
setSubmitting(true);
|
||||
@ -258,32 +261,99 @@ export function PatientRegistrationForm({
|
||||
if (form.photo && saved?.id) {
|
||||
try {
|
||||
await uploadFotoPaciente(saved.id, form.photo);
|
||||
} catch (uploadError) {
|
||||
// Não bloquear a criação/edição do paciente se a foto falhar.
|
||||
console.warn("Falha ao enviar foto do paciente:", uploadError);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (form.anexos.length && saved?.id) {
|
||||
for (const f of form.anexos) {
|
||||
try {
|
||||
await adicionarAnexo(saved.id, f);
|
||||
} catch (anexoError) {
|
||||
// Registrar erro de anexo e continuar com os próximos arquivos.
|
||||
console.warn("Falha ao adicionar anexo:", anexoError);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Se for criação de novo paciente e tiver email válido, cria usuário
|
||||
if (mode === "create" && form.email && form.email.includes('@')) {
|
||||
console.log("🔐 Iniciando criação de usuário para o paciente...");
|
||||
console.log("📧 Email:", form.email);
|
||||
console.log("👤 Nome:", form.nome);
|
||||
console.log("📱 Telefone:", form.telefone);
|
||||
|
||||
try {
|
||||
const userCredentials = await criarUsuarioPaciente({
|
||||
email: form.email,
|
||||
full_name: form.nome,
|
||||
phone_mobile: form.telefone,
|
||||
});
|
||||
|
||||
console.log("✅ Usuário criado com sucesso!", userCredentials);
|
||||
console.log("🔑 Senha gerada:", userCredentials.password);
|
||||
|
||||
// Armazena as credenciais e mostra o dialog
|
||||
console.log("📋 Antes de setCredentials - credentials atual:", credentials);
|
||||
console.log("📋 Antes de setShowCredentials - showCredentials atual:", showCredentials);
|
||||
|
||||
setCredentials(userCredentials);
|
||||
setShowCredentials(true);
|
||||
|
||||
console.log("📋 Depois de set - credentials:", userCredentials);
|
||||
console.log("📋 Depois de set - showCredentials: true");
|
||||
console.log("📋 Modo inline?", inline);
|
||||
console.log("📋 userCredentials completo:", JSON.stringify(userCredentials));
|
||||
|
||||
// Força re-render
|
||||
setTimeout(() => {
|
||||
console.log("⏰ Timeout - credentials:", credentials);
|
||||
console.log("⏰ Timeout - showCredentials:", showCredentials);
|
||||
}, 100);
|
||||
|
||||
console.log("📋 Credenciais definidas, dialog deve aparecer!");
|
||||
|
||||
// Salva o paciente para chamar onSaved depois
|
||||
setSavedPatient(saved);
|
||||
|
||||
// ⚠️ NÃO chama onSaved aqui! O dialog vai chamar quando fechar.
|
||||
// Se chamar agora, o formulário fecha e o dialog desaparece.
|
||||
console.log("⚠️ NÃO chamando onSaved ainda - aguardando dialog fechar");
|
||||
|
||||
// RETORNA AQUI para não executar o código abaixo
|
||||
return;
|
||||
|
||||
} catch (userError: any) {
|
||||
console.error("❌ ERRO ao criar usuário:", userError);
|
||||
console.error("📋 Stack trace:", userError?.stack);
|
||||
const errorMessage = userError?.message || "Erro desconhecido";
|
||||
console.error("<22> Mensagem:", errorMessage);
|
||||
|
||||
// Mostra erro mas fecha o formulário normalmente
|
||||
alert(`Paciente cadastrado com sucesso!\n\n⚠️ Porém, houve erro ao criar usuário de acesso:\n${errorMessage}\n\nVerifique os logs do console (F12) para mais detalhes.`);
|
||||
|
||||
// Fecha o formulário mesmo com erro na criação de usuário
|
||||
setForm(initial);
|
||||
setPhotoPreview(null);
|
||||
setServerAnexos([]);
|
||||
|
||||
if (inline) onClose?.();
|
||||
else onOpenChange?.(false);
|
||||
}
|
||||
} else {
|
||||
console.log("⚠️ Não criará usuário. Motivo:");
|
||||
console.log(" - Mode:", mode);
|
||||
console.log(" - Email:", form.email);
|
||||
console.log(" - Tem @:", form.email?.includes('@'));
|
||||
|
||||
// Se não for criar usuário, fecha normalmente
|
||||
setForm(initial);
|
||||
setPhotoPreview(null);
|
||||
setServerAnexos([]);
|
||||
|
||||
if (inline) onClose?.();
|
||||
else onOpenChange?.(false);
|
||||
|
||||
alert(mode === "create" ? "Paciente cadastrado!" : "Paciente atualizado!");
|
||||
}
|
||||
|
||||
onSaved?.(saved);
|
||||
setForm(initial);
|
||||
setPhotoPreview(null);
|
||||
setServerAnexos([]);
|
||||
|
||||
if (inline) onClose?.();
|
||||
else onOpenChange?.(false);
|
||||
|
||||
alert(mode === "create" ? "Paciente cadastrado!" : "Paciente atualizado!");
|
||||
} catch (err: any) {
|
||||
setErrors({ submit: err?.message || "Erro ao salvar paciente." });
|
||||
} finally {
|
||||
@ -622,18 +692,85 @@ export function PatientRegistrationForm({
|
||||
</>
|
||||
);
|
||||
|
||||
if (inline) return <div className="space-y-6">{content}</div>;
|
||||
if (inline) {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">{content}</div>
|
||||
|
||||
{/* Debug */}
|
||||
{console.log("🎨 RENDER inline - credentials:", credentials, "showCredentials:", showCredentials)}
|
||||
|
||||
{/* Dialog de credenciais */}
|
||||
{credentials && (
|
||||
<CredentialsDialog
|
||||
open={showCredentials}
|
||||
onOpenChange={(open) => {
|
||||
console.log("🔄 CredentialsDialog onOpenChange:", open);
|
||||
setShowCredentials(open);
|
||||
if (!open) {
|
||||
console.log("🔄 Dialog fechando - chamando onSaved e limpando formulário");
|
||||
|
||||
// Chama onSaved com o paciente salvo
|
||||
if (savedPatient) {
|
||||
console.log("✅ Chamando onSaved com paciente:", savedPatient.id);
|
||||
onSaved?.(savedPatient);
|
||||
}
|
||||
|
||||
// Limpa o formulário e fecha
|
||||
setForm(initial);
|
||||
setPhotoPreview(null);
|
||||
setServerAnexos([]);
|
||||
setCredentials(null);
|
||||
setSavedPatient(null);
|
||||
onClose?.();
|
||||
}
|
||||
}}
|
||||
email={credentials.email}
|
||||
password={credentials.password}
|
||||
userName={form.nome}
|
||||
userType="paciente"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" /> {title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{content}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<>
|
||||
{console.log("🎨 RENDER dialog - credentials:", credentials, "showCredentials:", showCredentials)}
|
||||
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" /> {title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{content}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog de credenciais */}
|
||||
{credentials && (
|
||||
<CredentialsDialog
|
||||
open={showCredentials}
|
||||
onOpenChange={(open) => {
|
||||
setShowCredentials(open);
|
||||
if (!open) {
|
||||
// Quando fechar o dialog, limpa o formulário e fecha o modal principal
|
||||
setForm(initial);
|
||||
setPhotoPreview(null);
|
||||
setServerAnexos([]);
|
||||
setCredentials(null);
|
||||
onOpenChange?.(false);
|
||||
}
|
||||
}}
|
||||
email={credentials.email}
|
||||
password={credentials.password}
|
||||
userName={form.nome}
|
||||
userType="paciente"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -5,7 +5,7 @@ import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { SimpleThemeToggle } from "@/components/layout/SimpleThemeToggle";
|
||||
import { SimpleThemeToggle } from "@/components/simple-theme-toggle";
|
||||
|
||||
export function Header() {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
@ -22,6 +22,7 @@ export function Header() {
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{}
|
||||
<nav className="hidden md:flex items-center gap-10">
|
||||
<Link
|
||||
href="/"
|
||||
@ -41,30 +42,31 @@ export function Header() {
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{}
|
||||
<div className="hidden md:flex items-center space-x-3">
|
||||
<SimpleThemeToggle />
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-primary bg-transparent shadow-sm shadow-blue-500/10 border border-primary hover:bg-blue-50 dark:shadow-none dark:hover:bg-primary dark:hover:text-primary-foreground"
|
||||
className="text-primary border-primary bg-transparent shadow-sm shadow-blue-500/10 border border-blue-200 hover:bg-blue-50 dark:shadow-none dark:border-primary dark:hover:bg-primary dark:hover:text-primary-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href="/login?role=paciente">Sou Paciente</Link>
|
||||
|
||||
<Link href="/login-paciente">Sou Paciente</Link>
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-sm shadow-blue-500/10 border border-transparent dark:shadow-none"
|
||||
asChild
|
||||
>
|
||||
<Link href="/login?role=profissional">Sou Profissional de Saúde</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-primary bg-transparent shadow-sm shadow-blue-500/10 border border-primary hover:bg-blue-50 dark:shadow-none dark:hover:bg-primary dark:hover:text-primary-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href="/login?role=administrador">Sou Administrador de uma Clínica</Link>
|
||||
<Button className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-sm shadow-blue-500/10 border border-blue-200 dark:shadow-none dark:border-transparent">
|
||||
<Link href="/login">Sou Profissional de Saúde</Link>
|
||||
</Button>
|
||||
<Link href="/login-admin">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-primary border-primary bg-transparent shadow-sm shadow-blue-500/10 border border-blue-200 hover:bg-blue-50 dark:shadow-none dark:border-primary dark:hover:bg-primary dark:hover:text-primary-foreground cursor-pointer"
|
||||
>
|
||||
Sou Administrador de uma Clínica
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<button
|
||||
className="md:hidden p-2"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
@ -74,6 +76,7 @@ export function Header() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{}
|
||||
{isMenuOpen && (
|
||||
<div className="md:hidden py-4 border-t border-border">
|
||||
<nav className="flex flex-col space-y-4">
|
||||
@ -95,24 +98,22 @@ export function Header() {
|
||||
<SimpleThemeToggle />
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-primary bg-transparent shadow-sm shadow-blue-500/10 border border-primary hover:bg-blue-50 dark:shadow-none dark:hover:bg-primary dark:hover:text-primary-foreground"
|
||||
className="text-primary border-primary bg-transparent shadow-sm shadow-blue-500/10 border border-blue-200 hover:bg-blue-50 dark:shadow-none dark:border-primary dark:hover:bg-primary dark:hover:text-primary-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href="/login?role=paciente">Sou Paciente</Link>
|
||||
<Link href="/login-paciente">Sou Paciente</Link>
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground w-full shadow-sm shadow-blue-500/10 border border-transparent dark:shadow-none"
|
||||
asChild
|
||||
>
|
||||
<Link href="/login?role=profissional">Sou Profissional de Saúde</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-primary bg-transparent w-full shadow-sm shadow-blue-500/10 border border-primary hover:bg-blue-50 dark:shadow-none dark:hover:bg-primary dark:hover:text-primary-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href="/login?role=administrador">Sou Administrador de uma Clínica</Link>
|
||||
<Button className="bg-primary hover:bg-primary/90 text-primary-foreground w-full shadow-sm shadow-blue-500/10 border border-blue-200 dark:shadow-none dark:border-transparent">
|
||||
<Link href="/login">Sou Profissional de Saúde</Link>
|
||||
</Button>
|
||||
<Link href="/login-admin">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-primary border-primary bg-transparent w-full shadow-sm shadow-blue-500/10 border border-blue-200 hover:bg-blue-50 dark:shadow-none dark:border-primary dark:hover:bg-primary dark:hover:text-primary-foreground cursor-pointer"
|
||||
>
|
||||
Sou Administrador de uma Clínica
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
@ -7,6 +7,7 @@ export function HeroSection() {
|
||||
<section className="py-8 lg:py-12 bg-background">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid lg:grid-cols-2 gap-8 items-center">
|
||||
{}
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="inline-block px-4 py-2 bg-accent/10 text-accent rounded-full text-sm font-medium">
|
||||
@ -22,25 +23,27 @@ export function HeroSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground cursor-pointer shadow-sm shadow-blue-500/10 border border-transparent dark:shadow-none"
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground cursor-pointer shadow-sm shadow-blue-500/10 border border-blue-200 dark:shadow-none dark:border-transparent"
|
||||
asChild
|
||||
>
|
||||
<Link href="/login?role=paciente">Portal do Paciente</Link>
|
||||
<Link href="/login-paciente">Portal do Paciente</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="text-primary bg-transparent cursor-pointer shadow-sm shadow-blue-500/10 border border-primary hover:bg-blue-50 dark:shadow-none dark:hover:bg-primary dark:hover:text-primary-foreground"
|
||||
className="text-primary border-primary bg-transparent cursor-pointer shadow-sm shadow-blue-500/10 border border-blue-200 hover:bg-blue-50 dark:shadow-none dark:border-primary dark:hover:bg-primary dark:hover:text-primary-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href="/login?role=profissional">Sou Profissional de Saúde</Link>
|
||||
<Link href="/login">Sou Profissional de Saúde</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="relative">
|
||||
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-accent/20 to-primary/20 p-6">
|
||||
<img
|
||||
@ -52,6 +55,7 @@ export function HeroSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="mt-10 grid md:grid-cols-3 gap-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user