diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..788806d --- /dev/null +++ b/next.config.mjs @@ -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; diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index f2988f1..0000000 --- a/package-lock.json +++ /dev/null @@ -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 - } - } - } - } -} diff --git a/susconecta/app/login-admin/page.tsx b/susconecta/app/(auth)/login-admin/page.tsx similarity index 98% rename from susconecta/app/login-admin/page.tsx rename to susconecta/app/(auth)/login-admin/page.tsx index f99f365..544ef43 100644 --- a/susconecta/app/login-admin/page.tsx +++ b/susconecta/app/(auth)/login-admin/page.tsx @@ -154,7 +154,7 @@ export default function LoginAdminPage() {
-
-
- - */} Opções » @@ -147,7 +193,7 @@ export default function AgendamentoPage() {
+ +
+ {/* --- AQUI ESTÁ A SUBSTITUIÇÃO --- */} {activeTab === "calendar" ? (
- { - 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) */} +
+ {managerLoading ? ( +
+
Conectando ao calendário — carregando agendamentos...
+
+ ) : ( + // EventManager ocupa a área principal e já recebe events da API +
+ +
+ )} +
+
+ ) : activeTab === "3d" ? ( + // O calendário 3D (ThreeDWallCalendar) foi MANTIDO 100% +
+
) : ( + // A Lista de Espera foi MANTIDA ); -} +} \ No newline at end of file diff --git a/susconecta/app/(main-routes)/consultas/page.tsx b/susconecta/app/(main-routes)/consultas/page.tsx index 889b622..0ce1acc 100644 --- a/susconecta/app/(main-routes)/consultas/page.tsx +++ b/susconecta/app/(main-routes)/consultas/page.tsx @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/susconecta/app/(main-routes)/dashboard/page.tsx b/susconecta/app/(main-routes)/dashboard/page.tsx index 1bd16a2..8f90e09 100644 --- a/susconecta/app/(main-routes)/dashboard/page.tsx +++ b/susconecta/app/(main-routes)/dashboard/page.tsx @@ -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() { Novo Paciente - - - @@ -340,7 +340,7 @@ export default function DashboardPage() {

{report.exam || 'Sem descrição'}

))} - @@ -388,7 +388,7 @@ export default function DashboardPage() { )} {/* 11. LINK PARA RELATÓRIOS */} -
+

Seção de Relatórios

Acesse a seção de relatórios médicos para gerenciar, visualizar e exportar documentos. diff --git a/susconecta/app/(main-routes)/dashboard/relatorios/page.tsx b/susconecta/app/(main-routes)/dashboard/relatorios/page.tsx index 126fc8e..c70081a 100644 --- a/susconecta/app/(main-routes)/dashboard/relatorios/page.tsx +++ b/susconecta/app/(main-routes)/dashboard/relatorios/page.tsx @@ -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: }, { label: "Absenteísmo", value: "7,2%", icon: }, - { label: "Satisfação", value: "92%", icon: }, + { label: "Satisfação", value: "Dados não foram disponibilizados", icon: }, { label: "Faturamento (Mês)", value: "R$ 45.000", icon: }, { label: "No-show", value: "5,1%", icon: }, ]; @@ -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>([]); + const [consultasData, setConsultasData] = useState>([]); + const [faturamentoData, setFaturamentoData] = useState>([]); + const [taxaNoShowState, setTaxaNoShowState] = useState>([]); + const [pacientesTop, setPacientesTop] = useState>([]); + const [medicosTop, setMedicosTop] = useState(medicosMaisProdutivos); + const [medicosPerformance, setMedicosPerformance] = useState>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [conveniosData, setConveniosData] = useState>(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: }, + { label: "Absenteísmo", value: "—", icon: }, + { label: "Satisfação", value: "Dados não foram disponibilizados", icon: }, + { label: "Faturamento (Mês)", value: "—", icon: }, + { label: "No-show", value: "—", icon: }, + ]); + + // 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 = {}; + 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 = {}; + 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(); + 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 = {}; + const doctorCounts: Record = {}; + const doctorNoShowCounts: Record = {}; + 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 = {}; + 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: }, + { label: "Absenteísmo", value: '—', icon: }, + { label: "Satisfação", value: 'Dados não foram disponibilizados', icon: }, + { label: "Faturamento (Mês)", value: `R$ ${faturamentoArr.at(-1)?.valor ?? 0}`, icon: }, + { label: "No-show", value: `${taxaArr.at(-1)?.noShow ?? 0}%`, icon: }, + ] 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 (

