fix/avatar #64
14
next.config.mjs
Normal file
14
next.config.mjs
Normal file
@ -0,0 +1,14 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
async rewrites() {
|
||||
return [
|
||||
// Proxy local → Supabase (bypass CORS no navegador)
|
||||
{
|
||||
source: '/proxy/supabase/:path*',
|
||||
destination: 'https://yuanqfswhberkoevtmfr.supabase.co/:path*',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
773
package-lock.json
generated
773
package-lock.json
generated
@ -1,773 +0,0 @@
|
||||
{
|
||||
"name": "riseup-squad20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.7",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@supabase/supabase-js": "^2.75.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-signature-canvas": "^1.1.0-alpha.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
|
||||
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react": {
|
||||
"version": "0.26.28",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
|
||||
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.1.2",
|
||||
"@floating-ui/utils": "^0.2.8",
|
||||
"tabbable": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
|
||||
"integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@headlessui/react": {
|
||||
"version": "2.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.7.tgz",
|
||||
"integrity": "sha512-WKdTymY8Y49H8/gUc/lIyYK1M+/6dq0Iywh4zTZVAaiTDprRfioxSgD0wnXTQTBpjpGJuTL1NO/mqEvc//5SSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.16",
|
||||
"@react-aria/focus": "^3.20.2",
|
||||
"@react-aria/interactions": "^3.25.0",
|
||||
"@tanstack/react-virtual": "^3.13.9",
|
||||
"use-sync-external-store": "^1.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroicons/react": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
|
||||
"integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">= 16 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/focus": {
|
||||
"version": "3.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.1.tgz",
|
||||
"integrity": "sha512-hmH1IhHlcQ2lSIxmki1biWzMbGgnhdxJUM0MFfzc71Rv6YAzhlx4kX3GYn4VNcjCeb6cdPv4RZ5vunV4kgMZYQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/interactions": "^3.25.5",
|
||||
"@react-aria/utils": "^3.30.1",
|
||||
"@react-types/shared": "^3.32.0",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/interactions": {
|
||||
"version": "3.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.5.tgz",
|
||||
"integrity": "sha512-EweYHOEvMwef/wsiEqV73KurX/OqnmbzKQa2fLxdULbec5+yDj6wVGaRHIzM4NiijIDe+bldEl5DG05CAKOAHA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/ssr": "^3.9.10",
|
||||
"@react-aria/utils": "^3.30.1",
|
||||
"@react-stately/flags": "^3.1.2",
|
||||
"@react-types/shared": "^3.32.0",
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/ssr": {
|
||||
"version": "3.9.10",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz",
|
||||
"integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/utils": {
|
||||
"version": "3.30.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.30.1.tgz",
|
||||
"integrity": "sha512-zETcbDd6Vf9GbLndO6RiWJadIZsBU2MMm23rBACXLmpRztkrIqPEb2RVdlLaq1+GklDx0Ii6PfveVjx+8S5U6A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/ssr": "^3.9.10",
|
||||
"@react-stately/flags": "^3.1.2",
|
||||
"@react-stately/utils": "^3.10.8",
|
||||
"@react-types/shared": "^3.32.0",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/flags": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz",
|
||||
"integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/utils": {
|
||||
"version": "3.10.8",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.8.tgz",
|
||||
"integrity": "sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/shared": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.0.tgz",
|
||||
"integrity": "sha512-t+cligIJsZYFMSPFMvsJMjzlzde06tZMOIOFa1OV5Z0BcMowrb2g4mB57j/9nP28iJIRYn10xCniQts+qadrqQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@restart/hooks": {
|
||||
"version": "0.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz",
|
||||
"integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.75.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.75.0.tgz",
|
||||
"integrity": "sha512-J8TkeqCOMCV4KwGKVoxmEBuDdHRwoInML2vJilthOo7awVCro2SM+tOcpljORwuBQ1vHUtV62Leit+5wlxrNtw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "2.6.15"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.75.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.75.0.tgz",
|
||||
"integrity": "sha512-18yk07Moj/xtQ28zkqswxDavXC3vbOwt1hDuYM3/7xPnwwpKnsmPyZ7bQ5th4uqiJzQ135t74La9tuaxBR6e7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "2.6.15"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/node-fetch": {
|
||||
"version": "2.6.15",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
|
||||
"integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "2.75.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.75.0.tgz",
|
||||
"integrity": "sha512-YfBz4W/z7eYCFyuvHhfjOTTzRrQIvsMG2bVwJAKEVVUqGdzqfvyidXssLBG0Fqlql1zJFgtsPpK1n4meHrI7tg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "2.6.15"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.75.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.75.0.tgz",
|
||||
"integrity": "sha512-B4Xxsf2NHd5cEnM6MGswOSPSsZKljkYXpvzKKmNxoUmNQOfB7D8HOa6NwHcUBSlxcjV+vIrYKcYXtavGJqeGrw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "2.6.15",
|
||||
"@types/phoenix": "^1.6.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"ws": "^8.18.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/storage-js": {
|
||||
"version": "2.75.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.75.0.tgz",
|
||||
"integrity": "sha512-wpJMYdfFDckDiHQaTpK+Ib14N/O2o0AAWWhguKvmmMurB6Unx17GGmYp5rrrqCTf8S1qq4IfIxTXxS4hzrUySg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "2.6.15"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.75.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.75.0.tgz",
|
||||
"integrity": "sha512-8UN/vATSgS2JFuJlMVr51L3eUDz+j1m7Ww63wlvHLKULzCDaVWYzvacCjBTLW/lX/vedI2LBI4Vg+01G9ufsJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.75.0",
|
||||
"@supabase/functions-js": "2.75.0",
|
||||
"@supabase/node-fetch": "2.6.15",
|
||||
"@supabase/postgrest-js": "2.75.0",
|
||||
"@supabase/realtime-js": "2.75.0",
|
||||
"@supabase/storage-js": "2.75.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.17",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
||||
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
|
||||
"integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
|
||||
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.1.tgz",
|
||||
"integrity": "sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/phoenix": {
|
||||
"version": "1.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
|
||||
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/signature_pad": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/signature_pad/-/signature_pad-2.3.6.tgz",
|
||||
"integrity": "sha512-v3j92gCQJoxomHhd+yaG4Vsf8tRS/XbzWKqDv85UsqjMGy4zhokuwKe4b6vhbgncKkh+thF+gpz6+fypTtnFqQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/warning": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz",
|
||||
"integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-arithmetic": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-arithmetic/-/date-arithmetic-4.1.0.tgz",
|
||||
"integrity": "sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.18",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
|
||||
"integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/globalize": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/globalize/-/globalize-0.1.1.tgz",
|
||||
"integrity": "sha512-5e01v8eLGfuQSOvx2MsDMOWS0GFtCx1wPzQSmcHw4hkxFzrQDBO3Xwg/m8Hr/7qXMrHeOIE29qWVzyv06u1TZA=="
|
||||
},
|
||||
"node_modules/invariant": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/moment-timezone": {
|
||||
"version": "0.5.48",
|
||||
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
|
||||
"integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"moment": "^2.29.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
|
||||
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-big-calendar": {
|
||||
"version": "1.19.4",
|
||||
"resolved": "https://registry.npmjs.org/react-big-calendar/-/react-big-calendar-1.19.4.tgz",
|
||||
"integrity": "sha512-FrvbDx2LF6JAWFD96LU1jjloppC5OgIvMYUYIPzAw5Aq+ArYFPxAjLqXc4DyxfsQDN0TJTMuS/BIbcSB7Pg0YA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.7",
|
||||
"clsx": "^1.2.1",
|
||||
"date-arithmetic": "^4.1.0",
|
||||
"dayjs": "^1.11.7",
|
||||
"dom-helpers": "^5.2.1",
|
||||
"globalize": "^0.1.1",
|
||||
"invariant": "^2.2.4",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"luxon": "^3.2.1",
|
||||
"memoize-one": "^6.0.0",
|
||||
"moment": "^2.29.4",
|
||||
"moment-timezone": "^0.5.40",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-overlays": "^5.2.1",
|
||||
"uncontrollable": "^7.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.14.0 || ^17 || ^18 || ^19",
|
||||
"react-dom": "^16.14.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-big-calendar/node_modules/clsx": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
|
||||
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
|
||||
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-lifecycles-compat": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
||||
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-overlays": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.1.tgz",
|
||||
"integrity": "sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.8",
|
||||
"@popperjs/core": "^2.11.6",
|
||||
"@restart/hooks": "^0.4.7",
|
||||
"@types/warning": "^3.0.0",
|
||||
"dom-helpers": "^5.2.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"uncontrollable": "^7.2.1",
|
||||
"warning": "^4.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.3.0",
|
||||
"react-dom": ">=16.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-signature-canvas": {
|
||||
"version": "1.1.0-alpha.2",
|
||||
"resolved": "https://registry.npmjs.org/react-signature-canvas/-/react-signature-canvas-1.1.0-alpha.2.tgz",
|
||||
"integrity": "sha512-tKUNk3Gmh04Ug4K8p5g8Is08BFUKvbXxi0PyetQ/f8OgCBzcx4vqNf9+OArY/TdNdfHtswXQNRwZD6tyELjkjQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.17.9",
|
||||
"@types/signature_pad": "^2.3.0",
|
||||
"signature_pad": "^2.3.2",
|
||||
"trim-canvas": "^0.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/agilgur5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/prop-types": "^15.7.3",
|
||||
"@types/react": "0.14 - 19",
|
||||
"prop-types": "^15.5.8",
|
||||
"react": "0.14 - 19",
|
||||
"react-dom": "0.14 - 19"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/prop-types": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.26.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
|
||||
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/signature_pad": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-2.3.2.tgz",
|
||||
"integrity": "sha512-peYXLxOsIY6MES2TrRLDiNg2T++8gGbpP2yaC+6Ohtxr+a2dzoaqWosWDY9sWqTAAk6E/TyQO+LJw9zQwyu5kA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
|
||||
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/trim-canvas": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/trim-canvas/-/trim-canvas-0.1.2.tgz",
|
||||
"integrity": "sha512-nd4Ga3iLFV94mdhW9JFMLpQbHUyCQuhFOD71PEAt1NjtMD5wbZctzhX8c3agHNybMR5zXD1XTGoIEWk995E6pQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/uncontrollable": {
|
||||
"version": "7.2.1",
|
||||
"resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz",
|
||||
"integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.6.3",
|
||||
"@types/react": ">=16.9.11",
|
||||
"invariant": "^2.2.4",
|
||||
"react-lifecycles-compat": "^3.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.14.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
|
||||
"integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
||||
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/warning": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
|
||||
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -154,7 +154,7 @@ export default function LoginAdminPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button variant="outline" asChild className="w-full hover:!bg-primary hover:!text-white hover:!border-primary transition-all duration-200">
|
||||
<Button variant="outline" asChild className="w-full hover:bg-primary! hover:text-white! hover:border-primary! transition-all duration-200">
|
||||
<Link href="/">
|
||||
Voltar ao Início
|
||||
</Link>
|
||||
@ -240,7 +240,7 @@ export default function LoginPacientePage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button variant="outline" asChild className="w-full hover:!bg-primary hover:!text-white hover:!border-primary transition-all duration-200">
|
||||
<Button variant="outline" asChild className="w-full hover:bg-primary! hover:text-white! hover:border-primary! transition-all duration-200">
|
||||
<Link href="/">
|
||||
Voltar ao Início
|
||||
</Link>
|
||||
@ -164,7 +164,7 @@ export default function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button variant="outline" asChild className="w-full hover:!bg-primary hover:!text-white hover:!border-primary transition-all duration-200">
|
||||
<Button variant="outline" asChild className="w-full hover:bg-primary! hover:text-white! hover:border-primary! transition-all duration-200">
|
||||
<Link href="/">
|
||||
Voltar ao Início
|
||||
</Link>
|
||||
@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CalendarRegistrationForm } from "@/components/forms/calendar-registration-form";
|
||||
import HeaderAgenda from "@/components/agenda/HeaderAgenda";
|
||||
import FooterAgenda from "@/components/agenda/FooterAgenda";
|
||||
import { CalendarRegistrationForm } from "@/components/features/forms/calendar-registration-form";
|
||||
import HeaderAgenda from "@/components/features/agenda/HeaderAgenda";
|
||||
import FooterAgenda from "@/components/features/agenda/FooterAgenda";
|
||||
import { useState } from "react";
|
||||
import { criarAgendamento } from '@/lib/api';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
.fc-media-screen {
|
||||
flex-grow: 1;
|
||||
height: 74vh;
|
||||
@ -38,4 +37,47 @@
|
||||
.fc-toolbar-title {
|
||||
font-weight: bold;
|
||||
color: var(--color-gray-900);
|
||||
}
|
||||
}
|
||||
|
||||
/* Compact mode for embedded EventManager */
|
||||
.compact-event-manager {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.compact-event-manager h2 {
|
||||
font-size: 1rem; /* menor título */
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.compact-event-manager .sm\\:flex { /* reduz grupo de botões */
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.compact-event-manager .button,
|
||||
.compact-event-manager .btn,
|
||||
.compact-event-manager .chakra-button {
|
||||
padding: 6px 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Inputs dentro do EventManager compactos */
|
||||
.compact-event-manager input,
|
||||
.compact-event-manager .input {
|
||||
padding: 6px 8px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* reduzir padding dos cards e dos toolbars internos */
|
||||
.compact-event-manager .p-4 { padding: 0.5rem; }
|
||||
.compact-event-manager .p-3 { padding: 0.4rem; }
|
||||
|
||||
/* reduzir altura das linhas na vista semana/dia custom */
|
||||
.compact-event-manager .min-h-16 { min-height: 3.2rem; }
|
||||
.compact-event-manager .min-h-20 { min-height: 3.6rem; }
|
||||
|
||||
/* tornar os botões de filtro menores */
|
||||
.compact-event-manager .dropdown-trigger,
|
||||
.compact-event-manager .dropdown-menu-trigger {
|
||||
padding: 6px 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* melhorar harmonia: menos margem entre header e calendário */
|
||||
.compact-event-manager { margin-top: 0.25rem; margin-bottom: 0.25rem; }
|
||||
@ -1,36 +1,44 @@
|
||||
"use client";
|
||||
|
||||
// Imports mantidos
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import pt_br_locale from "@fullcalendar/core/locales/pt-br";
|
||||
import FullCalendar from "@fullcalendar/react";
|
||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||
import { EventInput } from "@fullcalendar/core/index.js";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { PagesHeader } from "@/components/dashboard/header";
|
||||
import Link from "next/link";
|
||||
|
||||
// --- Imports do EventManager (NOVO) - MANTIDOS ---
|
||||
import { EventManager, type Event } from "@/components/features/general/event-manager";
|
||||
import { v4 as uuidv4 } from 'uuid'; // Usado para IDs de fallback
|
||||
|
||||
// Imports mantidos
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { PagesHeader } from "@/components/features/dashboard/header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { mockWaitingList } from "@/lib/mocks/appointment-mocks";
|
||||
import "./index.css";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ThreeDWallCalendar, CalendarEvent } from "@/components/ui/three-dwall-calendar"; // Calendário 3D mantido
|
||||
|
||||
const ListaEspera = dynamic(
|
||||
() => import("@/components/agendamento/ListaEspera"),
|
||||
() => import("@/components/features/agendamento/ListaEspera"),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export default function AgendamentoPage() {
|
||||
const [appointments, setAppointments] = useState<any[]>([]);
|
||||
const [waitingList, setWaitingList] = useState(mockWaitingList);
|
||||
const [activeTab, setActiveTab] = useState<"calendar" | "espera">("calendar");
|
||||
const [requestsList, setRequestsList] = useState<EventInput[]>();
|
||||
const [activeTab, setActiveTab] = useState<"calendar" | "espera" | "3d">("calendar");
|
||||
|
||||
const [threeDEvents, setThreeDEvents] = useState<CalendarEvent[]>([]);
|
||||
|
||||
// --- NOVO ESTADO ---
|
||||
// Estado para alimentar o NOVO EventManager com dados da API
|
||||
const [managerEvents, setManagerEvents] = useState<Event[]>([]);
|
||||
const [managerLoading, setManagerLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", (event) => {
|
||||
@ -40,57 +48,90 @@ export default function AgendamentoPage() {
|
||||
if (event.key === "f") {
|
||||
setActiveTab("espera");
|
||||
}
|
||||
if (event.key === "3") {
|
||||
setActiveTab("3d");
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch real appointments and map to calendar events
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
// listarAgendamentos accepts a query string; request a reasonable limit and order
|
||||
const arr = await (await import('@/lib/api')).listarAgendamentos('select=*&order=scheduled_at.desc&limit=500').catch(() => []);
|
||||
setManagerLoading(true);
|
||||
const api = await import('@/lib/api');
|
||||
const arr = await api.listarAgendamentos('select=*&order=scheduled_at.desc&limit=500').catch(() => []);
|
||||
if (!mounted) return;
|
||||
if (!arr || !arr.length) {
|
||||
setAppointments([]);
|
||||
setRequestsList([]);
|
||||
setThreeDEvents([]);
|
||||
setManagerEvents([]); // Limpa o novo calendário
|
||||
setManagerLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Batch-fetch patient names for display
|
||||
const patientIds = Array.from(new Set(arr.map((a: any) => a.patient_id).filter(Boolean)));
|
||||
const patients = (patientIds && patientIds.length) ? await (await import('@/lib/api')).buscarPacientesPorIds(patientIds) : [];
|
||||
const patients = (patientIds && patientIds.length) ? await api.buscarPacientesPorIds(patientIds) : [];
|
||||
const patientsById: Record<string, any> = {};
|
||||
(patients || []).forEach((p: any) => { if (p && p.id) patientsById[String(p.id)] = p; });
|
||||
|
||||
setAppointments(arr || []);
|
||||
|
||||
const events: EventInput[] = (arr || []).map((obj: any) => {
|
||||
// --- LÓGICA DE TRANSFORMAÇÃO PARA O NOVO EVENTMANAGER ---
|
||||
const newManagerEvents: Event[] = (arr || []).map((obj: any) => {
|
||||
const scheduled = obj.scheduled_at || obj.scheduledAt || obj.time || null;
|
||||
const start = scheduled ? new Date(scheduled) : null;
|
||||
const start = scheduled ? new Date(scheduled) : new Date();
|
||||
const duration = Number(obj.duration_minutes ?? obj.duration ?? 30) || 30;
|
||||
const end = new Date(start.getTime() + duration * 60 * 1000);
|
||||
|
||||
const patient = (patientsById[String(obj.patient_id)]?.full_name) || obj.patient_name || obj.patient_full_name || obj.patient || 'Paciente';
|
||||
const title = `${patient}: ${obj.appointment_type ?? obj.type ?? ''}`.trim();
|
||||
const color = obj.status === 'confirmed' ? '#68d68a' : obj.status === 'pending' ? '#ffe55f' : '#ff5f5fff';
|
||||
|
||||
let color = "gray"; // Cor padrão
|
||||
if (obj.status === 'confirmed') color = 'green';
|
||||
if (obj.status === 'pending') color = 'orange';
|
||||
|
||||
return {
|
||||
title,
|
||||
start: start || new Date(),
|
||||
end: start ? new Date(start.getTime() + duration * 60 * 1000) : undefined,
|
||||
color,
|
||||
extendedProps: { raw: obj },
|
||||
} as EventInput;
|
||||
id: obj.id || uuidv4(), // Usa ID da API ou gera um
|
||||
title: title,
|
||||
description: `Agendamento para ${patient}. Status: ${obj.status || 'N/A'}.`,
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
color: color,
|
||||
};
|
||||
});
|
||||
setRequestsList(events || []);
|
||||
setManagerEvents(newManagerEvents);
|
||||
setManagerLoading(false);
|
||||
// --- FIM DA LÓGICA ---
|
||||
|
||||
// Convert to 3D calendar events (MANTIDO 100%)
|
||||
const threeDEvents: CalendarEvent[] = (arr || []).map((obj: any) => {
|
||||
const scheduled = obj.scheduled_at || obj.scheduledAt || obj.time || null;
|
||||
const patient = (patientsById[String(obj.patient_id)]?.full_name) || obj.patient_name || obj.patient_full_name || obj.patient || 'Paciente';
|
||||
const appointmentType = obj.appointment_type ?? obj.type ?? 'Consulta';
|
||||
const title = `${patient}: ${appointmentType}`.trim();
|
||||
return {
|
||||
id: obj.id || String(Date.now()),
|
||||
title,
|
||||
date: scheduled ? new Date(scheduled).toISOString() : new Date().toISOString(),
|
||||
status: obj.status || 'pending',
|
||||
patient,
|
||||
type: appointmentType,
|
||||
};
|
||||
});
|
||||
setThreeDEvents(threeDEvents);
|
||||
} catch (err) {
|
||||
console.warn('[AgendamentoPage] falha ao carregar agendamentos', err);
|
||||
setAppointments([]);
|
||||
setRequestsList([]);
|
||||
setThreeDEvents([]);
|
||||
setManagerEvents([]); // Limpa o novo calendário
|
||||
setManagerLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
// mantive para caso a lógica de salvar consulta passe a funcionar
|
||||
// Handlers mantidos
|
||||
const handleSaveAppointment = (appointment: any) => {
|
||||
if (appointment.id) {
|
||||
setAppointments((prev) =>
|
||||
@ -109,24 +150,29 @@ export default function AgendamentoPage() {
|
||||
console.log(`Notificando paciente ${patientId}`);
|
||||
};
|
||||
|
||||
const handleAddEvent = (event: CalendarEvent) => {
|
||||
setThreeDEvents((prev) => [...prev, event]);
|
||||
};
|
||||
|
||||
const handleRemoveEvent = (id: string) => {
|
||||
setThreeDEvents((prev) => prev.filter((e) => e.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-row bg-background">
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="flex w-full flex-col gap-10 p-6">
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
{/* Todo o cabeçalho foi mantido */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{activeTab === "calendar" ? "Calendário" : "Lista de Espera"}</h1>
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
{activeTab === "calendar" ? "Calendário" : activeTab === "3d" ? "Calendário 3D" : "Lista de Espera"}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Navegue através dos atalhos: Calendário (C) ou Fila de espera
|
||||
(F).
|
||||
Navegue através dos atalhos: Calendário (C), Fila de espera (F) ou 3D (3).
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
{/* <Link href={"/agenda"}>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700">
|
||||
Agenda
|
||||
</Button>
|
||||
</Link> */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="bg-primary hover:bg-primary/90 px-5 py-1 text-primary-foreground rounded-sm">
|
||||
Opções »
|
||||
@ -147,7 +193,7 @@ export default function AgendamentoPage() {
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className="bg-muted hover:!bg-primary hover:!text-white transition-colors rounded-l-[100px] rounded-r-[0px]"
|
||||
className="bg-muted hover:bg-primary! hover:text-white! transition-colors rounded-l-[100px] rounded-r-none"
|
||||
onClick={() => setActiveTab("calendar")}
|
||||
>
|
||||
Calendário
|
||||
@ -155,7 +201,15 @@ export default function AgendamentoPage() {
|
||||
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className="bg-muted hover:!bg-primary hover:!text-white transition-colors rounded-r-[100px] rounded-l-[0px]"
|
||||
className="bg-muted hover:bg-primary! hover:text-white! transition-colors rounded-none"
|
||||
onClick={() => setActiveTab("3d")}
|
||||
>
|
||||
3D
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className="bg-muted hover:bg-primary! hover:text-white! transition-colors rounded-r-[100px] rounded-l-none"
|
||||
onClick={() => setActiveTab("espera")}
|
||||
>
|
||||
Lista de espera
|
||||
@ -164,29 +218,34 @@ export default function AgendamentoPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- AQUI ESTÁ A SUBSTITUIÇÃO --- */}
|
||||
{activeTab === "calendar" ? (
|
||||
<div className="flex w-full">
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
initialView="dayGridMonth"
|
||||
locale={pt_br_locale}
|
||||
timeZone={"America/Sao_Paulo"}
|
||||
events={requestsList}
|
||||
headerToolbar={{
|
||||
left: "prev,next today",
|
||||
center: "title",
|
||||
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
||||
}}
|
||||
dateClick={(info) => {
|
||||
info.view.calendar.changeView("timeGridDay", info.dateStr);
|
||||
}}
|
||||
selectable={true}
|
||||
selectMirror={true}
|
||||
dayMaxEvents={true}
|
||||
dayMaxEventRows={3}
|
||||
{/* mostra loading até managerEvents ser preenchido (API integrada desde a entrada) */}
|
||||
<div className="w-full">
|
||||
{managerLoading ? (
|
||||
<div className="flex items-center justify-center w-full min-h-[70vh]">
|
||||
<div className="text-sm text-muted-foreground">Conectando ao calendário — carregando agendamentos...</div>
|
||||
</div>
|
||||
) : (
|
||||
// EventManager ocupa a área principal e já recebe events da API
|
||||
<div className="w-full min-h-[70vh]">
|
||||
<EventManager events={managerEvents} className="compact-event-manager" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === "3d" ? (
|
||||
// O calendário 3D (ThreeDWallCalendar) foi MANTIDO 100%
|
||||
<div className="flex w-full justify-center">
|
||||
<ThreeDWallCalendar
|
||||
events={threeDEvents}
|
||||
onAddEvent={handleAddEvent}
|
||||
onRemoveEvent={handleRemoveEvent}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// A Lista de Espera foi MANTIDA
|
||||
<ListaEspera
|
||||
patients={waitingList}
|
||||
onNotify={handleNotifyPatient}
|
||||
@ -197,4 +256,4 @@ export default function AgendamentoPage() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -56,7 +56,7 @@ import {
|
||||
|
||||
import { mockProfessionals } from "@/lib/mocks/appointment-mocks";
|
||||
import { listarAgendamentos, buscarPacientesPorIds, buscarMedicosPorIds, atualizarAgendamento, buscarAgendamentoPorId, deletarAgendamento } from "@/lib/api";
|
||||
import { CalendarRegistrationForm } from "@/components/forms/calendar-registration-form";
|
||||
import { CalendarRegistrationForm } from "@/components/features/forms/calendar-registration-form";
|
||||
|
||||
const formatDate = (date: string | Date) => {
|
||||
if (!date) return "";
|
||||
@ -619,7 +619,7 @@ export default function ConsultasPage() {
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="hover:!bg-primary hover:!text-white transition-colors"
|
||||
className="hover:bg-primary! hover:text-white! transition-colors"
|
||||
>
|
||||
Primeira
|
||||
</Button>
|
||||
@ -628,7 +628,7 @@ export default function ConsultasPage() {
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="hover:!bg-primary hover:!text-white transition-colors"
|
||||
className="hover:bg-primary! hover:text-white! transition-colors"
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
@ -640,7 +640,7 @@ export default function ConsultasPage() {
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="hover:!bg-primary hover:!text-white transition-colors"
|
||||
className="hover:bg-primary! hover:text-white! transition-colors"
|
||||
>
|
||||
Próxima
|
||||
</Button>
|
||||
@ -649,7 +649,7 @@ export default function ConsultasPage() {
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="hover:!bg-primary hover:!text-white transition-colors"
|
||||
className="hover:bg-primary! hover:text-white! transition-colors"
|
||||
>
|
||||
Última
|
||||
</Button>
|
||||
|
||||
@ -20,8 +20,8 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { AlertCircle, Calendar, Users, Stethoscope, Clock, FileText, AlertTriangle, Plus, ArrowLeft } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { PatientRegistrationForm } from '@/components/forms/patient-registration-form';
|
||||
import { DoctorRegistrationForm } from '@/components/forms/doctor-registration-form';
|
||||
import { PatientRegistrationForm } from '@/components/features/forms/patient-registration-form';
|
||||
import { DoctorRegistrationForm } from '@/components/features/forms/doctor-registration-form';
|
||||
|
||||
interface DashboardStats {
|
||||
totalPatients: number;
|
||||
@ -283,15 +283,15 @@ export default function DashboardPage() {
|
||||
<Plus className="h-4 w-4" />
|
||||
Novo Paciente
|
||||
</Button>
|
||||
<Button onClick={() => router.push('/agenda')} variant="outline" className="gap-2 hover:!bg-primary hover:!text-white transition-colors">
|
||||
<Button onClick={() => router.push('/agenda')} variant="outline" className="gap-2 hover:bg-primary! hover:text-white! transition-colors">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Novo Agendamento
|
||||
</Button>
|
||||
<Button onClick={() => setShowDoctorForm(true)} variant="outline" className="gap-2 hover:!bg-primary hover:!text-white transition-colors">
|
||||
<Button onClick={() => setShowDoctorForm(true)} variant="outline" className="gap-2 hover:bg-primary! hover:text-white! transition-colors">
|
||||
<Stethoscope className="h-4 w-4" />
|
||||
Novo Médico
|
||||
</Button>
|
||||
<Button onClick={() => router.push('/dashboard/relatorios')} variant="outline" className="gap-2 hover:!bg-primary hover:!text-white transition-colors">
|
||||
<Button onClick={() => router.push('/dashboard/relatorios')} variant="outline" className="gap-2 hover:bg-primary! hover:text-white! transition-colors">
|
||||
<FileText className="h-4 w-4" />
|
||||
Ver Relatórios
|
||||
</Button>
|
||||
@ -340,7 +340,7 @@ export default function DashboardPage() {
|
||||
<p className="text-xs text-muted-foreground">{report.exam || 'Sem descrição'}</p>
|
||||
</div>
|
||||
))}
|
||||
<Button onClick={() => router.push('/dashboard/relatorios')} variant="ghost" className="w-full mt-2 hover:!bg-primary hover:!text-white transition-colors" size="sm">
|
||||
<Button onClick={() => router.push('/dashboard/relatorios')} variant="ghost" className="w-full mt-2 hover:bg-primary! hover:text-white! transition-colors" size="sm">
|
||||
Ver Todos
|
||||
</Button>
|
||||
</div>
|
||||
@ -388,7 +388,7 @@ export default function DashboardPage() {
|
||||
)}
|
||||
|
||||
{/* 11. LINK PARA RELATÓRIOS */}
|
||||
<div className="bg-gradient-to-r from-blue-500/10 to-purple-500/10 p-6 rounded-lg border border-blue-500/20">
|
||||
<div className="bg-linear-to-r from-blue-500/10 to-purple-500/10 p-6 rounded-lg border border-blue-500/20">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">Seção de Relatórios</h2>
|
||||
<p className="text-muted-foreground text-sm mb-4">
|
||||
Acesse a seção de relatórios médicos para gerenciar, visualizar e exportar documentos.
|
||||
|
||||
@ -1,16 +1,29 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileDown, BarChart2, Users, DollarSign, TrendingUp, UserCheck, CalendarCheck, ThumbsUp, User, Briefcase } from "lucide-react";
|
||||
import jsPDF from "jspdf";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, LineChart, Line, PieChart, Pie, Cell } from "recharts";
|
||||
import {
|
||||
countTotalPatients,
|
||||
countTotalDoctors,
|
||||
countAppointmentsToday,
|
||||
getAppointmentsByDateRange,
|
||||
listarAgendamentos,
|
||||
getUpcomingAppointments,
|
||||
getNewUsersLastDays,
|
||||
getPendingReports,
|
||||
buscarMedicosPorIds,
|
||||
buscarPacientesPorIds,
|
||||
} from "@/lib/api";
|
||||
|
||||
// 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: "Satisfação", value: "Dados não foram disponibilizados", 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" /> },
|
||||
];
|
||||
@ -42,13 +55,7 @@ const taxaNoShow = [
|
||||
{ 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 },
|
||||
];
|
||||
// pacientesMaisAtendidos static list removed — data will be fetched from the API
|
||||
|
||||
const medicosMaisProdutivos = [
|
||||
{ nome: "Dr. Carlos Andrade", consultas: 62 },
|
||||
@ -81,19 +88,260 @@ function exportPDF(title: string, content: string) {
|
||||
}
|
||||
|
||||
export default function RelatoriosPage() {
|
||||
// Local state that will be replaced by API data when available
|
||||
// Start with empty data to avoid showing fictitious frontend data while loading
|
||||
const [metricsState, setMetricsState] = useState<Array<{ label: string; value: any; icon: any }>>([]);
|
||||
const [consultasData, setConsultasData] = useState<Array<{ periodo: string; consultas: number }>>([]);
|
||||
const [faturamentoData, setFaturamentoData] = useState<Array<{ mes: string; valor: number }>>([]);
|
||||
const [taxaNoShowState, setTaxaNoShowState] = useState<Array<{ mes: string; noShow: number }>>([]);
|
||||
const [pacientesTop, setPacientesTop] = useState<Array<{ nome: string; consultas: number }>>([]);
|
||||
const [medicosTop, setMedicosTop] = useState(medicosMaisProdutivos);
|
||||
const [medicosPerformance, setMedicosPerformance] = useState<Array<{ nome: string; consultas: number; absenteismo: number }>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [conveniosData, setConveniosData] = useState<Array<{ nome: string; valor: number }>>(convenios);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch counts in parallel, then try to fetch a larger appointments list via listarAgendamentos.
|
||||
// If listarAgendamentos fails (for example: unauthenticated), fall back to getAppointmentsByDateRange(30).
|
||||
const [patientsCount, doctorsCount, appointmentsToday] = await Promise.all([
|
||||
countTotalPatients().catch(() => 0),
|
||||
countTotalDoctors().catch(() => 0),
|
||||
countAppointmentsToday().catch(() => 0),
|
||||
]);
|
||||
|
||||
let appointments: any[] = [];
|
||||
try {
|
||||
// Try to get a larger set of appointments (up to 1000) to compute top patients
|
||||
// select=patient_id,doctor_id,scheduled_at,status to reduce payload
|
||||
// include insurance_provider so we can aggregate convênios client-side
|
||||
appointments = await listarAgendamentos('select=patient_id,doctor_id,scheduled_at,status,insurance_provider&order=scheduled_at.desc&limit=1000');
|
||||
} catch (e) {
|
||||
// Fallback to the smaller helper if listarAgendamentos cannot be used (e.g., no auth token)
|
||||
console.warn('[relatorios] listarAgendamentos falhou, usando getAppointmentsByDateRange fallback', e);
|
||||
appointments = await getAppointmentsByDateRange(30).catch(() => []);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Update top metrics card
|
||||
setMetricsState([
|
||||
{ label: "Atendimentos", value: appointmentsToday ?? 0, icon: <CalendarCheck className="w-6 h-6 text-blue-500" /> },
|
||||
{ label: "Absenteísmo", value: "—", icon: <UserCheck className="w-6 h-6 text-red-500" /> },
|
||||
{ label: "Satisfação", value: "Dados não foram disponibilizados", icon: <ThumbsUp className="w-6 h-6 text-green-500" /> },
|
||||
{ label: "Faturamento (Mês)", value: "—", icon: <DollarSign className="w-6 h-6 text-emerald-500" /> },
|
||||
{ label: "No-show", value: "—", icon: <User className="w-6 h-6 text-yellow-500" /> },
|
||||
]);
|
||||
|
||||
// Build last 30 days series for consultas
|
||||
const daysCount = 30;
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const startTs = start.getTime() - (daysCount - 1) * 86400000; // include today
|
||||
const dayBuckets: Record<string, { periodo: string; consultas: number }> = {};
|
||||
for (let i = 0; i < daysCount; i++) {
|
||||
const d = new Date(startTs + i * 86400000);
|
||||
const iso = d.toISOString().split("T")[0];
|
||||
const periodo = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
dayBuckets[iso] = { periodo, consultas: 0 };
|
||||
}
|
||||
|
||||
// Count appointments per day
|
||||
const appts = Array.isArray(appointments) ? appointments : [];
|
||||
for (const a of appts) {
|
||||
try {
|
||||
const iso = (a.scheduled_at || '').toString().split('T')[0];
|
||||
if (iso && dayBuckets[iso]) dayBuckets[iso].consultas += 1;
|
||||
} catch (e) {
|
||||
// ignore malformed
|
||||
}
|
||||
}
|
||||
const consultasArr = Object.values(dayBuckets);
|
||||
setConsultasData(consultasArr);
|
||||
|
||||
// Estimate monthly faturamento for last 6 months using doctor.valor_consulta when available
|
||||
const monthsBack = 6;
|
||||
const monthMap: Record<string, { mes: string; valor: number; totalAppointments: number; noShowCount: number }> = {};
|
||||
const nowMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const monthKeys: string[] = [];
|
||||
for (let i = monthsBack - 1; i >= 0; i--) {
|
||||
const m = new Date(nowMonth.getFullYear(), nowMonth.getMonth() - i, 1);
|
||||
const key = `${m.getFullYear()}-${String(m.getMonth() + 1).padStart(2, '0')}`;
|
||||
monthKeys.push(key);
|
||||
monthMap[key] = { mes: m.toLocaleString('pt-BR', { month: 'short' }), valor: 0, totalAppointments: 0, noShowCount: 0 };
|
||||
}
|
||||
|
||||
// Filter appointments within monthsBack and group
|
||||
const apptsForMonths = appts.filter((a) => {
|
||||
try {
|
||||
const d = new Date(a.scheduled_at);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
return key in monthMap;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Collect unique doctor ids to fetch valor_consulta in bulk
|
||||
const doctorIds = Array.from(new Set(apptsForMonths.map((a: any) => String(a.doctor_id).trim()).filter(Boolean)));
|
||||
const doctors = doctorIds.length ? await buscarMedicosPorIds(doctorIds) : [];
|
||||
const doctorMap = new Map<string, any>();
|
||||
for (const d of doctors) doctorMap.set(String(d.id), d);
|
||||
|
||||
for (const a of apptsForMonths) {
|
||||
try {
|
||||
const d = new Date(a.scheduled_at);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
const doc = doctorMap.get(String(a.doctor_id));
|
||||
const price = doc && doc.valor_consulta ? Number(doc.valor_consulta) : 0;
|
||||
monthMap[key].valor += price;
|
||||
monthMap[key].totalAppointments += 1;
|
||||
if (String(a.status || '').toLowerCase() === 'no_show' || String(a.status || '').toLowerCase() === 'no-show') {
|
||||
monthMap[key].noShowCount += 1;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const faturamentoArr = monthKeys.map((k) => ({ mes: monthMap[k].mes, valor: Math.round(monthMap[k].valor) }));
|
||||
setFaturamentoData(faturamentoArr);
|
||||
|
||||
// Taxa no-show per month
|
||||
const taxaArr = monthKeys.map((k) => {
|
||||
const total = monthMap[k].totalAppointments || 0;
|
||||
const noShow = monthMap[k].noShowCount || 0;
|
||||
const pct = total ? Number(((noShow / total) * 100).toFixed(1)) : 0;
|
||||
return { mes: monthMap[k].mes, noShow: pct };
|
||||
});
|
||||
setTaxaNoShowState(taxaArr);
|
||||
|
||||
// Top patients and doctors (by number of appointments in the period)
|
||||
const patientCounts: Record<string, number> = {};
|
||||
const doctorCounts: Record<string, number> = {};
|
||||
const doctorNoShowCounts: Record<string, number> = {};
|
||||
for (const a of apptsForMonths) {
|
||||
if (a.patient_id) patientCounts[String(a.patient_id)] = (patientCounts[String(a.patient_id)] || 0) + 1;
|
||||
if (a.doctor_id) {
|
||||
const did = String(a.doctor_id);
|
||||
doctorCounts[did] = (doctorCounts[did] || 0) + 1;
|
||||
const status = String(a.status || '').toLowerCase();
|
||||
if (status === 'no_show' || status === 'no-show') doctorNoShowCounts[did] = (doctorNoShowCounts[did] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const topPatientIds = Object.entries(patientCounts).sort((a, b) => b[1] - a[1]).slice(0, 5).map((x) => x[0]);
|
||||
const topDoctorIds = Object.entries(doctorCounts).sort((a, b) => b[1] - a[1]).slice(0, 5).map((x) => x[0]);
|
||||
|
||||
const [patientsFetched, doctorsFetched] = await Promise.all([
|
||||
topPatientIds.length ? buscarPacientesPorIds(topPatientIds) : Promise.resolve([]),
|
||||
topDoctorIds.length ? buscarMedicosPorIds(topDoctorIds) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const pacientesList = topPatientIds.map((id) => {
|
||||
const p = (patientsFetched || []).find((x: any) => String(x.id) === String(id));
|
||||
return { nome: p ? p.full_name : id, consultas: patientCounts[id] || 0 };
|
||||
});
|
||||
|
||||
const medicosList = topDoctorIds.map((id) => {
|
||||
const m = (doctorsFetched || []).find((x: any) => String(x.id) === String(id));
|
||||
return { nome: m ? m.full_name : id, consultas: doctorCounts[id] || 0 };
|
||||
});
|
||||
|
||||
// Build performance list (consultas + absenteísmo)
|
||||
const perfIds = Object.keys(doctorCounts).sort((a, b) => (doctorCounts[b] || 0) - (doctorCounts[a] || 0)).slice(0, 5);
|
||||
const perfDoctors = (doctorsFetched && doctorsFetched.length) ? doctorsFetched : doctors;
|
||||
const perfList = perfIds.map((id) => {
|
||||
const d = (perfDoctors || []).find((x: any) => String(x.id) === String(id));
|
||||
const consultas = doctorCounts[id] || 0;
|
||||
const noShow = doctorNoShowCounts[id] || 0;
|
||||
const absenteismo = consultas ? Number(((noShow / consultas) * 100).toFixed(1)) : 0;
|
||||
return { nome: d ? d.full_name : id, consultas, absenteismo };
|
||||
});
|
||||
|
||||
// Use fetched list (may be empty) — do not fall back to static data for patients, but keep fallback for medicosTop
|
||||
setPacientesTop(pacientesList);
|
||||
setMedicosTop(medicosList.length ? medicosList : medicosMaisProdutivos);
|
||||
setMedicosPerformance(perfList.length ? perfList.slice(0,5) : performancePorMedico.map((p) => ({ nome: p.nome, consultas: p.consultas, absenteismo: p.absenteismo })).slice(0,5));
|
||||
|
||||
// Aggregate convênios (insurance providers) from appointments in the period
|
||||
try {
|
||||
const providerCounts: Record<string, number> = {};
|
||||
for (const a of apptsForMonths) {
|
||||
let prov: any = a?.insurance_provider ?? a?.insuranceProvider ?? a?.insurance ?? '';
|
||||
// If provider is an object, try to extract a human-friendly name
|
||||
if (prov && typeof prov === 'object') prov = prov.name || prov.full_name || prov.title || '';
|
||||
prov = String(prov || '').trim();
|
||||
const key = prov || 'Não disponibilizado';
|
||||
providerCounts[key] = (providerCounts[key] || 0) + 1;
|
||||
}
|
||||
|
||||
let conveniosArr = Object.entries(providerCounts).map(([nome, valor]) => ({ nome, valor }));
|
||||
if (!conveniosArr.length) {
|
||||
// No provider info at all — present a single bucket showing the total count as 'Não disponibilizado'
|
||||
conveniosArr = [{ nome: 'Não disponibilizado', valor: apptsForMonths.length }];
|
||||
} else {
|
||||
// Sort and keep top 5, group the rest into 'Outros'
|
||||
conveniosArr.sort((a, b) => b.valor - a.valor);
|
||||
if (conveniosArr.length > 5) {
|
||||
const top = conveniosArr.slice(0, 5);
|
||||
const others = conveniosArr.slice(5).reduce((s, c) => s + c.valor, 0);
|
||||
top.push({ nome: 'Outros', valor: others });
|
||||
conveniosArr = top;
|
||||
}
|
||||
}
|
||||
setConveniosData(conveniosArr);
|
||||
} catch (e) {
|
||||
// keep existing static conveniosData if something goes wrong
|
||||
console.warn('[relatorios] erro ao agregar convênios', e);
|
||||
}
|
||||
|
||||
// Update metrics cards with numbers we fetched
|
||||
setMetricsState([
|
||||
{ label: "Atendimentos", value: appointmentsToday ?? 0, icon: <CalendarCheck className="w-6 h-6 text-blue-500" /> },
|
||||
{ label: "Absenteísmo", value: '—', icon: <UserCheck className="w-6 h-6 text-red-500" /> },
|
||||
{ label: "Satisfação", value: 'Dados não foram disponibilizados', icon: <ThumbsUp className="w-6 h-6 text-green-500" /> },
|
||||
{ label: "Faturamento (Mês)", value: `R$ ${faturamentoArr.at(-1)?.valor ?? 0}`, icon: <DollarSign className="w-6 h-6 text-emerald-500" /> },
|
||||
{ label: "No-show", value: `${taxaArr.at(-1)?.noShow ?? 0}%`, icon: <User className="w-6 h-6 text-yellow-500" /> },
|
||||
] as any);
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('[relatorios] erro ao carregar dados', err);
|
||||
if (mounted) setError(err?.message ?? String(err));
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-background min-h-screen">
|
||||
<h1 className="text-2xl font-bold mb-6 text-foreground">Dashboard Executivo de Relatórios</h1>
|
||||
|
||||
{/* Métricas principais */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-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>
|
||||
))}
|
||||
{loading ? (
|
||||
// simple skeletons while loading to avoid showing fake data
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="p-4 bg-card border border-border rounded-lg shadow flex flex-col items-center justify-center">
|
||||
<div className="h-6 w-6 bg-muted rounded mb-2 animate-pulse" />
|
||||
<div className="h-6 w-20 bg-muted rounded mt-2 animate-pulse" />
|
||||
<div className="h-3 w-28 bg-muted rounded mt-3 animate-pulse" />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
metricsState.map((m) => (
|
||||
<div key={m.label} className="p-4 bg-card border border-border rounded-lg shadow flex flex-col items-center justify-center">
|
||||
{m.icon}
|
||||
<span className="text-2xl font-bold mt-2 text-foreground">{m.value}</span>
|
||||
<span className="text-sm text-muted-foreground mt-1 text-center">{m.label}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gráficos e Relatórios */}
|
||||
@ -102,34 +350,42 @@ export default function RelatoriosPage() {
|
||||
<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" className="hover:!bg-primary hover:!text-white transition-colors" onClick={() => exportPDF("Consultas por Período", "Resumo das consultas realizadas por período.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" 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>
|
||||
{loading ? (
|
||||
<div className="h-[220px] flex items-center justify-center text-muted-foreground">Carregando dados...</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={consultasData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="periodo" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="consultas" fill="#6366f1" name="Consultas" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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" className="hover:!bg-primary hover:!text-white transition-colors" onClick={() => exportPDF("Faturamento Mensal", "Resumo do faturamento mensal.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Faturamento Mensal", "Resumo do faturamento mensal.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={faturamentoMensal}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="mes" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="valor" stroke="#10b981" name="Faturamento" strokeWidth={3} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
{loading ? (
|
||||
<div className="h-[220px] flex items-center justify-center text-muted-foreground">Carregando dados...</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={faturamentoData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="mes" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="valor" stroke="#10b981" name="Faturamento" strokeWidth={3} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -138,27 +394,31 @@ export default function RelatoriosPage() {
|
||||
<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" className="hover:!bg-primary hover:!text-white transition-colors" onClick={() => exportPDF("Taxa de No-show", "Resumo da taxa de no-show.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" 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>
|
||||
{loading ? (
|
||||
<div className="h-[220px] flex items-center justify-center text-muted-foreground">Carregando dados...</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={taxaNoShowState}>
|
||||
<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" className="hover:!bg-primary hover:!text-white transition-colors" onClick={() => exportPDF("Satisfação dos Pacientes", "Resumo dos indicadores de satisfação.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" 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-2xl font-bold text-foreground">Dados não foram disponibilizados</span>
|
||||
<span className="text-muted-foreground mt-2">Índice de satisfação geral</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -169,7 +429,7 @@ export default function RelatoriosPage() {
|
||||
<div className="bg-card border border-border rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><Users className="w-5 h-5" /> Pacientes Mais Atendidos</h2>
|
||||
<Button size="sm" variant="outline" className="hover:!bg-primary hover:!text-white transition-colors" onClick={() => exportPDF("Pacientes Mais Atendidos", "Lista dos pacientes mais atendidos.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" 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>
|
||||
@ -179,12 +439,22 @@ export default function RelatoriosPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pacientesMaisAtendidos.map((p) => (
|
||||
<tr key={p.nome}>
|
||||
<td className="py-1">{p.nome}</td>
|
||||
<td className="py-1">{p.consultas}</td>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td className="py-4 text-muted-foreground" colSpan={2}>Carregando pacientes...</td>
|
||||
</tr>
|
||||
))}
|
||||
) : pacientesTop && pacientesTop.length ? (
|
||||
pacientesTop.map((p: { nome: string; consultas: number }) => (
|
||||
<tr key={p.nome}>
|
||||
<td className="py-1">{p.nome}</td>
|
||||
<td className="py-1">{p.consultas}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td className="py-4 text-muted-foreground" colSpan={2}>Nenhum paciente encontrado</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -193,7 +463,7 @@ export default function RelatoriosPage() {
|
||||
<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" className="hover:!bg-primary hover:!text-white transition-colors" onClick={() => exportPDF("Médicos Mais Produtivos", "Lista dos médicos mais produtivos.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Médicos Mais Produtivos", "Lista dos médicos mais produtivos.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
</div>
|
||||
<table className="w-full text-sm mt-4">
|
||||
<thead>
|
||||
@ -203,12 +473,22 @@ export default function RelatoriosPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{medicosMaisProdutivos.map((m) => (
|
||||
<tr key={m.nome}>
|
||||
<td className="py-1">{m.nome}</td>
|
||||
<td className="py-1">{m.consultas}</td>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td className="py-4 text-muted-foreground" colSpan={2}>Carregando médicos...</td>
|
||||
</tr>
|
||||
))}
|
||||
) : medicosTop && medicosTop.length ? (
|
||||
medicosTop.map((m) => (
|
||||
<tr key={m.nome}>
|
||||
<td className="py-1">{m.nome}</td>
|
||||
<td className="py-1">{m.consultas}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td className="py-4 text-muted-foreground" colSpan={2}>Nenhum médico encontrado</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -219,26 +499,30 @@ export default function RelatoriosPage() {
|
||||
<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" className="hover:!bg-primary hover:!text-white transition-colors" 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>
|
||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" 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>
|
||||
{loading ? (
|
||||
<div className="h-[220px] flex items-center justify-center text-muted-foreground">Carregando dados...</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={conveniosData} dataKey="valor" nameKey="nome" cx="50%" cy="50%" outerRadius={80} label>
|
||||
{conveniosData.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" className="hover:!bg-primary hover:!text-white transition-colors" onClick={() => exportPDF("Performance por Médico", "Resumo da performance por médico.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" 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>
|
||||
@ -249,7 +533,7 @@ export default function RelatoriosPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{performancePorMedico.map((m) => (
|
||||
{(loading ? performancePorMedico : medicosPerformance).map((m) => (
|
||||
<tr key={m.nome}>
|
||||
<td className="py-1">{m.nome}</td>
|
||||
<td className="py-1">{m.consultas}</td>
|
||||
|
||||
@ -9,9 +9,9 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { MoreHorizontal, Plus, Search, Edit, Trash2, ArrowLeft, Eye, Users } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DoctorRegistrationForm } from "@/components/forms/doctor-registration-form";
|
||||
import AvailabilityForm from '@/components/forms/availability-form'
|
||||
import ExceptionForm from '@/components/forms/exception-form'
|
||||
import { DoctorRegistrationForm } from "@/components/features/forms/doctor-registration-form";
|
||||
import AvailabilityForm from '@/components/features/forms/availability-form'
|
||||
import ExceptionForm from '@/components/features/forms/exception-form'
|
||||
import { listarDisponibilidades, DoctorAvailability, deletarDisponibilidade, listarExcecoes, DoctorException, deletarExcecao } from '@/lib/api'
|
||||
|
||||
|
||||
@ -20,7 +20,7 @@ import { listAssignmentsForUser } from '@/lib/assignment';
|
||||
|
||||
function normalizeMedico(m: any): Medico {
|
||||
const normalizeSex = (v: any) => {
|
||||
if (v === null || typeof v === 'undefined') return null;
|
||||
if (v === undefined) return null;
|
||||
const s = String(v || '').trim().toLowerCase();
|
||||
if (!s) return null;
|
||||
const male = new Set(['m','masc','male','masculino','homem','h','1','mas']);
|
||||
@ -623,7 +623,7 @@ export default function DoutoresPage() {
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="hover:!bg-primary hover:!text-white transition-colors"
|
||||
className="hover:bg-primary! hover:text-white! transition-colors"
|
||||
>
|
||||
Primeira
|
||||
</Button>
|
||||
@ -632,7 +632,7 @@ export default function DoutoresPage() {
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="hover:!bg-primary hover:!text-white transition-colors"
|
||||
className="hover:bg-primary! hover:text-white! transition-colors"
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
@ -644,7 +644,7 @@ export default function DoutoresPage() {
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="hover:!bg-primary hover:!text-white transition-colors"
|
||||
className="hover:bg-primary! hover:text-white! transition-colors"
|
||||
>
|
||||
Próxima
|
||||
</Button>
|
||||
@ -653,7 +653,7 @@ export default function DoutoresPage() {
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="hover:!bg-primary hover:!text-white transition-colors"
|
||||
className="hover:bg-primary! hover:text-white! transition-colors"
|
||||
>
|
||||
Última
|
||||
</Button>
|
||||
|
||||
@ -5,8 +5,8 @@ import { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Calculator, DollarSign } from "lucide-react";
|
||||
import HeaderAgenda from "@/components/agenda/HeaderAgenda";
|
||||
import FooterAgenda from "@/components/agenda/FooterAgenda";
|
||||
import HeaderAgenda from "@/components/features/agenda/HeaderAgenda";
|
||||
import FooterAgenda from "@/components/features/agenda/FooterAgenda";
|
||||
|
||||
export default function FinanceiroPage() {
|
||||
const router = useRouter();
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type React from "react";
|
||||
import ProtectedRoute from "@/components/ProtectedRoute";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import ProtectedRoute from "@/components/shared/ProtectedRoute";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { PagesHeader } from "@/components/dashboard/header";
|
||||
import { PagesHeader } from "@/components/features/dashboard/header";
|
||||
|
||||
export default function MainRoutesLayout({
|
||||
children,
|
||||
|
||||
@ -11,8 +11,8 @@ import { Label } from "@/components/ui/label";
|
||||
import { MoreHorizontal, Plus, Search, Eye, Edit, Trash2, ArrowLeft } from "lucide-react";
|
||||
|
||||
import { Paciente, Endereco, listarPacientes, buscarPacientes, buscarPacientePorId, excluirPaciente } from "@/lib/api";
|
||||
import { PatientRegistrationForm } from "@/components/forms/patient-registration-form";
|
||||
import AssignmentForm from "@/components/admin/AssignmentForm";
|
||||
import { PatientRegistrationForm } from "@/components/features/forms/patient-registration-form";
|
||||
import AssignmentForm from "@/components/features/admin/AssignmentForm";
|
||||
|
||||
|
||||
function normalizePaciente(p: any): Paciente {
|
||||
@ -320,7 +320,7 @@ export default function PacientesPage() {
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="hover:!bg-primary hover:!text-white transition-colors"
|
||||
className="hover:bg-primary! hover:text-white! transition-colors"
|
||||
>
|
||||
Primeira
|
||||
</Button>
|
||||
@ -329,7 +329,7 @@ export default function PacientesPage() {
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="hover:!bg-primary hover:!text-white transition-colors"
|
||||
className="hover:bg-primary! hover:text-white! transition-colors"
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
@ -341,7 +341,7 @@ export default function PacientesPage() {
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="hover:!bg-primary hover:!text-white transition-colors"
|
||||
className="hover:bg-primary! hover:text-white! transition-colors"
|
||||
>
|
||||
Próxima
|
||||
</Button>
|
||||
@ -350,7 +350,7 @@ export default function PacientesPage() {
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="hover:!bg-primary hover:!text-white transition-colors"
|
||||
className="hover:bg-primary! hover:text-white! transition-colors"
|
||||
>
|
||||
Última
|
||||
</Button>
|
||||
|
||||
@ -7,8 +7,8 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Search, ChevronDown } from "lucide-react";
|
||||
import { Plus } from "lucide-react";
|
||||
import HeaderAgenda from "@/components/agenda/HeaderAgenda";
|
||||
import FooterAgenda from "@/components/agenda/FooterAgenda";
|
||||
import HeaderAgenda from "@/components/features/agenda/HeaderAgenda";
|
||||
import FooterAgenda from "@/components/features/agenda/FooterAgenda";
|
||||
|
||||
export default function ProcedimentoPage() {
|
||||
const router = useRouter();
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type React from "react"
|
||||
import type { Metadata } from "next"
|
||||
import { AuthProvider } from "@/hooks/useAuth"
|
||||
import { ThemeProvider } from "@/components/theme-provider"
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider"
|
||||
import "./globals.css"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
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'
|
||||
@ -11,10 +12,10 @@ 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 { SimpleThemeToggle } from '@/components/ui/simple-theme-toggle'
|
||||
import { UploadAvatar } from '@/components/ui/upload-avatar'
|
||||
import Link from 'next/link'
|
||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||
import ProtectedRoute from '@/components/shared/ProtectedRoute'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { buscarPacientes, buscarPacientePorUserId, getUserInfo, listarAgendamentos, buscarMedicosPorIds, buscarMedicos, atualizarPaciente, buscarPacientePorId, getDoctorById } from '@/lib/api'
|
||||
@ -171,7 +172,6 @@ export default function PacientePage() {
|
||||
|
||||
loadProfile()
|
||||
return () => { mounted = false }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.id, user?.email])
|
||||
|
||||
// Load authoritative patient row for the logged-in user (prefer user_id lookup)
|
||||
@ -324,10 +324,84 @@ export default function PacientePage() {
|
||||
setNextAppt(null)
|
||||
}
|
||||
|
||||
// Load reports/laudos count
|
||||
// Load reports/laudos and compute count matching the Laudos session rules
|
||||
const reports = await listarRelatoriosPorPaciente(String(patientId)).catch(() => [])
|
||||
if (!mounted) return
|
||||
setExamsCount(Array.isArray(reports) ? reports.length : 0)
|
||||
let count = 0
|
||||
try {
|
||||
if (!Array.isArray(reports) || reports.length === 0) {
|
||||
count = 0
|
||||
} else {
|
||||
// Use the same robust doctor-resolution strategy as ExamesLaudos so
|
||||
// the card matches the list: try buscarMedicosPorIds, then per-id
|
||||
// getDoctorById and finally a REST fallback by user_id.
|
||||
const ids = Array.from(new Set((reports as any[]).map((r:any) => r.doctor_id || r.created_by || r.doctor).filter(Boolean).map(String)))
|
||||
if (ids.length === 0) {
|
||||
// fallback: count reports that have any direct doctor reference
|
||||
count = (reports as any[]).filter((r:any) => !!(r && (r.doctor_id || r.created_by || r.doctor || r.user_id))).length
|
||||
} else {
|
||||
const docs = await buscarMedicosPorIds(ids).catch(() => [])
|
||||
const map: Record<string, any> = {}
|
||||
for (const d of docs || []) {
|
||||
if (!d) continue
|
||||
try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
|
||||
try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = map[String(d.user_id)] || d } catch {}
|
||||
}
|
||||
|
||||
// Try per-id fallback using getDoctorById for any unresolved ids
|
||||
const unresolved = ids.filter(i => !map[i])
|
||||
if (unresolved.length) {
|
||||
for (const u of unresolved) {
|
||||
try {
|
||||
const d = await getDoctorById(String(u)).catch(() => null)
|
||||
if (d) {
|
||||
try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
|
||||
try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = d } catch {}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore per-id failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// REST fallback: try lookup by user_id for still unresolved ids
|
||||
const stillUnresolved = ids.filter(i => !map[i])
|
||||
if (stillUnresolved.length) {
|
||||
for (const u of stillUnresolved) {
|
||||
try {
|
||||
const token = (typeof window !== 'undefined') ? (localStorage.getItem('auth_token') || localStorage.getItem('token') || sessionStorage.getItem('auth_token') || sessionStorage.getItem('token')) : null
|
||||
const headers: Record<string,string> = { apikey: ENV_CONFIG.SUPABASE_ANON_KEY, Accept: 'application/json' }
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
const url = `${ENV_CONFIG.SUPABASE_URL}/rest/v1/doctors?user_id=eq.${encodeURIComponent(String(u))}&limit=1`
|
||||
const res = await fetch(url, { method: 'GET', headers })
|
||||
if (!res || res.status >= 400) continue
|
||||
const rows = await res.json().catch(() => [])
|
||||
if (rows && Array.isArray(rows) && rows.length) {
|
||||
const d = rows[0]
|
||||
if (d) {
|
||||
try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
|
||||
try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = d } catch {}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore network errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count only reports whose referenced doctor record has user_id
|
||||
count = (reports as any[]).filter((r:any) => {
|
||||
const maybeId = String(r.doctor_id || r.created_by || r.doctor || '')
|
||||
const doc = map[maybeId]
|
||||
return !!(doc && (doc.user_id || (doc as any).user_id))
|
||||
}).length
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
count = Array.isArray(reports) ? reports.length : 0
|
||||
}
|
||||
if (!mounted) return
|
||||
setExamsCount(count)
|
||||
} catch (e) {
|
||||
console.warn('[DashboardCards] erro ao carregar dados', e)
|
||||
if (!mounted) return
|
||||
@ -339,7 +413,7 @@ export default function PacientePage() {
|
||||
}
|
||||
load()
|
||||
return () => { mounted = false }
|
||||
}, [patientId])
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
|
||||
@ -353,7 +427,7 @@ export default function PacientePage() {
|
||||
{strings.proximaConsulta}
|
||||
</span>
|
||||
<span className="text-lg md:text-xl font-semibold text-foreground" aria-live="polite">
|
||||
{loading ? '—' : (nextAppt ?? '-')}
|
||||
{loading ? strings.carregando : (nextAppt ?? '-')}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
@ -367,7 +441,7 @@ export default function PacientePage() {
|
||||
{strings.ultimosExames}
|
||||
</span>
|
||||
<span className="text-lg md:text-xl font-semibold text-foreground" aria-live="polite">
|
||||
{loading ? '—' : (examsCount !== null ? String(examsCount) : '-')}
|
||||
{loading ? strings.carregando : (examsCount !== null ? String(examsCount) : '-')}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
@ -547,7 +621,7 @@ export default function PacientePage() {
|
||||
|
||||
loadAppointments()
|
||||
return () => { mounted = false }
|
||||
}, [patientId])
|
||||
}, [])
|
||||
|
||||
// Monta a URL de resultados com os filtros atuais
|
||||
const buildResultadosHref = () => {
|
||||
@ -557,7 +631,7 @@ export default function PacientePage() {
|
||||
if (localizacao) qs.set('local', localizacao)
|
||||
// indicate navigation origin so destination can alter UX (e.g., show modal instead of redirect)
|
||||
qs.set('origin', 'paciente')
|
||||
return `/resultados?${qs.toString()}`
|
||||
return `/paciente/resultados?${qs.toString()}`
|
||||
}
|
||||
|
||||
// derived lists for the page (computed after appointments state is declared)
|
||||
@ -567,16 +641,16 @@ export default function PacientePage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Hero Section */}
|
||||
<section className="bg-gradient-to-br from-card to-card/95 shadow-lg rounded-2xl border border-primary/10 p-8">
|
||||
<section className="bg-linear-to-br from-card to-card/95 shadow-lg rounded-2xl border border-primary/10 p-8">
|
||||
<div className="max-w-3xl mx-auto space-y-8">
|
||||
<header className="text-center space-y-4">
|
||||
<h2 className="text-4xl font-bold text-foreground">Agende sua próxima consulta</h2>
|
||||
<p className="text-lg text-muted-foreground leading-relaxed">Escolha o formato ideal, selecione a especialidade e encontre o profissional perfeito para você.</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-6 rounded-2xl border border-primary/15 bg-gradient-to-r from-primary/5 to-primary/10 p-8 shadow-sm">
|
||||
<div className="space-y-6 rounded-2xl border border-primary/15 bg-linear-to-r from-primary/5 to-primary/10 p-8 shadow-sm">
|
||||
<div className="flex justify-center">
|
||||
<Button asChild className="w-full md:w-auto px-10 py-3 bg-primary text-white hover:!bg-primary/90 hover:!text-white transition-all duration-200 font-semibold text-base rounded-lg shadow-md hover:shadow-lg active:scale-95">
|
||||
<Button asChild className="w-full md:w-auto px-10 py-3 bg-primary text-white hover:bg-primary/90! hover:text-white! transition-all duration-200 font-semibold text-base rounded-lg shadow-md hover:shadow-lg active:scale-95">
|
||||
<Link href={buildResultadosHref()} prefetch={false}>
|
||||
Pesquisar Médicos
|
||||
</Link>
|
||||
@ -595,7 +669,7 @@ export default function PacientePage() {
|
||||
</header>
|
||||
|
||||
{/* Date Navigation */}
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-primary/20 bg-gradient-to-r from-primary/5 to-primary/10 p-6 sm:flex-row sm:items-center sm:justify-between shadow-sm">
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-primary/20 bg-linear-to-r from-primary/5 to-primary/10 p-6 sm:flex-row sm:items-center sm:justify-between shadow-sm">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
@ -603,7 +677,7 @@ export default function PacientePage() {
|
||||
size="icon"
|
||||
onClick={(e: any) => { e.stopPropagation(); e.preventDefault(); navigateDate('prev') }}
|
||||
aria-label="Dia anterior"
|
||||
className={`group shadow-sm hover:!bg-primary hover:!text-white hover:!border-primary transition-all ${hoverPrimaryIconClass}`}
|
||||
className={`group shadow-sm hover:bg-primary! hover:text-white! hover:border-primary! transition-all ${hoverPrimaryIconClass}`}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5 transition group-hover:text-white" />
|
||||
</Button>
|
||||
@ -614,7 +688,7 @@ export default function PacientePage() {
|
||||
size="icon"
|
||||
onClick={(e: any) => { e.stopPropagation(); e.preventDefault(); navigateDate('next') }}
|
||||
aria-label="Próximo dia"
|
||||
className={`group shadow-sm hover:!bg-primary hover:!text-white hover:!border-primary transition-all ${hoverPrimaryIconClass}`}
|
||||
className={`group shadow-sm hover:bg-primary! hover:text-white! hover:border-primary! transition-all ${hoverPrimaryIconClass}`}
|
||||
>
|
||||
<ChevronRight className="h-5 w-5 transition group-hover:text-white" />
|
||||
</Button>
|
||||
@ -665,16 +739,16 @@ export default function PacientePage() {
|
||||
{/* Doctor Info */}
|
||||
<div className="flex items-start gap-4 min-w-0">
|
||||
<span
|
||||
className="mt-2 h-4 w-4 flex-shrink-0 rounded-full shadow-sm"
|
||||
className="mt-2 h-4 w-4 shrink-0 rounded-full shadow-sm"
|
||||
style={{ backgroundColor: consulta.status === 'Confirmada' ? '#10b981' : consulta.status === 'Pendente' ? '#f59e0b' : '#ef4444' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="space-y-3 min-w-0">
|
||||
<div className="font-bold flex items-center gap-2.5 text-foreground text-lg leading-tight">
|
||||
<Stethoscope className="h-5 w-5 text-primary flex-shrink-0" />
|
||||
<Stethoscope className="h-5 w-5 text-primary shrink-0" />
|
||||
<span className="truncate">{consulta.medico}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground break-words leading-relaxed">
|
||||
<p className="text-sm text-muted-foreground wrap-break-word leading-relaxed">
|
||||
<span className="font-medium text-foreground/70">{consulta.especialidade}</span>
|
||||
<span className="mx-1.5">•</span>
|
||||
<span>{consulta.local}</span>
|
||||
@ -684,7 +758,7 @@ export default function PacientePage() {
|
||||
|
||||
{/* Time */}
|
||||
<div className="flex items-center justify-start gap-2.5 text-foreground">
|
||||
<Clock className="h-5 w-5 text-primary flex-shrink-0" />
|
||||
<Clock className="h-5 w-5 text-primary shrink-0" />
|
||||
<span className="font-bold text-lg">{consulta.hora}</span>
|
||||
</div>
|
||||
|
||||
@ -692,10 +766,10 @@ export default function PacientePage() {
|
||||
<div className="flex items-center justify-start">
|
||||
<span className={`px-4 py-2.5 rounded-full text-xs font-bold text-white shadow-md transition-all ${
|
||||
consulta.status === 'Confirmada'
|
||||
? 'bg-gradient-to-r from-emerald-500 to-emerald-600 shadow-emerald-500/20'
|
||||
? 'bg-linear-to-r from-emerald-500 to-emerald-600 shadow-emerald-500/20'
|
||||
: consulta.status === 'Pendente'
|
||||
? 'bg-gradient-to-r from-amber-500 to-amber-600 shadow-amber-500/20'
|
||||
: 'bg-gradient-to-r from-red-500 to-red-600 shadow-red-500/20'
|
||||
? 'bg-linear-to-r from-amber-500 to-amber-600 shadow-amber-500/20'
|
||||
: 'bg-linear-to-r from-red-500 to-red-600 shadow-red-500/20'
|
||||
}`}>
|
||||
{consulta.status}
|
||||
</span>
|
||||
@ -706,7 +780,7 @@ export default function PacientePage() {
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="border border-primary/30 text-primary bg-primary/5 hover:!bg-primary hover:!text-white hover:!border-primary transition-all duration-200 focus-visible:ring-2 focus-visible:ring-primary/40 active:scale-95 text-xs font-semibold flex-1"
|
||||
className="border border-primary/30 text-primary bg-primary/5 hover:bg-primary! hover:text-white! hover:border-primary! transition-all duration-200 focus-visible:ring-2 focus-visible:ring-primary/40 active:scale-95 text-xs font-semibold flex-1"
|
||||
>
|
||||
Detalhes
|
||||
</Button>
|
||||
@ -714,7 +788,7 @@ export default function PacientePage() {
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="bg-primary/10 text-primary border border-primary/30 hover:!bg-primary hover:!text-white hover:!border-primary transition-all duration-200 focus-visible:ring-2 focus-visible:ring-primary/40 active:scale-95 text-xs font-semibold flex-1"
|
||||
className="bg-primary/10 text-primary border border-primary/30 hover:bg-primary! hover:text-white! hover:border-primary! transition-all duration-200 focus-visible:ring-2 focus-visible:ring-primary/40 active:scale-95 text-xs font-semibold flex-1"
|
||||
>
|
||||
Reagendar
|
||||
</Button>
|
||||
@ -723,7 +797,7 @@ export default function PacientePage() {
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="border border-destructive/30 text-destructive bg-destructive/5 hover:!bg-destructive hover:!text-white hover:!border-destructive transition-all duration-200 focus-visible:ring-2 focus-visible:ring-destructive/40 active:scale-95 text-xs font-semibold flex-1"
|
||||
className="border border-destructive/30 text-destructive bg-destructive/5 hover:bg-destructive! hover:text-white! hover:border-destructive! transition-all duration-200 focus-visible:ring-2 focus-visible:ring-destructive/40 active:scale-95 text-xs font-semibold flex-1"
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
@ -809,6 +883,7 @@ export default function PacientePage() {
|
||||
return false
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reports, searchTerm, doctorsMap, remoteMatch])
|
||||
|
||||
// When the search term looks like an id, attempt a direct fetch using the reports API
|
||||
@ -834,7 +909,7 @@ export default function PacientePage() {
|
||||
setSearchingRemote(true)
|
||||
setRemoteMatch(null)
|
||||
|
||||
if (looksLikeId) {
|
||||
if (looksLikeId && q) { // Adicionada verificação para q não ser vazio
|
||||
const r = await buscarRelatorioPorId(q).catch(() => null)
|
||||
if (!mounted) return
|
||||
if (r) setRemoteMatch(r)
|
||||
@ -847,9 +922,24 @@ export default function PacientePage() {
|
||||
if (q.length >= 2) {
|
||||
const docs = await buscarMedicos(q).catch(() => [])
|
||||
if (!mounted) return
|
||||
if (docs && Array.isArray(docs) && docs.length) {
|
||||
// fetch reports for matching doctors in parallel
|
||||
const promises = docs.map(d => listarRelatoriosPorMedico(String(d.id)).catch(() => []))
|
||||
if (docs && Array.isArray(docs) && docs.length) {
|
||||
// fetch reports for matching doctors in parallel. Some report rows
|
||||
// reference the doctor's account `user_id` in `requested_by` while
|
||||
// others reference the doctor's record `id`. Try both per doctor.
|
||||
const promises = docs.map(async (d: any) => {
|
||||
try {
|
||||
const byId = await listarRelatoriosPorMedico(String(d.id)).catch(() => [])
|
||||
if (Array.isArray(byId) && byId.length) return byId
|
||||
// fallback: if the doctor record has a user_id, try that too
|
||||
if (d && (d.user_id || d.userId)) {
|
||||
const byUser = await listarRelatoriosPorMedico(String(d.user_id || d.userId)).catch(() => [])
|
||||
if (Array.isArray(byUser) && byUser.length) return byUser
|
||||
}
|
||||
return []
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const arrays = await Promise.all(promises)
|
||||
if (!mounted) return
|
||||
const combined = ([] as any[]).concat(...arrays)
|
||||
@ -981,6 +1071,22 @@ export default function PacientePage() {
|
||||
}
|
||||
|
||||
setDoctorsMap(map)
|
||||
// After resolving doctor records, filter out reports whose doctor
|
||||
// record doesn't have a user_id (doctor_userid). If a report's
|
||||
// referenced doctor lacks user_id, we hide that laudo.
|
||||
try {
|
||||
const filtered = (reports || []).filter((r: any) => {
|
||||
const maybeId = String(r?.doctor_id || r?.created_by || r?.doctor || '')
|
||||
const doc = map[maybeId]
|
||||
return !!(doc && (doc.user_id || (doc as any).user_id))
|
||||
})
|
||||
// Only update when different to avoid extra cycles
|
||||
if (Array.isArray(filtered) && filtered.length !== (reports || []).length) {
|
||||
setReports(filtered)
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore filtering errors
|
||||
}
|
||||
setResolvingDoctors(false)
|
||||
} catch (e) {
|
||||
// ignore resolution errors
|
||||
@ -995,20 +1101,104 @@ export default function PacientePage() {
|
||||
if (!patientId) return
|
||||
setLoadingReports(true)
|
||||
setReportsError(null)
|
||||
listarRelatoriosPorPaciente(String(patientId))
|
||||
.then(res => {
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await listarRelatoriosPorPaciente(String(patientId)).catch(() => [])
|
||||
if (!mounted) return
|
||||
setReports(Array.isArray(res) ? res : [])
|
||||
})
|
||||
.catch(err => {
|
||||
|
||||
// If no reports, set empty and return
|
||||
if (!Array.isArray(res) || res.length === 0) {
|
||||
setReports([])
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve referenced doctor ids and only keep reports whose
|
||||
// referenced doctor record has a truthy user_id (i.e., created by a doctor)
|
||||
try {
|
||||
setResolvingDoctors(true)
|
||||
const ids = Array.from(new Set((res as any[]).map((r:any) => r.doctor_id || r.created_by || r.doctor).filter(Boolean).map(String)))
|
||||
const map: Record<string, any> = {}
|
||||
if (ids.length) {
|
||||
const docs = await buscarMedicosPorIds(ids).catch(() => [])
|
||||
for (const d of docs || []) {
|
||||
if (!d) continue
|
||||
try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
|
||||
try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = map[String(d.user_id)] || d } catch {}
|
||||
}
|
||||
|
||||
// per-id fallback
|
||||
const unresolved = ids.filter(i => !map[i])
|
||||
if (unresolved.length) {
|
||||
for (const u of unresolved) {
|
||||
try {
|
||||
const d = await getDoctorById(String(u)).catch(() => null)
|
||||
if (d) {
|
||||
try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
|
||||
try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = d } catch {}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// REST fallback by user_id
|
||||
const stillUnresolved = ids.filter(i => !map[i])
|
||||
if (stillUnresolved.length) {
|
||||
for (const u of stillUnresolved) {
|
||||
try {
|
||||
const token = (typeof window !== 'undefined') ? (localStorage.getItem('auth_token') || localStorage.getItem('token') || sessionStorage.getItem('auth_token') || sessionStorage.getItem('token')) : null
|
||||
const headers: Record<string,string> = { apikey: ENV_CONFIG.SUPABASE_ANON_KEY, Accept: 'application/json' }
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
const url = `${ENV_CONFIG.SUPABASE_URL}/rest/v1/doctors?user_id=eq.${encodeURIComponent(String(u))}&limit=1`
|
||||
const r = await fetch(url, { method: 'GET', headers })
|
||||
if (!r || r.status >= 400) continue
|
||||
const rows = await r.json().catch(() => [])
|
||||
if (rows && Array.isArray(rows) && rows.length) {
|
||||
const d = rows[0]
|
||||
if (d) {
|
||||
try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
|
||||
try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = d } catch {}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now filter reports to only those whose referenced doctor has user_id
|
||||
const filtered = (res || []).filter((r: any) => {
|
||||
const maybeId = String(r?.doctor_id || r?.created_by || r?.doctor || '')
|
||||
const doc = map[maybeId]
|
||||
return !!(doc && (doc.user_id || (doc as any).user_id))
|
||||
})
|
||||
|
||||
// Update doctorsMap and reports
|
||||
setDoctorsMap(map)
|
||||
setReports(filtered)
|
||||
setResolvingDoctors(false)
|
||||
return
|
||||
} catch (e) {
|
||||
// If resolution fails, fall back to setting raw results
|
||||
console.warn('[ExamesLaudos] falha ao resolver médicos para filtragem', e)
|
||||
setReports(Array.isArray(res) ? res : [])
|
||||
setResolvingDoctors(false)
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[ExamesLaudos] erro ao carregar laudos', err)
|
||||
if (!mounted) return
|
||||
setReportsError('Falha ao carregar laudos.')
|
||||
})
|
||||
.finally(() => { if (mounted) setLoadingReports(false) })
|
||||
} finally {
|
||||
if (mounted) setLoadingReports(false)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => { mounted = false }
|
||||
}, [patientId])
|
||||
}, [])
|
||||
|
||||
// When a report is selected, try to fetch doctor name if we have an id
|
||||
useEffect(() => {
|
||||
@ -1065,7 +1255,7 @@ export default function PacientePage() {
|
||||
}
|
||||
})()
|
||||
return () => { mounted = false }
|
||||
}, [selectedReport])
|
||||
}, [])
|
||||
|
||||
// reset pagination when reports change
|
||||
useEffect(() => {
|
||||
@ -1099,11 +1289,13 @@ export default function PacientePage() {
|
||||
) : (
|
||||
(() => {
|
||||
const total = Array.isArray(filteredReports) ? filteredReports.length : 0
|
||||
const totalPages = Math.max(1, Math.ceil(total / reportsPerPage))
|
||||
// enforce a maximum of 5 laudos per page
|
||||
const perPage = Math.max(1, Math.min(reportsPerPage || 5, 5))
|
||||
const totalPages = Math.max(1, Math.ceil(total / perPage))
|
||||
// keep page inside bounds
|
||||
const page = Math.min(Math.max(1, reportsPage), totalPages)
|
||||
const start = (page - 1) * reportsPerPage
|
||||
const end = start + reportsPerPage
|
||||
const start = (page - 1) * perPage
|
||||
const end = start + perPage
|
||||
const pageItems = (filteredReports || []).slice(start, end)
|
||||
|
||||
return (
|
||||
@ -1121,8 +1313,8 @@ export default function PacientePage() {
|
||||
<div className="text-base md:text-base text-muted-foreground mt-1">Data: {new Date(r.report_date || r.created_at || Date.now()).toLocaleDateString('pt-BR')}</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2 md:mt-0">
|
||||
<Button variant="outline" className="hover:!bg-primary hover:!text-white transition-colors" onClick={async () => { setSelectedReport(r); }}>{strings.visualizarLaudo}</Button>
|
||||
<Button variant="secondary" className="hover:!bg-primary hover:!text-white transition-colors" onClick={async () => { try { await navigator.clipboard.writeText(JSON.stringify(r)); setToast({ type: 'success', msg: 'Laudo copiado.' }) } catch { setToast({ type: 'error', msg: 'Falha ao copiar.' }) } }}>{strings.compartilhar}</Button>
|
||||
<Button variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={async () => { setSelectedReport(r); }}>{strings.visualizarLaudo}</Button>
|
||||
<Button variant="secondary" className="hover:bg-primary! hover:text-white! transition-colors" onClick={async () => { try { await navigator.clipboard.writeText(JSON.stringify(r)); setToast({ type: 'success', msg: 'Laudo copiado.' }) } catch { setToast({ type: 'error', msg: 'Falha ao copiar.' }) } }}>{strings.compartilhar}</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -1145,64 +1337,62 @@ export default function PacientePage() {
|
||||
<Dialog open={!!selectedReport} onOpenChange={open => !open && setSelectedReport(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Laudo Médico</DialogTitle>
|
||||
<DialogDescription>
|
||||
<DialogTitle>
|
||||
{selectedReport && (
|
||||
(() => {
|
||||
const looksLikeIdStr = (s: any) => {
|
||||
try {
|
||||
const hexOnly = String(s || '').replace(/[^0-9a-fA-F]/g, '');
|
||||
const len = (typeof hexOnly === 'string') ? hexOnly.length : (Number(hexOnly) || 0);
|
||||
return len >= 8;
|
||||
} catch { return false; }
|
||||
};
|
||||
const maybeId = selectedReport?.doctor_id || selectedReport?.created_by || selectedReport?.doctor || null;
|
||||
const derived = reportDoctorName ? reportTitle(selectedReport, reportDoctorName) : reportTitle(selectedReport);
|
||||
|
||||
if (looksLikeIdStr(derived)) {
|
||||
return <span className="font-semibold text-xl md:text-2xl text-muted-foreground">{strings.carregando}</span>;
|
||||
}
|
||||
if (resolvingDoctors && maybeId && !doctorsMap[String(maybeId)]) {
|
||||
return <span className="font-semibold text-xl md:text-2xl text-muted-foreground">{strings.carregando}</span>;
|
||||
}
|
||||
return <span className="font-semibold text-xl md:text-2xl">{derived}</span>;
|
||||
})()
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">Detalhes do laudo</DialogDescription>
|
||||
<div className="mt-4 space-y-3 max-h-96 overflow-y-auto">
|
||||
{selectedReport && (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
{
|
||||
// prefer the resolved doctor name; while resolving, show a loading indicator instead of raw IDs
|
||||
(() => {
|
||||
const looksLikeIdStr = (s: any) => {
|
||||
try {
|
||||
const hexOnly = String(s || '').replace(/[^0-9a-fA-F]/g, '')
|
||||
const len = (typeof hexOnly === 'string') ? hexOnly.length : (Number(hexOnly) || 0)
|
||||
return len >= 8
|
||||
} catch { return false }
|
||||
}
|
||||
const maybeId = selectedReport?.doctor_id || selectedReport?.created_by || selectedReport?.doctor || null
|
||||
// derive the title text
|
||||
const derived = reportDoctorName ? reportTitle(selectedReport, reportDoctorName) : reportTitle(selectedReport)
|
||||
// if the derived title looks like an id (UUID/hex) avoid showing it — show loading instead
|
||||
if (looksLikeIdStr(derived)) return <div className="font-semibold text-xl md:text-2xl text-muted-foreground">{strings.carregando}</div>
|
||||
if (resolvingDoctors && maybeId && !doctorsMap[String(maybeId)]) return <div className="font-semibold text-xl md:text-2xl text-muted-foreground">{strings.carregando}</div>
|
||||
return <div className="font-semibold text-xl md:text-2xl">{derived}</div>
|
||||
})()
|
||||
}
|
||||
<div className="text-sm text-muted-foreground">Data: {new Date(selectedReport.report_date || selectedReport.created_at || Date.now()).toLocaleDateString('pt-BR')}</div>
|
||||
{reportDoctorName && <div className="text-sm text-muted-foreground">Profissional: <strong className="text-foreground">{reportDoctorName}</strong></div>}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Data: {new Date(selectedReport.report_date || selectedReport.created_at || Date.now()).toLocaleDateString('pt-BR')}</div>
|
||||
{reportDoctorName && <div className="text-sm text-muted-foreground">Profissional: <strong className="text-foreground">{reportDoctorName}</strong></div>}
|
||||
|
||||
{/* Standardized laudo sections: CID, Exame, Diagnóstico, Conclusão, Notas (prefer HTML when available) */}
|
||||
{/* Standardized laudo sections */}
|
||||
{(() => {
|
||||
const cid = selectedReport.cid ?? selectedReport.cid_code ?? selectedReport.cidCode ?? selectedReport.cie ?? '-'
|
||||
const exam = selectedReport.exam ?? selectedReport.exame ?? selectedReport.especialidade ?? selectedReport.report_type ?? '-'
|
||||
const diagnosis = selectedReport.diagnosis ?? selectedReport.diagnostico ?? selectedReport.diagnosis_text ?? selectedReport.diagnostico_text ?? ''
|
||||
const conclusion = selectedReport.conclusion ?? selectedReport.conclusao ?? selectedReport.conclusion_text ?? selectedReport.conclusao_text ?? ''
|
||||
const notesHtml = selectedReport.content_html ?? selectedReport.conteudo_html ?? selectedReport.contentHtml ?? null
|
||||
const notesText = selectedReport.content ?? selectedReport.body ?? selectedReport.conteudo ?? selectedReport.notes ?? selectedReport.observacoes ?? ''
|
||||
const cid = selectedReport.cid ?? selectedReport.cid_code ?? selectedReport.cidCode ?? selectedReport.cie ?? '-';
|
||||
const exam = selectedReport.exam ?? selectedReport.exame ?? selectedReport.especialidade ?? selectedReport.report_type ?? '-';
|
||||
const diagnosis = selectedReport.diagnosis ?? selectedReport.diagnostico ?? selectedReport.diagnosis_text ?? selectedReport.diagnostico_text ?? '';
|
||||
const conclusion = selectedReport.conclusion ?? selectedReport.conclusao ?? selectedReport.conclusion_text ?? selectedReport.conclusao_text ?? '';
|
||||
const notesHtml = selectedReport.content_html ?? selectedReport.conteudo_html ?? selectedReport.contentHtml ?? null;
|
||||
const notesText = selectedReport.content ?? selectedReport.body ?? selectedReport.conteudo ?? selectedReport.notes ?? selectedReport.observacoes ?? '';
|
||||
return (
|
||||
<div className="space-y-3 mb-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">CID</div>
|
||||
<div className="text-foreground">{cid || '-'}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Exame</div>
|
||||
<div className="text-foreground">{exam || '-'}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Diagnóstico</div>
|
||||
<div className="whitespace-pre-line text-foreground">{diagnosis || '-'}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Conclusão</div>
|
||||
<div className="whitespace-pre-line text-foreground">{conclusion || '-'}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Notas do Profissional</div>
|
||||
{notesHtml ? (
|
||||
@ -1212,18 +1402,23 @@ export default function PacientePage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})()}
|
||||
{/* Optional: doctor signature or footer */}
|
||||
{selectedReport.doctor_signature && (
|
||||
<div className="mt-4 text-sm text-muted-foreground">Assinatura: <img src={selectedReport.doctor_signature} alt="assinatura" className="inline-block h-10" /></div>
|
||||
<div className="mt-4 text-sm text-muted-foreground">Assinatura: <Image src={selectedReport.doctor_signature} alt="assinatura" width={40} height={40} className="inline-block h-10 w-auto" /></div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSelectedReport(null)}>Fechar</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSelectedReport(null)}
|
||||
className="transition duration-200 hover:bg-primary/10 hover:text-primary dark:hover:bg-accent dark:hover:text-accent-foreground"
|
||||
>
|
||||
Fechar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@ -1346,7 +1541,7 @@ export default function PacientePage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<SimpleThemeToggle />
|
||||
<Button asChild variant="outline" className="hover:!bg-primary hover:!text-white hover:!border-primary transition-colors">
|
||||
<Button asChild variant="outline" className="hover:bg-primary! hover:text-white! hover:border-primary! transition-colors">
|
||||
<Link href="/">
|
||||
<Home className="h-4 w-4 mr-1" /> Início
|
||||
</Link>
|
||||
@ -1356,7 +1551,7 @@ export default function PacientePage() {
|
||||
variant="outline"
|
||||
aria-label={strings.sair}
|
||||
disabled={loading}
|
||||
className="text-destructive border-destructive hover:!bg-destructive hover:!text-white hover:!border-destructive transition-colors"
|
||||
className="text-destructive border-destructive hover:bg-destructive! hover:text-white! hover:border-destructive! transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-1" /> {strings.sair}
|
||||
</Button>
|
||||
@ -1372,7 +1567,7 @@ export default function PacientePage() {
|
||||
variant={tab==='dashboard'?'default':'ghost'}
|
||||
aria-current={tab==='dashboard'}
|
||||
onClick={()=>setTab('dashboard')}
|
||||
className={`w-full justify-start transition-colors hover:!bg-primary hover:!text-white cursor-pointer`}
|
||||
className={`w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer`}
|
||||
>
|
||||
<Calendar className="mr-2 h-4 w-4" />{strings.dashboard}
|
||||
</Button>
|
||||
@ -1380,7 +1575,7 @@ export default function PacientePage() {
|
||||
variant={tab==='consultas'?'default':'ghost'}
|
||||
aria-current={tab==='consultas'}
|
||||
onClick={()=>setTab('consultas')}
|
||||
className={`w-full justify-start transition-colors hover:!bg-primary hover:!text-white cursor-pointer`}
|
||||
className={`w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer`}
|
||||
>
|
||||
<Calendar className="mr-2 h-4 w-4" />{strings.consultas}
|
||||
</Button>
|
||||
@ -1388,7 +1583,7 @@ export default function PacientePage() {
|
||||
variant={tab==='exames'?'default':'ghost'}
|
||||
aria-current={tab==='exames'}
|
||||
onClick={()=>setTab('exames')}
|
||||
className={`w-full justify-start transition-colors hover:!bg-primary hover:!text-white cursor-pointer`}
|
||||
className={`w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer`}
|
||||
>
|
||||
<FileText className="mr-2 h-4 w-4" />{strings.exames}
|
||||
</Button>
|
||||
@ -1397,7 +1592,7 @@ export default function PacientePage() {
|
||||
variant={tab==='perfil'?'default':'ghost'}
|
||||
aria-current={tab==='perfil'}
|
||||
onClick={()=>setTab('perfil')}
|
||||
className={`w-full justify-start transition-colors hover:!bg-primary hover:!text-white cursor-pointer`}
|
||||
className={`w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer`}
|
||||
>
|
||||
<UserCog className="mr-2 h-4 w-4" />{strings.perfil}
|
||||
</Button>
|
||||
|
||||
@ -31,6 +31,7 @@ import {
|
||||
getAvailableSlots,
|
||||
criarAgendamento,
|
||||
criarAgendamentoDireto,
|
||||
listarAgendamentos,
|
||||
getUserInfo,
|
||||
buscarPacientes,
|
||||
listarDisponibilidades,
|
||||
@ -54,13 +55,16 @@ export default function ResultadosClient() {
|
||||
const params = useSearchParams()
|
||||
const router = useRouter()
|
||||
|
||||
// Filtros/controles da UI
|
||||
const [tipoConsulta, setTipoConsulta] = useState<TipoConsulta>(
|
||||
params?.get('tipo') === 'presencial' ? 'local' : 'teleconsulta'
|
||||
)
|
||||
const [especialidadeHero, setEspecialidadeHero] = useState<string>(params?.get('especialidade') || 'Psicólogo')
|
||||
// Filtros/controles da UI - initialize with defaults to avoid hydration mismatch
|
||||
const [tipoConsulta, setTipoConsulta] = useState<TipoConsulta>('teleconsulta')
|
||||
const [especialidadeHero, setEspecialidadeHero] = useState<string>('Psicólogo')
|
||||
const [convenio, setConvenio] = useState<string>('Todos')
|
||||
const [bairro, setBairro] = useState<string>('Todos')
|
||||
// Busca por nome do médico
|
||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||
|
||||
// Track if URL params have been synced to avoid race condition
|
||||
const [paramsSync, setParamsSync] = useState(false)
|
||||
|
||||
// Estado dinâmico
|
||||
const [patientId, setPatientId] = useState<string | null>(null)
|
||||
@ -104,7 +108,20 @@ export default function ResultadosClient() {
|
||||
const [bookingSuccessOpen, setBookingSuccessOpen] = useState(false)
|
||||
const [bookedWhenLabel, setBookedWhenLabel] = useState<string | null>(null)
|
||||
|
||||
// 1) Obter patientId a partir do usuário autenticado (email -> patients)
|
||||
// 1) Sincronize URL params with state after client mount (prevent hydration mismatch)
|
||||
useEffect(() => {
|
||||
if (!params) return
|
||||
const tipoParam = params.get('tipo')
|
||||
if (tipoParam === 'presencial') setTipoConsulta('local')
|
||||
|
||||
const especialidadeParam = params.get('especialidade')
|
||||
if (especialidadeParam) setEspecialidadeHero(especialidadeParam)
|
||||
|
||||
// Mark params as synced
|
||||
setParamsSync(true)
|
||||
}, [params])
|
||||
|
||||
// 2) Fetch patient ID from auth
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
;(async () => {
|
||||
@ -124,8 +141,32 @@ export default function ResultadosClient() {
|
||||
return () => { mounted = false }
|
||||
}, [])
|
||||
|
||||
// 2) Buscar médicos conforme especialidade selecionada
|
||||
// 3) Initial doctors fetch on mount (one-time initialization)
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
;(async () => {
|
||||
try {
|
||||
setLoadingMedicos(true)
|
||||
console.log('[ResultadosClient] Initial doctors fetch starting')
|
||||
const list = await buscarMedicos('medico').catch((err) => {
|
||||
console.error('[ResultadosClient] Initial fetch error:', err)
|
||||
return []
|
||||
})
|
||||
if (!mounted) return
|
||||
console.log('[ResultadosClient] Initial fetch completed, got:', list?.length || 0, 'doctors')
|
||||
setMedicos(Array.isArray(list) ? list : [])
|
||||
} finally {
|
||||
if (mounted) setLoadingMedicos(false)
|
||||
}
|
||||
})()
|
||||
return () => { mounted = false }
|
||||
}, [])
|
||||
|
||||
// 4) Re-fetch doctors when especialidade changes (after initial sync)
|
||||
useEffect(() => {
|
||||
// Skip if this is the initial render or if user is searching by name
|
||||
if (!paramsSync || (searchQuery && String(searchQuery).trim().length > 1)) return
|
||||
|
||||
let mounted = true
|
||||
;(async () => {
|
||||
try {
|
||||
@ -133,10 +174,15 @@ export default function ResultadosClient() {
|
||||
setMedicos([])
|
||||
setAgendaByDoctor({})
|
||||
setAgendasExpandida({})
|
||||
// termo de busca: usar a especialidade escolhida (fallback para string genérica)
|
||||
const termo = (especialidadeHero && especialidadeHero !== 'Veja mais') ? especialidadeHero : (params?.get('q') || 'medico')
|
||||
const list = await buscarMedicos(termo).catch(() => [])
|
||||
// termo de busca: usar a especialidade escolhida
|
||||
const termo = (especialidadeHero && especialidadeHero !== 'Veja mais') ? especialidadeHero : 'medico'
|
||||
console.log('[ResultadosClient] Fetching doctors with term:', termo)
|
||||
const list = await buscarMedicos(termo).catch((err) => {
|
||||
console.error('[ResultadosClient] buscarMedicos error:', err)
|
||||
return []
|
||||
})
|
||||
if (!mounted) return
|
||||
console.log('[ResultadosClient] Doctors fetched:', list?.length || 0)
|
||||
setMedicos(Array.isArray(list) ? list : [])
|
||||
} catch (e: any) {
|
||||
showToast('error', e?.message || 'Falha ao buscar profissionais')
|
||||
@ -145,7 +191,32 @@ export default function ResultadosClient() {
|
||||
}
|
||||
})()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [especialidadeHero])
|
||||
}, [especialidadeHero, paramsSync])
|
||||
|
||||
// 5) Debounced search by doctor name
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
const term = String(searchQuery || '').trim()
|
||||
const handle = setTimeout(async () => {
|
||||
if (!mounted) return
|
||||
// if no meaningful search, do nothing (the specialidade effect will run)
|
||||
if (!term || term.length < 2) return
|
||||
try {
|
||||
setLoadingMedicos(true)
|
||||
setMedicos([])
|
||||
setAgendaByDoctor({})
|
||||
setAgendasExpandida({})
|
||||
const list = await buscarMedicos(term).catch(() => [])
|
||||
if (!mounted) return
|
||||
setMedicos(Array.isArray(list) ? list : [])
|
||||
} catch (e: any) {
|
||||
showToast('error', e?.message || 'Falha ao buscar profissionais')
|
||||
} finally {
|
||||
if (mounted) setLoadingMedicos(false)
|
||||
}
|
||||
}, 350)
|
||||
return () => { mounted = false; clearTimeout(handle) }
|
||||
}, [searchQuery])
|
||||
|
||||
// 3) Carregar horários disponíveis para um médico (próximos 7 dias) e agrupar por dia
|
||||
async function loadAgenda(doctorId: string) {
|
||||
@ -172,7 +243,7 @@ export default function ResultadosClient() {
|
||||
days.push({ label, data: fmtDay(d), dateKey, horarios: [] })
|
||||
}
|
||||
|
||||
const onlyAvail = (res?.slots || []).filter(s => s.available)
|
||||
const onlyAvail = (res?.slots || []).filter((s: any) => s.available)
|
||||
for (const s of onlyAvail) {
|
||||
const dt = new Date(s.datetime)
|
||||
const key = dt.toISOString().split('T')[0]
|
||||
@ -237,7 +308,26 @@ export default function ResultadosClient() {
|
||||
}
|
||||
|
||||
// Open confirmation dialog for a selected slot instead of immediately booking
|
||||
function openConfirmDialog(doctorId: string, iso: string) {
|
||||
async function openConfirmDialog(doctorId: string, iso: string) {
|
||||
// Pre-check: ensure there is no existing appointment for this doctor at this exact datetime
|
||||
try {
|
||||
// build query: exact match on doctor_id and scheduled_at
|
||||
const params = new URLSearchParams();
|
||||
params.set('doctor_id', `eq.${String(doctorId)}`);
|
||||
params.set('scheduled_at', `eq.${String(iso)}`);
|
||||
params.set('limit', '1');
|
||||
const existing = await listarAgendamentos(params.toString()).catch(() => [])
|
||||
if (existing && (existing as any).length) {
|
||||
showToast('error', 'Não é possível agendar: já existe uma consulta neste horário para o profissional selecionado.')
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
// If checking fails (auth or network), surface a friendly error and avoid opening the dialog to prevent accidental duplicates.
|
||||
console.warn('[ResultadosClient] falha ao checar conflitos de agendamento', err)
|
||||
showToast('error', 'Não foi possível verificar disponibilidade. Tente novamente em instantes.')
|
||||
return
|
||||
}
|
||||
|
||||
setPendingAppointment({ doctorId, iso })
|
||||
setConfirmOpen(true)
|
||||
}
|
||||
@ -255,6 +345,24 @@ export default function ResultadosClient() {
|
||||
showToast('success', 'Iniciando agendamento...')
|
||||
setConfirmLoading(true)
|
||||
try {
|
||||
// Final conflict check to avoid race conditions: query appointments for same doctor + scheduled_at
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('doctor_id', `eq.${String(doctorId)}`);
|
||||
params.set('scheduled_at', `eq.${String(iso)}`);
|
||||
params.set('limit', '1');
|
||||
const existing = await listarAgendamentos(params.toString()).catch(() => [])
|
||||
if (existing && (existing as any).length) {
|
||||
showToast('error', 'Não é possível agendar: já existe uma consulta neste horário para o profissional selecionado.')
|
||||
setConfirmLoading(false)
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[ResultadosClient] falha ao checar conflito antes de criar agendamento', err)
|
||||
showToast('error', 'Falha ao verificar conflito de agendamento. Tente novamente.')
|
||||
setConfirmLoading(false)
|
||||
return
|
||||
}
|
||||
// Use direct POST to ensure creation even if availability checks would block
|
||||
await criarAgendamentoDireto({
|
||||
patient_id: String(patientId),
|
||||
@ -319,7 +427,7 @@ export default function ResultadosClient() {
|
||||
let start: Date
|
||||
let end: Date
|
||||
try {
|
||||
const parts = String(dateOnly).split('-').map((p) => Number(p))
|
||||
const parts = String(dateOnly).split('-').map(Number)
|
||||
if (parts.length === 3 && parts.every((n) => !Number.isNaN(n))) {
|
||||
const [y, m, d] = parts
|
||||
start = new Date(y, m - 1, d, 0, 0, 0, 0)
|
||||
@ -357,12 +465,12 @@ export default function ResultadosClient() {
|
||||
5: ['5','fri','friday','sexta','sexta-feira'],
|
||||
6: ['6','sat','saturday','sabado','sábado']
|
||||
}
|
||||
const allowed = (weekdayNames[weekdayNumber] || []).map(s => String(s).toLowerCase())
|
||||
const allowed = new Set((weekdayNames[weekdayNumber] || []).map(s => String(s).toLowerCase()))
|
||||
const matched = (disponibilidades || []).filter((d: any) => {
|
||||
try {
|
||||
const raw = String(d.weekday ?? d.weekday_name ?? d.day ?? d.day_of_week ?? '').toLowerCase()
|
||||
if (!raw) return false
|
||||
if (allowed.includes(raw)) return true
|
||||
if (allowed.has(raw)) return true
|
||||
if (typeof d.weekday === 'number' && d.weekday === weekdayNumber) return true
|
||||
if (typeof d.day_of_week === 'number' && d.day_of_week === weekdayNumber) return true
|
||||
return false
|
||||
@ -373,7 +481,7 @@ export default function ResultadosClient() {
|
||||
const windows = matched.map((d: any) => {
|
||||
const parseTime = (t?: string) => {
|
||||
if (!t) return { hh: 0, mm: 0, ss: 0 }
|
||||
const parts = String(t).split(':').map((p) => Number(p))
|
||||
const parts = String(t).split(':').map(Number)
|
||||
return { hh: parts[0] || 0, mm: parts[1] || 0, ss: parts[2] || 0 }
|
||||
}
|
||||
const s = parseTime(d.start_time)
|
||||
@ -420,8 +528,8 @@ export default function ResultadosClient() {
|
||||
cursorMs += perWindowStep * 60000
|
||||
}
|
||||
} else {
|
||||
const lastBackendMs = backendSlotsInWindow[backendSlotsInWindow.length - 1]
|
||||
let cursorMs = lastBackendMs + perWindowStep * 60000
|
||||
const lastBackendMs = backendSlotsInWindow.at(-1)
|
||||
let cursorMs = (lastBackendMs ?? 0) + perWindowStep * 60000
|
||||
while (cursorMs <= lastStartMs) {
|
||||
generatedSet.add(new Date(cursorMs).toISOString())
|
||||
cursorMs += perWindowStep * 60000
|
||||
@ -505,6 +613,20 @@ export default function ResultadosClient() {
|
||||
})
|
||||
}, [medicos, convenio, bairro])
|
||||
|
||||
// Paginação local para a lista de médicos
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [itemsPerPage, setItemsPerPage] = useState(5)
|
||||
|
||||
// Resetar para página 1 quando o conjunto de profissionais (filtro) ou itemsPerPage mudar
|
||||
useEffect(() => {
|
||||
setCurrentPage(1)
|
||||
}, [profissionais, itemsPerPage])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((profissionais || []).length / itemsPerPage))
|
||||
const paginatedProfissionais = (profissionais || []).slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
|
||||
const startItem = (profissionais || []).length ? (currentPage - 1) * itemsPerPage + 1 : 0
|
||||
const endItem = Math.min(currentPage * itemsPerPage, (profissionais || []).length)
|
||||
|
||||
// Render
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
@ -517,7 +639,7 @@ export default function ResultadosClient() {
|
||||
)}
|
||||
|
||||
{/* Confirmation dialog shown when a user selects a slot */}
|
||||
<Dialog open={confirmOpen} onOpenChange={(open) => { if (!open) { setConfirmOpen(false); setPendingAppointment(null); } }}>
|
||||
<Dialog open={confirmOpen} onOpenChange={(open: boolean) => { if (!open) { setConfirmOpen(false); setPendingAppointment(null); } }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmar agendamento</DialogTitle>
|
||||
@ -550,7 +672,7 @@ export default function ResultadosClient() {
|
||||
</Dialog>
|
||||
|
||||
{/* Booking success modal shown when origin=paciente */}
|
||||
<Dialog open={bookingSuccessOpen} onOpenChange={(open) => setBookingSuccessOpen(open)}>
|
||||
<Dialog open={bookingSuccessOpen} onOpenChange={(open: boolean) => setBookingSuccessOpen(open)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Consulta agendada</DialogTitle>
|
||||
@ -573,7 +695,7 @@ export default function ResultadosClient() {
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-full border-primary-foreground/30 bg-primary-foreground/10 text-primary-foreground hover:!bg-primary-foreground hover:!text-primary transition-colors"
|
||||
className="rounded-full border-primary-foreground/30 bg-primary-foreground/10 text-primary-foreground hover:bg-primary-foreground! hover:text-primary! transition-colors"
|
||||
>
|
||||
Ajustar filtros
|
||||
</Button>
|
||||
@ -600,7 +722,7 @@ export default function ResultadosClient() {
|
||||
<Toggle
|
||||
pressed={tipoConsulta === 'teleconsulta'}
|
||||
onPressedChange={() => setTipoConsulta('teleconsulta')}
|
||||
className={cn('rounded-full px-4 py-[10px] text-sm font-medium transition hover:bg-primary hover:text-primary-foreground focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-[0.97]',
|
||||
className={cn('rounded-full px-4 py-2.5 text-sm font-medium transition hover:bg-primary hover:text-primary-foreground focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-[0.97]',
|
||||
tipoConsulta === 'teleconsulta' ? 'bg-primary text-primary-foreground' : 'border border-primary/40 text-primary')}
|
||||
>
|
||||
<Globe className="mr-2 h-4 w-4" />
|
||||
@ -609,7 +731,7 @@ export default function ResultadosClient() {
|
||||
<Toggle
|
||||
pressed={tipoConsulta === 'local'}
|
||||
onPressedChange={() => setTipoConsulta('local')}
|
||||
className={cn('rounded-full px-4 py-[10px] text-sm font-medium transition hover:bg-primary hover:text-primary-foreground focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-[0.97]',
|
||||
className={cn('rounded-full px-4 py-2.5 text-sm font-medium transition hover:bg-primary hover:text-primary-foreground focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-[0.97]',
|
||||
tipoConsulta === 'local' ? 'bg-primary text-primary-foreground' : 'border border-primary/40 text-primary')}
|
||||
>
|
||||
<Building2 className="mr-2 h-4 w-4" />
|
||||
@ -617,7 +739,7 @@ export default function ResultadosClient() {
|
||||
</Toggle>
|
||||
|
||||
<Select value={convenio} onValueChange={setConvenio}>
|
||||
<SelectTrigger className="h-10 min-w-[180px] rounded-full border border-primary/40 bg-primary/10 text-primary transition duration-200 hover:!border-primary focus:ring-2 focus:ring-primary cursor-pointer">
|
||||
<SelectTrigger className="h-10 min-w-[180px] rounded-full border border-primary/40 bg-primary/10 text-primary transition duration-200 hover:border-primary! focus:ring-2 focus:ring-primary cursor-pointer">
|
||||
<SelectValue placeholder="Convênio" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -631,7 +753,7 @@ export default function ResultadosClient() {
|
||||
</Select>
|
||||
|
||||
<Select value={bairro} onValueChange={setBairro}>
|
||||
<SelectTrigger className="h-10 min-w-[160px] rounded-full border border-primary/40 bg-primary/10 text-primary transition duration-200 hover:!border-primary focus:ring-2 focus:ring-primary cursor-pointer">
|
||||
<SelectTrigger className="h-10 min-w-40 rounded-full border border-primary/40 bg-primary/10 text-primary transition duration-200 hover:border-primary! focus:ring-2 focus:ring-primary cursor-pointer">
|
||||
<SelectValue placeholder="Bairro" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -642,17 +764,51 @@ export default function ResultadosClient() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-full border border-primary/40 bg-primary/10 text-primary hover:!bg-primary hover:!text-white transition-colors"
|
||||
>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Mais filtros
|
||||
</Button>
|
||||
{/* Search input para buscar médico por nome */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Buscar médico por nome"
|
||||
value={searchQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
|
||||
className="min-w-[220px] rounded-full"
|
||||
/>
|
||||
{searchQuery ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-10"
|
||||
onClick={async () => {
|
||||
// limpar o termo de busca e restaurar a lista por especialidade
|
||||
setSearchQuery('')
|
||||
setCurrentPage(1)
|
||||
try {
|
||||
setLoadingMedicos(true)
|
||||
setMedicos([])
|
||||
setAgendaByDoctor({})
|
||||
setAgendasExpandida({})
|
||||
const termo = (especialidadeHero && especialidadeHero !== 'Veja mais') ? especialidadeHero : (params?.get('q') || 'medico')
|
||||
const list = await buscarMedicos(termo).catch(() => [])
|
||||
setMedicos(Array.isArray(list) ? list : [])
|
||||
} catch (e: any) {
|
||||
showToast('error', e?.message || 'Falha ao buscar profissionais')
|
||||
} finally {
|
||||
setLoadingMedicos(false)
|
||||
}
|
||||
}}
|
||||
>Limpar</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-full border border-primary/40 bg-primary/10 text-primary hover:bg-primary! hover:text-white! transition-colors"
|
||||
>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Mais filtros
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="ml-auto rounded-full text-primary hover:!bg-primary hover:!text-white transition-colors"
|
||||
className="ml-auto rounded-full text-primary hover:bg-primary! hover:text-white! transition-colors"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
Voltar
|
||||
@ -668,7 +824,7 @@ export default function ResultadosClient() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loadingMedicos && profissionais.map((medico) => {
|
||||
{!loadingMedicos && paginatedProfissionais.map((medico) => {
|
||||
const id = String(medico.id)
|
||||
const agenda = agendaByDoctor[id]
|
||||
const isLoadingAgenda = !!agendaLoading[id]
|
||||
@ -711,7 +867,7 @@ export default function ResultadosClient() {
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="ml-auto h-fit rounded-full text-primary hover:!bg-primary hover:!text-white transition-colors"
|
||||
className="ml-auto h-fit rounded-full text-primary hover:bg-primary! hover:text-white! transition-colors"
|
||||
onClick={() => {
|
||||
setMedicoSelecionado(medico)
|
||||
setAbaDetalhe('experiencia')
|
||||
@ -782,12 +938,12 @@ export default function ResultadosClient() {
|
||||
>
|
||||
Agendar consulta
|
||||
</Button>
|
||||
<Button variant="outline" className="h-11 rounded-full border-primary/40 bg-primary/10 text-primary hover:!bg-primary hover:!text-white transition-colors">
|
||||
<Button variant="outline" className="h-11 rounded-full border-primary/40 bg-primary/10 text-primary hover:bg-primary! hover:text-white! transition-colors">
|
||||
Enviar mensagem
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-11 rounded-full text-primary hover:!bg-primary hover:!text-white transition-colors"
|
||||
className="h-11 rounded-full text-primary hover:bg-primary! hover:text-white! transition-colors"
|
||||
onClick={() => {
|
||||
const willOpen = !agendasExpandida[id]
|
||||
setAgendasExpandida(prev => ({ ...prev, [id]: !prev[id] }))
|
||||
@ -806,50 +962,7 @@ export default function ResultadosClient() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Agenda: 4 colunas como no layout. Se ainda não carregou, mostra placeholders. */}
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<div className="grid min-w-[360px] grid-cols-4 gap-3">
|
||||
{(agenda || [
|
||||
{ label: 'HOJE', data: fmtDay(new Date()), horarios: [] },
|
||||
{ label: 'AMANHÃ', data: fmtDay(new Date(Date.now()+86400000)), horarios: [] },
|
||||
{ label: shortWeek[new Date(Date.now()+2*86400000).getDay()], data: fmtDay(new Date(Date.now()+2*86400000)), horarios: [] },
|
||||
{ label: shortWeek[new Date(Date.now()+3*86400000).getDay()], data: fmtDay(new Date(Date.now()+3*86400000)), horarios: [] },
|
||||
]).map((col, idx) => {
|
||||
const horarios = agendasExpandida[id] ? col.horarios : col.horarios.slice(0, 3)
|
||||
return (
|
||||
<div key={`${id}-${col.label}-${idx}`} className="rounded-2xl border border-border p-3 text-center">
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">{col.label}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{col.data}</p>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{isLoadingAgenda && !agenda ? (
|
||||
<span className="rounded-lg border border-dashed border-border px-2 py-3 text-[11px] text-muted-foreground">
|
||||
Carregando...
|
||||
</span>
|
||||
) : horarios.length ? (
|
||||
horarios.map(h => (
|
||||
<button
|
||||
key={h.iso}
|
||||
type="button"
|
||||
className="rounded-lg bg-primary/10 px-2 py-1 text-xs font-medium text-primary transition hover:bg-primary hover:text-primary-foreground"
|
||||
onClick={() => openConfirmDialog(id, h.iso)}
|
||||
>
|
||||
{h.label}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="rounded-lg border border-dashed border-border px-2 py-3 text-[11px] text-muted-foreground">
|
||||
Sem horários
|
||||
</span>
|
||||
)}
|
||||
{!agendasExpandida[id] && (col.horarios.length > 3) && (
|
||||
<span className="text-[10px] text-muted-foreground">+{col.horarios.length - 3} horários</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Horários compactos removidos conforme solicitação do design (colunas HOJE/AMANHÃ/etc.). */}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
@ -859,10 +972,33 @@ export default function ResultadosClient() {
|
||||
Nenhum profissional encontrado. Ajuste os filtros para ver outras opções.
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pagination controls */}
|
||||
{!loadingMedicos && profissionais.length > 0 && (
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span>Itens por página:</span>
|
||||
<select value={itemsPerPage} onChange={(e) => setItemsPerPage(Number(e.target.value))} className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary cursor-pointer">
|
||||
<option value={5}>5</option>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
</select>
|
||||
<span>Mostrando {startItem} a {endItem} de {profissionais.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setCurrentPage(1)} disabled={currentPage === 1} className="hover:bg-primary! hover:text-white!">Primeira</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="hover:bg-primary! hover:text-white!">Anterior</Button>
|
||||
<span className="text-sm text-muted-foreground">Página {currentPage} de {totalPages}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} className="hover:bg-primary! hover:text-white!">Próxima</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} className="hover:bg-primary! hover:text-white!">Última</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Dialog de perfil completo (mantido e adaptado) */}
|
||||
<Dialog open={!!medicoSelecionado} onOpenChange={open => !open && setMedicoSelecionado(null)}>
|
||||
<Dialog open={!!medicoSelecionado} onOpenChange={(open: boolean) => !open && setMedicoSelecionado(null)}>
|
||||
<DialogContent className="max-h[90vh] max-h-[90vh] w-full max-w-5xl overflow-y-auto border border-border bg-card p-0">
|
||||
{medicoSelecionado && (
|
||||
<>
|
||||
@ -978,7 +1114,7 @@ export default function ResultadosClient() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Dialog: Mostrar mais horários (escolher data arbitrária) */}
|
||||
<Dialog open={!!moreTimesForDoctor} onOpenChange={(open) => { if (!open) { setMoreTimesForDoctor(null); setMoreTimesSlots([]); setMoreTimesException(null); } }}>
|
||||
<Dialog open={!!moreTimesForDoctor} onOpenChange={(open: boolean) => { if (!open) { setMoreTimesForDoctor(null); setMoreTimesSlots([]); setMoreTimesException(null); } }}>
|
||||
<DialogContent className="w-full max-w-2xl border border-border bg-card p-6">
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle>Mais horários</DialogTitle>
|
||||
@ -3,7 +3,7 @@ import ResultadosClient from './ResultadosClient'
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen">Carregando...</div>}>
|
||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><span>Carregando...</span></div>}>
|
||||
<ResultadosClient />
|
||||
</Suspense>
|
||||
)
|
||||
@ -1,6 +1,6 @@
|
||||
import { Header } from "@/components/header"
|
||||
import { HeroSection } from "@/components/hero-section"
|
||||
import { Footer } from "@/components/footer"
|
||||
import { Header } from "@/components/layout/header"
|
||||
import { HeroSection } from "@/components/features/general/hero-section"
|
||||
import { Footer } from "@/components/layout/footer"
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import SignatureCanvas from "react-signature-canvas";
|
||||
import Link from "next/link";
|
||||
import ProtectedRoute from "@/components/ProtectedRoute";
|
||||
import ProtectedRoute from "@/components/shared/ProtectedRoute";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { buscarPacientes, listarPacientes, buscarPacientePorId, buscarPacientesPorIds, buscarMedicoPorId, buscarMedicosPorIds, buscarMedicos, listarAgendamentos, type Paciente, buscarRelatorioPorId, atualizarMedico } from "@/lib/api";
|
||||
import { useReports } from "@/hooks/useReports";
|
||||
import { CreateReportData } from "@/types/report-types";
|
||||
@ -12,7 +14,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select";
|
||||
import { SimpleThemeToggle } from "@/components/simple-theme-toggle";
|
||||
import { SimpleThemeToggle } from "@/components/ui/simple-theme-toggle";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -174,7 +176,8 @@ const ProfissionalPage = () => {
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, [user?.id, doctorId]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Carregar perfil do médico correspondente ao usuário logado
|
||||
useEffect(() => {
|
||||
@ -226,7 +229,8 @@ const ProfissionalPage = () => {
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, [user?.id, user?.email]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
@ -338,7 +342,7 @@ const ProfissionalPage = () => {
|
||||
// Helper: parse 'YYYY-MM-DD' into a local Date to avoid UTC parsing which can shift day
|
||||
const parseYMDToLocal = (ymd?: string) => {
|
||||
if (!ymd || typeof ymd !== 'string') return new Date();
|
||||
const parts = ymd.split('-').map((p) => Number(p));
|
||||
const parts = ymd.split('-').map(Number);
|
||||
if (parts.length < 3 || parts.some((n) => Number.isNaN(n))) return new Date(ymd);
|
||||
const [y, m, d] = parts;
|
||||
return new Date(y, (m || 1) - 1, d || 1);
|
||||
@ -369,7 +373,8 @@ const ProfissionalPage = () => {
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, [doctorId, user?.id, user?.email]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [doctorId]);
|
||||
const [editingEvent, setEditingEvent] = useState<any>(null);
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
const [showActionModal, setShowActionModal] = useState(false);
|
||||
@ -690,7 +695,7 @@ const ProfissionalPage = () => {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateDate('prev')}
|
||||
className="p-2 hover:!bg-primary hover:!text-white cursor-pointer transition-colors"
|
||||
className="p-2 hover:bg-primary! hover:text-white! cursor-pointer transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
@ -701,7 +706,7 @@ const ProfissionalPage = () => {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateDate('next')}
|
||||
className="p-2 hover:!bg-primary hover:!text-white cursor-pointer transition-colors"
|
||||
className="p-2 hover:bg-primary! hover:text-white! cursor-pointer transition-colors"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
@ -900,7 +905,7 @@ const ProfissionalPage = () => {
|
||||
variant={selectedRange === 'todos' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedRange('todos')}
|
||||
className="hover:!bg-primary hover:!text-white transition-colors"
|
||||
className="hover:bg-primary! hover:text-white! transition-colors"
|
||||
>
|
||||
Todos
|
||||
</Button>
|
||||
@ -908,7 +913,7 @@ const ProfissionalPage = () => {
|
||||
variant={selectedRange === 'semana' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedRange('semana')}
|
||||
className="hover:!bg-primary hover:!text-white transition-colors"
|
||||
className="hover:bg-primary! hover:text-white! transition-colors"
|
||||
>
|
||||
Semana
|
||||
</Button>
|
||||
@ -916,7 +921,7 @@ const ProfissionalPage = () => {
|
||||
variant={selectedRange === 'mes' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedRange('mes')}
|
||||
className="hover:!bg-primary hover:!text-white transition-colors"
|
||||
className="hover:bg-primary! hover:text-white! transition-colors"
|
||||
>
|
||||
Mês
|
||||
</Button>
|
||||
@ -1077,7 +1082,7 @@ const ProfissionalPage = () => {
|
||||
<Button size="sm" onClick={doSearch} disabled={searching}>
|
||||
Buscar
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleClear} className="hover:!bg-primary hover:!text-white transition-colors">
|
||||
<Button size="sm" variant="ghost" onClick={handleClear} className="hover:bg-primary! hover:text-white! transition-colors">
|
||||
Limpar
|
||||
</Button>
|
||||
</div>
|
||||
@ -1200,12 +1205,14 @@ const ProfissionalPage = () => {
|
||||
await loadAssignedLaudos();
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, [user?.id]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// sincroniza quando reports mudarem no hook (fallback)
|
||||
useEffect(() => {
|
||||
if (!laudos || laudos.length === 0) setLaudos(reports || []);
|
||||
}, [reports]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Sort reports newest-first (more recent dates at the top)
|
||||
const sortedLaudos = React.useMemo(() => {
|
||||
@ -1383,7 +1390,7 @@ const ProfissionalPage = () => {
|
||||
setIsViewing(true);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 hover:!bg-primary hover:!text-white transition-colors"
|
||||
className="flex items-center gap-1 hover:bg-primary! hover:text-white! transition-colors"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Ver Laudo
|
||||
@ -1668,8 +1675,7 @@ const ProfissionalPage = () => {
|
||||
|
||||
// Editor de Laudo Avançado (para novos laudos)
|
||||
function LaudoEditor({ pacientes, laudo, onClose, isNewLaudo, preSelectedPatient, createNewReport, updateExistingReport, reloadReports, onSaved }: { pacientes?: any[]; laudo?: any; onClose: () => void; isNewLaudo?: boolean; preSelectedPatient?: any; createNewReport?: (data: any) => Promise<any>; updateExistingReport?: (id: string, data: any) => Promise<any>; reloadReports?: () => Promise<void>; onSaved?: (r:any) => void }) {
|
||||
// Import useToast at the top level of the component
|
||||
const { toast } = require('@/hooks/use-toast').useToast();
|
||||
const { toast } = useToast();
|
||||
const [activeTab, setActiveTab] = useState("editor");
|
||||
const [content, setContent] = useState(laudo?.conteudo || "");
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
@ -1818,7 +1824,7 @@ const ProfissionalPage = () => {
|
||||
const sig = laudo.assinaturaImg ?? laudo.signature_image ?? laudo.signature ?? laudo.sign_image ?? null;
|
||||
if (sig) setAssinaturaImg(sig);
|
||||
}
|
||||
}, [laudo, isNewLaudo, pacienteSelecionado, listaPacientes, user]);
|
||||
}, [laudo, isNewLaudo, pacienteSelecionado, listaPacientes]);
|
||||
|
||||
// Histórico para desfazer/refazer
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
@ -2250,6 +2256,7 @@ const ProfissionalPage = () => {
|
||||
{imagens.map((img) => (
|
||||
<div key={img.id} className="border border-border rounded-lg p-2">
|
||||
{img.type.startsWith('image/') ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={img.url}
|
||||
alt={img.name}
|
||||
@ -2417,6 +2424,7 @@ const ProfissionalPage = () => {
|
||||
<h3 className="font-semibold mb-2">Imagens:</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{imagens.map((img) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
key={img.id}
|
||||
src={img.url}
|
||||
@ -2432,6 +2440,7 @@ const ProfissionalPage = () => {
|
||||
{campos.mostrarAssinatura && (
|
||||
<div className="mt-8 text-center">
|
||||
{assinaturaImg && assinaturaImg.length > 30 ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={assinaturaImg} alt="Assinatura Digital" className="mx-auto h-16 object-contain mb-2" />
|
||||
) : (
|
||||
<div className="h-16 mb-2 text-xs text-muted-foreground">Assine no campo ao lado para visualizar aqui.</div>
|
||||
@ -2457,7 +2466,7 @@ const ProfissionalPage = () => {
|
||||
Este editor permite escrever relatórios de forma livre, com formatação de texto rica.
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose} className="hover:!bg-primary hover:!text-white transition-colors">
|
||||
<Button variant="outline" onClick={onClose} className="hover:bg-primary! hover:text-white! transition-colors">
|
||||
Cancelar
|
||||
</Button>
|
||||
{/* botão 'Salvar Rascunho' removido por não ser utilizado */}
|
||||
@ -2528,7 +2537,11 @@ const ProfissionalPage = () => {
|
||||
} else if (typeof val === 'boolean') {
|
||||
if (origVal !== val) diff[k] = val;
|
||||
} else if (val !== undefined && val !== null) {
|
||||
if (JSON.stringify(origVal) !== JSON.stringify(val)) diff[k] = val;
|
||||
if (JSON.stringify(origVal) !== JSON.stringify(val)) {
|
||||
diff[k] = val;
|
||||
} else {
|
||||
// no change
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2656,7 +2669,7 @@ const ProfissionalPage = () => {
|
||||
<Button onClick={handleSaveProfile} className="flex items-center gap-2">
|
||||
Salvar
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleCancelEdit} className="hover:!bg-primary hover:!text-white transition-colors">
|
||||
<Button variant="outline" onClick={handleCancelEdit} className="hover:bg-primary! hover:text-white! transition-colors">
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
@ -2779,7 +2792,7 @@ const ProfissionalPage = () => {
|
||||
</Avatar>
|
||||
{isEditingProfile && (
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" size="sm" className="hover:!bg-primary hover:!text-white transition-colors">
|
||||
<Button variant="outline" size="sm" className="hover:bg-primary! hover:text-white! transition-colors">
|
||||
Alterar Foto
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@ -2875,7 +2888,7 @@ const ProfissionalPage = () => {
|
||||
<nav className="bg-card shadow-md rounded-lg border border-border p-3 space-y-1">
|
||||
<Button
|
||||
variant={activeSection === 'calendario' ? 'default' : 'ghost'}
|
||||
className="w-full justify-start transition-colors hover:!bg-primary hover:!text-white cursor-pointer"
|
||||
className="w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
||||
onClick={() => setActiveSection('calendario')}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
@ -2883,7 +2896,7 @@ const ProfissionalPage = () => {
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeSection === 'pacientes' ? 'default' : 'ghost'}
|
||||
className="w-full justify-start transition-colors hover:!bg-primary hover:!text-white cursor-pointer"
|
||||
className="w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
||||
onClick={() => setActiveSection('pacientes')}
|
||||
>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
@ -2891,7 +2904,7 @@ const ProfissionalPage = () => {
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeSection === 'laudos' ? 'default' : 'ghost'}
|
||||
className="w-full justify-start transition-colors hover:!bg-primary hover:!text-white cursor-pointer"
|
||||
className="w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
||||
onClick={() => setActiveSection('laudos')}
|
||||
>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
@ -2899,7 +2912,7 @@ const ProfissionalPage = () => {
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeSection === 'comunicacao' ? 'default' : 'ghost'}
|
||||
className="w-full justify-start transition-colors hover:!bg-primary hover:!text-white cursor-pointer"
|
||||
className="w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
||||
onClick={() => setActiveSection('comunicacao')}
|
||||
>
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
@ -2907,7 +2920,7 @@ const ProfissionalPage = () => {
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeSection === 'perfil' ? 'default' : 'ghost'}
|
||||
className="w-full justify-start transition-colors hover:!bg-primary hover:!text-white cursor-pointer"
|
||||
className="w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
||||
onClick={() => setActiveSection('perfil')}
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
@ -2957,7 +2970,7 @@ const ProfissionalPage = () => {
|
||||
<Button
|
||||
onClick={() => setShowPopup(false)}
|
||||
variant="outline"
|
||||
className="flex-1 hover:!bg-primary hover:!text-white transition-colors"
|
||||
className="flex-1 hover:bg-primary! hover:text-white! transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
@ -3072,7 +3085,7 @@ const ProfissionalPage = () => {
|
||||
<Button
|
||||
onClick={() => setShowActionModal(false)}
|
||||
variant="outline"
|
||||
className="w-full mt-2 hover:!bg-primary hover:!text-white transition-colors"
|
||||
className="w-full mt-2 hover:bg-primary! hover:text-white! transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Header } from "@/components/header"
|
||||
import { AboutSection } from "@/components/about-section"
|
||||
import { Footer } from "@/components/footer"
|
||||
import { Header } from "@/components/layout/header"
|
||||
import { AboutSection } from "@/components/features/general/about-section"
|
||||
import { Footer } from "@/components/layout/footer"
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
|
||||
118
susconecta/components/features/Calendario/Calendar.tsx
Normal file
118
susconecta/components/features/Calendario/Calendar.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import React, { useState, useCallback, useMemo } from "react";
|
||||
import { EventCard } from "./EventCard";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
// Types
|
||||
import { Event } from "@/components/features/general/event-manager";
|
||||
|
||||
// Week View Component
|
||||
export function WeekView({
|
||||
currentDate,
|
||||
events,
|
||||
onEventClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDrop,
|
||||
getColorClasses,
|
||||
}: {
|
||||
currentDate: Date;
|
||||
events: Event[];
|
||||
onEventClick: (event: Event) => void;
|
||||
onDragStart: (event: Event) => void;
|
||||
onDragEnd: () => void;
|
||||
onDrop: (date: Date, hour: number) => void;
|
||||
getColorClasses: (color: string) => { bg: string; text: string };
|
||||
}) {
|
||||
const startOfWeek = new Date(currentDate);
|
||||
startOfWeek.setDate(currentDate.getDay());
|
||||
|
||||
const weekDays = Array.from({ length: 7 }, (_, i) => {
|
||||
const day = new Date(startOfWeek);
|
||||
day.setDate(startOfWeek.getDate() + i);
|
||||
return day;
|
||||
});
|
||||
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
const getEventsForDayAndHour = (date: Date, hour: number) => {
|
||||
return events.filter((event) => {
|
||||
const eventDate = new Date(event.startTime);
|
||||
const eventHour = eventDate.getHours();
|
||||
return (
|
||||
eventDate.getDate() === date.getDate() &&
|
||||
eventDate.getMonth() === date.getMonth() &&
|
||||
eventDate.getFullYear() === date.getFullYear() &&
|
||||
eventHour === hour
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// dias da semana em pt-BR (abreviações)
|
||||
const weekDayNames = ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"];
|
||||
|
||||
return (
|
||||
<Card className="overflow-auto">
|
||||
<div className="grid grid-cols-8 border-b">
|
||||
<div className="border-r p-2 text-center text-xs font-medium sm:text-sm">
|
||||
Hora
|
||||
</div>
|
||||
{weekDays.map((day) => (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className="border-r p-2 text-center text-xs font-medium last:border-r-0 sm:text-sm"
|
||||
>
|
||||
<div className="hidden sm:block">
|
||||
{day.toLocaleDateString("pt-BR", { weekday: "short" })}
|
||||
</div>
|
||||
<div className="sm:hidden">
|
||||
{day.toLocaleDateString("pt-BR", { weekday: "narrow" })}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground sm:text-xs">
|
||||
{day.toLocaleDateString("pt-BR", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-8">
|
||||
{hours.map((hour) => (
|
||||
<React.Fragment key={`hour-${hour}`}>
|
||||
<div
|
||||
key={`time-${hour}`}
|
||||
className="border-b border-r p-1 text-[10px] text-muted-foreground sm:p-2 sm:text-xs"
|
||||
>
|
||||
{hour.toString().padStart(2, "0")}:00
|
||||
</div>
|
||||
{weekDays.map((day) => {
|
||||
const dayEvents = getEventsForDayAndHour(day, hour);
|
||||
return (
|
||||
<div
|
||||
key={`${day.toISOString()}-${hour}`}
|
||||
className="min-h-12 border-b border-r p-0.5 transition-colors hover:bg-accent/50 last:border-r-0 sm:min-h-16 sm:p-1"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => onDrop(day, hour)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{dayEvents.map((event) => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
onEventClick={onEventClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
getColorClasses={getColorClasses}
|
||||
variant="default"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
103
susconecta/components/features/Calendario/EventCard.tsx
Normal file
103
susconecta/components/features/Calendario/EventCard.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import React, { useState } from "react";
|
||||
import { Event } from "@/components/features/general/event-manager";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/*
|
||||
Componente leve para representar um evento no calendário.
|
||||
Compatível com o uso em Calendar.tsx (WeekView / DayView).
|
||||
*/
|
||||
|
||||
export function EventCard({
|
||||
event,
|
||||
onEventClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
getColorClasses,
|
||||
variant = "default",
|
||||
}: {
|
||||
event: Event;
|
||||
onEventClick: (e: Event) => void;
|
||||
onDragStart: (e: Event) => void;
|
||||
onDragEnd: () => void;
|
||||
getColorClasses: (color: string) => { bg: string; text: string };
|
||||
variant?: "default" | "compact" | "detailed";
|
||||
}) {
|
||||
const [hover, setHover] = useState(false);
|
||||
const color = getColorClasses?.(event.color) ?? { bg: "bg-slate-400", text: "text-white" };
|
||||
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
e.dataTransfer.setData("text/plain", event.id);
|
||||
onDragStart && onDragStart(event);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
onEventClick && onEventClick(event);
|
||||
};
|
||||
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={() => onDragEnd && onDragEnd()}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
className={cn(
|
||||
"rounded px-2 py-0.5 text-xs font-medium truncate",
|
||||
color.bg,
|
||||
color.text,
|
||||
"cursor-pointer transition-all",
|
||||
hover && "shadow-md scale-105"
|
||||
)}
|
||||
>
|
||||
{event.title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "detailed") {
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={() => onDragEnd && onDragEnd()}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
className={cn(
|
||||
"rounded-lg p-2 text-sm cursor-pointer transition-all",
|
||||
color.bg,
|
||||
color.text,
|
||||
hover && "shadow-lg scale-[1.02]"
|
||||
)}
|
||||
>
|
||||
<div className="font-semibold">{event.title}</div>
|
||||
{event.description && <div className="text-xs opacity-90 mt-1 line-clamp-2">{event.description}</div>}
|
||||
<div className="mt-1 text-[11px] opacity-80">
|
||||
{event.startTime?.toLocaleTimeString?.("pt-BR", { hour: "2-digit", minute: "2-digit" }) ?? ""} - {event.endTime?.toLocaleTimeString?.("pt-BR", { hour: "2-digit", minute: "2-digit" }) ?? ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// default
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={() => onDragEnd && onDragEnd()}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
className={cn(
|
||||
"relative rounded px-2 py-1 text-xs font-medium cursor-pointer transition-all",
|
||||
color.bg,
|
||||
color.text,
|
||||
hover && "shadow-md scale-105"
|
||||
)}
|
||||
>
|
||||
<div className="truncate">{event.title}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Save } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Label } from "../ui/label";
|
||||
import { Switch } from "../ui/switch";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Label } from "../../ui/label";
|
||||
import { Switch } from "../../ui/switch";
|
||||
import { useState } from "react";
|
||||
|
||||
interface FooterAgendaProps {
|
||||
@ -7,8 +7,8 @@ import { Input } from "@/components/ui/input"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { SidebarTrigger } from "../ui/sidebar"
|
||||
import { SimpleThemeToggle } from "@/components/simple-theme-toggle";
|
||||
import { SidebarTrigger } from "../../ui/sidebar"
|
||||
import { SimpleThemeToggle } from "@/components/ui/simple-theme-toggle";
|
||||
|
||||
export function PagesHeader({ title = "", subtitle = "" }: { title?: string, subtitle?: string }) {
|
||||
const { logout, user } = useAuth();
|
||||
@ -43,7 +43,7 @@ export function PagesHeader({ title = "", subtitle = "" }: { title?: string, sub
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" className="hover:!bg-primary hover:!text-white transition-colors">
|
||||
<Button variant="ghost" size="icon" className="hover:bg-primary! hover:text-white! transition-colors">
|
||||
<Bell className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@ -32,7 +32,7 @@ import { getAvatarPublicUrl } from '@/lib/api';
|
||||
;
|
||||
|
||||
import { buscarCepAPI } from "@/lib/api";
|
||||
import { CredentialsDialog } from "@/components/credentials-dialog";
|
||||
import { CredentialsDialog } from "@/components/features/general/credentials-dialog";
|
||||
|
||||
type FormacaoAcademica = {
|
||||
instituicao: string;
|
||||
@ -30,7 +30,7 @@ import { getAvatarPublicUrl } from '@/lib/api';
|
||||
|
||||
import { validarCPFLocal } from "@/lib/utils";
|
||||
import { verificarCpfDuplicado } from "@/lib/api";
|
||||
import { CredentialsDialog } from "@/components/credentials-dialog";
|
||||
import { CredentialsDialog } from "@/components/features/general/credentials-dialog";
|
||||
|
||||
type Mode = "create" | "edit";
|
||||
|
||||
1495
susconecta/components/features/general/calendarComponente/page.tsx
Normal file
1495
susconecta/components/features/general/calendarComponente/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1485
susconecta/components/features/general/event-manager.tsx
Normal file
1485
susconecta/components/features/general/event-manager.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,5 @@
|
||||
|
||||
|
||||
"use client"
|
||||
|
||||
import { ChevronUp } from "lucide-react"
|
||||
@ -5,7 +5,7 @@ import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { SimpleThemeToggle } from "@/components/simple-theme-toggle";
|
||||
import { SimpleThemeToggle } from "@/components/ui/simple-theme-toggle";
|
||||
|
||||
export function Header() {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
@ -34,18 +34,15 @@ function useChart() {
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
}) {
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
@ -55,9 +52,10 @@ function ChartContainer({
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-sector]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
@ -67,7 +65,8 @@ function ChartContainer({
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
})
|
||||
ChartContainer.displayName = "Chart"
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
@ -104,28 +103,33 @@ ${colorConfig
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}) {
|
||||
payload?: any[]
|
||||
label?: any
|
||||
}
|
||||
>(({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey
|
||||
}, ref) => {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
@ -134,14 +138,14 @@ function ChartTooltipContent({
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
||||
const key = `${labelKey || item.dataKey || item.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
let value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
if (labelFormatter && value) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
@ -172,27 +176,30 @@ function ChartTooltipContent({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
{payload.map((item: any, index: number) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
const indicatorColor = color || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
{formatter &&
|
||||
item.value !== undefined &&
|
||||
item.name !== undefined ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
@ -202,7 +209,7 @@ function ChartTooltipContent({
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
@ -233,7 +240,7 @@ function ChartTooltipContent({
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
@ -246,21 +253,20 @@ function ChartTooltipContent({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
ChartTooltipContent.displayName = "ChartTooltip"
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
hideIcon?: boolean
|
||||
payload?: any[]
|
||||
verticalAlign?: RechartsPrimitive.LegendProps["verticalAlign"]
|
||||
nameKey?: string
|
||||
}) {
|
||||
}
|
||||
>(({ className, hideIcon = false, payload, verticalAlign, nameKey }, ref) => {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
@ -269,13 +275,14 @@ function ChartLegendContent({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
{payload.map((item: any) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
@ -283,7 +290,7 @@ function ChartLegendContent({
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
@ -302,12 +309,13 @@ function ChartLegendContent({
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
ChartLegendContent.displayName = "ChartLegend"
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
payload: any,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
|
||||
@ -266,7 +266,7 @@ function SidebarTrigger({
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("size-7 hover:!bg-primary hover:!text-white transition-colors", className)}
|
||||
className={cn("size-7 hover:bg-primary! hover:text-white! transition-colors", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
|
||||
@ -17,7 +17,7 @@ export function SimpleThemeToggle() {
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
className="hover:!bg-primary hover:!text-white hover:!border-primary cursor-pointer !shadow-sm !shadow-black/10 !border-2 !border-black dark:!shadow-none dark:!border-border transition-colors"
|
||||
className="hover:bg-primary! hover:text-white! hover:border-primary! cursor-pointer shadow-sm! shadow-black/10! border-2! border-black! dark:shadow-none! dark:border-border! transition-colors"
|
||||
>
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
457
susconecta/components/ui/three-dwall-calendar.tsx
Normal file
457
susconecta/components/ui/three-dwall-calendar.tsx
Normal file
@ -0,0 +1,457 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Trash2, Calendar, Clock, User } from "lucide-react"
|
||||
import { v4 as uuidv4 } from "uuid"
|
||||
import { startOfMonth, endOfMonth, eachDayOfInterval, format } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
|
||||
export type CalendarEvent = {
|
||||
id: string
|
||||
title: string
|
||||
date: string // ISO
|
||||
status?: 'confirmed' | 'pending' | 'cancelled' | string
|
||||
patient?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
interface ThreeDWallCalendarProps {
|
||||
events: CalendarEvent[]
|
||||
onAddEvent?: (e: CalendarEvent) => void
|
||||
onRemoveEvent?: (id: string) => void
|
||||
panelWidth?: number
|
||||
panelHeight?: number
|
||||
columns?: number
|
||||
}
|
||||
|
||||
export function ThreeDWallCalendar({
|
||||
events,
|
||||
onAddEvent,
|
||||
onRemoveEvent,
|
||||
panelWidth = 160,
|
||||
panelHeight = 120,
|
||||
columns = 7,
|
||||
}: ThreeDWallCalendarProps) {
|
||||
const [dateRef, setDateRef] = React.useState<Date>(new Date())
|
||||
const [title, setTitle] = React.useState("")
|
||||
const [newDate, setNewDate] = React.useState("")
|
||||
const [selectedDay, setSelectedDay] = React.useState<Date | null>(null)
|
||||
const [isDialogOpen, setIsDialogOpen] = React.useState(false)
|
||||
const wallRef = React.useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// 3D tilt state
|
||||
const [tiltX, setTiltX] = React.useState(18)
|
||||
const [tiltY, setTiltY] = React.useState(0)
|
||||
const isDragging = React.useRef(false)
|
||||
const dragStart = React.useRef<{ x: number; y: number } | null>(null)
|
||||
const hasDragged = React.useRef(false)
|
||||
const clickStart = React.useRef<{ x: number; y: number } | null>(null)
|
||||
|
||||
// month days
|
||||
const days = eachDayOfInterval({
|
||||
start: startOfMonth(dateRef),
|
||||
end: endOfMonth(dateRef),
|
||||
})
|
||||
|
||||
const eventsForDay = (d: Date) =>
|
||||
events.filter((ev) => format(new Date(ev.date), "yyyy-MM-dd") === format(d, "yyyy-MM-dd"))
|
||||
|
||||
const selectedDayEvents = selectedDay ? eventsForDay(selectedDay) : []
|
||||
|
||||
const handleDayClick = (day: Date) => {
|
||||
console.log('Day clicked:', format(day, 'dd/MM/yyyy'))
|
||||
setSelectedDay(day)
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
|
||||
// Add event handler
|
||||
const handleAdd = () => {
|
||||
if (!title.trim() || !newDate) return
|
||||
onAddEvent?.({
|
||||
id: uuidv4(),
|
||||
title: title.trim(),
|
||||
date: new Date(newDate).toISOString(),
|
||||
})
|
||||
setTitle("")
|
||||
setNewDate("")
|
||||
}
|
||||
|
||||
// wheel tilt
|
||||
const onWheel = (e: React.WheelEvent) => {
|
||||
setTiltX((t) => Math.max(0, Math.min(50, t + e.deltaY * 0.02)))
|
||||
setTiltY((t) => Math.max(-45, Math.min(45, t + e.deltaX * 0.05)))
|
||||
}
|
||||
|
||||
// drag tilt
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
isDragging.current = true
|
||||
hasDragged.current = false
|
||||
dragStart.current = { x: e.clientX, y: e.clientY }
|
||||
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!isDragging.current || !dragStart.current) return
|
||||
const dx = e.clientX - dragStart.current.x
|
||||
const dy = e.clientY - dragStart.current.y
|
||||
|
||||
// Se moveu mais de 5 pixels, considera como drag
|
||||
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
|
||||
hasDragged.current = true
|
||||
}
|
||||
|
||||
setTiltY((t) => Math.max(-60, Math.min(60, t + dx * 0.1)))
|
||||
setTiltX((t) => Math.max(0, Math.min(60, t - dy * 0.1)))
|
||||
dragStart.current = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
const onPointerUp = () => {
|
||||
isDragging.current = false
|
||||
dragStart.current = null
|
||||
// Reset hasDragged após um curto delay para permitir o clique ser processado
|
||||
setTimeout(() => {
|
||||
hasDragged.current = false
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const gap = 12
|
||||
const rowCount = Math.ceil(days.length / columns)
|
||||
const wallCenterRow = (rowCount - 1) / 2
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4 items-center justify-between flex-wrap">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button onClick={() => setDateRef((d) => new Date(d.getFullYear(), d.getMonth() - 1, 1))}>
|
||||
Mês Anterior
|
||||
</Button>
|
||||
<div className="font-semibold text-lg">{format(dateRef, "MMMM yyyy", { locale: ptBR })}</div>
|
||||
<Button onClick={() => setDateRef((d) => new Date(d.getFullYear(), d.getMonth() + 1, 1))}>
|
||||
Próximo Mês
|
||||
</Button>
|
||||
{/* Botão Pacientes de hoje */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedDay(new Date())
|
||||
setIsDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
Pacientes de hoje
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Legenda de cores */}
|
||||
<div className="flex gap-3 items-center text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500 dark:bg-green-600"></div>
|
||||
<span>Confirmado</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500 dark:bg-yellow-600"></div>
|
||||
<span>Pendente</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500 dark:bg-red-600"></div>
|
||||
<span>Cancelado</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500 dark:bg-blue-600"></div>
|
||||
<span>Outros</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wall container */}
|
||||
<div className="relative">
|
||||
<div className="absolute top-2 left-2 z-10 bg-background/80 backdrop-blur-sm px-3 py-1.5 rounded-lg text-xs text-muted-foreground border border-border">
|
||||
💡 Arraste para rotacionar • Scroll para inclinar
|
||||
</div>
|
||||
<div
|
||||
ref={wallRef}
|
||||
onWheel={onWheel}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
className="w-full overflow-auto"
|
||||
style={{ perspective: 1200, maxWidth: 1100 }}
|
||||
>
|
||||
<div
|
||||
className="mx-auto"
|
||||
style={{
|
||||
width: Math.max(700, columns * (panelWidth + gap)),
|
||||
transformStyle: "preserve-3d",
|
||||
transform: `rotateX(${tiltX}deg) rotateY(${tiltY}deg)`,
|
||||
transition: "transform 120ms linear",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${columns}, ${panelWidth}px)`,
|
||||
gridAutoRows: `${panelHeight}px`,
|
||||
gap: `${gap}px`,
|
||||
transformStyle: "preserve-3d",
|
||||
padding: gap,
|
||||
}}
|
||||
>
|
||||
{days.map((day, idx) => {
|
||||
const row = Math.floor(idx / columns)
|
||||
const rowOffset = row - wallCenterRow
|
||||
const z = Math.max(-80, 40 - Math.abs(rowOffset) * 20)
|
||||
const dayEvents = eventsForDay(day)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className="relative cursor-pointer"
|
||||
style={{
|
||||
transform: `translateZ(${z}px)`,
|
||||
zIndex: Math.round(100 - Math.abs(rowOffset)),
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
clickStart.current = { x: e.clientX, y: e.clientY }
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (clickStart.current) {
|
||||
const dx = Math.abs(e.clientX - clickStart.current.x)
|
||||
const dy = Math.abs(e.clientY - clickStart.current.y)
|
||||
// Se moveu menos de 5 pixels, é um clique
|
||||
if (dx < 5 && dy < 5) {
|
||||
e.stopPropagation()
|
||||
handleDayClick(day)
|
||||
}
|
||||
clickStart.current = null
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Card className="h-full overflow-visible hover:shadow-lg transition-shadow">
|
||||
<CardContent className="p-2 h-full flex flex-col">
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<div className="text-sm font-medium">{format(day, "d")}</div>
|
||||
<div className="text-[9px] text-muted-foreground">
|
||||
{dayEvents.length > 0 && `${dayEvents.length} ${dayEvents.length === 1 ? 'paciente' : 'pacientes'}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground mb-1">{format(day, "EEE", { locale: ptBR })}</div>
|
||||
|
||||
{/* events */}
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{dayEvents.map((ev, i) => {
|
||||
// Calcular tamanho da bolinha baseado na quantidade de eventos
|
||||
const eventCount = dayEvents.length
|
||||
const ballSize = eventCount <= 3 ? 20 :
|
||||
eventCount <= 6 ? 16 :
|
||||
eventCount <= 10 ? 14 :
|
||||
eventCount <= 15 ? 12 : 10
|
||||
|
||||
const spacing = ballSize + 4
|
||||
const maxPerRow = Math.floor((panelWidth - 16) / spacing)
|
||||
const col = i % maxPerRow
|
||||
const row = Math.floor(i / maxPerRow)
|
||||
const left = 4 + (col * spacing)
|
||||
const top = 4 + (row * spacing)
|
||||
|
||||
// Cores baseadas no status
|
||||
const getStatusColor = () => {
|
||||
switch(ev.status) {
|
||||
case 'confirmed': return 'bg-green-500 dark:bg-green-600'
|
||||
case 'pending': return 'bg-yellow-500 dark:bg-yellow-600'
|
||||
case 'cancelled': return 'bg-red-500 dark:bg-red-600'
|
||||
default: return 'bg-blue-500 dark:bg-blue-600'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCard key={ev.id} openDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<div
|
||||
className={`absolute rounded-full ${getStatusColor()} flex items-center justify-center text-white cursor-pointer shadow-sm hover:shadow-md hover:scale-110 transition-all`}
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
width: ballSize,
|
||||
height: ballSize,
|
||||
fontSize: Math.max(6, ballSize / 3),
|
||||
transform: `translateZ(15px)`
|
||||
}}
|
||||
>
|
||||
•
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-64 p-3" side="top">
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold text-sm">{ev.title}</div>
|
||||
{ev.patient && ev.type && (
|
||||
<div className="text-xs space-y-1">
|
||||
<div><span className="font-medium">Paciente:</span> {ev.patient}</div>
|
||||
<div><span className="font-medium">Tipo:</span> {ev.type}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{format(new Date(ev.date), "PPP 'às' p", { locale: ptBR })}
|
||||
</div>
|
||||
{ev.status && (
|
||||
<div className="text-xs">
|
||||
<span className="font-medium">Status:</span>{' '}
|
||||
<span className={
|
||||
ev.status === 'confirmed' ? 'text-green-600 dark:text-green-400' :
|
||||
ev.status === 'pending' ? 'text-yellow-600 dark:text-yellow-400' :
|
||||
ev.status === 'cancelled' ? 'text-red-600 dark:text-red-400' :
|
||||
''
|
||||
}>
|
||||
{ev.status === 'confirmed' ? 'Confirmado' :
|
||||
ev.status === 'pending' ? 'Pendente' :
|
||||
ev.status === 'cancelled' ? 'Cancelado' : ev.status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{onRemoveEvent && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full h-7 text-xs hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => onRemoveEvent(ev.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1" />
|
||||
Remover
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dialog de detalhes do dia */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
{/* Navegação de dias */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSelectedDay((prev) => prev ? new Date(prev.getFullYear(), prev.getMonth(), prev.getDate() - 1) : new Date())}
|
||||
aria-label="Dia anterior"
|
||||
>
|
||||
❮
|
||||
</Button>
|
||||
<DialogTitle className="text-xl">
|
||||
{selectedDay && format(selectedDay, "dd 'de' MMMM 'de' yyyy", { locale: ptBR })}
|
||||
</DialogTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSelectedDay((prev) => prev ? new Date(prev.getFullYear(), prev.getMonth(), prev.getDate() + 1) : new Date())}
|
||||
aria-label="Próximo dia"
|
||||
>
|
||||
❯
|
||||
</Button>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
{selectedDayEvents.length} {selectedDayEvents.length === 1 ? 'paciente agendado' : 'pacientes agendados'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 mt-4">
|
||||
{selectedDayEvents.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Nenhum paciente agendado para este dia
|
||||
</div>
|
||||
) : (
|
||||
selectedDayEvents.map((ev) => {
|
||||
const getStatusColor = () => {
|
||||
switch(ev.status) {
|
||||
case 'confirmed': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
case 'pending': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||
case 'cancelled': return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
default: return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = () => {
|
||||
switch(ev.status) {
|
||||
case 'confirmed': return 'Confirmado'
|
||||
case 'pending': return 'Pendente'
|
||||
case 'cancelled': return 'Cancelado'
|
||||
default: return ev.status || 'Sem status'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card key={ev.id} className="overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="font-semibold">{ev.patient || ev.title}</h3>
|
||||
</div>
|
||||
|
||||
{ev.type && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
<span>{ev.type}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>{format(new Date(ev.date), "HH:mm", { locale: ptBR })}</span>
|
||||
</div>
|
||||
|
||||
<Badge className={getStatusColor()}>
|
||||
{getStatusText()}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{onRemoveEvent && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemoveEvent(ev.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Add event form */}
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input placeholder="Nome do paciente" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
<Input type="date" value={newDate} onChange={(e) => setNewDate(e.target.value)} />
|
||||
<Button onClick={handleAdd}>Adicionar Paciente</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -238,7 +238,7 @@ export async function criarDisponibilidade(input: DoctorAvailabilityCreate): Pro
|
||||
|
||||
// Normalize weekday to integer expected by the OpenAPI (0=Sunday .. 6=Saturday)
|
||||
const mapWeekdayToInt = (w?: string | number): number | null => {
|
||||
if (w === null || typeof w === 'undefined') return null;
|
||||
if (w === null || w === undefined) return null;
|
||||
if (typeof w === 'number') return Number(w);
|
||||
const s = String(w).toLowerCase().trim();
|
||||
const map: Record<string, number> = {
|
||||
@ -270,7 +270,7 @@ export async function criarDisponibilidade(input: DoctorAvailabilityCreate): Pro
|
||||
end_time: input.end_time,
|
||||
slot_minutes: input.slot_minutes ?? 30,
|
||||
appointment_type: input.appointment_type ?? 'presencial',
|
||||
active: typeof input.active === 'undefined' ? true : input.active,
|
||||
active: input.active === undefined ? true : input.active,
|
||||
created_by: createdBy,
|
||||
};
|
||||
|
||||
@ -307,7 +307,7 @@ export async function criarDisponibilidade(input: DoctorAvailabilityCreate): Pro
|
||||
end_time: end,
|
||||
slot_minutes: input.slot_minutes ?? 30,
|
||||
appointment_type: input.appointment_type ?? 'presencial',
|
||||
active: typeof input.active === 'undefined' ? true : input.active,
|
||||
active: input.active === undefined ? true : input.active,
|
||||
created_by: createdBy,
|
||||
};
|
||||
|
||||
@ -349,7 +349,7 @@ export async function criarDisponibilidade(input: DoctorAvailabilityCreate): Pro
|
||||
end_time: end,
|
||||
slot_minutes: input.slot_minutes ?? 30,
|
||||
appointment_type: input.appointment_type ?? 'presencial',
|
||||
active: typeof input.active === 'undefined' ? true : input.active,
|
||||
active: input.active === undefined ? true : input.active,
|
||||
created_by: createdBy,
|
||||
};
|
||||
try {
|
||||
@ -381,7 +381,7 @@ export async function criarDisponibilidade(input: DoctorAvailabilityCreate): Pro
|
||||
export async function listarDisponibilidades(params?: { doctorId?: string; active?: boolean }): Promise<DoctorAvailability[]> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.doctorId) qs.set('doctor_id', `eq.${encodeURIComponent(String(params.doctorId))}`);
|
||||
if (typeof params?.active !== 'undefined') qs.set('active', `eq.${params.active ? 'true' : 'false'}`);
|
||||
if (params?.active !== undefined) qs.set('active', `eq.${params.active ? 'true' : 'false'}`);
|
||||
|
||||
const url = `${REST}/doctor_availability${qs.toString() ? `?${qs.toString()}` : ''}`;
|
||||
const res = await fetch(url, { method: 'GET', headers: baseHeaders() });
|
||||
@ -616,9 +616,19 @@ function buildRedirectUrl(target?: 'paciente' | 'medico' | 'admin' | 'default',
|
||||
|
||||
const base = DEFAULT_REDIRECT_BASE.replace(/\/$/, '');
|
||||
let path = '/';
|
||||
if (target === 'paciente') path = '/paciente';
|
||||
else if (target === 'medico') path = '/profissional';
|
||||
else if (target === 'admin') path = '/dashboard';
|
||||
switch (target) {
|
||||
case 'paciente':
|
||||
path = '/paciente';
|
||||
break;
|
||||
case 'medico':
|
||||
path = '/profissional';
|
||||
break;
|
||||
case 'admin':
|
||||
path = '/dashboard';
|
||||
break;
|
||||
default:
|
||||
path = '/';
|
||||
}
|
||||
return `${base}${path}`;
|
||||
}
|
||||
|
||||
@ -732,7 +742,8 @@ async function parse<T>(res: Response): Promise<T> {
|
||||
}
|
||||
|
||||
// For other errors, log a concise error and try to produce a friendly message
|
||||
console.error('[API ERROR] Status:', res.status, json ? 'JSON response' : 'no-json', rawText ? 'raw body present' : 'no raw body');
|
||||
const endpoint = res.url ? new URL(res.url).pathname : 'unknown';
|
||||
console.error('[API ERROR] Status:', res.status, 'Endpoint:', endpoint, json ? 'JSON response' : 'no-json', rawText ? 'raw body present' : 'no raw body', 'Message:', msg || 'N/A');
|
||||
|
||||
// Mensagens amigáveis para erros comuns
|
||||
let friendlyMessage = msg;
|
||||
@ -837,7 +848,7 @@ export async function buscarPacientes(termo: string): Promise<Paciente[]> {
|
||||
|
||||
// Busca por ID se parece com UUID
|
||||
if (searchTerm.includes('-') && searchTerm.length > 10) {
|
||||
queries.push(`id=eq.${searchTerm}`);
|
||||
queries.push(`id=eq.${encodeURIComponent(searchTerm)}`);
|
||||
}
|
||||
|
||||
// Busca por CPF (com e sem formatação)
|
||||
@ -848,14 +859,14 @@ export async function buscarPacientes(termo: string): Promise<Paciente[]> {
|
||||
}
|
||||
|
||||
// Busca por nome (usando ilike para busca case-insensitive)
|
||||
// NOTA: apenas full_name existe, social_name foi removido
|
||||
if (searchTerm.length >= 2) {
|
||||
queries.push(`full_name=ilike.*${searchTerm}*`);
|
||||
queries.push(`social_name=ilike.*${searchTerm}*`);
|
||||
queries.push(`full_name=ilike.*${q}*`);
|
||||
}
|
||||
|
||||
// Busca por email se contém @
|
||||
if (searchTerm.includes('@')) {
|
||||
queries.push(`email=ilike.*${searchTerm}*`);
|
||||
queries.push(`email=ilike.*${q}*`);
|
||||
}
|
||||
|
||||
const results: Paciente[] = [];
|
||||
@ -864,13 +875,8 @@ export async function buscarPacientes(termo: string): Promise<Paciente[]> {
|
||||
// Executa as buscas e combina resultados únicos
|
||||
for (const query of queries) {
|
||||
try {
|
||||
const [key, val] = String(query).split('=');
|
||||
const params = new URLSearchParams();
|
||||
if (key && typeof val !== 'undefined') params.set(key, val);
|
||||
params.set('limit', '10');
|
||||
const url = `${REST}/patients?${params.toString()}`;
|
||||
const url = `${REST}/patients?${query}&limit=10`;
|
||||
const headers = baseHeaders();
|
||||
// Logs removidos por segurança
|
||||
const res = await fetch(url, { method: "GET", headers });
|
||||
const arr = await parse<Paciente[]>(res);
|
||||
|
||||
@ -883,7 +889,7 @@ export async function buscarPacientes(termo: string): Promise<Paciente[]> {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Erro na busca com query: ${query}`, error);
|
||||
console.warn(`[API] Erro na busca de pacientes com query: ${query}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1115,23 +1121,21 @@ export async function criarAgendamento(input: AppointmentCreate): Promise<Appoin
|
||||
}
|
||||
// Otherwise check overlap with scheduled time
|
||||
// Parse exception times and scheduled time to minutes
|
||||
const parseToMinutes = (t?: string | null) => {
|
||||
if (!t) return null;
|
||||
const parts = String(t).split(':').map((p) => Number(p));
|
||||
if (parts.length >= 2 && !Number.isNaN(parts[0]) && !Number.isNaN(parts[1])) return parts[0] * 60 + parts[1];
|
||||
return null;
|
||||
};
|
||||
const exStart = parseToMinutes(ex.start_time ?? undefined);
|
||||
const exEnd = parseToMinutes(ex.end_time ?? undefined);
|
||||
const parseToMinutes = (t?: string | null) => {
|
||||
if (!t) return null;
|
||||
const parts = String(t).split(':').map(Number);
|
||||
if (parts.length >= 2 && !Number.isNaN(parts[0]) && !Number.isNaN(parts[1])) return parts[0] * 60 + parts[1];
|
||||
return null;
|
||||
};
|
||||
const exStart = parseToMinutes(ex.start_time ?? undefined);
|
||||
const exEnd = parseToMinutes(ex.end_time ?? undefined);
|
||||
const sched = new Date(input.scheduled_at);
|
||||
const schedMinutes = sched.getHours() * 60 + sched.getMinutes();
|
||||
const schedDuration = input.duration_minutes ?? 30;
|
||||
const schedEndMinutes = schedMinutes + Number(schedDuration);
|
||||
if (exStart != null && exEnd != null) {
|
||||
if (schedMinutes < exEnd && exStart < schedEndMinutes) {
|
||||
const reason = ex.reason ? ` Motivo: ${ex.reason}` : '';
|
||||
throw new Error(`Não é possível agendar neste horário por uma exceção que bloqueia parte do dia.${reason}`);
|
||||
}
|
||||
if (exStart != null && exEnd != null && schedMinutes < exEnd && exStart < schedEndMinutes) {
|
||||
const reason = ex.reason ? ` Motivo: ${ex.reason}` : '';
|
||||
throw new Error(`Não é possível agendar neste horário por uma exceção que bloqueia parte do dia.${reason}`);
|
||||
}
|
||||
} catch (inner) {
|
||||
// Propagate the exception as user-facing error
|
||||
@ -1721,8 +1725,7 @@ export async function buscarMedicos(termo: string): Promise<Medico[]> {
|
||||
|
||||
const searchTerm = termo.toLowerCase().trim();
|
||||
const digitsOnly = searchTerm.replace(/\D/g, '');
|
||||
// Do not pre-encode the searchTerm here; we'll let URLSearchParams handle encoding
|
||||
const q = searchTerm;
|
||||
const q = encodeURIComponent(searchTerm);
|
||||
|
||||
// Monta queries para buscar em múltiplos campos
|
||||
const queries = [];
|
||||
@ -1734,21 +1737,19 @@ export async function buscarMedicos(termo: string): Promise<Medico[]> {
|
||||
|
||||
// Busca por CRM (com e sem formatação)
|
||||
if (digitsOnly.length >= 3) {
|
||||
queries.push(`crm=ilike.*${digitsOnly}*`);
|
||||
queries.push(`crm=ilike.*${encodeURIComponent(digitsOnly)}*`);
|
||||
}
|
||||
|
||||
// Busca por nome (usando ilike para busca case-insensitive)
|
||||
// NOTA: apenas full_name existe na tabela, nome_social foi removido
|
||||
if (searchTerm.length >= 2) {
|
||||
queries.push(`full_name=ilike.*${q}*`);
|
||||
queries.push(`nome_social=ilike.*${q}*`);
|
||||
}
|
||||
|
||||
// Busca por email se contém @
|
||||
if (searchTerm.includes('@')) {
|
||||
// Quando o usuário pesquisa por email (contendo '@'), limitar as queries apenas ao campo email.
|
||||
// Em alguns esquemas de banco / views, buscar por outros campos com um email pode provocar
|
||||
// erros de requisição (400) dependendo das colunas e políticas. Reduzimos o escopo para evitar 400s.
|
||||
queries.length = 0; // limpar queries anteriores
|
||||
queries.length = 0;
|
||||
queries.push(`email=ilike.*${q}*`);
|
||||
}
|
||||
|
||||
@ -1756,8 +1757,6 @@ export async function buscarMedicos(termo: string): Promise<Medico[]> {
|
||||
if (searchTerm.length >= 2) {
|
||||
queries.push(`specialty=ilike.*${q}*`);
|
||||
}
|
||||
|
||||
// Debug removido por segurança
|
||||
|
||||
const results: Medico[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
@ -1765,15 +1764,8 @@ export async function buscarMedicos(termo: string): Promise<Medico[]> {
|
||||
// Executa as buscas e combina resultados únicos
|
||||
for (const query of queries) {
|
||||
try {
|
||||
// Build the URL safely using URLSearchParams so special characters (like @) are encoded correctly
|
||||
// query is like 'nome_social=ilike.*something*' -> split into key/value
|
||||
const [key, val] = String(query).split('=');
|
||||
const params = new URLSearchParams();
|
||||
if (key && typeof val !== 'undefined') params.set(key, val);
|
||||
params.set('limit', '10');
|
||||
const url = `${REST}/doctors?${params.toString()}`;
|
||||
const url = `${REST}/doctors?${query}&limit=10`;
|
||||
const headers = baseHeaders();
|
||||
// Logs removidos por segurança
|
||||
const res = await fetch(url, { method: 'GET', headers });
|
||||
const arr = await parse<Medico[]>(res);
|
||||
|
||||
@ -1786,7 +1778,7 @@ export async function buscarMedicos(termo: string): Promise<Medico[]> {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Erro na busca com query: ${query}`, error);
|
||||
console.warn(`[API] Erro na busca de médicos com query: ${query}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1800,7 +1792,7 @@ export async function buscarMedicoPorId(id: string | number): Promise<Medico | n
|
||||
const sId = String(id);
|
||||
|
||||
// Helper para escape de aspas
|
||||
const escapeQuotes = (v: string) => v.replace(/"/g, '\\"');
|
||||
const escapeQuotes = (v: string) => JSON.stringify(v).slice(1, -1);
|
||||
|
||||
try {
|
||||
// 1) Se parece UUID, busca por id direto
|
||||
@ -2085,9 +2077,9 @@ export async function criarMedico(input: MedicoInput): Promise<Medico> {
|
||||
crm_uf: crmUf,
|
||||
create_user: false,
|
||||
};
|
||||
if (input.specialty) fallbackPayload.specialty = input.specialty;
|
||||
if (input.phone_mobile) fallbackPayload.phone_mobile = input.phone_mobile;
|
||||
if (typeof input.phone2 !== 'undefined') fallbackPayload.phone2 = input.phone2;
|
||||
if (input.specialty) fallbackPayload.specialty = input.specialty;
|
||||
if (input.phone_mobile) fallbackPayload.phone_mobile = input.phone_mobile;
|
||||
if (input.phone2 !== undefined) fallbackPayload.phone2 = input.phone2;
|
||||
|
||||
const url = `${API_BASE}/functions/v1/create-doctor`;
|
||||
const headers = { ...baseHeaders(), 'Content-Type': 'application/json' } as Record<string, string>;
|
||||
@ -2685,7 +2677,8 @@ export async function criarUsuarioPaciente(paciente: { email: string; full_name:
|
||||
|
||||
const parsed = await parse<any>(res as Response);
|
||||
// Attach the generated password so callers (UI) can display it if necessary
|
||||
return { ...(parsed || {}), password };
|
||||
if (parsed && typeof parsed === 'object') return { ...(parsed as any), password };
|
||||
return { password };
|
||||
}
|
||||
|
||||
|
||||
@ -2797,7 +2790,7 @@ export async function uploadFotoPaciente(_id: string | number, _file: File): Pro
|
||||
form.append('file', _file, `avatar.${ext}`);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
// Supabase requires the anon key in 'apikey' header for client-side uploads
|
||||
// Supabase requires the anon key in 'apikey' header for client-side uploads
|
||||
apikey: ENV_CONFIG.SUPABASE_ANON_KEY,
|
||||
// Accept json
|
||||
Accept: 'application/json',
|
||||
|
||||
@ -162,18 +162,23 @@ export async function listarRelatorios(filtros?: { patient_id?: string; status?:
|
||||
*/
|
||||
export async function buscarRelatorioPorId(id: string): Promise<Report> {
|
||||
try {
|
||||
// Log removido por segurança
|
||||
const resposta = await fetch(`${BASE_API_RELATORIOS}?id=eq.${id}`, {
|
||||
// Validar ID antes de fazer requisição
|
||||
if (!id || typeof id !== 'string' || id.trim() === '') {
|
||||
console.warn('[REPORTS] ID vazio ou inválido ao buscar relatório');
|
||||
throw new Error('ID de relatório inválido');
|
||||
}
|
||||
|
||||
const encodedId = encodeURIComponent(id.trim());
|
||||
const resposta = await fetch(`${BASE_API_RELATORIOS}?id=eq.${encodedId}`, {
|
||||
method: 'GET',
|
||||
headers: obterCabecalhos(),
|
||||
});
|
||||
const resultado = await tratarRespostaApi<Report[]>(resposta);
|
||||
const relatorio = Array.isArray(resultado) && resultado.length > 0 ? resultado[0] : null;
|
||||
// Log removido por segurança
|
||||
if (!relatorio) throw new Error('Relatório não encontrado');
|
||||
return relatorio;
|
||||
} catch (erro) {
|
||||
console.error('❌ [API RELATÓRIOS] Erro ao buscar relatório:', erro);
|
||||
console.error('[REPORTS] Erro ao buscar relatório:', erro);
|
||||
throw erro;
|
||||
}
|
||||
}
|
||||
@ -259,39 +264,38 @@ export async function deletarRelatorio(id: string): Promise<void> {
|
||||
*/
|
||||
export async function listarRelatoriosPorPaciente(idPaciente: string): Promise<Report[]> {
|
||||
try {
|
||||
// Logs removidos por segurança
|
||||
// Validar ID antes de fazer requisição
|
||||
if (!idPaciente || typeof idPaciente !== 'string' || idPaciente.trim() === '') {
|
||||
console.warn('[REPORTS] ID paciente vazio ou inválido ao listar relatórios');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Try a strict eq lookup first (encode the id)
|
||||
const encodedId = encodeURIComponent(String(idPaciente));
|
||||
const encodedId = encodeURIComponent(String(idPaciente).trim());
|
||||
let url = `${BASE_API_RELATORIOS}?patient_id=eq.${encodedId}`;
|
||||
const headers = obterCabecalhos();
|
||||
const masked = (headers as any)['Authorization'] ? `${String((headers as any)['Authorization']).slice(0,6)}...${String((headers as any)['Authorization']).slice(-6)}` : null;
|
||||
// Logs removidos por segurança
|
||||
const resposta = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
const resultado = await tratarRespostaApi<Report[]>(resposta);
|
||||
// Log removido por segurança
|
||||
// If eq returned results, return them. Otherwise retry using `in.(id)` which some setups prefer.
|
||||
if (Array.isArray(resultado) && resultado.length) return resultado;
|
||||
|
||||
// Retry with in.(id) clause as a fallback
|
||||
try {
|
||||
const inClause = encodeURIComponent(`(${String(idPaciente)})`);
|
||||
const inClause = encodeURIComponent(`(${String(idPaciente).trim()})`);
|
||||
const urlIn = `${BASE_API_RELATORIOS}?patient_id=in.${inClause}`;
|
||||
// Log removido por segurança
|
||||
const resp2 = await fetch(urlIn, { method: 'GET', headers });
|
||||
const res2 = await tratarRespostaApi<Report[]>(resp2);
|
||||
// Log removido por segurança
|
||||
return Array.isArray(res2) ? res2 : [];
|
||||
} catch (e) {
|
||||
// Log removido por segurança
|
||||
// Fallback falhou, retornar vazio
|
||||
return [];
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (erro) {
|
||||
console.error('❌ [API RELATÓRIOS] Erro ao buscar relatórios do paciente:', erro);
|
||||
throw erro;
|
||||
console.error('[REPORTS] Erro ao buscar relatórios do paciente:', erro);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -300,20 +304,24 @@ export async function listarRelatoriosPorPaciente(idPaciente: string): Promise<R
|
||||
*/
|
||||
export async function listarRelatoriosPorMedico(idMedico: string): Promise<Report[]> {
|
||||
try {
|
||||
console.log('👨⚕️ [API RELATÓRIOS] Buscando relatórios do médico:', idMedico);
|
||||
const url = `${BASE_API_RELATORIOS}?requested_by=eq.${idMedico}`;
|
||||
// Validar ID antes de fazer requisição
|
||||
if (!idMedico || typeof idMedico !== 'string' || idMedico.trim() === '') {
|
||||
console.warn('[REPORTS] ID médico vazio ou inválido ao listar relatórios');
|
||||
return [];
|
||||
}
|
||||
|
||||
const encodedId = encodeURIComponent(idMedico.trim());
|
||||
const url = `${BASE_API_RELATORIOS}?requested_by=eq.${encodedId}`;
|
||||
const headers = obterCabecalhos();
|
||||
// Logs removidos por segurança
|
||||
const resposta = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: obterCabecalhos(),
|
||||
});
|
||||
const resultado = await tratarRespostaApi<Report[]>(resposta);
|
||||
// Log removido por segurança
|
||||
return resultado;
|
||||
return Array.isArray(resultado) ? resultado : [];
|
||||
} catch (erro) {
|
||||
console.error('❌ [API RELATÓRIOS] Erro ao buscar relatórios do médico:', erro);
|
||||
throw erro;
|
||||
console.error('[REPORTS] Erro ao buscar relatórios do médico:', erro);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -328,19 +336,17 @@ export async function listarRelatoriosPorPacientes(ids: string[]): Promise<Repor
|
||||
const cleaned = ids.map(i => String(i).trim()).filter(Boolean);
|
||||
if (!cleaned.length) return [];
|
||||
|
||||
// monta cláusula in.(id1,id2,...)
|
||||
const inClause = cleaned.join(',');
|
||||
const url = `${BASE_API_RELATORIOS}?patient_id=in.(${inClause})`;
|
||||
// monta cláusula in.(id1,id2,...) com proper encoding
|
||||
const encodedIds = cleaned.map(id => encodeURIComponent(id)).join(',');
|
||||
const url = `${BASE_API_RELATORIOS}?patient_id=in.(${encodedIds})`;
|
||||
const headers = obterCabecalhos();
|
||||
// Logs removidos por segurança
|
||||
|
||||
const resposta = await fetch(url, { method: 'GET', headers });
|
||||
const resultado = await tratarRespostaApi<Report[]>(resposta);
|
||||
// Log removido por segurança
|
||||
return resultado;
|
||||
return Array.isArray(resultado) ? resultado : [];
|
||||
} catch (erro) {
|
||||
console.error('❌ [API RELATÓRIOS] Erro ao buscar relatórios para vários pacientes:', erro);
|
||||
throw erro;
|
||||
console.error('[REPORTS] Erro ao buscar relatórios para vários pacientes:', erro);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,9 @@
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname } from 'path'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
eslint: {
|
||||
@ -9,6 +15,9 @@ const nextConfig = {
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
// Define explicit output tracing root to silence Next.js workspace root warning
|
||||
// Set to the current package directory (susconecta)
|
||||
outputFileTracingRoot: __dirname,
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
|
||||
57
susconecta/package-lock.json
generated
57
susconecta/package-lock.json
generated
@ -49,6 +49,7 @@
|
||||
"cmdk": "latest",
|
||||
"date-fns": "4.1.0",
|
||||
"embla-carousel-react": "latest",
|
||||
"framer-motion": "^12.23.24",
|
||||
"geist": "^1.3.1",
|
||||
"input-otp": "latest",
|
||||
"jspdf": "^3.0.3",
|
||||
@ -65,6 +66,7 @@
|
||||
"sonner": "latest",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^13.0.0",
|
||||
"vaul": "latest",
|
||||
"zod": "3.25.67"
|
||||
},
|
||||
@ -5738,6 +5740,33 @@
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.23.24",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz",
|
||||
"integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.23.23",
|
||||
"motion-utils": "^12.23.6",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@ -7140,6 +7169,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.23.23",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
|
||||
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.23.6",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
|
||||
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@ -9175,6 +9219,19 @@
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
||||
|
||||
@ -51,6 +51,7 @@
|
||||
"cmdk": "latest",
|
||||
"date-fns": "4.1.0",
|
||||
"embla-carousel-react": "latest",
|
||||
"framer-motion": "^12.23.24",
|
||||
"geist": "^1.3.1",
|
||||
"input-otp": "latest",
|
||||
"jspdf": "^3.0.3",
|
||||
@ -67,6 +68,7 @@
|
||||
"sonner": "latest",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^13.0.0",
|
||||
"vaul": "latest",
|
||||
"zod": "3.25.67"
|
||||
},
|
||||
|
||||
9
susconecta/pnpm-lock.yaml
generated
9
susconecta/pnpm-lock.yaml
generated
@ -179,6 +179,9 @@ importers:
|
||||
tailwindcss-animate:
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7(tailwindcss@4.1.13)
|
||||
uuid:
|
||||
specifier: ^13.0.0
|
||||
version: 13.0.0
|
||||
vaul:
|
||||
specifier: latest
|
||||
version: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@ -3321,6 +3324,10 @@ packages:
|
||||
utrie@1.0.2:
|
||||
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
|
||||
|
||||
uuid@13.0.0:
|
||||
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
|
||||
hasBin: true
|
||||
|
||||
vaul@1.1.2:
|
||||
resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
|
||||
peerDependencies:
|
||||
@ -6686,6 +6693,8 @@ snapshots:
|
||||
base64-arraybuffer: 1.0.2
|
||||
optional: true
|
||||
|
||||
uuid@13.0.0: {}
|
||||
|
||||
vaul@1.1.2(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user