Compare commits
12 Commits
b0ab1e86ca
...
107bba89d8
| Author | SHA1 | Date | |
|---|---|---|---|
| 107bba89d8 | |||
|
|
0a7d3f3ae4 | ||
|
|
a43fdcc655 | ||
|
|
d7fbcab6a6 | ||
|
|
40bf3e0e6f | ||
|
|
a994a70d90 | ||
| 8955446bc7 | |||
| 11c8b790ba | |||
| 0510ef8a36 | |||
| 8284ccbadd | |||
| 63e5a2ca9d | |||
| 20f7d79474 |
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,86 +1,263 @@
|
||||
|
||||
"use client";
|
||||
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileDown } from "lucide-react";
|
||||
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 } from "recharts";
|
||||
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() {
|
||||
// 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>
|
||||
<h1 className="text-2xl font-bold mb-6 text-foreground">Dashboard Executivo de 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>
|
||||
{/* 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>
|
||||
|
||||
{/* 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 }}>
|
||||
{/* 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 />
|
||||
<Legend />
|
||||
<Bar dataKey="faturamento" fill="#10b981" name="Faturamento" />
|
||||
<Bar dataKey="despesas" fill="#ef4444" name="Despesas" />
|
||||
</BarChart>
|
||||
<Line type="monotone" dataKey="valor" stroke="#10b981" name="Faturamento" strokeWidth={3} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<Button onClick={exportFinanceiroPDF} className="mt-4">
|
||||
<FileDown className="mr-2 h-4 w-4" /> Exportar PDF
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
@ -33,7 +33,16 @@ export default function LoginPacientePage() {
|
||||
console.error('[LOGIN-PACIENTE] Erro no login:', err)
|
||||
|
||||
if (err instanceof AuthenticationError) {
|
||||
setError(err.message)
|
||||
// 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.')
|
||||
}
|
||||
|
||||
@ -35,7 +35,16 @@ export default function LoginPage() {
|
||||
console.error('[LOGIN-PROFISSIONAL] Erro no login:', err)
|
||||
|
||||
if (err instanceof AuthenticationError) {
|
||||
setError(err.message)
|
||||
// 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.')
|
||||
}
|
||||
|
||||
@ -1,94 +1,577 @@
|
||||
'use client'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
// 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 { User, LogOut, Home } from 'lucide-react'
|
||||
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 () => {
|
||||
console.log('[PACIENTE] Iniciando logout...')
|
||||
await logout()
|
||||
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-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"
|
||||
>
|
||||
<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" />
|
||||
Voltar ao Início
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
)
|
||||
|
||||
@ -10,6 +10,7 @@ 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,
|
||||
@ -2032,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":
|
||||
@ -2049,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);
|
||||
};
|
||||
@ -2084,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]')
|
||||
@ -2383,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 */}
|
||||
@ -2443,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>
|
||||
@ -3270,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">
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
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();
|
||||
@ -44,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
|
||||
|
||||
@ -24,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;
|
||||
@ -150,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]);
|
||||
|
||||
@ -391,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:", {
|
||||
@ -935,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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -25,10 +25,13 @@ import {
|
||||
listarAnexos,
|
||||
removerAnexo,
|
||||
buscarPacientePorId,
|
||||
criarUsuarioPaciente,
|
||||
CreateUserWithPasswordResponse,
|
||||
} from "@/lib/api";
|
||||
|
||||
import { validarCPFLocal } from "@/lib/utils";
|
||||
import { verificarCpfDuplicado } from "@/lib/api";
|
||||
import { verificarCpfDuplicado } from "@/lib/api";
|
||||
import { CredentialsDialog } from "@/components/credentials-dialog";
|
||||
|
||||
|
||||
|
||||
@ -104,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]);
|
||||
|
||||
@ -264,15 +272,88 @@ export function PatientRegistrationForm({
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@ -611,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,31 +5,84 @@ import eslint from "@eslint/js";
|
||||
import nextPlugin from "@next/eslint-plugin-next";
|
||||
import unicornPlugin from "eslint-plugin-unicorn";
|
||||
import prettierConfig from "eslint-config-prettier";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname } from "path";
|
||||
|
||||
export default [
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
{
|
||||
ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts"],
|
||||
},
|
||||
// Base JS/TS config
|
||||
{
|
||||
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",
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"@next/next": nextPlugin,
|
||||
},
|
||||
rules: {
|
||||
...nextPlugin.configs.recommended.rules,
|
||||
...nextPlugin.configs["core-web-vitals"].rules,
|
||||
...unicornPlugin.configs.recommended.rules,
|
||||
...nextPlugin.configs.recommended.rules,
|
||||
...nextPlugin.configs["core-web-vitals"].rules,
|
||||
}
|
||||
},
|
||||
// TypeScript specific config
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
plugins: {
|
||||
"unicorn": unicornPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...tseslint.configs.recommended.rules,
|
||||
...unicornPlugin.configs.recommended.rules,
|
||||
// Disable noisy unicorn rules
|
||||
"unicorn/prevent-abbreviations": "off",
|
||||
"unicorn/filename-case": "off",
|
||||
"unicorn/no-null": "off",
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
"unicorn/no-array-for-each": "off",
|
||||
"unicorn/catch-error-name": "off",
|
||||
"unicorn/explicit-length-check": "off",
|
||||
"unicorn/no-array-reduce": "off",
|
||||
"unicorn/prefer-spread": "off",
|
||||
"unicorn/no-document-cookie": "off",
|
||||
"unicorn/prefer-query-selector": "off",
|
||||
"unicorn/prefer-add-event-listener": "off",
|
||||
"unicorn/prefer-string-slice": "off",
|
||||
"unicorn/prefer-string-replace-all": "off",
|
||||
"unicorn/prefer-number-properties": "off",
|
||||
"unicorn/consistent-existence-index-check": "off",
|
||||
"unicorn/no-negated-condition": "off",
|
||||
"unicorn/switch-case-braces": "off",
|
||||
"unicorn/prefer-global-this": "off",
|
||||
"unicorn/no-useless-undefined": "off",
|
||||
"unicorn/no-array-callback-reference": "off",
|
||||
"unicorn/no-array-sort": "off",
|
||||
"unicorn/numeric-separators-style": "off",
|
||||
"unicorn/prefer-optional-catch-binding": "off",
|
||||
"unicorn/prefer-ternary": "off",
|
||||
"unicorn/prefer-code-point": "off",
|
||||
"unicorn/prefer-single-call": "off",
|
||||
}
|
||||
},
|
||||
prettierConfig,
|
||||
];
|
||||
...compat.extends("next/core-web-vitals"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
// lib/api.ts
|
||||
|
||||
import { ENV_CONFIG } from '@/lib/env-config';
|
||||
import { API_KEY } from '@/lib/config';
|
||||
|
||||
export type ApiOk<T = any> = {
|
||||
success?: boolean;
|
||||
data: T;
|
||||
@ -194,7 +197,32 @@ async function parse<T>(res: Response): Promise<T> {
|
||||
console.error("[API ERROR]", res.url, res.status, json);
|
||||
const code = (json && (json.error?.code || json.code)) ?? res.status;
|
||||
const msg = (json && (json.error?.message || json.message)) ?? res.statusText;
|
||||
throw new Error(`${code}: ${msg}`);
|
||||
|
||||
// Mensagens amigáveis para erros comuns
|
||||
let friendlyMessage = `${code}: ${msg}`;
|
||||
|
||||
// Erro de CPF duplicado
|
||||
if (code === '23505' && msg.includes('patients_cpf_key')) {
|
||||
friendlyMessage = 'Já existe um paciente cadastrado com este CPF. Por favor, verifique se o paciente já está registrado no sistema ou use um CPF diferente.';
|
||||
}
|
||||
// Erro de email duplicado (paciente)
|
||||
else if (code === '23505' && msg.includes('patients_email_key')) {
|
||||
friendlyMessage = 'Já existe um paciente cadastrado com este email. Por favor, use um email diferente.';
|
||||
}
|
||||
// Erro de CRM duplicado (médico)
|
||||
else if (code === '23505' && msg.includes('doctors_crm')) {
|
||||
friendlyMessage = 'Já existe um médico cadastrado com este CRM. Por favor, verifique se o médico já está registrado no sistema.';
|
||||
}
|
||||
// Erro de email duplicado (médico)
|
||||
else if (code === '23505' && msg.includes('doctors_email_key')) {
|
||||
friendlyMessage = 'Já existe um médico cadastrado com este email. Por favor, use um email diferente.';
|
||||
}
|
||||
// Outros erros de constraint unique
|
||||
else if (code === '23505') {
|
||||
friendlyMessage = 'Registro duplicado: já existe um cadastro com essas informações no sistema.';
|
||||
}
|
||||
|
||||
throw new Error(friendlyMessage);
|
||||
}
|
||||
|
||||
return (json?.data ?? json) as T;
|
||||
@ -552,6 +580,432 @@ export async function excluirMedico(id: string | number): Promise<void> {
|
||||
await parse<any>(res);
|
||||
}
|
||||
|
||||
// ===== USUÁRIOS =====
|
||||
export type UserRole = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
role: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export async function listarUserRoles(): Promise<UserRole[]> {
|
||||
const url = `https://mock.apidog.com/m1/1053378-0-default/rest/v1/user_roles`;
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: baseHeaders(),
|
||||
});
|
||||
return await parse<UserRole[]>(res);
|
||||
}
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
email_confirmed_at: string;
|
||||
created_at: string;
|
||||
last_sign_in_at: string;
|
||||
};
|
||||
|
||||
export type CurrentUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
email_confirmed_at: string;
|
||||
created_at: string;
|
||||
last_sign_in_at: string;
|
||||
};
|
||||
|
||||
export type Profile = {
|
||||
id: string;
|
||||
full_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
avatar_url: string;
|
||||
disabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ProfileInput = Partial<Omit<Profile, 'id' | 'created_at' | 'updated_at'>>;
|
||||
|
||||
export type Permissions = {
|
||||
isAdmin: boolean;
|
||||
isManager: boolean;
|
||||
isDoctor: boolean;
|
||||
isSecretary: boolean;
|
||||
isAdminOrManager: boolean;
|
||||
};
|
||||
|
||||
export type UserInfo = {
|
||||
user: User;
|
||||
profile: Profile;
|
||||
roles: string[];
|
||||
permissions: Permissions;
|
||||
};
|
||||
|
||||
export async function getCurrentUser(): Promise<CurrentUser> {
|
||||
const url = `https://mock.apidog.com/m1/1053378-0-default/auth/v1/user`;
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: baseHeaders(),
|
||||
});
|
||||
return await parse<CurrentUser>(res);
|
||||
}
|
||||
|
||||
export async function getUserInfo(): Promise<UserInfo> {
|
||||
const url = `https://mock.apidog.com/m1/1053378-0-default/functions/v1/user-info`;
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: baseHeaders(),
|
||||
});
|
||||
return await parse<UserInfo>(res);
|
||||
}
|
||||
|
||||
export type CreateUserInput = {
|
||||
email: string;
|
||||
full_name: string;
|
||||
phone: string;
|
||||
role: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export type CreatedUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
phone: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
export type CreateUserResponse = {
|
||||
success: boolean;
|
||||
user: CreatedUser;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export type CreateUserWithPasswordResponse = {
|
||||
success: boolean;
|
||||
user: CreatedUser;
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
// Função para gerar 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}!`;
|
||||
}
|
||||
|
||||
export async function criarUsuario(input: CreateUserInput): Promise<CreateUserResponse> {
|
||||
const url = `https://mock.apidog.com/m1/1053378-0-default/functions/v1/create-user`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { ...baseHeaders(), "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return await parse<CreateUserResponse>(res);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CRIAÇÃO DE USUÁRIOS NO SUPABASE AUTH
|
||||
// Vínculo com pacientes/médicos por EMAIL
|
||||
// ============================================
|
||||
|
||||
// Criar usuário para MÉDICO no Supabase Auth (sistema de autenticação)
|
||||
export async function criarUsuarioMedico(medico: {
|
||||
email: string;
|
||||
full_name: string;
|
||||
phone_mobile: string;
|
||||
}): Promise<CreateUserWithPasswordResponse> {
|
||||
|
||||
const senha = gerarSenhaAleatoria();
|
||||
|
||||
console.log('🏥 [CRIAR MÉDICO] Iniciando criação no Supabase Auth...');
|
||||
console.log('📧 Email:', medico.email);
|
||||
console.log('👤 Nome:', medico.full_name);
|
||||
console.log('📱 Telefone:', medico.phone_mobile);
|
||||
console.log('🔑 Senha gerada:', senha);
|
||||
|
||||
// Endpoint do Supabase Auth (mesmo que auth.ts usa)
|
||||
const signupUrl = `${ENV_CONFIG.SUPABASE_URL}/auth/v1/signup`;
|
||||
|
||||
const payload = {
|
||||
email: medico.email,
|
||||
password: senha,
|
||||
data: {
|
||||
userType: 'profissional', // Para login em /login -> /profissional
|
||||
full_name: medico.full_name,
|
||||
phone: medico.phone_mobile,
|
||||
}
|
||||
};
|
||||
|
||||
console.log('📤 [CRIAR MÉDICO] Enviando para:', signupUrl);
|
||||
|
||||
try {
|
||||
const response = await fetch(signupUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"apikey": API_KEY,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
console.log('📋 [CRIAR MÉDICO] Status da resposta:', response.status, response.statusText);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('❌ [CRIAR MÉDICO] Erro na resposta:', errorText);
|
||||
|
||||
// Tenta parsear o erro para pegar mensagem específica
|
||||
let errorMsg = `Erro ao criar usuário (${response.status})`;
|
||||
try {
|
||||
const errorData = JSON.parse(errorText);
|
||||
errorMsg = errorData.msg || errorData.message || errorData.error_description || errorMsg;
|
||||
|
||||
// Mensagens amigáveis para erros comuns
|
||||
if (errorMsg.includes('already registered') || errorMsg.includes('already exists')) {
|
||||
errorMsg = 'Este email já está cadastrado no sistema';
|
||||
} else if (errorMsg.includes('invalid email')) {
|
||||
errorMsg = 'Formato de email inválido';
|
||||
} else if (errorMsg.includes('weak password')) {
|
||||
errorMsg = 'Senha muito fraca';
|
||||
}
|
||||
} catch (e) {
|
||||
// Se não conseguir parsear, usa mensagem genérica
|
||||
}
|
||||
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
console.log('✅ [CRIAR MÉDICO] Usuário criado com sucesso no Supabase Auth!');
|
||||
console.log('🆔 User ID:', responseData.user?.id || responseData.id);
|
||||
|
||||
// 🔧 AUTO-CONFIRMAR EMAIL: Fazer login automático logo após criar usuário
|
||||
// Isso força o Supabase a confirmar o email automaticamente
|
||||
if (responseData.user?.email_confirmed_at === null || !responseData.user?.email_confirmed_at) {
|
||||
console.warn('⚠️ [CRIAR MÉDICO] Email NÃO confirmado - tentando auto-confirmar via login...');
|
||||
|
||||
try {
|
||||
const loginUrl = `${ENV_CONFIG.SUPABASE_URL}/auth/v1/token?grant_type=password`;
|
||||
console.log('🔧 [AUTO-CONFIRMAR] Fazendo login automático para confirmar email...');
|
||||
|
||||
const loginResponse = await fetch(loginUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'apikey': ENV_CONFIG.SUPABASE_ANON_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: medico.email,
|
||||
password: senha,
|
||||
}),
|
||||
});
|
||||
|
||||
if (loginResponse.ok) {
|
||||
const loginData = await loginResponse.json();
|
||||
console.log('✅ [AUTO-CONFIRMAR] Login automático realizado com sucesso!');
|
||||
console.log('📦 [AUTO-CONFIRMAR] Email confirmado:', loginData.user?.email_confirmed_at ? 'SIM ✅' : 'NÃO ❌');
|
||||
|
||||
// Atualizar responseData com dados do login (que tem email confirmado)
|
||||
if (loginData.user) {
|
||||
responseData.user = loginData.user;
|
||||
}
|
||||
} else {
|
||||
const errorText = await loginResponse.text();
|
||||
console.error('❌ [AUTO-CONFIRMAR] Falha no login automático:', loginResponse.status, errorText);
|
||||
console.warn('⚠️ [AUTO-CONFIRMAR] Usuário pode não conseguir fazer login imediatamente!');
|
||||
}
|
||||
} catch (confirmError) {
|
||||
console.error('❌ [AUTO-CONFIRMAR] Erro ao tentar fazer login automático:', confirmError);
|
||||
console.warn('⚠️ [AUTO-CONFIRMAR] Continuando sem confirmação automática...');
|
||||
}
|
||||
} else {
|
||||
console.log('✅ [CRIAR MÉDICO] Email confirmado automaticamente!');
|
||||
}
|
||||
|
||||
// Log bem visível com as credenciais para teste
|
||||
console.log('🔐🔐🔐 ========================================');
|
||||
console.log('🔐 CREDENCIAIS DO MÉDICO CRIADO:');
|
||||
console.log('🔐 Email:', medico.email);
|
||||
console.log('🔐 Senha:', senha);
|
||||
console.log('🔐 Pode fazer login?', responseData.user?.email_confirmed_at ? 'SIM ✅' : 'NÃO ❌ (precisa confirmar email)');
|
||||
console.log('🔐 ========================================');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: responseData.user || responseData,
|
||||
email: medico.email,
|
||||
password: senha,
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('❌ [CRIAR MÉDICO] Erro ao criar usuário:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Criar usuário para PACIENTE no Supabase Auth (sistema de autenticação)
|
||||
export async function criarUsuarioPaciente(paciente: {
|
||||
email: string;
|
||||
full_name: string;
|
||||
phone_mobile: string;
|
||||
}): Promise<CreateUserWithPasswordResponse> {
|
||||
|
||||
const senha = gerarSenhaAleatoria();
|
||||
|
||||
console.log('🏥 [CRIAR PACIENTE] Iniciando criação no Supabase Auth...');
|
||||
console.log('📧 Email:', paciente.email);
|
||||
console.log('👤 Nome:', paciente.full_name);
|
||||
console.log('📱 Telefone:', paciente.phone_mobile);
|
||||
console.log('🔑 Senha gerada:', senha);
|
||||
|
||||
// Endpoint do Supabase Auth (mesmo que auth.ts usa)
|
||||
const signupUrl = `${ENV_CONFIG.SUPABASE_URL}/auth/v1/signup`;
|
||||
|
||||
const payload = {
|
||||
email: paciente.email,
|
||||
password: senha,
|
||||
data: {
|
||||
userType: 'paciente', // Para login em /login-paciente -> /paciente
|
||||
full_name: paciente.full_name,
|
||||
phone: paciente.phone_mobile,
|
||||
}
|
||||
};
|
||||
|
||||
console.log('📤 [CRIAR PACIENTE] Enviando para:', signupUrl);
|
||||
|
||||
try {
|
||||
const response = await fetch(signupUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"apikey": API_KEY,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
console.log('📋 [CRIAR PACIENTE] Status da resposta:', response.status, response.statusText);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('❌ [CRIAR PACIENTE] Erro na resposta:', errorText);
|
||||
|
||||
// Tenta parsear o erro para pegar mensagem específica
|
||||
let errorMsg = `Erro ao criar usuário (${response.status})`;
|
||||
try {
|
||||
const errorData = JSON.parse(errorText);
|
||||
errorMsg = errorData.msg || errorData.message || errorData.error_description || errorMsg;
|
||||
|
||||
// Mensagens amigáveis para erros comuns
|
||||
if (errorMsg.includes('already registered') || errorMsg.includes('already exists')) {
|
||||
errorMsg = 'Este email já está cadastrado no sistema';
|
||||
} else if (errorMsg.includes('invalid email')) {
|
||||
errorMsg = 'Formato de email inválido';
|
||||
} else if (errorMsg.includes('weak password')) {
|
||||
errorMsg = 'Senha muito fraca';
|
||||
}
|
||||
} catch (e) {
|
||||
// Se não conseguir parsear, usa mensagem genérica
|
||||
}
|
||||
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
console.log('✅ [CRIAR PACIENTE] Usuário criado com sucesso no Supabase Auth!');
|
||||
console.log('🆔 User ID:', responseData.user?.id || responseData.id);
|
||||
console.log('📦 [CRIAR PACIENTE] Resposta completa do Supabase:', JSON.stringify(responseData, null, 2));
|
||||
|
||||
// VERIFICAÇÃO CRÍTICA: O usuário foi realmente criado?
|
||||
if (!responseData.user && !responseData.id) {
|
||||
console.error('⚠️⚠️⚠️ AVISO: Supabase retornou sucesso mas SEM user ID!');
|
||||
console.error('Isso pode significar que o usuário NÃO foi criado de verdade!');
|
||||
}
|
||||
|
||||
const userId = responseData.user?.id || responseData.id;
|
||||
|
||||
// 🔧 AUTO-CONFIRMAR EMAIL: Fazer login automático logo após criar usuário
|
||||
// Isso força o Supabase a confirmar o email automaticamente
|
||||
if (responseData.user?.email_confirmed_at === null || !responseData.user?.email_confirmed_at) {
|
||||
console.warn('⚠️ [CRIAR PACIENTE] Email NÃO confirmado - tentando auto-confirmar via login...');
|
||||
|
||||
try {
|
||||
const loginUrl = `${ENV_CONFIG.SUPABASE_URL}/auth/v1/token?grant_type=password`;
|
||||
console.log('🔧 [AUTO-CONFIRMAR] Fazendo login automático para confirmar email...');
|
||||
|
||||
const loginResponse = await fetch(loginUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'apikey': ENV_CONFIG.SUPABASE_ANON_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: paciente.email,
|
||||
password: senha,
|
||||
}),
|
||||
});
|
||||
|
||||
console.log('🔍 [AUTO-CONFIRMAR] Status do login automático:', loginResponse.status);
|
||||
|
||||
if (loginResponse.ok) {
|
||||
const loginData = await loginResponse.json();
|
||||
console.log('✅ [AUTO-CONFIRMAR] Login automático realizado com sucesso!');
|
||||
console.log('📦 [AUTO-CONFIRMAR] Dados completos do login:', JSON.stringify(loginData, undefined, 2));
|
||||
console.log('📧 [AUTO-CONFIRMAR] Email confirmado:', loginData.user?.email_confirmed_at ? 'SIM ✅' : 'NÃO ❌');
|
||||
console.log('👤 [AUTO-CONFIRMAR] UserType no metadata:', loginData.user?.user_metadata?.userType);
|
||||
console.log('🎯 [AUTO-CONFIRMAR] Email verified:', loginData.user?.user_metadata?.email_verified);
|
||||
|
||||
// Atualizar responseData com dados do login (que tem email confirmado)
|
||||
if (loginData.user) {
|
||||
responseData.user = loginData.user;
|
||||
}
|
||||
} else {
|
||||
const errorText = await loginResponse.text();
|
||||
console.error('❌ [AUTO-CONFIRMAR] Falha no login automático:', loginResponse.status, errorText);
|
||||
console.warn('⚠️ [AUTO-CONFIRMAR] Usuário pode não conseguir fazer login imediatamente!');
|
||||
|
||||
// Tentar parsear o erro para entender melhor
|
||||
try {
|
||||
const errorData = JSON.parse(errorText);
|
||||
console.error('📋 [AUTO-CONFIRMAR] Detalhes do erro:', errorData);
|
||||
} catch (e) {
|
||||
console.error('📋 [AUTO-CONFIRMAR] Erro não é JSON:', errorText);
|
||||
}
|
||||
}
|
||||
} catch (confirmError) {
|
||||
console.error('❌ [AUTO-CONFIRMAR] Erro ao tentar fazer login automático:', confirmError);
|
||||
console.warn('⚠️ [AUTO-CONFIRMAR] Continuando sem confirmação automática...');
|
||||
}
|
||||
} else {
|
||||
console.log('✅ [CRIAR PACIENTE] Email confirmado automaticamente!');
|
||||
}
|
||||
|
||||
// Log bem visível com as credenciais para teste
|
||||
console.log('🔐🔐🔐 ========================================');
|
||||
console.log('🔐 CREDENCIAIS DO PACIENTE CRIADO:');
|
||||
console.log('🔐 Email:', paciente.email);
|
||||
console.log('🔐 Senha:', senha);
|
||||
console.log('🔐 UserType:', 'paciente');
|
||||
console.log('🔐 Pode fazer login?', responseData.user?.email_confirmed_at ? 'SIM ✅' : 'NÃO ❌ (precisa confirmar email)');
|
||||
console.log('🔐 ========================================');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: responseData.user || responseData,
|
||||
email: paciente.email,
|
||||
password: senha,
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('❌ [CRIAR PACIENTE] Erro ao criar usuário:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== CEP (usado nos formulários) =====
|
||||
export async function buscarCepAPI(cep: string): Promise<{
|
||||
logradouro?: string;
|
||||
@ -588,3 +1042,14 @@ export async function adicionarAnexoMedico(_id: string | number, _file: File): P
|
||||
export async function removerAnexoMedico(_id: string | number, _anexoId: string | number): Promise<void> {}
|
||||
export async function uploadFotoMedico(_id: string | number, _file: File): Promise<{ foto_url?: string; thumbnail_url?: string }> { return {}; }
|
||||
export async function removerFotoMedico(_id: string | number): Promise<void> {}
|
||||
|
||||
// ===== PERFIS DE USUÁRIOS =====
|
||||
export async function listarPerfis(): Promise<Profile[]> {
|
||||
const url = `https://mock.apidog.com/m1/1053378-0-default/rest/v1/profiles`;
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: baseHeaders(),
|
||||
});
|
||||
return await parse<Profile[]>(res);
|
||||
}
|
||||
|
||||
|
||||
@ -103,6 +103,11 @@ export async function loginUser(
|
||||
payload,
|
||||
timestamp: new Date().toLocaleTimeString()
|
||||
});
|
||||
|
||||
console.log('🔑 [AUTH-API] Credenciais sendo usadas no login:');
|
||||
console.log('📧 Email:', email);
|
||||
console.log('🔐 Senha:', password);
|
||||
console.log('👤 UserType:', userType);
|
||||
|
||||
// Delay para visualizar na aba Network
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
@ -113,53 +118,19 @@ export async function loginUser(
|
||||
// Debug: Log request sem credenciais sensíveis
|
||||
debugRequest('POST', url, getLoginHeaders(), payload);
|
||||
|
||||
let response = await fetch(url, {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: getLoginHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
// Se login falhar com 400, tentar criar usuário automaticamente
|
||||
if (!response.ok && response.status === 400) {
|
||||
console.log('[AUTH-API] Login falhou (400), tentando criar usuário...');
|
||||
|
||||
const signupUrl = `${ENV_CONFIG.SUPABASE_URL}/auth/v1/signup`;
|
||||
const signupPayload = {
|
||||
email,
|
||||
password,
|
||||
data: {
|
||||
userType: userType,
|
||||
name: email.split('@')[0],
|
||||
}
|
||||
};
|
||||
|
||||
debugRequest('POST', signupUrl, getLoginHeaders(), signupPayload);
|
||||
|
||||
const signupResponse = await fetch(signupUrl, {
|
||||
method: 'POST',
|
||||
headers: getLoginHeaders(),
|
||||
body: JSON.stringify(signupPayload),
|
||||
});
|
||||
|
||||
if (signupResponse.ok) {
|
||||
console.log('[AUTH-API] Usuário criado, tentando login novamente...');
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: getLoginHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[AUTH-API] Login response: ${response.status} ${response.statusText}`, {
|
||||
url: response.url,
|
||||
status: response.status,
|
||||
timestamp: new Date().toLocaleTimeString()
|
||||
});
|
||||
|
||||
// Se ainda for 400, mostrar detalhes do erro
|
||||
// Se falhar, mostrar detalhes do erro
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const errorText = await response.text();
|
||||
|
||||
1
susconecta/lib/report.ts
Normal file
1
susconecta/lib/report.ts
Normal file
@ -0,0 +1 @@
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"dev": "next dev",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint .",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
@ -86,6 +86,7 @@
|
||||
"tailwindcss": "^4.1.9",
|
||||
"tw-animate-css": "1.3.3",
|
||||
"typescript": "^5",
|
||||
"typescript-eslint": "^8.45.0"
|
||||
"typescript-eslint": "^8.45.0",
|
||||
"@eslint/eslintrc": "^3"
|
||||
}
|
||||
}
|
||||
|
||||
3
susconecta/pnpm-lock.yaml
generated
3
susconecta/pnpm-lock.yaml
generated
@ -183,6 +183,9 @@ importers:
|
||||
specifier: 3.25.67
|
||||
version: 3.25.67
|
||||
devDependencies:
|
||||
'@eslint/eslintrc':
|
||||
specifier: ^3
|
||||
version: 3.3.1
|
||||
'@eslint/js':
|
||||
specifier: ^9.36.0
|
||||
version: 9.36.0
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user