Dashboard Executivo de Relatórios

{/* Métricas principais */}
- {metricas.map((m) => ( -
- {m.icon} - {m.value} - {m.label} -
- ))} + {loading ? ( + // simple skeletons while loading to avoid showing fake data + Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+ )) + ) : ( + metricsState.map((m) => ( +
+ {m.icon} + {m.value} + {m.label} +
+ )) + )}
{/* Gráficos e Relatórios */} @@ -102,34 +350,42 @@ export default function RelatoriosPage() {

Consultas por Período

- +
- - - - - - - - - + {loading ? ( +
Carregando dados...
+ ) : ( + + + + + + + + + + )}
{/* Faturamento mensal/anual */}

Faturamento Mensal

- +
- - - - - - - - - + {loading ? ( +
Carregando dados...
+ ) : ( + + + + + + + + + + )}
@@ -138,27 +394,31 @@ export default function RelatoriosPage() {

Taxa de No-show

- +
- - - - - - - - - + {loading ? ( +
Carregando dados...
+ ) : ( + + + + + + + + + + )}
{/* Indicadores de satisfação */}

Satisfação dos Pacientes

- +
- 92% + Dados não foram disponibilizados Índice de satisfação geral
@@ -169,7 +429,7 @@ export default function RelatoriosPage() {

Pacientes Mais Atendidos

- +
@@ -179,12 +439,22 @@ export default function RelatoriosPage() { - {pacientesMaisAtendidos.map((p) => ( - - - + {loading ? ( + + - ))} + ) : pacientesTop && pacientesTop.length ? ( + pacientesTop.map((p: { nome: string; consultas: number }) => ( + + + + + )) + ) : ( + + + + )}
{p.nome}{p.consultas}
Carregando pacientes...
{p.nome}{p.consultas}
Nenhum paciente encontrado
@@ -193,7 +463,7 @@ export default function RelatoriosPage() {

Médicos Mais Produtivos

- +
@@ -203,12 +473,22 @@ export default function RelatoriosPage() { - {medicosMaisProdutivos.map((m) => ( - - - + {loading ? ( + + - ))} + ) : medicosTop && medicosTop.length ? ( + medicosTop.map((m) => ( + + + + + )) + ) : ( + + + + )}
{m.nome}{m.consultas}
Carregando médicos...
{m.nome}{m.consultas}
Nenhum médico encontrado
@@ -219,26 +499,30 @@ export default function RelatoriosPage() {

Análise de Convênios

- +
- - - - {convenios.map((entry, index) => ( - - ))} - - - - - + {loading ? ( +
Carregando dados...
+ ) : ( + + + + {conveniosData.map((entry, index) => ( + + ))} + + + + + + )}
{/* Performance por médico */}

Performance por Médico

- +
@@ -249,7 +533,7 @@ export default function RelatoriosPage() { - {performancePorMedico.map((m) => ( + {(loading ? performancePorMedico : medicosPerformance).map((m) => ( diff --git a/susconecta/app/(main-routes)/doutores/page.tsx b/susconecta/app/(main-routes)/doutores/page.tsx index 3e958f7..5761f68 100644 --- a/susconecta/app/(main-routes)/doutores/page.tsx +++ b/susconecta/app/(main-routes)/doutores/page.tsx @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/susconecta/app/(main-routes)/financeiro/page.tsx b/susconecta/app/(main-routes)/financeiro/page.tsx index acab08f..190305a 100644 --- a/susconecta/app/(main-routes)/financeiro/page.tsx +++ b/susconecta/app/(main-routes)/financeiro/page.tsx @@ -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(); diff --git a/susconecta/app/(main-routes)/layout.tsx b/susconecta/app/(main-routes)/layout.tsx index 6269b21..a673113 100644 --- a/susconecta/app/(main-routes)/layout.tsx +++ b/susconecta/app/(main-routes)/layout.tsx @@ -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, diff --git a/susconecta/app/(main-routes)/pacientes/page.tsx b/susconecta/app/(main-routes)/pacientes/page.tsx index df77a10..3ebb358 100644 --- a/susconecta/app/(main-routes)/pacientes/page.tsx +++ b/susconecta/app/(main-routes)/pacientes/page.tsx @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/susconecta/app/(main-routes)/procedimento/page.tsx b/susconecta/app/(main-routes)/procedimento/page.tsx index bccaaba..7edd710 100644 --- a/susconecta/app/(main-routes)/procedimento/page.tsx +++ b/susconecta/app/(main-routes)/procedimento/page.tsx @@ -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(); diff --git a/susconecta/app/layout.tsx b/susconecta/app/layout.tsx index b7b71f1..4f6ef6b 100644 --- a/susconecta/app/layout.tsx +++ b/susconecta/app/layout.tsx @@ -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 = { diff --git a/susconecta/app/paciente/page.tsx b/susconecta/app/paciente/page.tsx index 5e427d1..697c02f 100644 --- a/susconecta/app/paciente/page.tsx +++ b/susconecta/app/paciente/page.tsx @@ -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 = {} + 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 = { 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 (
@@ -353,7 +427,7 @@ export default function PacientePage() { {strings.proximaConsulta} - {loading ? '—' : (nextAppt ?? '-')} + {loading ? strings.carregando : (nextAppt ?? '-')}
@@ -367,7 +441,7 @@ export default function PacientePage() { {strings.ultimosExames} - {loading ? '—' : (examsCount !== null ? String(examsCount) : '-')} + {loading ? strings.carregando : (examsCount !== null ? String(examsCount) : '-')} @@ -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 (
{/* Hero Section */} -
+

Agende sua próxima consulta

Escolha o formato ideal, selecione a especialidade e encontre o profissional perfeito para você.

-
+
- @@ -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}`} > @@ -665,16 +739,16 @@ export default function PacientePage() { {/* Doctor Info */}
- + {consulta.medico}
-

+

{consulta.especialidade} {consulta.local} @@ -684,7 +758,7 @@ export default function PacientePage() { {/* Time */}

- + {consulta.hora}
@@ -692,10 +766,10 @@ export default function PacientePage() {
{consulta.status} @@ -706,7 +780,7 @@ export default function PacientePage() { @@ -714,7 +788,7 @@ export default function PacientePage() { @@ -723,7 +797,7 @@ export default function PacientePage() { @@ -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 = {} + 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 = { 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() {
Data: {new Date(r.report_date || r.created_at || Date.now()).toLocaleDateString('pt-BR')}
- - + +
))} @@ -1145,64 +1337,62 @@ export default function PacientePage() { !open && setSelectedReport(null)}> - Laudo Médico - + + {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 {strings.carregando}; + } + if (resolvingDoctors && maybeId && !doctorsMap[String(maybeId)]) { + return {strings.carregando}; + } + return {derived}; + })() + )} + + Detalhes do laudo +
{selectedReport && ( <> -
- { - // 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
{strings.carregando}
- if (resolvingDoctors && maybeId && !doctorsMap[String(maybeId)]) return
{strings.carregando}
- return
{derived}
- })() - } -
Data: {new Date(selectedReport.report_date || selectedReport.created_at || Date.now()).toLocaleDateString('pt-BR')}
- {reportDoctorName &&
Profissional: {reportDoctorName}
} -
+
Data: {new Date(selectedReport.report_date || selectedReport.created_at || Date.now()).toLocaleDateString('pt-BR')}
+ {reportDoctorName &&
Profissional: {reportDoctorName}
} - {/* 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 ( -
+
CID
{cid || '-'}
-
Exame
{exam || '-'}
-
Diagnóstico
{diagnosis || '-'}
-
Conclusão
{conclusion || '-'}
-
Notas do Profissional
{notesHtml ? ( @@ -1212,18 +1402,23 @@ export default function PacientePage() { )}
- ) + ); })()} - {/* Optional: doctor signature or footer */} {selectedReport.doctor_signature && ( -
Assinatura: assinatura
+
Assinatura:
)} )} - +
- +
@@ -1346,7 +1541,7 @@ export default function PacientePage() {
- @@ -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`} > {strings.dashboard} @@ -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`} > {strings.consultas} @@ -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`} > {strings.exames} @@ -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`} > {strings.perfil} diff --git a/susconecta/app/resultados/ResultadosClient.tsx b/susconecta/app/paciente/resultados/ResultadosClient.tsx similarity index 78% rename from susconecta/app/resultados/ResultadosClient.tsx rename to susconecta/app/paciente/resultados/ResultadosClient.tsx index 8c6391d..1fa8519 100644 --- a/susconecta/app/resultados/ResultadosClient.tsx +++ b/susconecta/app/paciente/resultados/ResultadosClient.tsx @@ -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( - params?.get('tipo') === 'presencial' ? 'local' : 'teleconsulta' - ) - const [especialidadeHero, setEspecialidadeHero] = useState(params?.get('especialidade') || 'Psicólogo') + // Filtros/controles da UI - initialize with defaults to avoid hydration mismatch + const [tipoConsulta, setTipoConsulta] = useState('teleconsulta') + const [especialidadeHero, setEspecialidadeHero] = useState('Psicólogo') const [convenio, setConvenio] = useState('Todos') const [bairro, setBairro] = useState('Todos') + // Busca por nome do médico + const [searchQuery, setSearchQuery] = useState('') + + // Track if URL params have been synced to avoid race condition + const [paramsSync, setParamsSync] = useState(false) // Estado dinâmico const [patientId, setPatientId] = useState(null) @@ -104,7 +108,20 @@ export default function ResultadosClient() { const [bookingSuccessOpen, setBookingSuccessOpen] = useState(false) const [bookedWhenLabel, setBookedWhenLabel] = useState(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 (
@@ -517,7 +639,7 @@ export default function ResultadosClient() { )} {/* Confirmation dialog shown when a user selects a slot */} - { if (!open) { setConfirmOpen(false); setPendingAppointment(null); } }}> + { if (!open) { setConfirmOpen(false); setPendingAppointment(null); } }}> Confirmar agendamento @@ -550,7 +672,7 @@ export default function ResultadosClient() { {/* Booking success modal shown when origin=paciente */} - setBookingSuccessOpen(open)}> + setBookingSuccessOpen(open)}> Consulta agendada @@ -573,7 +695,7 @@ export default function ResultadosClient() {
@@ -600,7 +722,7 @@ export default function ResultadosClient() { 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')} > @@ -609,7 +731,7 @@ export default function ResultadosClient() { 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')} > @@ -617,7 +739,7 @@ export default function ResultadosClient() { - + {/* Search input para buscar médico por nome */} +
+ ) => setSearchQuery(e.target.value)} + className="min-w-[220px] rounded-full" + /> + {searchQuery ? ( + + ) : ( + + )} +
-
- {/* Agenda: 4 colunas como no layout. Se ainda não carregou, mostra placeholders. */} -
-
- {(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 ( -
-

{col.label}

-

{col.data}

-
- {isLoadingAgenda && !agenda ? ( - - Carregando... - - ) : horarios.length ? ( - horarios.map(h => ( - - )) - ) : ( - - Sem horários - - )} - {!agendasExpandida[id] && (col.horarios.length > 3) && ( - +{col.horarios.length - 3} horários - )} -
-
- ) - })} -
-
+ {/* Horários compactos removidos conforme solicitação do design (colunas HOJE/AMANHÃ/etc.). */} ) })} @@ -859,10 +972,33 @@ export default function ResultadosClient() { Nenhum profissional encontrado. Ajuste os filtros para ver outras opções. )} + + {/* Pagination controls */} + {!loadingMedicos && profissionais.length > 0 && ( +
+
+ Itens por página: + + Mostrando {startItem} a {endItem} de {profissionais.length} +
+ +
+ + + Página {currentPage} de {totalPages} + + +
+
+ )}
{/* Dialog de perfil completo (mantido e adaptado) */} - !open && setMedicoSelecionado(null)}> + !open && setMedicoSelecionado(null)}> {medicoSelecionado && ( <> @@ -978,7 +1114,7 @@ export default function ResultadosClient() { {/* Dialog: Mostrar mais horários (escolher data arbitrária) */} - { if (!open) { setMoreTimesForDoctor(null); setMoreTimesSlots([]); setMoreTimesException(null); } }}> + { if (!open) { setMoreTimesForDoctor(null); setMoreTimesSlots([]); setMoreTimesException(null); } }}> Mais horários diff --git a/susconecta/app/resultados/page.tsx b/susconecta/app/paciente/resultados/page.tsx similarity index 60% rename from susconecta/app/resultados/page.tsx rename to susconecta/app/paciente/resultados/page.tsx index 1318172..12b177a 100644 --- a/susconecta/app/resultados/page.tsx +++ b/susconecta/app/paciente/resultados/page.tsx @@ -3,7 +3,7 @@ import ResultadosClient from './ResultadosClient' export default function Page() { return ( - Carregando...
}> + Carregando...}> ) diff --git a/susconecta/app/page.tsx b/susconecta/app/page.tsx index 3ee76cc..d62f5fb 100644 --- a/susconecta/app/page.tsx +++ b/susconecta/app/page.tsx @@ -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 ( diff --git a/susconecta/app/profissional/page.tsx b/susconecta/app/profissional/page.tsx index 74794b5..485fc90 100644 --- a/susconecta/app/profissional/page.tsx +++ b/susconecta/app/profissional/page.tsx @@ -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(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" > @@ -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" > @@ -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 @@ -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 @@ -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 @@ -1077,7 +1082,7 @@ const ProfissionalPage = () => { - @@ -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" > 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; updateExistingReport?: (id: string, data: any) => Promise; reloadReports?: () => Promise; 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([]); @@ -2250,6 +2256,7 @@ const ProfissionalPage = () => { {imagens.map((img) => (
{img.type.startsWith('image/') ? ( + // eslint-disable-next-line @next/next/no-img-element {img.name} {

Imagens:

{imagens.map((img) => ( + // eslint-disable-next-line @next/next/no-img-element { {campos.mostrarAssinatura && (
{assinaturaImg && assinaturaImg.length > 30 ? ( + // eslint-disable-next-line @next/next/no-img-element Assinatura Digital ) : (
Assine no campo ao lado para visualizar aqui.
@@ -2457,7 +2466,7 @@ const ProfissionalPage = () => { Este editor permite escrever relatórios de forma livre, com formatação de texto rica.
- {/* 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 = () => { -
@@ -2779,7 +2792,7 @@ const ProfissionalPage = () => { {isEditingProfile && (
-

@@ -2875,7 +2888,7 @@ const ProfissionalPage = () => {

- diff --git a/susconecta/components/forms/availability-form.tsx b/susconecta/components/features/forms/availability-form.tsx similarity index 100% rename from susconecta/components/forms/availability-form.tsx rename to susconecta/components/features/forms/availability-form.tsx diff --git a/susconecta/components/forms/calendar-registration-form.tsx b/susconecta/components/features/forms/calendar-registration-form.tsx similarity index 100% rename from susconecta/components/forms/calendar-registration-form.tsx rename to susconecta/components/features/forms/calendar-registration-form.tsx diff --git a/susconecta/components/forms/doctor-registration-form.tsx b/susconecta/components/features/forms/doctor-registration-form.tsx similarity index 99% rename from susconecta/components/forms/doctor-registration-form.tsx rename to susconecta/components/features/forms/doctor-registration-form.tsx index 75e0d1d..415070c 100644 --- a/susconecta/components/forms/doctor-registration-form.tsx +++ b/susconecta/components/features/forms/doctor-registration-form.tsx @@ -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; diff --git a/susconecta/components/forms/exception-form.tsx b/susconecta/components/features/forms/exception-form.tsx similarity index 100% rename from susconecta/components/forms/exception-form.tsx rename to susconecta/components/features/forms/exception-form.tsx diff --git a/susconecta/components/forms/patient-registration-form.tsx b/susconecta/components/features/forms/patient-registration-form.tsx similarity index 99% rename from susconecta/components/forms/patient-registration-form.tsx rename to susconecta/components/features/forms/patient-registration-form.tsx index 77746d6..1ff1dde 100644 --- a/susconecta/components/forms/patient-registration-form.tsx +++ b/susconecta/components/features/forms/patient-registration-form.tsx @@ -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"; diff --git a/susconecta/components/about-section.tsx b/susconecta/components/features/general/about-section.tsx similarity index 100% rename from susconecta/components/about-section.tsx rename to susconecta/components/features/general/about-section.tsx diff --git a/susconecta/components/features/general/calendarComponente/page.tsx b/susconecta/components/features/general/calendarComponente/page.tsx new file mode 100644 index 0000000..cc25cd4 --- /dev/null +++ b/susconecta/components/features/general/calendarComponente/page.tsx @@ -0,0 +1,1495 @@ +"use client" + +import { useState, useCallback, useMemo } from "react" +import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { ChevronLeft, ChevronRight, Plus, Calendar, Clock, Grid3x3, List, Search, Filter, X } from "lucide-react" +import { cn } from "@/lib/utils" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuCheckboxItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +export interface Event { + id: string + title: string + description?: string + startTime: Date + endTime: Date + color: string + category?: string + attendees?: string[] + tags?: string[] +} + +export interface EventManagerProps { + events?: Event[] + onEventCreate?: (event: Omit) => void + onEventUpdate?: (id: string, event: Partial) => void + onEventDelete?: (id: string) => void + categories?: string[] + colors?: { name: string; value: string; bg: string; text: string }[] + defaultView?: "month" | "week" | "day" | "list" + className?: string + availableTags?: string[] +} + +const defaultColors = [ + { name: "Blue", value: "blue", bg: "bg-blue-500", text: "text-blue-700" }, + { name: "Green", value: "green", bg: "bg-green-500", text: "text-green-700" }, + { name: "Purple", value: "purple", bg: "bg-purple-500", text: "text-purple-700" }, + { name: "Orange", value: "orange", bg: "bg-orange-500", text: "text-orange-700" }, + { name: "Pink", value: "pink", bg: "bg-pink-500", text: "text-pink-700" }, + { name: "Red", value: "red", bg: "bg-red-500", text: "text-red-700" }, +] + +export function EventManager({ + events: initialEvents = [], + onEventCreate, + onEventUpdate, + onEventDelete, + categories = ["Meeting", "Task", "Reminder", "Personal"], + colors = defaultColors, + defaultView = "month", + className, + availableTags = ["Important", "Urgent", "Work", "Personal", "Team", "Client"], +}: EventManagerProps) { + const [events, setEvents] = useState(initialEvents) + const [currentDate, setCurrentDate] = useState(new Date()) + const [view, setView] = useState<"month" | "week" | "day" | "list">(defaultView) + const [selectedEvent, setSelectedEvent] = useState(null) + const [isDialogOpen, setIsDialogOpen] = useState(false) + const [isCreating, setIsCreating] = useState(false) + const [draggedEvent, setDraggedEvent] = useState(null) + const [newEvent, setNewEvent] = useState>({ + title: "", + description: "", + color: colors[0].value, + category: categories[0], + tags: [], + }) + + const [searchQuery, setSearchQuery] = useState("") + const [selectedColors, setSelectedColors] = useState([]) + const [selectedTags, setSelectedTags] = useState([]) + const [selectedCategories, setSelectedCategories] = useState([]) + + const filteredEvents = useMemo(() => { + return events.filter((event) => { + // Search filter + if (searchQuery) { + const query = searchQuery.toLowerCase() + const matchesSearch = + event.title.toLowerCase().includes(query) || + event.description?.toLowerCase().includes(query) || + event.category?.toLowerCase().includes(query) || + event.tags?.some((tag) => tag.toLowerCase().includes(query)) + + if (!matchesSearch) return false + } + + // Color filter + if (selectedColors.length > 0 && !selectedColors.includes(event.color)) { + return false + } + + // Tag filter + if (selectedTags.length > 0) { + const hasMatchingTag = event.tags?.some((tag) => selectedTags.includes(tag)) + if (!hasMatchingTag) return false + } + + // Category filter + if (selectedCategories.length > 0 && event.category && !selectedCategories.includes(event.category)) { + return false + } + + return true + }) + }, [events, searchQuery, selectedColors, selectedTags, selectedCategories]) + + const hasActiveFilters = selectedColors.length > 0 || selectedTags.length > 0 || selectedCategories.length > 0 + + const clearFilters = () => { + setSelectedColors([]) + setSelectedTags([]) + setSelectedCategories([]) + setSearchQuery("") + } + + const handleCreateEvent = useCallback(() => { + if (!newEvent.title || !newEvent.startTime || !newEvent.endTime) return + + const event: Event = { + id: Math.random().toString(36).substr(2, 9), + title: newEvent.title, + description: newEvent.description, + startTime: newEvent.startTime, + endTime: newEvent.endTime, + color: newEvent.color || colors[0].value, + category: newEvent.category, + attendees: newEvent.attendees, + tags: newEvent.tags || [], + } + + setEvents((prev) => [...prev, event]) + onEventCreate?.(event) + setIsDialogOpen(false) + setIsCreating(false) + setNewEvent({ + title: "", + description: "", + color: colors[0].value, + category: categories[0], + tags: [], + }) + }, [newEvent, colors, categories, onEventCreate]) + + const handleUpdateEvent = useCallback(() => { + if (!selectedEvent) return + + setEvents((prev) => prev.map((e) => (e.id === selectedEvent.id ? selectedEvent : e))) + onEventUpdate?.(selectedEvent.id, selectedEvent) + setIsDialogOpen(false) + setSelectedEvent(null) + }, [selectedEvent, onEventUpdate]) + + const handleDeleteEvent = useCallback( + (id: string) => { + setEvents((prev) => prev.filter((e) => e.id !== id)) + onEventDelete?.(id) + setIsDialogOpen(false) + setSelectedEvent(null) + }, + [onEventDelete], + ) + + const handleDragStart = useCallback((event: Event) => { + setDraggedEvent(event) + }, []) + + const handleDragEnd = useCallback(() => { + setDraggedEvent(null) + }, []) + + const handleDrop = useCallback( + (date: Date, hour?: number) => { + if (!draggedEvent) return + + const duration = draggedEvent.endTime.getTime() - draggedEvent.startTime.getTime() + const newStartTime = new Date(date) + if (hour !== undefined) { + newStartTime.setHours(hour, 0, 0, 0) + } + const newEndTime = new Date(newStartTime.getTime() + duration) + + const updatedEvent = { + ...draggedEvent, + startTime: newStartTime, + endTime: newEndTime, + } + + setEvents((prev) => prev.map((e) => (e.id === draggedEvent.id ? updatedEvent : e))) + onEventUpdate?.(draggedEvent.id, updatedEvent) + setDraggedEvent(null) + }, + [draggedEvent, onEventUpdate], + ) + + const navigateDate = useCallback( + (direction: "prev" | "next") => { + setCurrentDate((prev) => { + const newDate = new Date(prev) + if (view === "month") { + newDate.setMonth(prev.getMonth() + (direction === "next" ? 1 : -1)) + } else if (view === "week") { + newDate.setDate(prev.getDate() + (direction === "next" ? 7 : -7)) + } else if (view === "day") { + newDate.setDate(prev.getDate() + (direction === "next" ? 1 : -1)) + } + return newDate + }) + }, + [view], + ) + + const getColorClasses = useCallback( + (colorValue: string) => { + const color = colors.find((c) => c.value === colorValue) + return color || colors[0] + }, + [colors], + ) + + const toggleTag = (tag: string, isCreating: boolean) => { + if (isCreating) { + setNewEvent((prev) => ({ + ...prev, + tags: prev.tags?.includes(tag) ? prev.tags.filter((t) => t !== tag) : [...(prev.tags || []), tag], + })) + } else { + setSelectedEvent((prev) => + prev + ? { + ...prev, + tags: prev.tags?.includes(tag) ? prev.tags.filter((t) => t !== tag) : [...(prev.tags || []), tag], + } + : null, + ) + } + } + + return ( +
+ {/* Header */} +
+
+

+ {view === "month" && + currentDate.toLocaleDateString("en-US", { + month: "long", + year: "numeric", + })} + {view === "week" && + `Week of ${currentDate.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })}`} + {view === "day" && + currentDate.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + })} + {view === "list" && "All Events"} +

+
+ + + +
+
+ +
+ {/* Mobile: Select dropdown */} +
+ +
+ + {/* Desktop: Button group */} +
+ + + + +
+ + +
+
+ +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> + {searchQuery && ( + + )} +
+ + {/* Mobile: Horizontal scroll with full-length buttons */} +
+
+ {/* Color Filter */} + + + + + + Filter by Color + + {colors.map((color) => ( + { + setSelectedColors((prev) => + checked ? [...prev, color.value] : prev.filter((c) => c !== color.value), + ) + }} + > +
+
+ {color.name} +
+ + ))} + + + + {/* Tag Filter */} + + + + + + Filter by Tag + + {availableTags.map((tag) => ( + { + setSelectedTags((prev) => (checked ? [...prev, tag] : prev.filter((t) => t !== tag))) + }} + > + {tag} + + ))} + + + + {/* Category Filter */} + + + + + + Filter by Category + + {categories.map((category) => ( + { + setSelectedCategories((prev) => + checked ? [...prev, category] : prev.filter((c) => c !== category), + ) + }} + > + {category} + + ))} + + + + {hasActiveFilters && ( + + )} +
+
+ + {/* Desktop: Original layout */} +
+ {/* Color Filter */} + + + + + + Filter by Color + + {colors.map((color) => ( + { + setSelectedColors((prev) => + checked ? [...prev, color.value] : prev.filter((c) => c !== color.value), + ) + }} + > +
+
+ {color.name} +
+ + ))} + + + + {/* Tag Filter */} + + + + + + Filter by Tag + + {availableTags.map((tag) => ( + { + setSelectedTags((prev) => (checked ? [...prev, tag] : prev.filter((t) => t !== tag))) + }} + > + {tag} + + ))} + + + + {/* Category Filter */} + + + + + + Filter by Category + + {categories.map((category) => ( + { + setSelectedCategories((prev) => + checked ? [...prev, category] : prev.filter((c) => c !== category), + ) + }} + > + {category} + + ))} + + + + {hasActiveFilters && ( + + )} +
+
+ + {hasActiveFilters && ( +
+ Active filters: + {selectedColors.map((colorValue) => { + const color = getColorClasses(colorValue) + return ( + +
+ {color.name} + + + ) + })} + {selectedTags.map((tag) => ( + + {tag} + + + ))} + {selectedCategories.map((category) => ( + + {category} + + + ))} +
+ )} + + {/* Calendar Views - Pass filteredEvents instead of events */} + {view === "month" && ( + { + setSelectedEvent(event) + setIsDialogOpen(true) + }} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onDrop={handleDrop} + getColorClasses={getColorClasses} + /> + )} + + {view === "week" && ( + { + setSelectedEvent(event) + setIsDialogOpen(true) + }} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onDrop={handleDrop} + getColorClasses={getColorClasses} + /> + )} + + {view === "day" && ( + { + setSelectedEvent(event) + setIsDialogOpen(true) + }} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onDrop={handleDrop} + getColorClasses={getColorClasses} + /> + )} + + {view === "list" && ( + { + setSelectedEvent(event) + setIsDialogOpen(true) + }} + getColorClasses={getColorClasses} + /> + )} + + {/* Event Dialog */} + + + + {isCreating ? "Create Event" : "Event Details"} + + {isCreating ? "Add a new event to your calendar" : "View and edit event details"} + + + +
+
+ + + isCreating + ? setNewEvent((prev) => ({ ...prev, title: e.target.value })) + : setSelectedEvent((prev) => (prev ? { ...prev, title: e.target.value } : null)) + } + placeholder="Event title" + /> +
+ +
+ +
{m.nome} {m.consultas}