diff --git a/package-lock.json b/package-lock.json index b145dcc..bd804cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,27 @@ "name": "web", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "axios": "^1.13.2", + "chart.js": "^4.5.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "js-cookie": "^3.0.5", + "leaflet": "^1.9.4", + "lucide-react": "^0.562.0", "next": "16.1.1", "react": "19.2.3", - "react-dom": "19.2.3" + "react-chartjs-2": "^5.3.1", + "react-dom": "19.2.3", + "react-leaflet": "^5.0.0", + "tailwind-merge": "^3.4.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/js-cookie": "^3.0.6", + "@types/leaflet": "^1.9.21", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -277,6 +292,60 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -1022,6 +1091,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1227,6 +1302,17 @@ "node": ">=12.4.0" } }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1532,6 +1618,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1546,6 +1646,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "20.19.27", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", @@ -2367,6 +2477,12 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -2393,6 +2509,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2501,7 +2628,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -2575,12 +2701,34 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "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/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2601,6 +2749,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2698,6 +2858,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2759,6 +2929,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2786,7 +2965,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -2898,7 +3076,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2908,7 +3085,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2946,7 +3122,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -2959,7 +3134,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3217,6 +3391,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3575,6 +3750,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -3591,11 +3786,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3656,7 +3866,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -3681,7 +3890,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -3769,7 +3977,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3841,7 +4048,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3854,7 +4060,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -3870,7 +4075,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4405,6 +4609,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4518,6 +4731,13 @@ "node": ">=0.10" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause", + "peer": true + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4839,6 +5059,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4853,7 +5082,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4883,6 +5111,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -5353,6 +5602,12 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5394,6 +5649,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz", + "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-dom": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", @@ -5414,6 +5679,20 @@ "dev": true, "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6026,6 +6305,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", diff --git a/package.json b/package.json index e8681c5..22c14a4 100644 --- a/package.json +++ b/package.json @@ -3,18 +3,33 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev -p 3001", "build": "next build", - "start": "next start", + "start": "next start -p 3001", "lint": "eslint" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "axios": "^1.13.2", + "chart.js": "^4.5.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "js-cookie": "^3.0.5", + "leaflet": "^1.9.4", + "lucide-react": "^0.562.0", "next": "16.1.1", "react": "19.2.3", - "react-dom": "19.2.3" + "react-chartjs-2": "^5.3.1", + "react-dom": "19.2.3", + "react-leaflet": "^5.0.0", + "tailwind-merge": "^3.4.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/js-cookie": "^3.0.6", + "@types/leaflet": "^1.9.21", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..0c38bea Binary files /dev/null and b/public/favicon.png differ diff --git a/public/ignosi.png b/public/ignosi.png new file mode 100644 index 0000000..e8e7d53 Binary files /dev/null and b/public/ignosi.png differ diff --git a/public/ignosilogo.png b/public/ignosilogo.png new file mode 100644 index 0000000..4e71013 Binary files /dev/null and b/public/ignosilogo.png differ diff --git a/src/app/api/view-pdf/[filename]/route.ts b/src/app/api/view-pdf/[filename]/route.ts new file mode 100644 index 0000000..53e8f8a --- /dev/null +++ b/src/app/api/view-pdf/[filename]/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ filename: string }> } +) { + const { filename } = await context.params; + + // Define potential paths to the backend uploads directory + // We need to handle different CWD contexts (app root vs workspace root) + const potentialPaths = [ + path.join(process.cwd(), '..', 'api', 'uploads', filename), // If CWD is apps/web + path.join(process.cwd(), 'apps', 'api', 'uploads', filename), // If CWD is workspace root + path.resolve(process.cwd(), '../../apps/api/uploads', filename), // fallback + 'c:/ignosidev/Igcrm/apps/api/uploads/' + filename // Absolute path fallback + ]; + + let filePath = ''; + for (const p of potentialPaths) { + // console.log('Checking path:', p); + if (fs.existsSync(p)) { + filePath = p; + console.log('Found PDF at:', filePath); + break; + } + } + + if (!filePath) { + console.error(`PDF not found. Filename: ${filename}. Searched in:`, potentialPaths); + return new NextResponse('File not found', { status: 404 }); + } + + try { + const fileBuffer = fs.readFileSync(filePath); + return new NextResponse(fileBuffer, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `inline; filename="${filename}"`, + }, + }); + } catch (error) { + console.error('Error reading PDF:', error); + return new NextResponse('Internal Server Error', { status: 500 }); + } +} diff --git a/src/app/dashboard/opportunities/page.tsx b/src/app/dashboard/opportunities/page.tsx new file mode 100644 index 0000000..dd61467 --- /dev/null +++ b/src/app/dashboard/opportunities/page.tsx @@ -0,0 +1,9 @@ +import OpportunityBoard from '@/components/OpportunityBoard'; + +export default function OpportunitiesPage() { + return ( +
+ +
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..b240be2 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,223 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/context/AuthContext'; +import dynamic from 'next/dynamic'; +import ClientList from '@/components/ClientList'; +import DashboardOverview from '@/components/DashboardOverview'; +import ProductManager from '@/components/ProductManager'; +import UserManager from '@/components/UserManager'; +import ExpenseApproval from '@/components/ExpenseApproval'; +import QuoteManager from '@/components/QuoteManager'; +import Reports from '@/components/Reports'; +import OpportunityBoard from '@/components/OpportunityBoard'; +import TargetManager from '@/components/TargetManager'; +import IncentiveManager from '@/components/IncentiveManager'; +import FollowupsManager from '@/components/FollowupsManager'; +import FunnelAnalysisPage from '@/components/FunnelAnalysisPage'; +import CallLogs from '@/components/CallLogs'; +import Settings from '@/components/Settings'; +import { LayoutDashboard, Map, Users, Package, LogOut, Menu, UserPlus, DollarSign, FileText, BarChart, TrendingUp, Briefcase, IndianRupee, Target, CalendarCheck, GitMerge, PhoneCall, Settings as SettingsIcon } from 'lucide-react'; +import clsx from 'clsx'; + +const LiveMap = dynamic(() => import('@/components/LiveMap'), { + ssr: false, + loading: () =>

Loading Map...

, +}); + +export default function DashboardPage() { + const { user, isLoading, logout } = useAuth(); + const router = useRouter(); + const [activeTab, setActiveTab] = useState('dashboard'); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + + useEffect(() => { + if (!isLoading && !user) { + router.push('/login'); + } + }, [user, isLoading, router]); + + + if (isLoading || !user) { + return
Loading...
; + } + + const menuItems = [ + { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, + ...(user?.role === 'ADMIN' || user?.role === 'GENERAL_MANAGER' ? [{ id: 'tracking', label: 'Live Tracking', icon: Map }] : []), + { id: 'opportunities', label: 'Opportunities', icon: Briefcase }, + { id: 'clients', label: 'Clients', icon: Users }, + { id: 'quotes', label: 'Quotes', icon: FileText }, + { id: 'expenses', label: 'Expenses', icon: IndianRupee }, + { id: 'incentives', label: 'Incentives', icon: TrendingUp }, + { id: 'reports', label: 'Reports', icon: BarChart }, + ...(user?.role === 'ADMIN' || user?.role === 'GENERAL_MANAGER' ? [{ id: 'targets', label: 'Targets', icon: Target }] : []), + { id: 'followups', label: 'Follow-ups', icon: CalendarCheck }, + { id: 'call-logs', label: 'Call Logs', icon: PhoneCall }, + ...(user?.role === 'ADMIN' || user?.role === 'GENERAL_MANAGER' ? [{ id: 'funnel-analysis', label: 'Funnel Analysis', icon: GitMerge }] : []), + { id: 'products', label: 'Products', icon: Package }, + { id: 'users', label: 'Users', icon: UserPlus }, + { id: 'settings', label: 'Settings', icon: SettingsIcon }, + ]; + + const renderContent = () => { + switch (activeTab) { + case 'dashboard': + return ; + case 'tracking': + return ( +
+
+
+

Team Tracking

+

Real-time proximity of sales team and client leads

+
+
+
+
+ Team Active +
+
+
+
+ Clients Mapped +
+
+
+
+ +
+
+ ); + case 'opportunities': + return ; + case 'clients': + return ; + case 'quotes': + return ; + case 'expenses': + return ; + case 'incentives': + return ; + case 'reports': + return ; + case 'followups': + return
; + case 'call-logs': + return ; + case 'funnel-analysis': + return ; + case 'products': + return ; + case 'targets': + return ; + case 'users': + return ; + case 'settings': + return ; + default: + return ; + } + }; + + return ( +
+ {/* Top Navigation - Odoo 17 Style */} + + +
+ {/* Sidebar - Odoo 17 Style */} + + + {/* Main Content */} +
+
+ {renderContent()} +
+
+
+
+ ); +} diff --git a/src/app/favicon.ico b/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/src/app/favicon.ico and /dev/null differ diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..60b0d1c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,26 +1,54 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + --background: #f8f9fa; + --foreground: #212529; + --odoo-primary: #714b67; + --odoo-secondary: #00a09d; + --odoo-accent: #875a7b; + --card-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06); } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } + --color-odoo-primary: var(--odoo-primary); + --color-odoo-secondary: var(--odoo-secondary); + --color-odoo-accent: var(--odoo-accent); + --font-sans: "Inter", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; +} + +.odoo-card { + background: white; + border-radius: 8px; + box-shadow: var(--card-shadow); + transition: transform 0.2s, box-shadow 0.2s; +} + +.odoo-card:hover { + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +.custom-scrollbar::-webkit-scrollbar { + width: 6px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: #dee2e6; + border-radius: 10px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: #ced4da; } diff --git a/src/app/icon.png b/src/app/icon.png new file mode 100644 index 0000000..4e71013 Binary files /dev/null and b/src/app/icon.png differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..3afc4b8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { AuthProvider } from "@/context/AuthContext"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -13,8 +14,13 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "IgCRM", + description: "Advanced Sales & Client Relationship Management", + icons: { + icon: "/ignosilogo.png", + shortcut: "/ignosilogo.png", + apple: "/ignosilogo.png", + }, }; export default function RootLayout({ @@ -27,7 +33,9 @@ export default function RootLayout({ - {children} + + {children} + ); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..5ac85b1 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,195 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useAuth } from '@/context/AuthContext'; +import { Mail, Lock, ShieldCheck, Eye, EyeOff, LayoutDashboard, ArrowRight } from 'lucide-react'; + +export default function LoginPage() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [rememberMe, setRememberMe] = useState(false); + const [error, setError] = useState(null); + const [focusedField, setFocusedField] = useState(null); + + const { login, isLoading } = useAuth(); + + useEffect(() => { + const savedEmail = localStorage.getItem('rememberedEmail'); + if (savedEmail) { + setEmail(savedEmail); + setRememberMe(true); + } + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + try { + await login(email, password); + + if (rememberMe) { + localStorage.setItem('rememberedEmail', email); + } else { + localStorage.removeItem('rememberedEmail'); + } + } catch (err: any) { + console.error(err); + setError('Invalid email or password. Please try again.'); + } + }; + + const handleForgotPassword = (e: React.MouseEvent) => { + e.preventDefault(); + alert("Please contact the administrator to reset your password."); + }; + + return ( +
+ {/* Advanced Mesh Background */} +
+
+
+
+
+ +
+ {/* Logo Area */} +
+
+
+ Ignosi Logo +
+

+ IgCRM Enterprise +

+

Sales Intelligence Platform

+
+
+ + {/* Glassmorphic Login Card */} +
+
+ +
+ {error && ( +
+ + {error} +
+ )} + +
+ {/* Email Input */} +
+ +
+
+ +
+ setEmail(e.target.value)} + onFocus={() => setFocusedField('email')} + onBlur={() => setFocusedField(null)} + className="block w-full pl-12 pr-4 py-4 bg-slate-50/50 border border-slate-200 rounded-2xl text-slate-800 placeholder-slate-400 focus:outline-none focus:ring-4 focus:ring-odoo-primary/10 focus:border-odoo-primary transition-all font-medium text-lg" + placeholder="name@company.com" + required + /> +
+
+ + {/* Password Input */} +
+
+ +
+
+
+ +
+ setPassword(e.target.value)} + onFocus={() => setFocusedField('password')} + onBlur={() => setFocusedField(null)} + className="block w-full pl-12 pr-12 py-4 bg-slate-50/50 border border-slate-200 rounded-2xl text-slate-800 placeholder-slate-400 focus:outline-none focus:ring-4 focus:ring-odoo-primary/10 focus:border-odoo-primary transition-all font-medium text-lg" + placeholder="••••••••" + required + /> + +
+
+
+ +
+ + + Forgot Access? + +
+ + +
+ + {/* Footer / Trust Section */} +
+
+ + Enterprise Security +
+

+ Authorized personnel only. Sessions are monitored for security compliance. +

+
+
+ + {/* Additional Links */} +
+ + +
+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 295f8fd..a74cb27 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,5 @@ -import Image from "next/image"; +import { redirect } from "next/navigation"; export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
-
- ); + redirect("/dashboard"); } diff --git a/src/components/CallLogs.tsx b/src/components/CallLogs.tsx new file mode 100644 index 0000000..c79ced4 --- /dev/null +++ b/src/components/CallLogs.tsx @@ -0,0 +1,207 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import api from '@/lib/axios'; +import { PhoneCall, Search, Calendar, Filter, Users, Download, ArrowUpRight } from 'lucide-react'; +import { useAuth } from '@/context/AuthContext'; + +interface CallLog { + id: string; + type: string; + description: string; + leadsGenerated: number; + metadata: string; // JSON string + createdAt: string; + userId: string; + user: { name: string }; +} + +export default function CallLogsPage() { + const { user } = useAuth(); + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + + // Filters + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [clientId, setClientId] = useState(''); + const [userIdFilter, setUserIdFilter] = useState(''); + + const [team, setTeam] = useState<{id: string, name: string}[]>([]); + + useEffect(() => { + // Set default dates (Last 7 days) + const end = new Date(); + const start = new Date(); + start.setDate(end.getDate() - 7); + setStartDate(start.toISOString().split('T')[0]); + setEndDate(end.toISOString().split('T')[0]); + + // Fetch team members if Admin/Manager + if (['ADMIN', 'GENERAL_MANAGER', 'MANAGER'].includes(user?.role || '')) { + api.get('/users').then(res => setTeam(res.data)).catch(() => {}); + } + }, [user]); + + const fetchLogs = async () => { + if (!startDate || !endDate) return; + setLoading(true); + try { + const res = await api.get('/strategic-activities', { + params: { + startDate, + endDate, + userId: userIdFilter || undefined, + clientId: clientId || undefined + } + }); + // Filter only call types + const filtered = res.data.filter((l: any) => ['COLD_CALLING', 'WHATSAPP_CAMPAIGN', 'CALL'].includes(l.type)); + setLogs(filtered); + } catch (error) { + console.error("Failed to fetch logs", error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchLogs(); + }, [startDate, endDate, userIdFilter, clientId]); + + return ( +
+ {/* Header */} +
+
+

+ Call & Conversion Logs +

+

Track call activity and monitor quality lead conversions

+
+
+ + {/* Filter Bar */} +
+
+ + setStartDate(e.target.value)} + className="bg-transparent text-sm outline-none font-medium text-gray-700" + /> + to + setEndDate(e.target.value)} + className="bg-transparent text-sm outline-none font-medium text-gray-700" + /> +
+ +
+ + setClientId(e.target.value)} + className="bg-transparent text-sm outline-none font-medium text-gray-700 w-full" + /> +
+ + {team.length > 0 && ( +
+ + +
+ )} +
+ + {/* Data Table */} +
+ {loading ? ( +
Loading call logs...
+ ) : logs.length === 0 ? ( +
+ +

No call logs found for this period.

+
+ ) : ( +
+ + + + + + + + + + + + {logs.map(log => { + let meta: any = {}; + try { meta = typeof log.metadata === 'string' ? JSON.parse(log.metadata) : (log.metadata || {}); } catch(e) {} + + const statusConverted = meta.convertedToStatus; + + const STATUS_STYLES: any = { + QUALITY: { bg: 'bg-green-100', text: 'text-green-700', border: 'border-green-200', label: 'Quality Lead' }, + POTENTIAL: { bg: 'bg-yellow-100', text: 'text-yellow-700', border: 'border-yellow-200', label: 'Potential' }, + DEMO: { bg: 'bg-purple-100', text: 'text-purple-700', border: 'border-purple-200', label: 'Demo' }, + SALES: { bg: 'bg-sky-100', text: 'text-sky-700', border: 'border-sky-200', label: 'Sales' }, + CLOSED: { bg: 'bg-red-100', text: 'text-red-700', border: 'border-red-200', label: 'Closed' } + }; + + const style = statusConverted ? STATUS_STYLES[statusConverted] : null; + + return ( + + + + + + + + ); + })} + +
Date & TimeTeam MemberClientActivityStatus
+ {new Date(log.createdAt).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })} + + {log.user?.name || 'Unknown'} + +
{meta.clientName || 'No Client Linked'}
+
+ + {log.type.replace('_', ' ')} + +

{log.description}

+
+ {style ? ( + + {style.label} + + ) : ( + + Logged + + )} +
+
+ )} +
+
+ ); +} diff --git a/src/components/ClientList.tsx b/src/components/ClientList.tsx new file mode 100644 index 0000000..a062ff0 --- /dev/null +++ b/src/components/ClientList.tsx @@ -0,0 +1,520 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import api from '../lib/axios'; +import { useAuth } from '@/context/AuthContext'; +import { UserPlus, Edit2, Search, ArrowLeft, Calendar, FileText, MapPin, Download } from 'lucide-react'; +import ClientModal from './ClientModal'; + +interface Client { + id?: string; + name: string; + email: string; + phone: string; + address?: string; + landmark?: string; + status: string; + assignedTo?: string; + createdAt?: string; +} + +interface Enquiry { + id: string; + createdAt: string; + products: { name: string }[]; + conversation: string; + quotes?: { id: string, totalAmount: number, status: string, pdfUrl?: string, createdAt: string }[]; +} + +interface Followup { + id: string; + notes: string; + status: string; + date: string; + createdAt: string; +} + +const STATUS_OPTIONS = ['LEAD', 'PROSPECT', 'CUSTOMER', 'CLOSED']; + +export default function ClientList() { + const { user } = useAuth(); + const [clients, setClients] = useState([]); + const [filteredClients, setFilteredClients] = useState([]); + const [selectedClient, setSelectedClient] = useState(null); + const [enquiries, setEnquiries] = useState([]); + const [followups, setFollowups] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [users, setUsers] = useState([]); + + // Followup Form + const [newFollowup, setNewFollowup] = useState(''); + const [followupDate, setFollowupDate] = useState(''); + const [followupStatus, setFollowupStatus] = useState('PENDING'); + const [assignedUserId, setAssignedUserId] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Modal State + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingClient, setEditingClient] = useState(null); + + useEffect(() => { + fetchClients(); + fetchUsers(); + }, []); + + const fetchUsers = async () => { + try { + const { data } = await api.get('/users'); + setUsers(data); + } catch (error) { + console.error('Failed to fetch users', error); + } + }; + + useEffect(() => { + if (searchQuery) { + const lower = searchQuery.toLowerCase(); + setFilteredClients(clients.filter(c => + c.name.toLowerCase().includes(lower) || + c.email?.toLowerCase().includes(lower) || + c.phone?.includes(lower) + )); + } else { + setFilteredClients(clients); + } + }, [searchQuery, clients]); + + const fetchClients = async () => { + try { + const response = await api.get('/clients'); + setClients(response.data); + setFilteredClients(response.data); + } catch (error) { + console.error(error); + } + }; + + const handleClientClick = async (client: Client) => { + setSelectedClient(client); + setEnquiries([]); + setFollowups([]); // Clear previous + try { + // Fetch Enquiries + const enqRes = await api.get('/enquiries'); + const ClientEnquiries = enqRes.data.filter((e: any) => e.clientId === client.id); + setEnquiries(ClientEnquiries); + + // Fetch Followups + // Ideally backend supports filtering, but filtering client-side for now as per established pattern + const followRes = await api.get('/followups'); + const clientFollowups = followRes.data.filter((f: any) => f.clientId === client.id); + // Sort by date desc + clientFollowups.sort((a: Followup, b: Followup) => new Date(b.date).getTime() - new Date(a.date).getTime()); + setFollowups(clientFollowups); + + } catch (error) { + console.error(error); + } + }; + + const handleAddFollowup = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedClient) return; + if (!user || !user.id) { + alert('User not authenticated'); + return; + } + + setIsSubmitting(true); + try { + const response = await api.post('/followups', { + clientId: selectedClient.id, + notes: newFollowup, + date: new Date(followupDate).toISOString(), + status: followupStatus, + userId: assignedUserId || user.id + }); + + // Optimistic Update or direct append + // Assuming backend returns the created object + const createdFollowup = response.data; + setFollowups([createdFollowup, ...followups]); + + setNewFollowup(''); + setFollowupDate(''); + setFollowupStatus('PENDING'); + setAssignedUserId(''); + // alert('Follow-up added'); // Removed annoying alert + } catch (error) { + console.error(error); + alert('Failed to add follow-up'); + } finally { + setIsSubmitting(false); + } + }; + + const handleStatusUpdate = async (newStatus: string) => { + if (!selectedClient) return; + try { + const response = await api.patch(`/clients/${selectedClient.id}`, { status: newStatus }); + + // Update local state + const updatedClient = response.data; + setSelectedClient(updatedClient); + + // Update in list + setClients(clients.map(c => c.id === updatedClient.id ? updatedClient : c)); + + } catch (error) { + console.error("Failed to update status", error); + alert("Failed to update status"); + } + }; + + const handleSaveClient = async (clientData: any) => { + try { + if (clientData.id) { + // Update + const response = await api.patch(`/clients/${clientData.id}`, clientData); + const updated = response.data; + setClients(clients.map(c => c.id === updated.id ? updated : c)); + if (selectedClient?.id === updated.id) setSelectedClient(updated); + } else { + // Create + const response = await api.post('/clients', clientData); + const created = response.data; + setClients([created, ...clients]); + } + } catch (error) { + console.error("Failed to save client", error); + throw error; + } + }; + + const handleDeleteClient = async (id: string) => { + try { + await api.delete(`/clients/${id}`); + setClients(clients.filter(c => c.id !== id)); + if (selectedClient?.id === id) setSelectedClient(null); + } catch (error) { + console.error("Failed to delete client", error); + throw error; + } + }; + + const handleFollowupStatusToggle = async (followup: Followup) => { + if (followup.status !== 'PENDING') return; + + const confirmUpdate = window.confirm("Mark this task as DONE?"); + if (!confirmUpdate) return; + + try { + const response = await api.patch(`/followups/${followup.id}`, { status: 'DONE' }); + const updatedFollowup = response.data; + + // Update state + setFollowups(followups.map(f => f.id === updatedFollowup.id ? updatedFollowup : f)); + } catch (error) { + console.error("Failed to update followup status", error); + alert("Failed to update status"); + } + }; + + return ( +
+ {/* Header */} +
+
+

Client Database

+

Manage your leads and customers

+
+ {!selectedClient ? ( +
+
+ + setSearchQuery(e.target.value)} + /> +
+ +
+ ) : ( + + )} +
+ + {/* Content Area */} +
+ {/* List View */} + {!selectedClient && ( + + + + + + + + + + + {filteredClients.length > 0 ? filteredClients.map((client) => ( + handleClientClick(client)}> + + + + + + )) : ( + + + + )} + +
NameContactStatusAction
+
+
+ {client.name.charAt(0).toUpperCase()} +
+
{client.name}
+
+
+
{client.email || 'N/A'}
+
{client.phone}
+
+ + {client.status} + + + +
+ No clients found matching your search. +
+ )} + + {/* Detailed View */} + {selectedClient && ( +
+ + +
+
+

{selectedClient.name}

+
+ {selectedClient.email} + + {selectedClient.phone} +
+
+
+ +
+ {STATUS_OPTIONS.map((status) => ( + + ))} +
+
+
+ +
+ + {/* Left Col: Activity Timeline (Followups) */} +
+
+
+

Follow-up Timeline

+ {followups.length} Records +
+ +
+
+