first commit
parent
6009ec4ce9
commit
3e8d86c980
|
|
@ -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",
|
||||
|
|
|
|||
21
package.json
21
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",
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 201 KiB |
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import OpportunityBoard from '@/components/OpportunityBoard';
|
||||
|
||||
export default function OpportunitiesPage() {
|
||||
return (
|
||||
<div className="p-6 h-full">
|
||||
<OpportunityBoard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: () => <p>Loading Map...</p>,
|
||||
});
|
||||
|
||||
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 <div className="flex justify-center items-center h-screen">Loading...</div>;
|
||||
}
|
||||
|
||||
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 <DashboardOverview />;
|
||||
case 'tracking':
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-80px)] space-y-4">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div>
|
||||
<h2 className="text-2xl font-extrabold text-odoo-primary tracking-tight">Team Tracking</h2>
|
||||
<p className="text-slate-500 text-sm font-medium">Real-time proximity of sales team and client leads</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 bg-slate-50 px-4 py-2 rounded-2xl border border-slate-100 shadow-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 rounded-full bg-odoo-primary animate-pulse"></div>
|
||||
<span className="text-xs font-bold text-slate-600 uppercase tracking-wider">Team Active</span>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-slate-200"></div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 rounded-full bg-odoo-secondary"></div>
|
||||
<span className="text-xs font-bold text-slate-600 uppercase tracking-wider">Clients Mapped</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 border-2 border-slate-100 rounded-[32px] overflow-hidden shadow-[0_8px_30px_rgb(0,0,0,0.04)] bg-white relative">
|
||||
<LiveMap />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'opportunities':
|
||||
return <OpportunityBoard />;
|
||||
case 'clients':
|
||||
return <ClientList />;
|
||||
case 'quotes':
|
||||
return <QuoteManager />;
|
||||
case 'expenses':
|
||||
return <ExpenseApproval />;
|
||||
case 'incentives':
|
||||
return <IncentiveManager />;
|
||||
case 'reports':
|
||||
return <Reports />;
|
||||
case 'followups':
|
||||
return <div className="p-6"><FollowupsManager /></div>;
|
||||
case 'call-logs':
|
||||
return <CallLogs />;
|
||||
case 'funnel-analysis':
|
||||
return <FunnelAnalysisPage />;
|
||||
case 'products':
|
||||
return <ProductManager />;
|
||||
case 'targets':
|
||||
return <TargetManager />;
|
||||
case 'users':
|
||||
return <UserManager />;
|
||||
case 'settings':
|
||||
return <Settings />;
|
||||
default:
|
||||
return <DashboardOverview />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f8f9fa] flex flex-col font-sans">
|
||||
{/* Top Navigation - Odoo 17 Style */}
|
||||
<nav className="bg-odoo-primary text-white shadow-md z-30 h-12 flex items-center px-4 shrink-0">
|
||||
<div className="flex items-center space-x-4 w-1/3">
|
||||
<button
|
||||
className="p-1.5 rounded-md hover:bg-white/10 transition-colors"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<div className="flex items-center space-x-2 cursor-pointer hover:bg-white/10 px-2 py-1 rounded transition-colors">
|
||||
<div className="h-6 flex items-center justify-center">
|
||||
<img src="/ignosi.png" alt="Logo" className="h-full w-auto object-contain" />
|
||||
</div>
|
||||
<div className="h-4 w-px bg-white/20 mx-1"></div>
|
||||
<span className="font-bold text-sm tracking-tight">IgCRM <span className="text-white/70 font-medium">Enterprise</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Centered Search Bar */}
|
||||
<div className="flex-1 max-w-xl hidden md:block">
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Menu size={16} className="text-white/50" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="block w-full bg-white/10 border-transparent rounded-full py-1.5 pl-10 pr-3 text-sm placeholder-white/50 focus:bg-white focus:text-gray-900 focus:ring-0 focus:placeholder-gray-400 transition-all outline-none"
|
||||
placeholder="Search or type a command..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-3 w-1/3">
|
||||
<div className="flex items-center space-x-2 group cursor-pointer px-2 py-1 rounded hover:bg-white/10 transition-colors">
|
||||
<div className="w-6 h-6 rounded-full bg-odoo-secondary flex items-center justify-center text-[10px] font-bold shadow-inner">
|
||||
{user.name?.charAt(0)}
|
||||
</div>
|
||||
<span className="text-xs font-medium hidden sm:block">{user.name}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="p-1.5 rounded-md hover:bg-rose-500/20 text-white/80 hover:text-white transition-all"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden relative">
|
||||
{/* Sidebar - Odoo 17 Style */}
|
||||
<aside className={clsx(
|
||||
"bg-[#e9ecef]/50 backdrop-blur-sm w-64 flex-shrink-0 border-r border-gray-200 transition-all duration-300 absolute md:relative z-20 h-full",
|
||||
mobileMenuOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||
)}>
|
||||
<div className="h-full flex flex-col py-4">
|
||||
<nav className="flex-1 space-y-0.5 px-2">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setActiveTab(item.id);
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className={clsx(
|
||||
"w-full flex items-center px-4 py-2.5 text-[13px] font-medium rounded transition-all",
|
||||
isActive
|
||||
? "bg-white text-odoo-primary shadow-sm border-l-4 border-odoo-primary"
|
||||
: "text-gray-600 hover:bg-gray-200/50 hover:text-gray-900"
|
||||
)}
|
||||
>
|
||||
<Icon className={clsx("mr-3 h-4 w-4", isActive ? "text-odoo-primary" : "text-gray-400")} />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="px-4 pt-4 border-t border-gray-200/50">
|
||||
<div className="text-[10px] uppercase tracking-widest text-gray-400 font-bold text-center">
|
||||
IgCRM Enterprise
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 overflow-auto bg-white w-full relative">
|
||||
<div className="max-w-[1600px] mx-auto min-h-full">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 201 KiB |
|
|
@ -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({
|
|||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [focusedField, setFocusedField] = useState<string | null>(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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#F8FAFC] relative overflow-hidden font-sans">
|
||||
{/* Advanced Mesh Background */}
|
||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-[15%] -left-[5%] w-[60%] h-[60%] rounded-full bg-odoo-primary/10 blur-[120px] animate-pulse"></div>
|
||||
<div className="absolute top-[10%] -right-[5%] w-[40%] h-[40%] rounded-full bg-odoo-secondary/10 blur-[120px] animation-delay-2000"></div>
|
||||
<div className="absolute -bottom-[10%] left-[15%] w-[50%] h-[50%] rounded-full bg-odoo-accent/10 blur-[120px] animation-delay-4000"></div>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-[480px] mx-4 relative z-10">
|
||||
{/* Logo Area */}
|
||||
<div className="flex flex-col items-center mb-10 group">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="h-12 mb-4 flex items-center justify-center">
|
||||
<img src="/ignosi.png" alt="Ignosi Logo" className="h-full w-auto object-contain" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-black text-odoo-primary tracking-tight mb-2">
|
||||
IgCRM <span className="text-odoo-secondary">Enterprise</span>
|
||||
</h1>
|
||||
<p className="text-slate-400 font-bold text-[10px] uppercase tracking-[0.4em]">Sales Intelligence Platform</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Glassmorphic Login Card */}
|
||||
<div className="bg-white/95 backdrop-blur-2xl rounded-[32px] shadow-[0_20px_50px_rgba(113,75,103,0.15)] border border-white p-10 md:p-14 relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-odoo-primary via-odoo-secondary to-odoo-primary opacity-50"></div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-700 p-4 rounded-2xl text-sm font-semibold border border-red-100 flex items-center animate-in fade-in slide-in-from-top-4 duration-300">
|
||||
<ShieldCheck className="w-5 h-5 mr-3 flex-shrink-0 text-red-500" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Email Input */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-slate-700 ml-1">Work Email</label>
|
||||
<div className={`relative transition-all duration-300 ${focusedField === 'email' ? 'scale-[1.01]' : ''}`}>
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<Mail className={`w-5 h-5 transition-colors duration-300 ${focusedField === 'email' ? 'text-odoo-primary' : 'text-slate-400'}`} />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Input */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center ml-1">
|
||||
<label className="text-sm font-bold text-slate-700">Password</label>
|
||||
</div>
|
||||
<div className={`relative transition-all duration-300 ${focusedField === 'password' ? 'scale-[1.01]' : ''}`}>
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<Lock className={`w-5 h-5 transition-colors duration-300 ${focusedField === 'password' ? 'text-odoo-primary' : 'text-slate-400'}`} />
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-4 flex items-center text-slate-400 hover:text-odoo-primary transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center space-x-3 cursor-pointer group">
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="peer sr-only"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
/>
|
||||
<div className="w-5 h-5 border-2 border-slate-200 rounded-md bg-white peer-checked:bg-odoo-primary peer-checked:border-odoo-primary transition-all"></div>
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-white absolute left-0.5 opacity-0 peer-checked:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-slate-600 group-hover:text-odoo-primary transition-colors">Remember me</span>
|
||||
</label>
|
||||
<a href="#" onClick={handleForgotPassword} className="text-sm font-bold text-odoo-primary hover:text-odoo-accent transition-colors underline-offset-4 hover:underline">
|
||||
Forgot Access?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full relative overflow-hidden group bg-odoo-primary hover:bg-odoo-accent text-white font-bold py-5 px-6 rounded-[20px] shadow-xl hover:shadow-2xl transition-all duration-300 disabled:opacity-70 disabled:cursor-not-allowed transform active:scale-[0.98] flex items-center justify-center space-x-3"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<svg className="animate-spin h-6 w-6 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span className="text-xl">Authenticating...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xl">Sign In to Dashboard</span>
|
||||
<ArrowRight className="w-6 h-6 transform group-hover:translate-x-1 transition-transform" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Footer / Trust Section */}
|
||||
<div className="mt-12 flex flex-col items-center border-t border-slate-100 pt-8">
|
||||
<div className="flex items-center space-x-2 text-slate-400 mb-4">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
<span className="text-xs font-bold uppercase tracking-widest">Enterprise Security</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-300 max-w-[200px] text-center uppercase tracking-tighter leading-tight font-medium">
|
||||
Authorized personnel only. Sessions are monitored for security compliance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Links */}
|
||||
<div className="mt-8 flex justify-center space-x-6">
|
||||
<button className="text-slate-400 hover:text-odoo-primary text-sm font-semibold transition-colors">Help Center</button>
|
||||
<button className="text-slate-400 hover:text-odoo-primary text-sm font-semibold transition-colors">Privacy Policy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,65 +1,5 @@
|
|||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CallLog[]>([]);
|
||||
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 (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-gray-800 flex items-center gap-2">
|
||||
<PhoneCall className="text-odoo-primary" /> Call & Conversion Logs
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">Track call activity and monitor quality lead conversions</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100 flex flex-wrap gap-4 items-center">
|
||||
<div className="flex items-center gap-2 bg-gray-50 px-3 py-2 rounded-lg border border-gray-200">
|
||||
<Calendar size={16} className="text-gray-400" />
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="bg-transparent text-sm outline-none font-medium text-gray-700"
|
||||
/>
|
||||
<span className="text-gray-400">to</span>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="bg-transparent text-sm outline-none font-medium text-gray-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-[200px] flex items-center gap-2 bg-gray-50 px-3 py-2 rounded-lg border border-gray-200">
|
||||
<Search size={16} className="text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search Client Name..."
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
className="bg-transparent text-sm outline-none font-medium text-gray-700 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{team.length > 0 && (
|
||||
<div className="flex items-center gap-2 bg-gray-50 px-3 py-2 rounded-lg border border-gray-200">
|
||||
<Users size={16} className="text-gray-400" />
|
||||
<select
|
||||
value={userIdFilter}
|
||||
onChange={(e) => setUserIdFilter(e.target.value)}
|
||||
className="bg-transparent text-sm outline-none font-medium text-gray-700"
|
||||
>
|
||||
<option value="">All Users</option>
|
||||
{team.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="h-64 flex items-center justify-center text-gray-400">Loading call logs...</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="h-64 flex flex-col items-center justify-center text-gray-400 gap-2">
|
||||
<PhoneCall size={32} className="opacity-20" />
|
||||
<p>No call logs found for this period.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-gray-50 text-gray-500 font-bold border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-6 py-4">Date & Time</th>
|
||||
<th className="px-6 py-4">Team Member</th>
|
||||
<th className="px-6 py-4">Client</th>
|
||||
<th className="px-6 py-4">Activity</th>
|
||||
<th className="px-6 py-4">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{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 (
|
||||
<tr key={log.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-gray-600">
|
||||
{new Date(log.createdAt).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap font-medium text-gray-800">
|
||||
{log.user?.name || 'Unknown'}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-bold text-gray-800">{meta.clientName || 'No Client Linked'}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 min-w-[300px]">
|
||||
<span className="inline-block px-2 py-0.5 rounded text-[10px] font-bold bg-gray-100 text-gray-600 mb-1">
|
||||
{log.type.replace('_', ' ')}
|
||||
</span>
|
||||
<p className="text-gray-600">{log.description}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{style ? (
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold ${style.bg} ${style.text} border ${style.border}`}>
|
||||
<ArrowUpRight size={12} /> {style.label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold bg-gray-100 text-gray-500">
|
||||
Logged
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Client[]>([]);
|
||||
const [filteredClients, setFilteredClients] = useState<Client[]>([]);
|
||||
const [selectedClient, setSelectedClient] = useState<Client | null>(null);
|
||||
const [enquiries, setEnquiries] = useState<Enquiry[]>([]);
|
||||
const [followups, setFollowups] = useState<Followup[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
|
||||
// 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<Client | null>(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 (
|
||||
<div className="bg-white shadow-xl rounded-2xl overflow-hidden border border-gray-100 h-[calc(100vh-100px)] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-800">Client Database</h3>
|
||||
<p className="text-sm text-gray-500">Manage your leads and customers</p>
|
||||
</div>
|
||||
{!selectedClient ? (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search clients..."
|
||||
className="pl-10 pr-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none transition-all text-sm w-64"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setEditingClient(null); setIsModalOpen(true); }}
|
||||
className="bg-odoo-primary hover:bg-odoo-primary/90 text-white px-4 py-2 rounded-xl text-sm font-bold shadow-lg shadow-odoo-primary/10 flex items-center space-x-2 transition-all active:scale-95"
|
||||
>
|
||||
<UserPlus size={18} />
|
||||
<span>New Client</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { setEditingClient(selectedClient); setIsModalOpen(true); }}
|
||||
className="bg-white hover:bg-gray-50 text-gray-700 border border-gray-200 px-4 py-2 rounded-xl text-sm font-bold shadow-sm flex items-center space-x-2 transition-all active:scale-95"
|
||||
>
|
||||
<Edit2 size={16} className="text-odoo-primary" />
|
||||
<span>Edit Details</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* List View */}
|
||||
{!selectedClient && (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Contact</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-bold text-gray-500 uppercase tracking-wider">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredClients.length > 0 ? filteredClients.map((client) => (
|
||||
<tr key={client.id} className="hover:bg-gray-50 transition-colors cursor-pointer group" onClick={() => handleClientClick(client)}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="h-10 w-10 rounded-full bg-odoo-primary/10 flex items-center justify-center text-odoo-primary font-bold mr-3">
|
||||
{client.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-gray-900 group-hover:text-odoo-primary transition-colors">{client.name}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-600">{client.email || 'N/A'}</div>
|
||||
<div className="text-sm text-gray-500">{client.phone}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full border
|
||||
${client.status === 'CUSTOMER' ? 'bg-odoo-secondary/10 text-odoo-secondary border-odoo-secondary/20' :
|
||||
client.status === 'LEAD' ? 'bg-odoo-primary/10 text-odoo-primary border-odoo-primary/20' :
|
||||
client.status === 'PROSPECT' ? 'bg-amber-50 text-amber-700 border-amber-200' :
|
||||
'bg-gray-100 text-gray-800 border-gray-200'}`}>
|
||||
{client.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button className="text-gray-400 group-hover:text-odoo-primary transition-colors font-semibold">View →</button>
|
||||
</td>
|
||||
</tr>
|
||||
)) : (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-10 text-center text-gray-500">
|
||||
No clients found matching your search.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Detailed View */}
|
||||
{selectedClient && (
|
||||
<div className="p-6">
|
||||
<button onClick={() => setSelectedClient(null)} className="mb-6 flex items-center text-sm font-medium text-gray-500 hover:text-odoo-primary transition-colors">
|
||||
<span className="mr-2">←</span> Back to Client List
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 bg-gray-50 p-6 rounded-xl border border-gray-100">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-1">{selectedClient.name}</h2>
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span>{selectedClient.email}</span>
|
||||
<span>•</span>
|
||||
<span>{selectedClient.phone}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 md:mt-0">
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Current Status</label>
|
||||
<div className="flex bg-white rounded-lg shadow-sm border border-gray-200 p-1">
|
||||
{STATUS_OPTIONS.map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={(e) => { e.stopPropagation(); handleStatusUpdate(status); }}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${selectedClient.status === status
|
||||
? 'bg-odoo-primary text-white shadow'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
{/* Left Col: Activity Timeline (Followups) */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50 flex justify-between items-center">
|
||||
<h3 className="font-bold text-gray-800">Follow-up Timeline</h3>
|
||||
<span className="text-xs font-medium bg-odoo-primary/10 text-odoo-primary px-2 py-0.5 rounded-full">{followups.length} Records</span>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-50 border-b border-gray-100">
|
||||
<form onSubmit={handleAddFollowup} className="flex flex-col gap-3">
|
||||
<textarea
|
||||
placeholder="Write a note about this interaction..."
|
||||
value={newFollowup}
|
||||
onChange={e => setNewFollowup(e.target.value)}
|
||||
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none text-sm shadow-sm bg-white"
|
||||
rows={2}
|
||||
required
|
||||
/>
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={followupDate}
|
||||
onChange={e => setFollowupDate(e.target.value)}
|
||||
className="flex-1 min-w-[200px] p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white"
|
||||
required
|
||||
/>
|
||||
<select
|
||||
value={followupStatus}
|
||||
onChange={e => setFollowupStatus(e.target.value)}
|
||||
className="p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white"
|
||||
>
|
||||
<option value="DONE">Done (Log)</option>
|
||||
<option value="PENDING">Pending (Task)</option>
|
||||
</select>
|
||||
{(user?.role === 'ADMIN' || user?.role === 'GENERAL_MANAGER') && (
|
||||
<select
|
||||
value={assignedUserId}
|
||||
onChange={e => setAssignedUserId(e.target.value)}
|
||||
className="p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white"
|
||||
>
|
||||
<option value="">Self (Assign to Me)</option>
|
||||
{users.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="bg-odoo-primary hover:bg-odoo-primary/90 text-white px-6 py-2 rounded-lg text-sm font-semibold shadow-sm transition-all disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Adding...' : 'Add Note'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[500px] overflow-y-auto p-6 space-y-6">
|
||||
{followups.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-400">
|
||||
<p>No follow-up history yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
followups.map((f, index) => (
|
||||
<div key={f.id} className="relative pl-6 border-l-2 border-gray-200 last:border-0 pb-2">
|
||||
<div className={`absolute -left-[9px] top-0 h-4 w-4 rounded-full border-4 border-white shadow-sm ${f.status === 'DONE' ? 'bg-odoo-secondary' : 'bg-yellow-400'}`}></div>
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-100 shadow-sm hover:shadow-md transition-shadow">
|
||||
<p className="text-gray-800 text-sm whitespace-pre-wrap">{f.notes || (f as any).description}</p>
|
||||
<div className="flex justify-between items-center mt-3 pt-2 border-t border-gray-50">
|
||||
<span className="text-xs font-semibold text-odoo-primary">
|
||||
📅 {new Date(f.date).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleFollowupStatusToggle(f)}
|
||||
className={`text-[10px] font-bold px-2 py-0.5 rounded uppercase cursor-pointer hover:opacity-80 transition-opacity ${f.status === 'DONE' ? 'bg-odoo-secondary/10 text-odoo-secondary' : 'bg-yellow-50 text-yellow-700'}`}
|
||||
title={f.status === 'PENDING' ? "Click to mark as DONE" : "Completed"}
|
||||
>
|
||||
{f.status}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Col: Info & Enquiries */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
|
||||
<h3 className="font-bold text-gray-800 mb-4">Past Enquiries</h3>
|
||||
<div className="space-y-4">
|
||||
{enquiries.length === 0 ? <p className="text-sm text-gray-500 italic">No enquiries on record.</p> :
|
||||
enquiries.map(enq => (
|
||||
<div key={enq.id} className="p-3 bg-gray-50 rounded-lg border border-gray-100 space-y-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="font-semibold text-sm text-gray-800">{enq.products.map(p => p.name).join(', ')}</p>
|
||||
<span className="text-xs text-gray-400">{new Date(enq.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 line-clamp-2 italic">"{enq.conversation}"</p>
|
||||
|
||||
{/* Quote History inside Enquiry Card */}
|
||||
{enq.quotes && enq.quotes.length > 0 && (
|
||||
<div className="pt-3 border-t border-gray-200 mt-2 space-y-2">
|
||||
<div className="text-[10px] uppercase font-black text-gray-400 tracking-wider">Related Quotations</div>
|
||||
{enq.quotes.map(quote => (
|
||||
<div key={quote.id} className="flex justify-between items-center bg-white p-2 rounded-lg border border-gray-100 shadow-sm">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold text-gray-400">{new Date(quote.createdAt).toLocaleDateString()}</span>
|
||||
<span className="text-xs font-black text-gray-800">₹{quote.totalAmount.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{quote.pdfUrl && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(quote.pdfUrl, '_blank');
|
||||
}}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors text-odoo-primary"
|
||||
title="Download PDF"
|
||||
>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
)}
|
||||
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full border
|
||||
${quote.status === 'SENT' ? 'bg-odoo-primary/10 text-odoo-primary border-odoo-primary/20' :
|
||||
quote.status === 'ACCEPTED' ? 'bg-emerald-50 text-emerald-600 border-emerald-200' :
|
||||
'bg-gray-50 text-gray-500 border-gray-200'}`}>
|
||||
{quote.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
|
||||
<h3 className="font-bold text-gray-800 mb-2">Location</h3>
|
||||
{/* Placeholder Map or Address */}
|
||||
<div className="bg-gray-100 h-32 rounded-lg flex items-center justify-center text-gray-400 text-sm mb-3">
|
||||
Map View Unavailable
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
📍
|
||||
{/* Assuming address field exists on Client interface, might need to add it if commonly used */}
|
||||
{(selectedClient as any).address || (selectedClient as any).location || 'No address provided'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ClientModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSave={handleSaveClient}
|
||||
onDelete={handleDeleteClient}
|
||||
client={editingClient}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Save, Trash2, Loader2, User } from 'lucide-react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import api from '../lib/axios';
|
||||
|
||||
interface Client {
|
||||
id?: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address?: string;
|
||||
landmark?: string;
|
||||
status: string;
|
||||
assignedTo?: string;
|
||||
user?: { id: string, name: string };
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
interface ClientModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (client: Client) => Promise<void>;
|
||||
onDelete?: (id: string) => Promise<void>;
|
||||
client: Client | null;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = ['LEAD', 'PROSPECT', 'CUSTOMER', 'CLOSED'];
|
||||
|
||||
export default function ClientModal({ isOpen, onClose, onSave, onDelete, client }: ClientModalProps) {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [formData, setFormData] = useState<Client>({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
landmark: '',
|
||||
status: 'LEAD',
|
||||
assignedTo: '',
|
||||
});
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchAssignees();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const fetchAssignees = async () => {
|
||||
setLoadingUsers(true);
|
||||
try {
|
||||
let endpoint = '/users';
|
||||
if (currentUser?.role === 'MANAGER') {
|
||||
endpoint = '/users/me/subordinates';
|
||||
}
|
||||
|
||||
const response = await api.get(endpoint);
|
||||
setUsers(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users', error);
|
||||
} finally {
|
||||
setLoadingUsers(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (client) {
|
||||
setFormData({
|
||||
name: client.name || '',
|
||||
email: client.email || '',
|
||||
phone: client.phone || '',
|
||||
address: client.address || '',
|
||||
landmark: client.landmark || '',
|
||||
status: client.status || 'LEAD',
|
||||
assignedTo: client.assignedTo || '',
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
landmark: '',
|
||||
status: 'LEAD',
|
||||
assignedTo: currentUser?.id || '',
|
||||
});
|
||||
}
|
||||
}, [client, isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSave({ ...formData, id: client?.id });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Failed to save client');
|
||||
} finally {
|
||||
setSubmitting(true);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!client?.id || !onDelete) return;
|
||||
if (!window.confirm('Are you sure you want to delete this client? This action cannot be undone.')) return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
await onDelete(client.id);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Failed to delete client');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[10001] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="bg-white rounded-[24px] shadow-2xl w-full max-w-lg overflow-hidden border border-gray-100 flex flex-col max-h-[90vh]">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 bg-gray-50 border-b border-gray-100 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-800">{client ? 'Edit Client' : 'Create New Client'}</h3>
|
||||
<p className="text-xs text-gray-500">{client ? 'Update contact information and status' : 'Add a new lead or prospect to your database'}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
|
||||
<X size={20} className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-6 overflow-y-auto custom-scrollbar space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Company / Contact Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none transition-all"
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Phone Number *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.phone}
|
||||
onChange={e => setFormData({ ...formData, phone: e.target.value })}
|
||||
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none transition-all"
|
||||
placeholder="Enter phone"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={e => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none transition-all"
|
||||
placeholder="Enter email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Street Address</label>
|
||||
<textarea
|
||||
value={formData.address}
|
||||
onChange={e => setFormData({ ...formData, address: e.target.value })}
|
||||
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none transition-all"
|
||||
placeholder="Enter address"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Landmark / Area</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.landmark}
|
||||
onChange={e => setFormData({ ...formData, landmark: e.target.value })}
|
||||
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none transition-all"
|
||||
placeholder="e.g. Near Metro Station"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Lifecycle Status</label>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{STATUS_OPTIONS.map(status => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, status })}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all border ${formData.status === status
|
||||
? 'bg-odoo-primary text-white border-odoo-primary'
|
||||
: 'bg-white text-gray-500 border-gray-200 hover:border-odoo-primary hover:text-odoo-primary'
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(currentUser?.role === 'ADMIN' || currentUser?.role === 'MANAGER') && (
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Assigned To</label>
|
||||
<select
|
||||
value={formData.assignedTo}
|
||||
onChange={e => setFormData({ ...formData, assignedTo: e.target.value })}
|
||||
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none transition-all"
|
||||
>
|
||||
<option value="">Select teammate</option>
|
||||
<option value={currentUser.id}>Myself ({currentUser.name})</option>
|
||||
{users.filter(u => u.id !== currentUser.id).map(u => (
|
||||
<option key={u.id} value={u.id}>{u.name} ({u.role})</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[10px] text-gray-400 mt-1">
|
||||
{currentUser?.role === 'MANAGER' ? 'Only subordinates are shown.' : 'All users are shown.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 bg-gray-50 border-t border-gray-100 flex justify-between items-center">
|
||||
{client ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting || submitting}
|
||||
className="flex items-center space-x-2 text-rose-500 hover:text-rose-600 font-bold text-sm px-4 py-2 rounded-xl hover:bg-rose-50 transition-colors"
|
||||
>
|
||||
{deleting ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
) : <div></div>}
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 text-sm font-bold text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || deleting}
|
||||
className={`flex items-center space-x-2 bg-odoo-primary hover:bg-odoo-primary/90 text-white px-8 py-2.5 rounded-xl font-bold shadow-lg shadow-odoo-primary/20 transition-all active:scale-95 disabled:opacity-50`}
|
||||
>
|
||||
{submitting ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
|
||||
<span>{client ? 'Update Client' : 'Create Client'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement
|
||||
} from 'chart.js';
|
||||
import { Bar, Pie } from 'react-chartjs-2';
|
||||
import api from '../lib/axios';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement
|
||||
);
|
||||
|
||||
export default function DashboardCharts() {
|
||||
const [stats, setStats] = useState({
|
||||
enquiries: 0,
|
||||
converted: 0,
|
||||
expenses: 0,
|
||||
sales: 0
|
||||
});
|
||||
|
||||
// Mock data for charts if API not fully ready or for visual demo
|
||||
// In real scenario, fetch aggregation from backend
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
// We might need a dashboard stats endpoint
|
||||
// For now, doing simple counts via existing list endpoints (not efficient for large data)
|
||||
const [enqRes, expRes, clientsRes] = await Promise.all([
|
||||
api.get('/enquiries'),
|
||||
api.get('/expenses'),
|
||||
api.get('/clients')
|
||||
]);
|
||||
|
||||
// Simple aggregation
|
||||
const enquiriesCount = enqRes.data.length;
|
||||
const convertedCount = clientsRes.data.filter((c: any) => c.status === 'CUSTOMER' || c.status === 'CLOSED').length;
|
||||
const totalExpense = expRes.data.reduce((sum: number, exp: any) => sum + (exp.status === 'APPROVED' ? exp.amount : 0), 0);
|
||||
|
||||
setStats({
|
||||
enquiries: enquiriesCount,
|
||||
converted: convertedCount,
|
||||
expenses: totalExpense,
|
||||
sales: convertedCount * 1000 // Mock Average Ticket Size
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch dashboard stats", err);
|
||||
}
|
||||
};
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
|
||||
const barData = {
|
||||
labels: ['Enquiries', 'Accessions', 'Conversions'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Count',
|
||||
data: [stats.enquiries, stats.enquiries * 0.5 /* Mock Accession */, stats.converted],
|
||||
backgroundColor: 'rgba(113, 75, 103, 0.7)',
|
||||
borderRadius: 4,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const pieData = {
|
||||
labels: ['Funds Inflow (Sales)', 'Funds Outflow (Expenses)'],
|
||||
datasets: [
|
||||
{
|
||||
label: '$ Value',
|
||||
data: [stats.sales, stats.expenses],
|
||||
backgroundColor: [
|
||||
'rgba(0, 160, 157, 0.6)',
|
||||
'rgba(240, 105, 140, 0.6)',
|
||||
],
|
||||
borderColor: [
|
||||
'rgba(0, 160, 157, 1)',
|
||||
'rgba(240, 105, 140, 1)',
|
||||
],
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div className="odoo-card p-6">
|
||||
<h3 className="text-lg font-bold mb-4 text-gray-800">Pipeline Overview</h3>
|
||||
<Bar options={{ responsive: true, plugins: { legend: { display: false } } }} data={barData} />
|
||||
</div>
|
||||
<div className="odoo-card p-6">
|
||||
<h3 className="text-lg font-bold mb-4 text-gray-800">Financial Overview</h3>
|
||||
<div className="h-64 flex justify-center">
|
||||
<Pie data={pieData} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,491 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
TrendingUp,
|
||||
Briefcase,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
Search,
|
||||
Filter,
|
||||
ArrowRight,
|
||||
IndianRupee,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler
|
||||
} from 'chart.js';
|
||||
import { Bar, Pie, Line } from 'react-chartjs-2';
|
||||
import api from '../lib/axios';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import FunnelAnalytics from './FunnelAnalytics';
|
||||
import TeamPerformance from './TeamPerformance';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler
|
||||
);
|
||||
|
||||
interface DashboardStats {
|
||||
kpis: {
|
||||
enquiriesToday: number;
|
||||
pipelineValue: number;
|
||||
pipelineCount: number;
|
||||
monthlyRevenue: number;
|
||||
contributionRevenue: number;
|
||||
conversionRate: number;
|
||||
pendingExpenses: number;
|
||||
};
|
||||
performance: {
|
||||
score: number;
|
||||
tag: string;
|
||||
breakdown: {
|
||||
revenue: number;
|
||||
conversion: number;
|
||||
activity: number;
|
||||
discipline: number;
|
||||
quality: number;
|
||||
};
|
||||
} | null;
|
||||
target: {
|
||||
monthly: number;
|
||||
minimum: number;
|
||||
weekly: number;
|
||||
dailyLead: number;
|
||||
achieved: number;
|
||||
requiredLeads?: number;
|
||||
requiredDemos?: number;
|
||||
} | null;
|
||||
recentActivity: {
|
||||
enquiries: any[];
|
||||
opportunities: any[];
|
||||
};
|
||||
quarterly: {
|
||||
status: 'NORMAL' | 'WARNING' | 'ACTION';
|
||||
suggestions: string[];
|
||||
recentScores: { date: string, score: number }[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function DashboardOverview() {
|
||||
const { user } = useAuth();
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
const [statsRes, quarterlyRes] = await Promise.all([
|
||||
api.get('/dashboard/stats'),
|
||||
api.get(`/performance/quarterly/${user?.id}`)
|
||||
]);
|
||||
setStats({ ...statsRes.data, quarterly: quarterlyRes.data });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dashboard stats', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
if (user?.id) fetchDashboardData();
|
||||
}, [user?.id]);
|
||||
|
||||
if (loading || !stats) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-odoo-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { kpis, recentActivity, performance, target, quarterly } = stats;
|
||||
|
||||
const kpiCards = [
|
||||
{
|
||||
label: "Performance Score",
|
||||
value: performance ? `${Math.round(performance.score)}/100` : 'N/A',
|
||||
subValue: performance?.tag.replace('_', ' '),
|
||||
icon: CheckCircle2,
|
||||
color: performance?.score && performance.score > 80 ? 'bg-emerald-500' : performance?.score && performance.score > 50 ? 'bg-amber-500' : 'bg-rose-500',
|
||||
trend: performance?.score && performance.score > 80 ? 'EXCELLENT' : 'KEEP GOING',
|
||||
trendUp: true
|
||||
},
|
||||
{
|
||||
label: "Open Pipeline",
|
||||
value: `₹${(kpis.pipelineValue / 100000).toFixed(1)}L`,
|
||||
subValue: `${kpis.pipelineCount} deals`,
|
||||
icon: Briefcase,
|
||||
color: 'bg-odoo-primary',
|
||||
trend: '+5.4',
|
||||
trendUp: true
|
||||
},
|
||||
{
|
||||
label: "Contribution (MTD)",
|
||||
value: `₹${(kpis.contributionRevenue / 1000).toFixed(1)}k`,
|
||||
subValue: `of ₹${(kpis.monthlyRevenue / 1000).toFixed(1)}k total`,
|
||||
icon: TrendingUp,
|
||||
color: 'bg-indigo-500',
|
||||
trend: '50/50 SPLIT',
|
||||
trendUp: true
|
||||
},
|
||||
{
|
||||
label: "Target Gap",
|
||||
value: target ? `₹${((target.monthly - target.achieved) / 1000).toFixed(1)}k` : 'N/A',
|
||||
subValue: target ? `${Math.round((target.achieved / target.monthly) * 100)}% reached` : 'No Target',
|
||||
icon: IndianRupee,
|
||||
color: 'bg-emerald-500',
|
||||
trend: target && target.achieved >= target.minimum ? 'QUALIFIED' : 'PENDING',
|
||||
trendUp: target && target.achieved >= target.minimum
|
||||
}
|
||||
];
|
||||
|
||||
const pipelineChartData = {
|
||||
labels: ['Lead', 'Qualified', 'Potential', 'Demo', 'Won'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Deals',
|
||||
data: [15, 8, 5, 3, 2], // Placeholder for visual richness
|
||||
backgroundColor: [
|
||||
'rgba(113, 75, 103, 0.7)',
|
||||
'rgba(113, 75, 103, 0.5)',
|
||||
'rgba(0, 160, 157, 0.5)',
|
||||
'rgba(0, 160, 157, 0.7)',
|
||||
'rgba(16, 185, 129, 0.8)',
|
||||
],
|
||||
borderRadius: 8,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-1 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
{/* Header / Welcome */}
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between px-2">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black text-slate-800 tracking-tight">
|
||||
Dashboard
|
||||
</h1>
|
||||
<p className="text-slate-500 font-medium">
|
||||
Welcome back, <span className="text-odoo-primary font-bold">{user?.name}</span>. Here's what's happening today.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 md:mt-0 flex items-center space-x-2">
|
||||
<button className="flex items-center space-x-2 bg-white border border-slate-200 px-4 py-2 rounded-xl text-sm font-bold text-slate-600 hover:bg-slate-50 transition-all shadow-sm">
|
||||
<Filter size={16} />
|
||||
<span>Filter</span>
|
||||
</button>
|
||||
<button className="bg-odoo-primary text-white p-2.5 rounded-xl shadow-lg shadow-odoo-primary/20 hover:scale-105 transition-all">
|
||||
<Search size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{kpiCards.map((card, i) => (
|
||||
<div key={i} className="odoo-card p-6 flex flex-col relative overflow-hidden group hover:shadow-xl transition-all duration-300">
|
||||
<div className={`absolute top-0 right-0 w-24 h-24 ${card.color} opacity-[0.03] rounded-bl-full translate-x-4 -translate-y-4 group-hover:scale-110 transition-transform`} />
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`p-3 rounded-2xl ${card.color} bg-opacity-10 text-white`}>
|
||||
<card.icon size={24} className={card.color.replace('bg-', 'text-')} />
|
||||
</div>
|
||||
<div className={`flex items-center text-[10px] font-black px-2 py-1 rounded-full ${card.trendUp ? 'bg-emerald-50 text-emerald-600' : 'bg-rose-50 text-rose-600'}`}>
|
||||
{card.trendUp ? <ArrowUpRight size={12} className="mr-0.5" /> : <ArrowDownRight size={12} className="mr-0.5" />}
|
||||
{card.trend}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-2xl font-black text-slate-800 tracking-tight">{card.value}</h3>
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">{card.label}</p>
|
||||
{card.subValue && <span className="text-[10px] text-slate-300 font-medium">{card.subValue}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Main Stats Area */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
{/* Performance Warnings (Quarterly Logic) - ADMIN ONLY */}
|
||||
{(user?.role === 'ADMIN' || user?.role === 'GENERAL_MANAGER') && quarterly && quarterly.status !== 'NORMAL' && (
|
||||
<div className={`rounded-[24px] p-6 flex items-center justify-between group shadow-lg animate-pulse ${
|
||||
quarterly.status === 'ACTION' ? 'bg-rose-600 text-white' : 'bg-amber-500 text-white'
|
||||
}`}>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="bg-white/20 p-3 rounded-2xl">
|
||||
<AlertCircle size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-black text-lg uppercase tracking-tight">
|
||||
Performance {quarterly.status}
|
||||
</h4>
|
||||
<p className="text-sm font-bold opacity-80">
|
||||
{quarterly.status === 'ACTION'
|
||||
? "Immediate improvement required to meet organizational standards."
|
||||
: "Performance has been below minimum for 2 months. Attention required."
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/20 px-4 py-2 rounded-xl font-black text-xs uppercase">
|
||||
{quarterly.status}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team Performance Leaderboard - ADMIN ONLY */}
|
||||
{(user?.role === 'ADMIN' || user?.role === 'GENERAL_MANAGER') && (
|
||||
<TeamPerformance />
|
||||
)}
|
||||
|
||||
{/* Efficiency Funnel */}
|
||||
<FunnelAnalytics />
|
||||
|
||||
{/* Improvement Suggestions - ADMIN ONLY */}
|
||||
{(user?.role === 'ADMIN' || user?.role === 'GENERAL_MANAGER') && quarterly && quarterly.suggestions.length > 0 && (
|
||||
<div className="odoo-card p-6 border-l-4 border-indigo-500 bg-indigo-50/30">
|
||||
<h3 className="font-bold text-slate-800 mb-4 flex items-center">
|
||||
<TrendingUp className="mr-2 text-indigo-500" size={18} />
|
||||
Performance Improvement Plan
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{quarterly.suggestions.map((suggestion, idx) => (
|
||||
<div key={idx} className="flex items-start space-x-3">
|
||||
<div className="mt-1 w-1.5 h-1.5 rounded-full bg-indigo-400 shrink-0" />
|
||||
<p className="text-sm text-slate-600 font-medium leading-relaxed">{suggestion}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending Actions (Conditional) */}
|
||||
{(user?.role === 'ADMIN' || user?.role === 'MANAGER') && kpis.pendingExpenses > 0 && (
|
||||
<div className="bg-rose-50 border border-rose-100 rounded-[24px] p-6 flex items-center justify-between group cursor-pointer hover:bg-rose-100/50 transition-all">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="bg-rose-500 text-white p-3 rounded-2xl shadow-lg shadow-rose-200">
|
||||
<IndianRupee size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-extrabold text-rose-900">Attention Required</h4>
|
||||
<p className="text-sm text-rose-700/70 font-medium">You have {kpis.pendingExpenses} pending expense claims to approve.</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="text-rose-400 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Enquiries List */}
|
||||
<div className="odoo-card overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-50 flex items-center justify-between">
|
||||
<h3 className="font-bold text-slate-800">Latest Enquiries</h3>
|
||||
<button className="text-[11px] font-black text-odoo-primary uppercase tracking-widest hover:underline">View All</button>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-50">
|
||||
{recentActivity.enquiries.map((enq, index) => (
|
||||
<div key={index} className="px-6 py-4 flex items-center justify-between hover:bg-slate-50/50 transition-all group">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-indigo-50 flex items-center justify-center text-indigo-500 font-black group-hover:bg-indigo-500 group-hover:text-white transition-all">
|
||||
{enq.client?.name?.charAt(0) || 'E'}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-slate-800">{enq.client?.name || 'Quick Enquiry'}</h4>
|
||||
<p className="text-xs text-slate-400">{enq.user?.name} • {formatDistanceToNow(new Date(enq.createdAt), { addSuffix: true })}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="hidden sm:block text-right">
|
||||
<p className="text-xs font-black text-slate-700">New Lead</p>
|
||||
<p className="text-[10px] text-slate-300 font-bold uppercase tracking-tighter">Inbound</p>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-slate-50 text-slate-400 opacity-0 group-hover:opacity-100 transition-all">
|
||||
<ArrowRight size={14} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Activity */}
|
||||
<div className="space-y-8">
|
||||
{/* Target Achievement Widget */}
|
||||
<div className="odoo-card p-6">
|
||||
<h3 className="font-bold text-slate-800 mb-6">Target Achievement</h3>
|
||||
{target ? (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex justify-between text-xs font-bold mb-2">
|
||||
<span className="text-slate-400">MONTHLY TARGET</span>
|
||||
<span className="text-odoo-primary">₹{(target.monthly / 1000).toFixed(0)}k</span>
|
||||
</div>
|
||||
<div className="h-3 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-odoo-primary transition-all duration-1000"
|
||||
style={{ width: `${Math.min(100, (target.achieved / target.monthly) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-2 font-medium">Achieved: ₹{(target.achieved / 1000).toFixed(1)}k</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-xs font-bold mb-2">
|
||||
<span className="text-slate-400">MINIMUM TARGET</span>
|
||||
<span className="text-amber-600">₹{(target.minimum / 1000).toFixed(0)}k</span>
|
||||
</div>
|
||||
<div className="h-3 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-amber-500 transition-all duration-1000"
|
||||
style={{ width: `${Math.min(100, (target.achieved / target.minimum) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{target.achieved >= target.minimum ? (
|
||||
<p className="text-[10px] text-emerald-600 mt-2 font-bold flex items-center">
|
||||
<CheckCircle2 size={10} className="mr-1" /> MINIMUM REACHED
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[10px] text-amber-600 mt-2 font-bold">₹{((target.minimum - target.achieved)/1000).toFixed(1)}k to go</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Benchmarks */}
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-slate-50">
|
||||
<div className="bg-slate-50 p-3 rounded-xl">
|
||||
<p className="text-[9px] font-black text-slate-400 uppercase tracking-tighter mb-1">Daily Leads</p>
|
||||
<p className="text-sm font-black text-slate-700">{target.dailyLead}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 p-3 rounded-xl">
|
||||
<p className="text-[9px] font-black text-slate-400 uppercase tracking-tighter mb-1">Req. Demos</p>
|
||||
<p className="text-sm font-black text-slate-700">{target.requiredDemos || Math.ceil(target.monthly / 40000) * 3}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<AlertCircle className="text-slate-200 mb-2" size={32} />
|
||||
<p className="text-xs text-slate-400 font-bold">NO TARGET SET</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Performance Breakdown */}
|
||||
<div className="odoo-card p-6">
|
||||
<h3 className="font-bold text-slate-800 mb-6">Performance Mix</h3>
|
||||
{performance ? (
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ label: 'Revenue', score: performance.breakdown.revenue, max: 40, color: 'bg-emerald-500' },
|
||||
{ label: 'Conversion', score: performance.breakdown.conversion, max: 20, color: 'bg-odoo-secondary' },
|
||||
{ label: 'Activity', score: performance.breakdown.activity, max: 15, color: 'bg-indigo-500' },
|
||||
{ label: 'Discipline', score: performance.breakdown.discipline, max: 15, color: 'bg-amber-500' },
|
||||
{ label: 'Data Quality', score: performance.breakdown.quality, max: 10, color: 'bg-rose-500' },
|
||||
].map((item, idx) => (
|
||||
<div key={idx}>
|
||||
<div className="flex justify-between text-[10px] font-black uppercase tracking-widest mb-1.5">
|
||||
<span className="text-slate-400">{item.label}</span>
|
||||
<span className="text-slate-800">{Math.round(item.score)}/{item.max}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-slate-50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${item.color} transition-all duration-700`}
|
||||
style={{ width: `${(item.score / item.max) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400 text-center py-4">No data available</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Marketing Impact Breakdown */}
|
||||
<div className="odoo-card p-6">
|
||||
<h3 className="font-bold text-slate-800 mb-6 flex items-center">
|
||||
<TrendingUp className="mr-2 text-indigo-500" size={18} />
|
||||
Marketing Impact
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ label: 'WhatsApp', count: 12, color: 'text-emerald-600', bg: 'bg-emerald-50' },
|
||||
{ label: 'Posters', count: 45, color: 'text-indigo-600', bg: 'bg-indigo-50' },
|
||||
{ label: 'Exhibitions', count: 2, color: 'text-amber-600', bg: 'bg-amber-50' },
|
||||
].map((act, i) => (
|
||||
<div key={i} className={`flex items-center justify-between p-3 rounded-xl ${act.bg}`}>
|
||||
<span className={`text-[10px] font-black uppercase tracking-widest ${act.color}`}>{act.label}</span>
|
||||
<span className="text-lg font-black text-slate-800">{act.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-4 text-center font-bold italic underline cursor-pointer hover:text-odoo-primary transition-colors">View Detailed Reports</p>
|
||||
</div>
|
||||
|
||||
{/* Recent Opportunities Updates */}
|
||||
<div className="odoo-card overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-50">
|
||||
<h3 className="font-bold text-slate-800">Pipeline Pulse</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{recentActivity.opportunities.map((op, index) => (
|
||||
<div key={index} className="flex space-x-4 relative">
|
||||
{index !== recentActivity.opportunities.length - 1 && (
|
||||
<div className="absolute left-[7px] top-8 bottom-[-24px] w-[2px] bg-slate-100" />
|
||||
)}
|
||||
<div className={`mt-1.5 w-4 h-4 rounded-full border-4 border-white shadow-sm shrink-0 z-10 ${
|
||||
op.stage === 'WON' ? 'bg-emerald-500' : 'bg-odoo-primary'
|
||||
}`} />
|
||||
<div>
|
||||
<p className="text-xs font-black text-slate-800 leading-tight">
|
||||
{op.title}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400 font-medium mt-0.5">
|
||||
Updated to <span className="text-odoo-primary font-bold">{op.stage}</span> • {formatDistanceToNow(new Date(op.updatedAt))} ago
|
||||
</p>
|
||||
<div className="mt-2 text-[13px] font-bold text-slate-700">
|
||||
₹{op.value.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Guard Extra Widget */}
|
||||
<div className="bg-odoo-primary rounded-[28px] p-6 text-white relative overflow-hidden">
|
||||
<div className="absolute top-[-20px] right-[-20px] w-32 h-32 bg-white/10 rounded-full blur-2xl" />
|
||||
<h4 className="text-lg font-black mb-1">Growth Index</h4>
|
||||
<p className="text-xs text-white/60 mb-6 font-medium">Your productivity is up by 14% this week. Keep tracking your leads!</p>
|
||||
<div className="flex items-center space-x-3 text-xs font-black bg-white/10 w-fit px-3 py-1.5 rounded-full">
|
||||
<TrendingUp size={14} />
|
||||
<span>Level Up</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../lib/axios';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface Expense {
|
||||
id: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
imageUrl?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
user: {
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function ExpenseApproval() {
|
||||
const [expenses, setExpenses] = useState<Expense[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchExpenses();
|
||||
}, []);
|
||||
|
||||
const fetchExpenses = async () => {
|
||||
try {
|
||||
const response = await api.get('/expenses');
|
||||
setExpenses(response.data);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateStatus = async (id: string, status: string) => {
|
||||
try {
|
||||
await api.patch(`/expenses/${id}`, { status });
|
||||
fetchExpenses();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Failed to update status');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="odoo-card p-6 mb-8">
|
||||
<h3 className="text-xl font-bold mb-4 text-gray-800">Expense Approvals</h3>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{loading ? <tr><td colSpan={6} className="text-center py-4">Loading...</td></tr> :
|
||||
expenses.length === 0 ? <tr><td colSpan={6} className="text-center py-4">No expenses found</td></tr> :
|
||||
expenses.map(expense => (
|
||||
<tr key={expense.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{format(new Date(expense.createdAt), 'MMM dd, yyyy')}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{expense.user.name}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{expense.description}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">₹{expense.amount}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${expense.status === 'APPROVED' ? 'bg-odoo-secondary/10 text-odoo-secondary' :
|
||||
expense.status === 'REJECTED' ? 'bg-rose-100 text-rose-800' :
|
||||
'bg-amber-100 text-amber-800'}`}>
|
||||
{expense.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
|
||||
{expense.status === 'PENDING' && (
|
||||
<>
|
||||
<button onClick={() => handleUpdateStatus(expense.id, 'APPROVED')} className="text-odoo-secondary hover:text-odoo-secondary/80 font-semibold px-2">Approve</button>
|
||||
<button onClick={() => handleUpdateStatus(expense.id, 'REJECTED')} className="text-rose-600 hover:text-rose-900 font-semibold px-2">Reject</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,426 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../lib/axios';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { Calendar, User, Building2, Filter, CheckCircle2, Clock, AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface Followup {
|
||||
id: string;
|
||||
notes: string;
|
||||
status: string;
|
||||
date: string;
|
||||
createdAt: string;
|
||||
client?: { id: string; name: string };
|
||||
user?: { id: string; name: string };
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
userId: string;
|
||||
clientId: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export default function FollowupsManager() {
|
||||
const { user } = useAuth();
|
||||
const [followups, setFollowups] = useState<Followup[]>([]);
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [clients, setClients] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [reassigning, setReassigning] = useState<string | null>(null); // followup id being reassigned
|
||||
const [reassignUserId, setReassignUserId] = useState('');
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
userId: '', clientId: '', dateFrom: '', dateTo: '', status: ''
|
||||
});
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [newFollowup, setNewFollowup] = useState({
|
||||
userId: '',
|
||||
clientId: '',
|
||||
notes: '',
|
||||
date: '',
|
||||
time: '10:00'
|
||||
});
|
||||
|
||||
const isAdminOrGM = ['ADMIN', 'GENERAL_MANAGER'].includes(user?.role || '');
|
||||
|
||||
useEffect(() => {
|
||||
fetchFollowups();
|
||||
if (isAdminOrGM) {
|
||||
api.get('/users').then(r => setUsers(r.data)).catch(() => {});
|
||||
api.get('/clients').then(r => setClients(r.data)).catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchFollowups = async (f: FilterState = filters) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (f.userId) params.append('userId', f.userId);
|
||||
if (f.clientId) params.append('clientId', f.clientId);
|
||||
if (f.dateFrom) params.append('dateFrom', f.dateFrom);
|
||||
if (f.dateTo) params.append('dateTo', f.dateTo);
|
||||
if (f.status) params.append('status', f.status);
|
||||
const res = await api.get(`/followups?${params.toString()}`);
|
||||
setFollowups(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterChange = (key: keyof FilterState, value: string) => {
|
||||
const updated = { ...filters, [key]: value };
|
||||
setFilters(updated);
|
||||
};
|
||||
|
||||
const handleApply = () => fetchFollowups(filters);
|
||||
|
||||
const handleReset = () => {
|
||||
const reset: FilterState = { userId: '', clientId: '', dateFrom: '', dateTo: '', status: '' };
|
||||
setFilters(reset);
|
||||
fetchFollowups(reset);
|
||||
};
|
||||
|
||||
const handleMarkDone = async (id: string) => {
|
||||
if (!window.confirm('Mark this follow-up as DONE?')) return;
|
||||
try {
|
||||
await api.patch(`/followups/${id}`, { status: 'DONE' });
|
||||
setFollowups(followups.map(f => f.id === id ? { ...f, status: 'DONE' } : f));
|
||||
} catch (e) {
|
||||
alert('Failed to update status.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReassign = async (followupId: string) => {
|
||||
if (!reassignUserId) { alert('Please select a user to reassign to.'); return; }
|
||||
try {
|
||||
await api.patch(`/followups/${followupId}`, { userId: reassignUserId });
|
||||
setReassigning(null);
|
||||
setReassignUserId('');
|
||||
fetchFollowups(filters);
|
||||
} catch (e) {
|
||||
alert('Failed to reassign task.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newFollowup.clientId || !newFollowup.userId || !newFollowup.date) {
|
||||
alert('Please fill in all required fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const dateStr = `${newFollowup.date}T${newFollowup.time}:00`;
|
||||
await api.post('/followups', {
|
||||
clientId: newFollowup.clientId,
|
||||
userId: newFollowup.userId,
|
||||
notes: newFollowup.notes,
|
||||
date: new Date(dateStr).toISOString(),
|
||||
status: 'PENDING'
|
||||
});
|
||||
setIsCreateModalOpen(false);
|
||||
setNewFollowup({ userId: '', clientId: '', notes: '', date: '', time: '10:00' });
|
||||
fetchFollowups(filters);
|
||||
alert('Follow-up scheduled successfully!');
|
||||
} catch (e) {
|
||||
alert('Failed to create follow-up.');
|
||||
}
|
||||
};
|
||||
|
||||
const groupByDate = (items: Followup[]) => {
|
||||
const map: Record<string, Followup[]> = {};
|
||||
items.forEach(f => {
|
||||
const key = new Date(f.date).toLocaleDateString('en-IN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
if (!map[key]) map[key] = [];
|
||||
map[key].push(f);
|
||||
});
|
||||
return Object.entries(map);
|
||||
};
|
||||
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||
|
||||
const stats = {
|
||||
total: followups.length,
|
||||
pending: followups.filter(f => f.status === 'PENDING').length,
|
||||
overdue: followups.filter(f => f.status === 'PENDING' && new Date(f.date) < today).length,
|
||||
done: followups.filter(f => f.status === 'DONE').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow-xl rounded-2xl overflow-hidden border border-gray-100">
|
||||
{/* Header */}
|
||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-800">Follow-up Manager</h3>
|
||||
<p className="text-sm text-gray-500">Track and manage all scheduled follow-ups</p>
|
||||
</div>
|
||||
{isAdminOrGM && (
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="bg-odoo-primary text-white px-4 py-2 rounded-xl text-sm font-black hover:shadow-lg transition-all flex items-center gap-2"
|
||||
>
|
||||
<span>📅</span> Schedule New
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-4 divide-x divide-gray-100 border-b border-gray-100">
|
||||
{[
|
||||
{ label: 'Total', value: stats.total, icon: <Filter size={14}/>, color: 'text-gray-600', bg: 'bg-gray-50' },
|
||||
{ label: 'Pending', value: stats.pending, icon: <Clock size={14}/>, color: 'text-amber-600', bg: 'bg-amber-50' },
|
||||
{ label: 'Overdue', value: stats.overdue, icon: <AlertTriangle size={14}/>, color: 'text-red-600', bg: 'bg-red-50' },
|
||||
{ label: 'Done', value: stats.done, icon: <CheckCircle2 size={14}/>, color: 'text-emerald-600', bg: 'bg-emerald-50' },
|
||||
].map(s => (
|
||||
<div key={s.label} className={`${s.bg} px-6 py-4 flex items-center gap-3`}>
|
||||
<div className={`${s.color}`}>{s.icon}</div>
|
||||
<div>
|
||||
<div className={`text-2xl font-black ${s.color}`}>{s.value}</div>
|
||||
<div className="text-xs text-gray-500 font-semibold">{s.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="px-6 py-4 bg-gray-50/50 border-b border-gray-100">
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
{isAdminOrGM && (
|
||||
<>
|
||||
<div className="flex-1 min-w-[160px]">
|
||||
<label className="block text-xs font-semibold text-gray-500 mb-1"><User size={10} className="inline mr-1"/>User</label>
|
||||
<select
|
||||
value={filters.userId}
|
||||
onChange={e => handleFilterChange('userId', e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white"
|
||||
>
|
||||
<option value="">All Users</option>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[160px]">
|
||||
<label className="block text-xs font-semibold text-gray-500 mb-1"><Building2 size={10} className="inline mr-1"/>Client</label>
|
||||
<select
|
||||
value={filters.clientId}
|
||||
onChange={e => handleFilterChange('clientId', e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white"
|
||||
>
|
||||
<option value="">All Clients</option>
|
||||
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="min-w-[140px]">
|
||||
<label className="block text-xs font-semibold text-gray-500 mb-1"><Calendar size={10} className="inline mr-1"/>From</label>
|
||||
<input type="date" value={filters.dateFrom} onChange={e => handleFilterChange('dateFrom', e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white" />
|
||||
</div>
|
||||
<div className="min-w-[140px]">
|
||||
<label className="block text-xs font-semibold text-gray-500 mb-1"><Calendar size={10} className="inline mr-1"/>To</label>
|
||||
<input type="date" value={filters.dateTo} onChange={e => handleFilterChange('dateTo', e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white" />
|
||||
</div>
|
||||
<div className="min-w-[120px]">
|
||||
<label className="block text-xs font-semibold text-gray-500 mb-1">Status</label>
|
||||
<select value={filters.status} onChange={e => handleFilterChange('status', e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white">
|
||||
<option value="">All</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="DONE">Done</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleApply} className="bg-odoo-primary text-white px-4 py-2 rounded-lg text-sm font-bold hover:bg-odoo-primary/90 transition-all">
|
||||
Apply
|
||||
</button>
|
||||
<button onClick={handleReset} className="border border-gray-200 text-gray-600 px-3 py-2 rounded-lg text-sm hover:bg-gray-100 transition-all">
|
||||
<RefreshCw size={14}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline View */}
|
||||
<div className="overflow-y-auto max-h-[600px] p-6 space-y-8">
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-400">Loading follow-ups...</div>
|
||||
) : followups.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-4xl mb-3">📭</p>
|
||||
<p className="text-gray-500 font-semibold">No follow-ups match these filters.</p>
|
||||
</div>
|
||||
) : (
|
||||
groupByDate(followups).map(([dateLabel, items]) => {
|
||||
const dateObj = new Date(items[0].date);
|
||||
dateObj.setHours(0,0,0,0);
|
||||
const isToday = dateObj.getTime() === today.getTime();
|
||||
const isPast = dateObj < today;
|
||||
return (
|
||||
<div key={dateLabel}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={`text-xs font-black px-3 py-1 rounded-full uppercase tracking-wider ${isToday ? 'bg-odoo-primary text-white' : isPast ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'}`}>
|
||||
{isToday ? '📅 Today' : isPast ? `⚠️ ${dateLabel}` : dateLabel}
|
||||
</div>
|
||||
<div className="flex-1 h-px bg-gray-100"/>
|
||||
<span className="text-xs text-gray-400 font-semibold">{items.length} task{items.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{items.map(f => (
|
||||
<div key={f.id} className={`flex items-start gap-4 p-4 rounded-xl border transition-all ${f.status === 'DONE' ? 'bg-gray-50 border-gray-100 opacity-70' : isPast && f.status === 'PENDING' ? 'bg-red-50 border-red-200' : 'bg-white border-gray-200 hover:shadow-md'}`}>
|
||||
<div className={`mt-1 w-3 h-3 rounded-full flex-shrink-0 ${f.status === 'DONE' ? 'bg-emerald-500' : isPast ? 'bg-red-500' : 'bg-amber-400'}`}/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{f.client && <span className="text-sm font-bold text-odoo-primary">{f.client.name}</span>}
|
||||
{f.user && isAdminOrGM && <span className="text-xs text-gray-500 font-semibold">• Assigned to {f.user.name}</span>}
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 mt-1 leading-relaxed">{f.notes}</p>
|
||||
<p className="text-xs text-gray-400 mt-2 font-semibold">
|
||||
🕐 {new Date(f.date).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex flex-col items-end gap-2">
|
||||
{f.status === 'DONE' ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-bold px-3 py-1 bg-emerald-100 text-emerald-700 rounded-full">
|
||||
<CheckCircle2 size={11}/> Done
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleMarkDone(f.id)}
|
||||
className="text-xs font-bold px-3 py-1.5 bg-odoo-primary text-white rounded-lg hover:bg-odoo-primary/90 transition-all active:scale-95"
|
||||
>
|
||||
Mark Done
|
||||
</button>
|
||||
)}
|
||||
{/* Reassign — Admin/GM only */}
|
||||
{isAdminOrGM && f.status !== 'DONE' && (
|
||||
reassigning === f.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
value={reassignUserId}
|
||||
onChange={e => setReassignUserId(e.target.value)}
|
||||
className="text-xs p-1.5 border border-gray-300 rounded-lg outline-none focus:ring-2 focus:ring-odoo-primary bg-white"
|
||||
autoFocus
|
||||
>
|
||||
<option value="">Pick user...</option>
|
||||
{users.filter(u => u.id !== f.user?.id).map(u => (
|
||||
<option key={u.id} value={u.id}>{u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleReassign(f.id)}
|
||||
className="text-xs font-bold px-2 py-1.5 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-all"
|
||||
>Go</button>
|
||||
<button
|
||||
onClick={() => { setReassigning(null); setReassignUserId(''); }}
|
||||
className="text-xs px-2 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-100 transition-all"
|
||||
>✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { setReassigning(f.id); setReassignUserId(''); }}
|
||||
className="text-xs font-semibold px-3 py-1 border border-amber-300 text-amber-700 bg-amber-50 rounded-lg hover:bg-amber-100 transition-all"
|
||||
>
|
||||
↩ Reassign
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Follow-up Modal */}
|
||||
{isCreateModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[999] flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||
<div className="bg-odoo-primary px-6 py-4 text-white flex justify-between items-center">
|
||||
<h3 className="text-lg font-bold">Schedule New Follow-up</h3>
|
||||
<button onClick={() => setIsCreateModalOpen(false)} className="hover:bg-white/20 p-1 rounded-lg">✕</button>
|
||||
</div>
|
||||
<form onSubmit={handleCreateSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase tracking-wider">Client *</label>
|
||||
<select
|
||||
required
|
||||
value={newFollowup.clientId}
|
||||
onChange={e => setNewFollowup({ ...newFollowup, clientId: e.target.value })}
|
||||
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary"
|
||||
>
|
||||
<option value="">Select Client...</option>
|
||||
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase tracking-wider">Assign To *</label>
|
||||
<select
|
||||
required
|
||||
value={newFollowup.userId}
|
||||
onChange={e => setNewFollowup({ ...newFollowup, userId: e.target.value })}
|
||||
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary"
|
||||
>
|
||||
<option value="">Assign User...</option>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.name} ({u.role})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase tracking-wider">Date *</label>
|
||||
<input
|
||||
type="date" required
|
||||
value={newFollowup.date}
|
||||
onChange={e => setNewFollowup({ ...newFollowup, date: e.target.value })}
|
||||
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase tracking-wider">Time *</label>
|
||||
<input
|
||||
type="time" required
|
||||
value={newFollowup.time}
|
||||
onChange={e => setNewFollowup({ ...newFollowup, time: e.target.value })}
|
||||
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase tracking-wider">Notes / Task *</label>
|
||||
<textarea
|
||||
required
|
||||
value={newFollowup.notes}
|
||||
onChange={e => setNewFollowup({ ...newFollowup, notes: e.target.value })}
|
||||
placeholder="What needs to be done?"
|
||||
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary h-24 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateModalOpen(false)}
|
||||
className="flex-1 px-4 py-3 border border-gray-200 rounded-xl font-bold text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-[2] bg-odoo-primary text-white px-4 py-3 rounded-xl font-black shadow-lg hover:shadow-odoo-primary/20 hover:scale-[1.02] transition-all"
|
||||
>
|
||||
Schedule Task
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../lib/axios';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { Settings2, Users, TrendingUp, PhoneCall, Target, CheckCircle2, AlertTriangle, Save, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface FunnelRatios {
|
||||
callsToQuality: number;
|
||||
qualityToPotential: number;
|
||||
potentialToDemo: number;
|
||||
demoToWon: number;
|
||||
}
|
||||
|
||||
interface TeamMemberFunnel {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
funnel: {
|
||||
actual: { calls: number; quality: number; potential: number; demo: number; won: number };
|
||||
ideal: { calls: number; quality: number; potential: number; demo: number; won: number };
|
||||
deviation: { quality: number; potential: number; demo: number; won: number };
|
||||
};
|
||||
}
|
||||
|
||||
const STAGE_META = [
|
||||
{ key: 'calls', label: 'Calls', color: '#64748b', bg: '#f1f5f9' },
|
||||
{ key: 'quality', label: 'Quality Leads', color: '#6366f1', bg: '#eef2ff' },
|
||||
{ key: 'potential', label: 'Potentials', color: '#f59e0b', bg: '#fffbeb' },
|
||||
{ key: 'demo', label: 'Demos', color: '#3b82f6', bg: '#eff6ff' },
|
||||
{ key: 'won', label: 'Won', color: '#10b981', bg: '#f0fdf4' },
|
||||
];
|
||||
|
||||
function MiniBar({ actual, ideal, color }: { actual: number; ideal: number; color: string }) {
|
||||
const max = Math.max(actual, ideal, 1);
|
||||
const pct = Math.min(100, (actual / max) * 100);
|
||||
const idealPct = Math.min(100, (ideal / max) * 100);
|
||||
const good = actual >= ideal;
|
||||
return (
|
||||
<div className="h-3 bg-gray-100 rounded-full overflow-visible relative mt-1">
|
||||
<div className="h-full rounded-full transition-all duration-700" style={{ width: `${pct}%`, backgroundColor: color }} />
|
||||
{ideal > 0 && (
|
||||
<div className="absolute top-0 bottom-0 w-0.5 bg-gray-400 z-10" style={{ left: `${idealPct}%` }} title={`Ideal: ${ideal}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FunnelAnalysisPage() {
|
||||
const { user } = useAuth();
|
||||
const [ratios, setRatios] = useState<FunnelRatios>({ callsToQuality: 5, qualityToPotential: 3.33, potentialToDemo: 1.5, demoToWon: 2 });
|
||||
const [editRatios, setEditRatios] = useState<FunnelRatios>({ callsToQuality: 5, qualityToPotential: 3.33, potentialToDemo: 1.5, demoToWon: 2 });
|
||||
const [team, setTeam] = useState<TeamMemberFunnel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'config' | 'team'>('team');
|
||||
const [selectedUser, setSelectedUser] = useState<TeamMemberFunnel | null>(null);
|
||||
|
||||
const isAdminOrGM = ['ADMIN', 'GENERAL_MANAGER'].includes(user?.role || '');
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const [cfgRes, teamRes] = await Promise.all([
|
||||
api.get('/performance/funnel-config'),
|
||||
isAdminOrGM ? api.get('/performance/team-funnel') : Promise.resolve({ data: [] }),
|
||||
]);
|
||||
setRatios(cfgRes.data);
|
||||
setEditRatios(cfgRes.data);
|
||||
setTeam(teamRes.data);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await api.patch('/performance/funnel-config', editRatios);
|
||||
setRatios(res.data);
|
||||
alert('Funnel ratios saved successfully!');
|
||||
} catch { alert('Failed to save.'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
// Preview how ratios translate to numbers starting from 50 calls
|
||||
const PREVIEW_CALLS = 50;
|
||||
const previewQuality = Math.round(PREVIEW_CALLS / editRatios.callsToQuality);
|
||||
const previewPotential = Math.round(previewQuality / editRatios.qualityToPotential);
|
||||
const previewDemo = Math.round(previewPotential / editRatios.potentialToDemo);
|
||||
const previewWon = Math.round(previewDemo / editRatios.demoToWon);
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center h-64 text-gray-400">Loading funnel analysis...</div>;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-gray-800">Funnel Analysis</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">Configure conversion benchmarks & analyse team efficiency</p>
|
||||
</div>
|
||||
{isAdminOrGM && (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setActiveTab('team')} className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${activeTab === 'team' ? 'bg-odoo-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
|
||||
<Users size={14} className="inline mr-1" />Team View
|
||||
</button>
|
||||
<button onClick={() => setActiveTab('config')} className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${activeTab === 'config' ? 'bg-odoo-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
|
||||
<Settings2 size={14} className="inline mr-1" />Configure Ratios
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config Tab */}
|
||||
{activeTab === 'config' && isAdminOrGM && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Ratio Editors */}
|
||||
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-6">
|
||||
<h3 className="font-bold text-gray-800 mb-1">Ideal Conversion Ratios</h3>
|
||||
<p className="text-xs text-gray-500 mb-6">Set how many of each stage are needed to produce the next</p>
|
||||
<div className="space-y-5">
|
||||
{[
|
||||
{ key: 'callsToQuality', label: 'Calls → Quality Leads', desc: 'e.g. 5 = 1 quality per 5 calls', from: 'Calls', to: 'Quality Leads' },
|
||||
{ key: 'qualityToPotential', label: 'Quality → Potentials', desc: 'e.g. 3.33 = 1 potential per 3.33 quality', from: 'Quality', to: 'Potential' },
|
||||
{ key: 'potentialToDemo', label: 'Potentials → Demos', desc: 'e.g. 1.5 = 1 demo per 1.5 potentials', from: 'Potential', to: 'Demo' },
|
||||
{ key: 'demoToWon', label: 'Demos → Closures', desc: 'e.g. 2 = 1 closure per 2 demos', from: 'Demo', to: 'Won' },
|
||||
].map(({ key, label, desc, from, to }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-xs font-bold text-gray-600 uppercase tracking-wide mb-2">{label}</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500 w-20 text-right">{from}</span>
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.1"
|
||||
value={(editRatios as any)[key]}
|
||||
onChange={e => setEditRatios({ ...editRatios, [key]: parseFloat(e.target.value) || 1 })}
|
||||
className="w-full p-2.5 border border-gray-300 rounded-lg text-center font-black text-odoo-primary focus:ring-2 focus:ring-odoo-primary outline-none"
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-gray-400 font-bold">÷</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 w-20">{to}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 mt-1 text-center">{desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="mt-6 w-full bg-odoo-primary text-white py-3 rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-odoo-primary/90 transition-all disabled:opacity-50"
|
||||
>
|
||||
<Save size={16} />{saving ? 'Saving...' : 'Save Ratios'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Live Preview */}
|
||||
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-6">
|
||||
<h3 className="font-bold text-gray-800 mb-1">Live Preview</h3>
|
||||
<p className="text-xs text-gray-500 mb-6">Based on {PREVIEW_CALLS} calls, the ideal funnel would be:</p>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ label: 'Calls', value: PREVIEW_CALLS, color: '#64748b', bg: '#f1f5f9' },
|
||||
{ label: 'Quality Leads', value: previewQuality, color: '#6366f1', bg: '#eef2ff' },
|
||||
{ label: 'Potentials', value: previewPotential, color: '#f59e0b', bg: '#fffbeb' },
|
||||
{ label: 'Demos', value: previewDemo, color: '#3b82f6', bg: '#eff6ff' },
|
||||
{ label: 'Won', value: previewWon, color: '#10b981', bg: '#f0fdf4' },
|
||||
].map((s, i, arr) => (
|
||||
<div key={s.label} className="flex items-center gap-4">
|
||||
<div className="w-28 text-right text-xs font-bold text-gray-500">{s.label}</div>
|
||||
<div className="flex-1 h-8 rounded-xl flex items-center px-3" style={{ backgroundColor: s.bg }}>
|
||||
<div className="h-4 rounded-lg transition-all duration-700" style={{ width: `${Math.max(5, (s.value / PREVIEW_CALLS) * 100)}%`, backgroundColor: s.color }} />
|
||||
</div>
|
||||
<div className="w-10 text-center font-black text-gray-800">{s.value}</div>
|
||||
{i > 0 && (
|
||||
<div className="w-20 text-[10px] text-gray-400 font-semibold">
|
||||
{Math.round((s.value / arr[i-1].value) * 100)}% conv.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team Funnel View */}
|
||||
{activeTab === 'team' && (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
{/* Team List */}
|
||||
<div className="xl:col-span-1 bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-100 bg-gray-50">
|
||||
<h3 className="font-bold text-gray-800">Team Members</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-50">
|
||||
{team.length === 0 && (
|
||||
<div className="p-8 text-center text-gray-400 text-sm">No team data available</div>
|
||||
)}
|
||||
{team.map(m => {
|
||||
const won = m.funnel?.actual?.won || 0;
|
||||
const calls = m.funnel?.actual?.calls || 0;
|
||||
const efficiency = calls > 0 ? Math.round((won / calls) * 1000) / 10 : 0;
|
||||
const isSelected = selectedUser?.id === m.id;
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => setSelectedUser(m)}
|
||||
className={`w-full text-left px-5 py-4 transition-all hover:bg-gray-50 ${isSelected ? 'bg-odoo-primary/5 border-l-4 border-odoo-primary' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-bold text-gray-800">{m.name}</p>
|
||||
<p className="text-[10px] text-gray-400 uppercase font-semibold mt-0.5">{m.role?.replace('_', ' ')}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-black text-odoo-primary">{won} Won</p>
|
||||
<p className="text-[10px] text-gray-400">{efficiency}% eff.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-1">
|
||||
{['calls','quality','potential','demo','won'].map((k, i) => (
|
||||
<div key={k} className="flex-1 h-1.5 rounded-full" style={{ backgroundColor: STAGE_META[i].color, opacity: Math.max(0.2, ((m.funnel?.actual as any)?.[k] || 0) / Math.max((m.funnel?.actual?.calls || 1), 1)) }} />
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Funnel */}
|
||||
<div className="xl:col-span-2 bg-white rounded-2xl border border-gray-200 shadow-sm p-6">
|
||||
{!selectedUser ? (
|
||||
<div className="h-full flex items-center justify-center text-gray-400 flex-col gap-3">
|
||||
<TrendingUp size={40} className="opacity-30" />
|
||||
<p className="text-sm font-semibold">Select a team member to view their funnel</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-black text-gray-800">{selectedUser.name}</h3>
|
||||
<p className="text-xs text-gray-400 uppercase font-semibold">{selectedUser.role?.replace('_', ' ')} • This Month</p>
|
||||
</div>
|
||||
<button onClick={() => setSelectedUser(null)} className="text-gray-400 hover:text-gray-600 text-xl font-bold">✕</button>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
{STAGE_META.map((stage, i) => {
|
||||
const actual = (selectedUser.funnel?.actual as any)?.[stage.key] || 0;
|
||||
const ideal = (selectedUser.funnel?.ideal as any)?.[stage.key] || 0;
|
||||
const deviation = i === 0 ? 0 : actual - ideal;
|
||||
const isGood = deviation >= 0;
|
||||
const max = Math.max(actual, ideal, 1);
|
||||
return (
|
||||
<div key={stage.key}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: stage.color }} />
|
||||
<span className="text-xs font-bold text-gray-600 uppercase tracking-wide">{stage.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-black text-gray-800">{actual} <span className="text-[10px] text-gray-400 font-semibold">actual</span></span>
|
||||
{i > 0 && <span className="text-xs font-bold px-2 py-0.5 rounded" style={{ backgroundColor: isGood ? '#dcfce7' : '#fee2e2', color: isGood ? '#16a34a' : '#dc2626' }}>
|
||||
{isGood ? '+' : ''}{deviation.toFixed(1)}
|
||||
</span>}
|
||||
{i > 0 && <span className="text-[10px] text-gray-400">ideal: {ideal}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-5 bg-gray-50 rounded-full overflow-hidden relative border border-gray-100">
|
||||
{i > 0 && ideal > 0 && <div className="absolute top-0 bottom-0 w-0.5 bg-gray-300 z-10" style={{ left: `${Math.min(95, (ideal / max) * 100)}%` }} />}
|
||||
<div className="h-full rounded-full transition-all duration-700" style={{ width: `${Math.min(100, (actual / max) * 100)}%`, backgroundColor: stage.color, opacity: 0.85 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'Call→Quality', val: selectedUser.funnel?.actual?.calls > 0 ? `${Math.round((selectedUser.funnel?.actual?.quality / selectedUser.funnel?.actual?.calls) * 100)}%` : 'N/A' },
|
||||
{ label: 'Quality→Demo', val: selectedUser.funnel?.actual?.quality > 0 ? `${Math.round((selectedUser.funnel?.actual?.demo / selectedUser.funnel?.actual?.quality) * 100)}%` : 'N/A' },
|
||||
{ label: 'Demo→Won', val: selectedUser.funnel?.actual?.demo > 0 ? `${Math.round((selectedUser.funnel?.actual?.won / selectedUser.funnel?.actual?.demo) * 100)}%` : 'N/A' },
|
||||
].map(s => (
|
||||
<div key={s.label} className="bg-gray-50 rounded-xl p-3 text-center border border-gray-100">
|
||||
<p className="text-lg font-black text-odoo-primary">{s.val}</p>
|
||||
<p className="text-[10px] text-gray-500 font-semibold mt-1">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { TrendingUp, AlertTriangle, Target, CheckCircle2, PhoneCall } from 'lucide-react';
|
||||
import api from '../lib/axios';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
interface FunnelData {
|
||||
actual: {
|
||||
calls: number;
|
||||
quality: number;
|
||||
potential: number;
|
||||
demo: number;
|
||||
won: number;
|
||||
};
|
||||
ratios: {
|
||||
callsToQuality: number;
|
||||
qualityToPotential: number;
|
||||
potentialToDemo: number;
|
||||
demoToWon: number;
|
||||
};
|
||||
ideal: {
|
||||
calls: number;
|
||||
quality: number;
|
||||
potential: number;
|
||||
demo: number;
|
||||
won: number;
|
||||
};
|
||||
deviation: {
|
||||
quality: number;
|
||||
potential: number;
|
||||
demo: number;
|
||||
won: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function FunnelAnalytics() {
|
||||
const { user } = useAuth();
|
||||
const [data, setData] = useState<FunnelData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFunnel = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const response = await api.get(`/performance/funnel/${user.id}`);
|
||||
setData(response.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch funnel data', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchFunnel();
|
||||
}, [user]);
|
||||
|
||||
if (loading || !data) return null;
|
||||
|
||||
const IndianRupee = ({ size, className }: any) => (
|
||||
<span className={className} style={{ fontSize: size }}>₹</span>
|
||||
);
|
||||
|
||||
const steps = [
|
||||
{ label: 'Calls', actual: data.actual.calls, ideal: data.ideal.calls, icon: PhoneCall, color: 'bg-slate-400' },
|
||||
{ label: 'Quality Leads', actual: data.actual.quality, ideal: data.ideal.quality, icon: Target, color: 'bg-indigo-500' },
|
||||
{ label: 'Potentials', actual: data.actual.potential, ideal: data.ideal.potential, icon: TrendingUp, color: 'bg-amber-500' },
|
||||
{ label: 'Demos', actual: data.actual.demo, ideal: data.ideal.demo, icon: CheckCircle2, color: 'bg-blue-500' },
|
||||
{ label: 'Won', actual: data.actual.won, ideal: data.ideal.won, icon: IndianRupee, color: 'bg-emerald-500' },
|
||||
];
|
||||
|
||||
|
||||
return (
|
||||
<div className="odoo-card p-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-slate-800">Efficiency Funnel</h3>
|
||||
<p className="text-xs text-slate-400 font-medium tracking-wide uppercase">
|
||||
Deviation from Ideal {data.ratios ? `${Math.round(50)}:${Math.round(50/data.ratios.callsToQuality)}:${Math.round(50/data.ratios.callsToQuality/data.ratios.qualityToPotential)}:${Math.round(50/data.ratios.callsToQuality/data.ratios.qualityToPotential/data.ratios.potentialToDemo)}:${Math.round(50/data.ratios.callsToQuality/data.ratios.qualityToPotential/data.ratios.potentialToDemo/data.ratios.demoToWon)}` : '50:10:3:2:1'} Ratios
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 bg-slate-50 px-3 py-1 rounded-full border border-slate-100">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500"></div>
|
||||
<span className="text-[10px] font-bold text-slate-500">REAL-TIME ANALYSIS</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{steps.map((step, idx) => {
|
||||
const deviation = idx === 0 ? 0 : step.actual - step.ideal;
|
||||
const isNeg = deviation < 0;
|
||||
|
||||
return (
|
||||
<div key={idx} className="relative">
|
||||
<div className="flex items-center justify-between mb-1.5 px-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`p-1.5 rounded-lg ${step.color} text-white`}>
|
||||
<step.icon size={12} />
|
||||
</div>
|
||||
<span className="text-xs font-bold text-slate-600 uppercase tracking-tight">{step.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-black text-slate-800 leading-none">{step.actual}</p>
|
||||
<p className="text-[9px] text-slate-400 font-bold uppercase leading-none mt-1">Actual</p>
|
||||
</div>
|
||||
{idx > 0 && (
|
||||
<div className={`text-right px-2 py-0.5 rounded ${isNeg ? 'bg-rose-50 text-rose-600' : 'bg-emerald-50 text-emerald-600'}`}>
|
||||
<p className="text-[11px] font-black leading-none">{isNeg ? '' : '+'}{deviation}</p>
|
||||
<p className="text-[8px] font-bold uppercase tracking-tighter">GAP</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-4 bg-slate-50 rounded-full overflow-hidden relative border border-slate-100">
|
||||
{/* Ideal Marker */}
|
||||
{idx > 0 && step.ideal > 0 && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-slate-300 z-10 border-l border-white shadow-sm"
|
||||
style={{ left: `${Math.min(95, (step.ideal / Math.max(step.actual, step.ideal)) * 100)}%` }}
|
||||
title={`Ideal: ${step.ideal}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Actual Bar */}
|
||||
<div
|
||||
className={`h-full ${step.color} opacity-90 transition-all duration-1000 ease-out`}
|
||||
style={{ width: `${Math.min(100, (step.actual / Math.max(step.actual, step.ideal)) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{idx < steps.length - 1 && (
|
||||
<div className="flex justify-center -my-1 relative z-20">
|
||||
<div className="w-px h-2 bg-slate-100" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 p-4 bg-slate-50 rounded-2xl border border-slate-100">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="p-2 bg-indigo-100 text-indigo-600 rounded-xl">
|
||||
<AlertTriangle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-black text-slate-800 uppercase tracking-tight">Intelligence Insight</h4>
|
||||
<p className="text-xs text-slate-500 font-medium leading-relaxed mt-1">
|
||||
{data.deviation.won < 0
|
||||
? `You are behind the ideal closure rate. Your biggest drop is at the ${steps.find((s, i) => i > 0 && data.deviation[s.label.toLowerCase().split(' ')[0] as keyof typeof data.deviation] < 0)?.label} stage.`
|
||||
: "You are exceeding the benchmark conversion rates. Excellent funnel management!"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../lib/axios';
|
||||
|
||||
interface Incentive {
|
||||
id: string;
|
||||
targetAmount: number;
|
||||
rewardAmount: number;
|
||||
type: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
user: { name: string };
|
||||
achievedAmount: number;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function IncentiveManager() {
|
||||
const [incentives, setIncentives] = useState<Incentive[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Form
|
||||
const [selectedUser, setSelectedUser] = useState('');
|
||||
const [targetAmount, setTargetAmount] = useState('');
|
||||
const [rewardAmount, setRewardAmount] = useState('');
|
||||
const [type, setType] = useState('MONTHLY');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [usersRes, incRes] = await Promise.all([
|
||||
api.get('/users'),
|
||||
api.get('/incentives')
|
||||
]);
|
||||
setUsers(usersRes.data);
|
||||
setIncentives(incRes.data);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.post('/incentives', {
|
||||
userId: selectedUser,
|
||||
targetAmount: parseFloat(targetAmount),
|
||||
rewardAmount: parseFloat(rewardAmount),
|
||||
type,
|
||||
startDate: new Date(startDate).toISOString(),
|
||||
endDate: new Date(endDate).toISOString(),
|
||||
});
|
||||
fetchData();
|
||||
// Reset form
|
||||
setSelectedUser('');
|
||||
setTargetAmount('');
|
||||
setRewardAmount('');
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
alert('Incentive Target Set Successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Failed to set incentive');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="odoo-card p-6 mb-8">
|
||||
<h3 className="text-xl font-bold mb-4 text-gray-800">Incentive Targets</h3>
|
||||
|
||||
<form onSubmit={handleCreate} className="mb-8 grid grid-cols-1 md:grid-cols-2 gap-6 bg-gray-50 p-6 rounded-xl border border-gray-100">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Sales Person</label>
|
||||
<select value={selectedUser} onChange={e => setSelectedUser(e.target.value)} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" required>
|
||||
<option value="">Select User</option>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Type</label>
|
||||
<select value={type} onChange={e => setType(e.target.value)} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white">
|
||||
<option value="DAILY">Daily</option>
|
||||
<option value="WEEKLY">Weekly</option>
|
||||
<option value="MONTHLY">Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Target Amount (₹)</label>
|
||||
<input type="number" value={targetAmount} onChange={e => setTargetAmount(e.target.value)} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Reward / Incentive (₹)</label>
|
||||
<input type="number" value={rewardAmount} onChange={e => setRewardAmount(e.target.value)} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Start Date</label>
|
||||
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">End Date</label>
|
||||
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white" required />
|
||||
</div>
|
||||
<div className="md:col-span-2 pt-2">
|
||||
<button type="submit" disabled={creating} className="bg-odoo-primary text-white px-6 py-2.5 rounded-lg hover:bg-odoo-primary/90 font-bold shadow-sm transition-all transform hover:-translate-y-0.5">
|
||||
{creating ? 'Saving...' : 'Set Target'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Target</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Achieved</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Reward</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{loading ? <tr><td colSpan={6} className="text-center py-4">Loading...</td></tr> :
|
||||
incentives.length === 0 ? <tr><td colSpan={6} className="text-center py-4">No incentives set</td></tr> :
|
||||
incentives.map(inc => (
|
||||
<tr key={inc.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">{inc.user?.name}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">{inc.type}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">₹{inc.targetAmount}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">₹{inc.achievedAmount}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">₹{inc.rewardAmount}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{inc.achievedAmount >= inc.targetAmount ?
|
||||
<span className="text-odoo-secondary font-bold">Achieved</span> :
|
||||
<span className="text-amber-600 font-medium">In Progress</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,373 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, Tooltip, useMap, Polyline } from 'react-leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import L from 'leaflet';
|
||||
import api from '../lib/axios';
|
||||
import { Maximize2, RefreshCw, Users, MapPin, Search, LogOut } from 'lucide-react';
|
||||
|
||||
// --- Custom Odoo Markers ---
|
||||
const createCustomIcon = (color: string) => {
|
||||
return L.divIcon({
|
||||
html: `
|
||||
<div style="position: relative; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;">
|
||||
<div style="position: absolute; width: 28px; height: 28px; background-color: ${color}; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); border: 2px solid white; box-shadow: 0 4px 12px rgba(0,0,0,0.4);"></div>
|
||||
<div style="position: absolute; width: 10px; height: 10px; background-color: white; border-radius: 50%; top: 7px; left: 9px; z-index: 10;"></div>
|
||||
</div>
|
||||
`,
|
||||
className: 'marker-custom',
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 32],
|
||||
popupAnchor: [0, -32],
|
||||
});
|
||||
};
|
||||
|
||||
const TeamIcon = createCustomIcon('#714B67'); // Odoo Purple
|
||||
const ClientIcon = createCustomIcon('#00A09D'); // Odoo Teal
|
||||
const CheckOutIcon = createCustomIcon('#D9534F'); // Red for Check-out
|
||||
|
||||
interface TeamMember {
|
||||
id: string;
|
||||
user: { id: string; name: string; email: string };
|
||||
checkInLat: number | null;
|
||||
checkInLng: number | null;
|
||||
checkOutLat: number | null;
|
||||
checkOutLng: number | null;
|
||||
checkInTime: string;
|
||||
checkOutTime?: string;
|
||||
route?: [number, number][]; // New field for route map
|
||||
}
|
||||
|
||||
interface ClientMarker {
|
||||
id: string;
|
||||
name: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
status: string;
|
||||
landmark?: string;
|
||||
}
|
||||
|
||||
// Controller component to handle map manipulation
|
||||
function MapController({ team, clients, fitCounter, selectedRoute, selectedUserId }: { team: TeamMember[], clients: ClientMarker[], fitCounter: number, selectedRoute?: [number, number][], selectedUserId: string | null }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
// If a specific user is selected, zoom directly into them
|
||||
if (selectedUserId) {
|
||||
const member = team.find(m => m.user.id === selectedUserId);
|
||||
if (member && member.checkInLat && member.checkInLng) {
|
||||
map.flyTo([member.checkInLat, member.checkInLng], 16, { duration: 1.5 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const points: [number, number][] = [];
|
||||
team.forEach(t => t.checkInLat !== null && t.checkInLng !== null && points.push([t.checkInLat, t.checkInLng]));
|
||||
clients.forEach(c => c.lat !== null && c.lng !== null && points.push([c.lat, c.lng]));
|
||||
if (selectedRoute) selectedRoute.forEach(p => points.push(p));
|
||||
|
||||
if (points.length > 0) {
|
||||
const bounds = L.latLngBounds(points);
|
||||
map.fitBounds(bounds, { padding: [100, 100], maxZoom: 14, animate: true });
|
||||
}
|
||||
}, [fitCounter, map, team, clients, selectedRoute, selectedUserId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function LiveMap() {
|
||||
const [teamLocations, setTeamLocations] = useState<TeamMember[]>([]);
|
||||
const [clientLocations, setClientLocations] = useState<ClientMarker[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [selectedRoute, setSelectedRoute] = useState<[number, number][]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [showClients, setShowClients] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fitCounter, setFitCounter] = useState(0);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [attendanceRes, clientsRes] = await Promise.all([
|
||||
api.get(`/attendance?date=${selectedDate}`),
|
||||
api.get('/clients')
|
||||
]);
|
||||
|
||||
// Deduplicate to show only latest position per user
|
||||
const latestTeam: TeamMember[] = [];
|
||||
const userEmails = new Set();
|
||||
(attendanceRes.data as TeamMember[]).forEach(rec => {
|
||||
if (rec.user && !userEmails.has(rec.user.email)) {
|
||||
latestTeam.push(rec);
|
||||
userEmails.add(rec.user.email);
|
||||
}
|
||||
});
|
||||
|
||||
setTeamLocations(latestTeam);
|
||||
setClientLocations(clientsRes.data.filter((c: any) => c.lat !== null && c.lng !== null));
|
||||
|
||||
// Auto-fit on first successful load
|
||||
if (loading) setFitCounter(prev => prev + 1);
|
||||
|
||||
// If a user is selected, fetch their full route
|
||||
if (selectedUserId) {
|
||||
const routeRes = await api.get(`/locations/user/${selectedUserId}?date=${selectedDate}`);
|
||||
setSelectedRoute(routeRes.data.map((l: any) => [l.lat, l.lng]));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch tracking data", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loading, selectedUserId, selectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 20000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData, selectedDate]);
|
||||
|
||||
const defaultCenter: [number, number] = [20.5937, 78.9629];
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full group bg-slate-50 flex overflow-hidden">
|
||||
{/* Sidebar for Selection - Odoo Style */}
|
||||
<div className="w-80 border-r border-slate-100 bg-white/80 backdrop-blur-md flex flex-col z-20 transition-all shadow-xl">
|
||||
<div className="p-6 border-b border-slate-50 space-y-4">
|
||||
<h3 className="text-xl font-black text-odoo-primary flex items-center space-x-2">
|
||||
<Users size={20} />
|
||||
<span>Sales Hierarchy</span>
|
||||
</h3>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 tracking-widest pl-1">Tracking Date</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={(e) => {
|
||||
setSelectedDate(e.target.value);
|
||||
setSelectedRoute([]); // Clear previous route
|
||||
setFitCounter(prev => prev + 1); // Fit map on date change
|
||||
}}
|
||||
className="w-full px-4 py-2 bg-odoo-primary/5 border border-odoo-primary/20 rounded-xl text-sm font-bold text-odoo-primary focus:ring-2 focus:ring-odoo-primary outline-none transition-all cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search members..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-odoo-primary focus:border-transparent outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { setSelectedUserId(null); setSelectedRoute([]); }}
|
||||
className={`w-full text-left px-4 py-3 rounded-2xl transition-all flex items-center space-x-3 ${!selectedUserId ? 'bg-odoo-primary text-white shadow-lg' : 'hover:bg-slate-100 text-slate-600'}`}
|
||||
>
|
||||
<MapPin size={18} />
|
||||
<span className="font-bold text-sm">All Active Members</span>
|
||||
</button>
|
||||
|
||||
<div className="h-4"></div>
|
||||
<div className="text-[10px] font-black text-slate-300 uppercase tracking-widest px-4 mb-2">Team Members</div>
|
||||
|
||||
{teamLocations.filter(m => m.user.name.toLowerCase().includes(searchTerm.toLowerCase()) || m.user.email.toLowerCase().includes(searchTerm.toLowerCase())).map(member => (
|
||||
<button
|
||||
key={member.id}
|
||||
onClick={() => {
|
||||
setSelectedUserId(member.user.id);
|
||||
setFitCounter(prev => prev + 1); // Trigger zoom
|
||||
}}
|
||||
className={`w-full text-left px-4 py-4 rounded-2xl transition-all border-2 ${selectedUserId === member.user.id ? 'border-odoo-primary bg-odoo-primary/5 text-odoo-primary shadow-sm' : 'border-transparent hover:border-slate-200 text-slate-500'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-black text-sm">{member.user.name}</span>
|
||||
<div className={`w-2 h-2 rounded-full ${member.checkInLat ? 'bg-emerald-400 animate-pulse' : 'bg-slate-300'}`}></div>
|
||||
</div>
|
||||
<div className="text-[10px] opacity-60 font-bold mt-1 uppercase tracking-tighter truncate">{member.user.email}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map Container */}
|
||||
<div className="relative flex-1">
|
||||
{/* Overlay Header / Controls - GUARANTEED VISIBILITY */}
|
||||
<div className="absolute top-6 right-6 z-[9999] flex flex-col space-y-3 items-end">
|
||||
<button
|
||||
onClick={() => setFitCounter(prev => prev + 1)}
|
||||
className="bg-white hover:bg-odoo-primary hover:text-white text-odoo-primary px-5 py-3 rounded-2xl shadow-2xl transition-all active:scale-95 flex items-center space-x-3 border border-slate-100 group/btn"
|
||||
>
|
||||
<Maximize2 size={18} className="group-hover/btn:rotate-90 transition-transform duration-500" />
|
||||
<span className="font-bold text-sm">Recenter Map</span>
|
||||
</button>
|
||||
|
||||
<div className="bg-white/90 backdrop-blur-md p-4 rounded-2xl shadow-xl border border-white/50 space-y-2 min-w-[180px]">
|
||||
<div className="flex items-center justify-between text-xs font-bold text-slate-400 uppercase tracking-tighter">
|
||||
<span>Legend</span>
|
||||
{loading && <RefreshCw size={10} className="animate-spin" />}
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-3 h-3 rounded-full bg-odoo-primary shadow-sm"></div>
|
||||
<span className="text-sm font-bold text-slate-700">Sales Team ({teamLocations.length})</span>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setShowClients(!showClients)}
|
||||
className={`flex items-center justify-between p-2 rounded-xl transition-all cursor-pointer border ${showClients ? 'bg-odoo-secondary/10 border-odoo-secondary/30' : 'bg-slate-50 border-transparent hover:bg-slate-100'}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-3 h-3 rounded-full bg-odoo-secondary shadow-sm ${!showClients && 'opacity-30'}`}></div>
|
||||
<span className={`text-sm font-bold ${showClients ? 'text-odoo-secondary' : 'text-slate-400'}`}>Clients ({clientLocations.length})</span>
|
||||
</div>
|
||||
<div className={`w-8 h-4 rounded-full relative transition-colors ${showClients ? 'bg-odoo-secondary' : 'bg-slate-200'}`}>
|
||||
<div className={`absolute top-0.5 w-3 h-3 bg-white rounded-full transition-all ${showClients ? 'left-4.5' : 'left-0.5'}`}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500 shadow-sm"></div>
|
||||
<span className="text-sm font-bold text-slate-700">Check-out Point</span>
|
||||
</div>
|
||||
{selectedRoute.length > 0 && (
|
||||
<div className="flex items-center space-x-3 pt-2 border-t border-slate-100 mt-2">
|
||||
<div className="w-6 h-0.5 bg-odoo-primary rounded-full"></div>
|
||||
<span className="text-xs font-black text-odoo-primary uppercase">Active Route</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && teamLocations.length === 0 && (
|
||||
<div className="absolute inset-0 z-[10000] bg-white/60 backdrop-blur-sm flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-16 h-16 rounded-full border-4 border-odoo-primary border-t-transparent animate-spin mb-4"></div>
|
||||
<span className="font-extrabold text-odoo-primary tracking-widest uppercase">Initializing Radar...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MapContainer
|
||||
center={defaultCenter}
|
||||
zoom={5}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
zoomControl={false}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution='© OpenStreetMap'
|
||||
/>
|
||||
|
||||
<MapController
|
||||
team={teamLocations}
|
||||
clients={clientLocations}
|
||||
fitCounter={fitCounter}
|
||||
selectedRoute={selectedRoute.length > 0 ? selectedRoute : undefined}
|
||||
selectedUserId={selectedUserId}
|
||||
/>
|
||||
|
||||
{/* Route Line */}
|
||||
{selectedRoute.length > 1 && (
|
||||
<Polyline
|
||||
positions={selectedRoute}
|
||||
color="#714B67"
|
||||
weight={4}
|
||||
opacity={0.8}
|
||||
dashArray="10, 5"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Team Markers with PERSISTENT NAMES */}
|
||||
{teamLocations.map((loc) => (
|
||||
loc.checkInLat !== null && loc.checkInLng !== null && (
|
||||
<Marker
|
||||
key={`team-in-${loc.id}`}
|
||||
position={[loc.checkInLat, loc.checkInLng]}
|
||||
icon={TeamIcon}
|
||||
>
|
||||
<Tooltip permanent direction="top" offset={[0, -32]} className="custom-marker-tooltip">
|
||||
<span className="font-bold text-odoo-primary whitespace-nowrap">{loc.user?.name} (In)</span>
|
||||
</Tooltip>
|
||||
<Popup>
|
||||
<div className="p-1">
|
||||
<div className="font-bold text-slate-800">{loc.user?.name}</div>
|
||||
<div className="text-[10px] text-slate-400 mb-2">Check-in at {new Date(loc.checkInTime).toLocaleTimeString()}</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
)
|
||||
))}
|
||||
|
||||
{/* Check-out Markers */}
|
||||
{teamLocations.map((loc) => (
|
||||
loc.checkOutLat !== null && loc.checkOutLng !== null && (
|
||||
<Marker
|
||||
key={`team-out-${loc.id}`}
|
||||
position={[loc.checkOutLat, loc.checkOutLng]}
|
||||
icon={CheckOutIcon}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -32]}>
|
||||
<span className="font-bold text-red-600 whitespace-nowrap">{loc.user?.name} (Out)</span>
|
||||
</Tooltip>
|
||||
<Popup>
|
||||
<div className="p-1">
|
||||
<div className="font-bold text-slate-800">{loc.user?.name}</div>
|
||||
<div className="text-[10px] text-red-400 mb-2">Checked out at {new Date(loc.checkOutTime!).toLocaleTimeString()}</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
)
|
||||
))}
|
||||
|
||||
{/* Client Markers - Conditional */}
|
||||
{showClients && clientLocations.map((client) => (
|
||||
client.lat !== null && client.lng !== null && (
|
||||
<Marker key={`client-${client.id}`} position={[client.lat, client.lng]} icon={ClientIcon}>
|
||||
<Tooltip direction="top" offset={[0, -32]}>
|
||||
<span className="font-bold text-odoo-secondary">{client.name}</span>
|
||||
</Tooltip>
|
||||
<Popup>
|
||||
<div className="p-1 text-center">
|
||||
<div className="font-bold text-slate-800">{client.name}</div>
|
||||
<div className="text-[10px] uppercase font-bold text-odoo-secondary mt-1">{client.status}</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
)
|
||||
))}
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
{/* Global Indicator for map state */}
|
||||
<div className="absolute bottom-6 left-6 z-[9999]">
|
||||
<div className="bg-slate-900/80 backdrop-blur-md text-white px-4 py-2 rounded-full text-[10px] font-bold uppercase tracking-[0.2em] shadow-2xl flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></div>
|
||||
<span>Live Tracking Engine Active</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx global>{`
|
||||
.custom-marker-tooltip {
|
||||
background: white !important;
|
||||
border: 2px solid #714B67 !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 4px 8px !important;
|
||||
box-shadow: 0 4px 12px rgba(113,75,103,0.2) !important;
|
||||
}
|
||||
.custom-marker-tooltip:before {
|
||||
border-top-color: #714B67 !important;
|
||||
}
|
||||
.leaflet-container {
|
||||
background: #f1f5f9 !important;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,816 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Pencil, Plus, TrendingUp, Star, MoreHorizontal, Clock, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragOverlay,
|
||||
defaultDropAnimationSideEffects
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
useSortable
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import api from '@/lib/axios';
|
||||
|
||||
// --- Types ---
|
||||
interface Opportunity {
|
||||
id: string;
|
||||
title: string;
|
||||
value: number;
|
||||
stage: 'LEAD' | 'QUALIFIED' | 'POTENTIAL' | 'DEMO' | 'WON' | 'LOST';
|
||||
client: { name: string; id: string };
|
||||
user: { name: string };
|
||||
priority?: 'Low' | 'Normal' | 'High';
|
||||
clientId: string;
|
||||
assignedTo: string;
|
||||
status?: 'on_track' | 'late' | 'no_activity';
|
||||
demoPersonName?: string;
|
||||
demoContactDetails?: string;
|
||||
keyQueries?: string;
|
||||
objections?: string;
|
||||
competitorMention?: string;
|
||||
expectedCloseDate?: string;
|
||||
paymentMode?: string;
|
||||
specialRate?: number;
|
||||
freeOffers?: string;
|
||||
negotiationRemarks?: string;
|
||||
}
|
||||
|
||||
interface ColumnProps {
|
||||
id: string;
|
||||
title: string;
|
||||
items: Opportunity[];
|
||||
totalValue: number;
|
||||
onAddClick: (stage: string) => void;
|
||||
onEditClick: (item: Opportunity) => void;
|
||||
colorTheme: { bg: string; text: string; accent: string; bar: string };
|
||||
}
|
||||
|
||||
// --- Configuration ---
|
||||
const STAGE_CONFIG: Record<string, { title: string; bg: string; text: string; accent: string; bar: string }> = {
|
||||
'LEAD': {
|
||||
title: 'New Lead',
|
||||
bg: 'bg-[#f8f9fa]',
|
||||
text: 'text-gray-700',
|
||||
accent: 'bg-gray-400',
|
||||
bar: 'bg-gray-300'
|
||||
},
|
||||
'QUALIFIED': {
|
||||
title: 'Qualified',
|
||||
bg: 'bg-[#f8f9fa]',
|
||||
text: 'text-gray-700',
|
||||
accent: 'bg-gray-500',
|
||||
bar: 'bg-gray-400'
|
||||
},
|
||||
'POTENTIAL': {
|
||||
title: 'Potential',
|
||||
bg: 'bg-[#f8f9fa]',
|
||||
text: 'text-gray-700',
|
||||
accent: 'bg-amber-500',
|
||||
bar: 'bg-amber-400'
|
||||
},
|
||||
'DEMO': {
|
||||
title: 'Demo',
|
||||
bg: 'bg-[#f8f9fa]',
|
||||
text: 'text-gray-700',
|
||||
accent: 'bg-blue-500',
|
||||
bar: 'bg-blue-400'
|
||||
},
|
||||
'WON': {
|
||||
title: 'Won',
|
||||
bg: 'bg-[#e7f3f2]',
|
||||
text: 'text-[#00A09D]',
|
||||
accent: 'bg-[#00A09D]',
|
||||
bar: 'bg-[#00A09D]'
|
||||
}
|
||||
};
|
||||
|
||||
// --- Sortable Item Component ---
|
||||
const SortableItem = ({ opportunity, onEdit }: { opportunity: Opportunity; onEdit: (op: Opportunity) => void }) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: opportunity.id, data: { ...opportunity } });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 50 : 0,
|
||||
};
|
||||
|
||||
const renderStars = (priority?: string) => {
|
||||
const count = priority === 'High' ? 3 : priority === 'Normal' ? 2 : 1;
|
||||
return (
|
||||
<div className="flex space-x-0.5">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
size={12}
|
||||
className={i < count ? "fill-amber-400 text-amber-400" : "text-gray-200"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StatusDot = ({ status }: { status?: string }) => {
|
||||
let color = 'bg-gray-300';
|
||||
if (status === 'on_track') color = 'bg-[#00A09D]';
|
||||
if (status === 'late') color = 'bg-rose-500';
|
||||
return <div className={`w-2 h-2 rounded-full ${color}`} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={`odoo-card p-3 mb-2 cursor-grab active:cursor-grabbing border-t-2 border-transparent hover:border-odoo-primary group relative
|
||||
${isDragging ? 'opacity-50 rotate-1 shadow-lg' : ''}`}
|
||||
onClick={() => onEdit(opportunity)}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<h4 className="text-[13px] font-semibold text-gray-800 leading-snug break-words pr-4 line-clamp-2">
|
||||
{opportunity.title}
|
||||
</h4>
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<MoreHorizontal size={14} className="text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-[14px] font-bold text-gray-900 mb-2">
|
||||
₹{opportunity.value.toLocaleString()}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-auto">
|
||||
<div className="flex items-center space-x-2">
|
||||
{renderStars(opportunity.priority)}
|
||||
<StatusDot status={opportunity.status || 'on_track'} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-[11px] text-gray-500 font-medium truncate max-w-[80px]">
|
||||
{opportunity.client?.name}
|
||||
</span>
|
||||
<div className="h-5 w-5 rounded-full bg-odoo-accent/20 flex items-center justify-center text-[9px] font-bold text-odoo-primary border border-odoo-primary/10">
|
||||
{opportunity.user?.name?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Column Component ---
|
||||
const Column = ({ id, title, items, totalValue, onAddClick, onEditClick, colorTheme }: ColumnProps) => {
|
||||
const { setNodeRef } = useSortable({ id });
|
||||
const [quickTitle, setQuickTitle] = useState('');
|
||||
|
||||
const handleQuickAdd = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && quickTitle.trim()) {
|
||||
// In a real app, this would call an API
|
||||
onAddClick(id); // For now, open modal
|
||||
setQuickTitle('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} className={`flex flex-col h-full min-w-[280px] w-72 bg-[#e9ecef]/40 rounded-lg mx-1`}>
|
||||
{/* Header */}
|
||||
<div className="p-3">
|
||||
<div className="flex justify-between items-baseline mb-1">
|
||||
<h3 className={`font-bold text-[14px] ${colorTheme.text} flex items-center`}>
|
||||
{title}
|
||||
<span className="ml-2 text-[11px] font-normal text-gray-500 bg-gray-200/50 px-1.5 py-0.5 rounded">
|
||||
{items.length}
|
||||
</span>
|
||||
</h3>
|
||||
<div className="text-[14px] font-extrabold text-gray-700">
|
||||
₹{totalValue.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`h-1 w-full rounded-full bg-gray-200 overflow-hidden`}>
|
||||
<div className={`h-full ${colorTheme.bar}`} style={{ width: '40%' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List Area */}
|
||||
<div className="flex-1 px-2 pb-2 overflow-y-auto custom-scrollbar">
|
||||
<SortableContext
|
||||
id={id}
|
||||
items={items.map((item) => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="pt-1">
|
||||
{items.map((op) => (
|
||||
<SortableItem key={op.id} opportunity={op} onEdit={onEditClick} />
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
|
||||
<div className="mt-2 group">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Quick add..."
|
||||
value={quickTitle}
|
||||
onChange={(e) => setQuickTitle(e.target.value)}
|
||||
onKeyDown={handleQuickAdd}
|
||||
className="w-full bg-white/50 border-none rounded p-2 text-[12px] focus:bg-white focus:ring-1 focus:ring-odoo-primary outline-none transition-all"
|
||||
/>
|
||||
<button
|
||||
onClick={() => onAddClick(id)}
|
||||
className="absolute right-2 top-2 text-gray-400 hover:text-odoo-primary"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Main Board Component ---
|
||||
export default function OpportunityBoard() {
|
||||
const { user } = useAuth();
|
||||
const [items, setItems] = useState<Opportunity[]>([]);
|
||||
const [clients, setClients] = useState<any[]>([]);
|
||||
const [assignees, setAssignees] = useState<any[]>([]);
|
||||
const [loadingAssignees, setLoadingAssignees] = useState(false);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [newItemData, setNewItemData] = useState({
|
||||
title: '',
|
||||
value: '',
|
||||
stage: 'LEAD',
|
||||
clientId: '',
|
||||
priority: 'Normal',
|
||||
assignedTo: '',
|
||||
expectedCloseDate: '',
|
||||
demoPersonName: '',
|
||||
demoContactDetails: '',
|
||||
keyQueries: '',
|
||||
objections: '',
|
||||
competitorMention: '',
|
||||
paymentMode: '',
|
||||
specialRate: '',
|
||||
freeOffers: '',
|
||||
negotiationRemarks: '',
|
||||
creatorId: '',
|
||||
demoOwnerId: '',
|
||||
closingOwnerId: ''
|
||||
});
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOpportunities();
|
||||
fetchClients();
|
||||
fetchAssignees();
|
||||
}, []);
|
||||
|
||||
const fetchOpportunities = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/opportunities');
|
||||
// Mock some Odoo features like status
|
||||
const enrichedData = data.map((item: any) => ({
|
||||
...item,
|
||||
status: ['on_track', 'late', 'no_activity'][Math.floor(Math.random() * 3)]
|
||||
}));
|
||||
setItems(enrichedData);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch opportunities', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchClients = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/clients');
|
||||
setClients(data);
|
||||
} catch (err) { console.error(err) }
|
||||
}
|
||||
|
||||
const fetchAssignees = async () => {
|
||||
setLoadingAssignees(true);
|
||||
try {
|
||||
let endpoint = '/users';
|
||||
if (['MANAGER', 'TEAM_LEADER'].includes(user?.role || '')) {
|
||||
endpoint = '/users/me/subordinates';
|
||||
}
|
||||
const { data } = await api.get(endpoint);
|
||||
setAssignees(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch assignees', err);
|
||||
} finally {
|
||||
setLoadingAssignees(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateClick = (stage?: string) => {
|
||||
setEditingId(null);
|
||||
setNewItemData({
|
||||
title: '',
|
||||
value: '',
|
||||
stage: stage || 'LEAD',
|
||||
clientId: '',
|
||||
priority: 'Normal',
|
||||
assignedTo: user?.id || '',
|
||||
expectedCloseDate: '',
|
||||
demoPersonName: '',
|
||||
demoContactDetails: '',
|
||||
keyQueries: '',
|
||||
objections: '',
|
||||
competitorMention: '',
|
||||
paymentMode: '',
|
||||
specialRate: '',
|
||||
freeOffers: '',
|
||||
negotiationRemarks: '',
|
||||
creatorId: user?.id || '',
|
||||
demoOwnerId: '',
|
||||
closingOwnerId: ''
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditClick = (item: Opportunity) => {
|
||||
setEditingId(item.id);
|
||||
setNewItemData({
|
||||
title: item.title,
|
||||
value: String(item.value),
|
||||
stage: item.stage,
|
||||
clientId: item.clientId || (item.client && item.client.id) || '',
|
||||
priority: (item.priority as any) || 'Normal',
|
||||
assignedTo: item.assignedTo || (item as any).userId || user?.id || '',
|
||||
expectedCloseDate: item.expectedCloseDate ? new Date(item.expectedCloseDate).toISOString().split('T')[0] : '',
|
||||
demoPersonName: item.demoPersonName || '',
|
||||
demoContactDetails: item.demoContactDetails || '',
|
||||
keyQueries: item.keyQueries || '',
|
||||
objections: item.objections || '',
|
||||
competitorMention: item.competitorMention || '',
|
||||
paymentMode: item.paymentMode || '',
|
||||
specialRate: item.specialRate ? String(item.specialRate) : '',
|
||||
freeOffers: item.freeOffers || '',
|
||||
negotiationRemarks: item.negotiationRemarks || '',
|
||||
creatorId: (item as any).creatorId || '',
|
||||
demoOwnerId: (item as any).demoOwnerId || '',
|
||||
closingOwnerId: (item as any).closingOwnerId || ''
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const payload = {
|
||||
...newItemData,
|
||||
value: Number(newItemData.value),
|
||||
specialRate: newItemData.specialRate ? Number(newItemData.specialRate) : undefined
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
await api.patch(`/opportunities/${editingId}`, payload);
|
||||
} else {
|
||||
await api.post('/opportunities', payload);
|
||||
}
|
||||
|
||||
setIsModalOpen(false);
|
||||
setEditingId(null);
|
||||
fetchOpportunities();
|
||||
} catch (error: any) {
|
||||
console.error("Failed to save deal", error);
|
||||
const msg = error.response?.data?.message || "Failed to save deal";
|
||||
alert(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (event: any) => {
|
||||
setActiveId(event.active.id);
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: any) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
|
||||
if (!over) return;
|
||||
|
||||
const activeId = active.id;
|
||||
const overId = over.id;
|
||||
|
||||
const activeItem = items.find((i) => i.id === activeId);
|
||||
if (!activeItem) return;
|
||||
|
||||
let newStage = overId;
|
||||
const overItem = items.find(i => i.id === overId);
|
||||
if (overItem) {
|
||||
newStage = overItem.stage;
|
||||
}
|
||||
|
||||
const stages = ['LEAD', 'QUALIFIED', 'POTENTIAL', 'DEMO', 'WON'];
|
||||
if (activeItem.stage !== newStage && stages.includes(newStage as any)) {
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === activeId ? { ...item, stage: newStage as any } : item
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
await api.patch(`/opportunities/${activeId}`, { stage: newStage });
|
||||
} catch (error: any) {
|
||||
if (newStage === 'DEMO' || newStage === 'WON') {
|
||||
// Open modal and explicitly set the target stage
|
||||
setEditingId(activeItem.id);
|
||||
setNewItemData({
|
||||
title: activeItem.title,
|
||||
value: String(activeItem.value),
|
||||
stage: newStage as any,
|
||||
clientId: activeItem.clientId || (activeItem.client && activeItem.client.id) || '',
|
||||
priority: (activeItem.priority as any) || 'Normal',
|
||||
assignedTo: (activeItem as any).assignedTo || user?.id || '',
|
||||
expectedCloseDate: activeItem.expectedCloseDate ? new Date(activeItem.expectedCloseDate).toISOString().split('T')[0] : '',
|
||||
demoPersonName: activeItem.demoPersonName || '',
|
||||
demoContactDetails: activeItem.demoContactDetails || '',
|
||||
keyQueries: activeItem.keyQueries || '',
|
||||
objections: activeItem.objections || '',
|
||||
competitorMention: activeItem.competitorMention || '',
|
||||
paymentMode: activeItem.paymentMode || '',
|
||||
specialRate: activeItem.specialRate ? String(activeItem.specialRate) : '',
|
||||
freeOffers: activeItem.freeOffers || '',
|
||||
negotiationRemarks: activeItem.negotiationRemarks || '',
|
||||
creatorId: (activeItem as any).creatorId || '',
|
||||
demoOwnerId: (activeItem as any).demoOwnerId || '',
|
||||
closingOwnerId: (activeItem as any).closingOwnerId || ''
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
} else {
|
||||
const msg = error.response?.data?.message || "Failed to update stage.";
|
||||
alert(msg);
|
||||
}
|
||||
fetchOpportunities();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stages = ['LEAD', 'QUALIFIED', 'POTENTIAL', 'DEMO', 'WON'];
|
||||
const getColumnTotal = (stage: string) => items.filter(i => i.stage === stage).reduce((sum, i) => sum + i.value, 0);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-white">
|
||||
{/* Control Panel - Odoo Style */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between px-4 py-2 border-b border-gray-200 space-y-2 md:space-y-0">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h2 className="text-[18px] font-bold text-gray-800">Pipeline</h2>
|
||||
<div className="flex items-center bg-gray-100 rounded p-0.5">
|
||||
<button className="bg-white shadow-sm px-3 py-1 text-xs font-bold rounded text-odoo-primary">Kanban</button>
|
||||
<button className="px-3 py-1 text-xs font-medium text-gray-500 hover:bg-white/50 rounded transition-all">List</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCreateClick('LEAD')}
|
||||
className="bg-odoo-primary hover:bg-odoo-primary/90 text-white px-4 py-1.5 rounded text-[13px] font-bold shadow-sm transition-all"
|
||||
>
|
||||
NEW
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="flex items-center space-x-2 bg-gray-50 border border-gray-200 px-3 py-1.5 rounded-full w-64">
|
||||
<TrendingUp size={14} className="text-gray-400" />
|
||||
<span className="text-[12px] font-bold text-gray-400 uppercase tracking-tighter">Total Pipeline:</span>
|
||||
<span className="text-[14px] font-extrabold text-odoo-primary">
|
||||
₹{items.reduce((sum, i) => sum + i.value, 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-x-auto bg-[#f8f9fa] p-4">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex h-full min-w-max pb-4">
|
||||
{stages.map((stage) => (
|
||||
<Column
|
||||
key={stage}
|
||||
id={stage}
|
||||
title={STAGE_CONFIG[stage].title}
|
||||
items={items.filter((i) => i.stage === stage)}
|
||||
totalValue={getColumnTotal(stage)}
|
||||
onAddClick={handleCreateClick}
|
||||
onEditClick={handleEditClick}
|
||||
colorTheme={STAGE_CONFIG[stage]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeId ? (
|
||||
<div className="rotate-2 shadow-2xl scale-105">
|
||||
<SortableItem opportunity={items.find(i => i.id === activeId)!} onEdit={() => { }} />
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* Modal - Refined Odoo Style */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-[2px] z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded shadow-2xl w-full max-w-2xl overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200 flex justify-between items-center">
|
||||
<h3 className="font-bold text-[18px] text-gray-800">{editingId ? 'Edit Deal' : 'New Deal'}</h3>
|
||||
<button onClick={() => setIsModalOpen(false)} className="p-1 rounded hover:bg-gray-200 text-gray-400 transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleCreateSubmit} className="flex flex-col max-h-[90vh]">
|
||||
<div className="flex-1 overflow-y-auto p-8 grid grid-cols-2 gap-x-12 gap-y-6">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-[13px] font-bold text-gray-500 mb-1">Opportunity Title</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full border-b-2 border-gray-200 py-2 text-[18px] font-semibold focus:border-odoo-primary outline-none transition-all placeholder:text-gray-300"
|
||||
value={newItemData.title}
|
||||
onChange={e => setNewItemData({ ...newItemData, title: e.target.value })}
|
||||
placeholder="e.g. Website Redesign"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[13px] font-bold text-gray-500 mb-1">Expected Revenue (₹)</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-gray-400 font-bold">₹</span>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
className="w-full border-b border-gray-200 py-1 focus:border-odoo-primary outline-none"
|
||||
value={newItemData.value}
|
||||
onChange={e => setNewItemData({ ...newItemData, value: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[13px] font-bold text-gray-500 mb-1">Priority</label>
|
||||
<div className="flex space-x-4 mt-2">
|
||||
{[1, 2, 3].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => setNewItemData({ ...newItemData, priority: star === 3 ? 'High' : star === 2 ? 'Normal' : 'Low' })}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<Star
|
||||
size={20}
|
||||
className={star <= (newItemData.priority === 'High' ? 3 : newItemData.priority === 'Normal' ? 2 : 1)
|
||||
? "fill-amber-400 text-amber-400"
|
||||
: "text-gray-200 hover:text-gray-300"}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[13px] font-bold text-gray-500 mb-1">Client</label>
|
||||
<select
|
||||
required
|
||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[14px]"
|
||||
value={newItemData.clientId}
|
||||
onChange={e => setNewItemData({ ...newItemData, clientId: e.target.value })}
|
||||
>
|
||||
<option value="">Select a client...</option>
|
||||
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[13px] font-bold text-gray-500 mb-1">Stage</label>
|
||||
<select
|
||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[14px]"
|
||||
value={newItemData.stage}
|
||||
onChange={e => setNewItemData({ ...newItemData, stage: e.target.value })}
|
||||
>
|
||||
{stages.map(s => <option key={s} value={s}>{STAGE_CONFIG[s].title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{['ADMIN', 'GENERAL_MANAGER', 'MANAGER', 'TEAM_LEADER'].includes(user?.role || '') && (
|
||||
<div className="col-span-2 grid grid-cols-2 gap-4 border-t border-gray-100 pt-4">
|
||||
<div>
|
||||
<label className="block text-[13px] font-bold text-gray-500 mb-1">Lead Creator</label>
|
||||
<select
|
||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[14px]"
|
||||
value={newItemData.creatorId}
|
||||
onChange={e => setNewItemData({ ...newItemData, creatorId: e.target.value })}
|
||||
>
|
||||
<option value="">Select creator...</option>
|
||||
{assignees.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.name} ({u.role})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] font-bold text-gray-500 mb-1">Primary Owner (Assigned)</label>
|
||||
<select
|
||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[14px]"
|
||||
value={newItemData.assignedTo}
|
||||
onChange={e => setNewItemData({ ...newItemData, assignedTo: e.target.value })}
|
||||
>
|
||||
<option value="">Select teammate...</option>
|
||||
{assignees.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.name} ({u.role})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DEMO STAGE FIELDS */}
|
||||
{(newItemData.stage === 'DEMO' || newItemData.stage === 'WON') && (
|
||||
<div className="col-span-2 grid grid-cols-2 gap-4 p-4 bg-blue-50/50 rounded border border-blue-100">
|
||||
<div className="col-span-2 font-bold text-blue-700 text-[13px] mb-2 border-b border-blue-100 pb-1">
|
||||
DEMO STAGE INFORMATION (MANDATORY)
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] font-bold text-gray-500 mb-1">Demo Person Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
|
||||
value={newItemData.demoPersonName}
|
||||
onChange={e => setNewItemData({ ...newItemData, demoPersonName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] font-bold text-gray-500 mb-1">Demo Owner (Select Staff) *</label>
|
||||
<select
|
||||
required
|
||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
|
||||
value={newItemData.demoOwnerId}
|
||||
onChange={e => setNewItemData({ ...newItemData, demoOwnerId: e.target.value })}
|
||||
>
|
||||
<option value="">Select staff...</option>
|
||||
{assignees.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] font-bold text-gray-500 mb-1">Contact Details *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
|
||||
value={newItemData.demoContactDetails}
|
||||
onChange={e => setNewItemData({ ...newItemData, demoContactDetails: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] font-bold text-gray-500 mb-1">Expected Closing Date *</label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
|
||||
value={newItemData.expectedCloseDate}
|
||||
onChange={e => setNewItemData({ ...newItemData, expectedCloseDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] font-bold text-gray-500 mb-1">Competitor Mention *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
|
||||
value={newItemData.competitorMention}
|
||||
onChange={e => setNewItemData({ ...newItemData, competitorMention: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-[12px] font-bold text-gray-500 mb-1">Key Queries & Objections *</label>
|
||||
<textarea
|
||||
required
|
||||
rows={2}
|
||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px] resize-none"
|
||||
placeholder="Key queries, objections raised..."
|
||||
value={newItemData.keyQueries}
|
||||
onChange={e => setNewItemData({ ...newItemData, keyQueries: e.target.value, objections: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CLOSING STAGE FIELDS */}
|
||||
{newItemData.stage === 'WON' && (
|
||||
<div className="col-span-2 grid grid-cols-2 gap-4 p-4 bg-green-50/50 rounded border border-green-100 mt-2">
|
||||
<div className="col-span-2 font-bold text-green-700 text-[13px] mb-2 border-b border-green-100 pb-1">
|
||||
CLOSING STAGE INFORMATION (MANDATORY)
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] font-bold text-gray-500 mb-1">Payment Mode *</label>
|
||||
<select
|
||||
required
|
||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
|
||||
value={newItemData.paymentMode}
|
||||
onChange={e => setNewItemData({ ...newItemData, paymentMode: e.target.value })}
|
||||
>
|
||||
<option value="">Select mode...</option>
|
||||
<option value="CASH">Cash</option>
|
||||
<option value="ONLINE">Online Transfer / UPI</option>
|
||||
<option value="CHEQUE">Cheque</option>
|
||||
<option value="CARD">Credit/Debit Card</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] font-bold text-gray-500 mb-1">Special Rate Offered *</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
|
||||
value={newItemData.specialRate}
|
||||
onChange={e => setNewItemData({ ...newItemData, specialRate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] font-bold text-gray-500 mb-1">Free Offers Given</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
|
||||
value={newItemData.freeOffers}
|
||||
onChange={e => setNewItemData({ ...newItemData, freeOffers: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] font-bold text-gray-500 mb-1">Closing Owner *</label>
|
||||
<select
|
||||
required
|
||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
|
||||
value={newItemData.closingOwnerId}
|
||||
onChange={e => setNewItemData({ ...newItemData, closingOwnerId: e.target.value })}
|
||||
>
|
||||
<option value="">Select closer...</option>
|
||||
{assignees.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] font-bold text-gray-500 mb-1">Final Negotiation Remarks *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
|
||||
value={newItemData.negotiationRemarks}
|
||||
onChange={e => setNewItemData({ ...newItemData, negotiationRemarks: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-4 bg-gray-50 border-t border-gray-200 flex space-x-3 shrink-0">
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-odoo-primary hover:bg-odoo-primary/90 text-white px-6 py-2 rounded font-bold text-[14px] shadow-md transition-all"
|
||||
>
|
||||
{editingId ? 'SAVE' : 'CREATE'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 px-6 py-2 rounded font-bold text-[14px] transition-all"
|
||||
>
|
||||
DISCARD
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../lib/axios';
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
// imageUrl removed
|
||||
}
|
||||
|
||||
export default function ProductManager() {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [price, setPrice] = useState('');
|
||||
// const [imageUrl, setImageUrl] = useState(''); // Removed
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
}, []);
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
const response = await api.get('/products');
|
||||
setProducts(response.data);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.post('/products', {
|
||||
name,
|
||||
description,
|
||||
price: parseFloat(price),
|
||||
// imageUrl
|
||||
});
|
||||
setName('');
|
||||
setDescription('');
|
||||
setPrice('');
|
||||
// setImageUrl('');
|
||||
fetchProducts();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Failed to create product');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure?')) return;
|
||||
try {
|
||||
await api.delete(`/products/${id}`);
|
||||
fetchProducts();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Failed to delete product');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="odoo-card p-6 mb-8">
|
||||
<h3 className="text-xl font-bold mb-4 text-gray-800">Product Management</h3>
|
||||
|
||||
<form onSubmit={handleCreate} className="mb-8 grid grid-cols-1 md:grid-cols-2 gap-6 bg-gray-50 p-6 rounded-xl border border-gray-100">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Name</label>
|
||||
<input type="text" value={name} onChange={e => setName(e.target.value)} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Price (₹)</label>
|
||||
<input type="number" value={price} onChange={e => setPrice(e.target.value)} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" required />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea value={description} onChange={e => setDescription(e.target.value)} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
|
||||
</div>
|
||||
{/* Image URL field removed */}
|
||||
<div className="md:col-span-2 pt-2">
|
||||
<button type="submit" disabled={creating} className="bg-odoo-primary text-white px-6 py-2.5 rounded-lg hover:bg-odoo-primary/90 font-bold shadow-sm transition-all transform hover:-translate-y-0.5">
|
||||
{creating ? 'Saving...' : 'Add Product'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Price (₹)</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{loading ? <tr><td colSpan={4} className="text-center py-4">Loading...</td></tr> :
|
||||
products.length === 0 ? <tr><td colSpan={4} className="text-center py-4">No products found</td></tr> :
|
||||
products.map(product => (
|
||||
<tr key={product.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">{product.name}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">₹{product.price}</td>
|
||||
<td className="px-6 py-4">{product.description}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button onClick={() => handleDelete(product.id)} className="text-rose-600 hover:text-rose-900 font-semibold">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,442 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../lib/axios';
|
||||
import { Upload, FileText, Send, Mail, Edit2, Check, ExternalLink, Trash2 } from 'lucide-react';
|
||||
|
||||
interface Quote {
|
||||
id: string;
|
||||
totalAmount: number;
|
||||
status: string;
|
||||
pdfUrl?: string; // New field
|
||||
enquiry: {
|
||||
id: string;
|
||||
client: {
|
||||
name: string;
|
||||
phone: string;
|
||||
email?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface Enquiry {
|
||||
id: string;
|
||||
client: {
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function QuoteManager() {
|
||||
const [quotes, setQuotes] = useState<Quote[]>([]);
|
||||
const [enquiries, setEnquiries] = useState<Enquiry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Form State
|
||||
const [selectedEnquiry, setSelectedEnquiry] = useState('');
|
||||
const [status, setStatus] = useState('DRAFT');
|
||||
const [totalAmount, setTotalAmount] = useState<string>('');
|
||||
const [pdfUrl, setPdfUrl] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
// Edit State
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editAmount, setEditAmount] = useState<string>('');
|
||||
const [editStatus, setEditStatus] = useState<string>('');
|
||||
const [editPdfUrl, setEditPdfUrl] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [quotesRes, enquiriesRes] = await Promise.all([
|
||||
api.get('/quotes'),
|
||||
api.get('/enquiries')
|
||||
]);
|
||||
setQuotes(quotesRes.data);
|
||||
setEnquiries(enquiriesRes.data);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setLoading(false);
|
||||
alert('Failed to load data');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const res = await api.post('/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
setPdfUrl(res.data.url);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('File upload failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!pdfUrl) {
|
||||
alert('Please upload a PDF quote first');
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.post('/quotes', {
|
||||
enquiryId: selectedEnquiry,
|
||||
items: [],
|
||||
totalAmount: Number(totalAmount),
|
||||
status,
|
||||
pdfUrl,
|
||||
userId: '52fa316c-77e3-4bfd-80d8-72e96c6b999e'
|
||||
});
|
||||
|
||||
setSelectedEnquiry('');
|
||||
setTotalAmount('');
|
||||
setStatus('DRAFT');
|
||||
setPdfUrl('');
|
||||
fetchData();
|
||||
alert('Quote created successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Failed to create quote');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startEditing = (quote: Quote) => {
|
||||
setEditingId(quote.id);
|
||||
setEditAmount(quote.totalAmount.toString());
|
||||
setEditStatus(quote.status);
|
||||
setEditPdfUrl(quote.pdfUrl || '');
|
||||
};
|
||||
|
||||
const saveEdit = async (id: string) => {
|
||||
try {
|
||||
await api.patch(`/quotes/${id}`, {
|
||||
totalAmount: Number(editAmount),
|
||||
status: editStatus,
|
||||
pdfUrl: editPdfUrl
|
||||
});
|
||||
setEditingId(null);
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Failed to update quote');
|
||||
}
|
||||
};
|
||||
|
||||
const getPdfUrl = (path: string) => {
|
||||
if (!path) return '';
|
||||
// Extract filename from path (handling both /upload/files/X and /uploads/X formats)
|
||||
const parts = path.split('/');
|
||||
const filename = parts[parts.length - 1];
|
||||
|
||||
// Use local Next.js API route to serve the file
|
||||
return `/api/view-pdf/${filename}`;
|
||||
};
|
||||
|
||||
const handleSendAction = async (quote: Quote, type: 'whatsapp' | 'email') => {
|
||||
try {
|
||||
// Call backend to send or at least log the attempt
|
||||
// For WhatsApp media sending, backend is required if we want to attach file automatically (and assuming public URL)
|
||||
await api.post(`/quotes/${quote.id}/send`, { type });
|
||||
|
||||
// Also update local state if needed, though fetchData usually refreshe it
|
||||
fetchData();
|
||||
alert(`${type === 'whatsapp' ? 'WhatsApp' : 'Email'} sent successfully!`);
|
||||
} catch (error) {
|
||||
console.error('Failed to send', error);
|
||||
alert(`Failed to send via ${type}. Check backend logs.`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Create Quote Section */}
|
||||
<div className="bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-100">
|
||||
<div className="bg-odoo-primary p-6">
|
||||
<h3 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<FileText className="w-6 h-6" />
|
||||
Create New Quote
|
||||
</h3>
|
||||
<p className="text-white/80 mt-1">Upload a PDF and assign it to a client enquiry.</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<form onSubmit={handleCreate} className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{/* Left Column: Details */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Select Enquiry</label>
|
||||
<select
|
||||
value={selectedEnquiry}
|
||||
onChange={e => setSelectedEnquiry(e.target.value)}
|
||||
className="w-full rounded-lg border-gray-300 shadow-sm focus:border-odoo-primary focus:ring-odoo-primary h-10 px-3 transition-colors text-sm"
|
||||
required
|
||||
>
|
||||
<option value="">-- Choose Client Enquiry --</option>
|
||||
{enquiries.map(enq => (
|
||||
<option key={enq.id} value={enq.id}>{enq.client.name} (ID: {enq.id.substring(0, 8)})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Total Amount (₹)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={totalAmount}
|
||||
onChange={e => setTotalAmount(e.target.value)}
|
||||
className="w-full rounded-lg border-gray-300 shadow-sm focus:border-odoo-primary focus:ring-odoo-primary h-10 px-3 text-sm"
|
||||
min="0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Initial Status</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={e => setStatus(e.target.value)}
|
||||
className="w-full rounded-lg border-gray-300 shadow-sm focus:border-odoo-primary focus:ring-odoo-primary h-10 px-3 text-sm"
|
||||
>
|
||||
<option value="DRAFT">Draft</option>
|
||||
<option value="SENT">Sent</option>
|
||||
<option value="ACCEPTED">Accepted</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Upload */}
|
||||
<div className="flex flex-col justify-center">
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Quote PDF</label>
|
||||
<div className={`relative border-2 border-dashed rounded-xl p-8 flex flex-col items-center justify-center text-center transition-colors ${pdfUrl ? 'border-odoo-secondary bg-odoo-secondary/5' : 'border-gray-200 hover:border-odoo-primary bg-gray-50/50'}`}>
|
||||
{pdfUrl ? (
|
||||
<div className="space-y-3">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto text-green-600">
|
||||
<Check className="w-8 h-8" />
|
||||
</div>
|
||||
<p className="text-green-800 font-medium">PDF Uploaded Successfully</p>
|
||||
<a href={getPdfUrl(pdfUrl)} target="_blank" rel="noreferrer" className="text-sm text-blue-600 underline hover:text-blue-800">
|
||||
View Uploaded File
|
||||
</a>
|
||||
<button type="button" onClick={() => setPdfUrl('')} className="text-xs text-red-500 hover:text-red-700 block mx-auto mt-2">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-16 h-16 bg-odoo-primary/10 rounded-full flex items-center justify-center mb-4 text-odoo-primary">
|
||||
{uploading ? <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-odoo-primary"></div> : <Upload className="w-8 h-8" />}
|
||||
</div>
|
||||
<p className="text-gray-600 mb-2">Drag and drop or click to upload</p>
|
||||
<p className="text-xs text-gray-400">PDF files only</p>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
onChange={handleFileUpload}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
disabled={uploading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating || !pdfUrl}
|
||||
className={`w-full py-3 rounded-lg font-bold text-white shadow-lg transition-all transform hover:-translate-y-0.5 ${creating || !pdfUrl ? 'bg-gray-400 cursor-not-allowed' : 'bg-odoo-primary hover:bg-odoo-primary/90 hover:shadow-xl'}`}
|
||||
>
|
||||
{creating ? 'Creating Quote...' : 'Create Quote'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List Quotes Section */}
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{quotes.map(quote => (
|
||||
<div key={quote.id} className="bg-white rounded-xl shadow-md overflow-hidden hover:shadow-lg transition-shadow border border-gray-100">
|
||||
<div className="p-6 flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
|
||||
{/* Quote Info */}
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className="text-lg font-bold text-gray-900">{quote.enquiry?.client?.name || 'Unknown Client'}</h4>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-semibold ${quote.status === 'ACCEPTED' ? 'bg-odoo-secondary/10 text-odoo-secondary' :
|
||||
quote.status === 'SENT' ? 'bg-odoo-primary/10 text-odoo-primary' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{quote.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>ID: {quote.id.substring(0, 8)}</span>
|
||||
<span>•</span>
|
||||
{editingId === quote.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>₹</span>
|
||||
<input
|
||||
type="number"
|
||||
value={editAmount}
|
||||
onChange={e => setEditAmount(e.target.value)}
|
||||
className="w-24 border rounded px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="font-medium text-gray-700">₹{quote.totalAmount.toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PDF Editing Section */}
|
||||
{editingId === quote.id && (
|
||||
<div className="mt-2 p-3 bg-gray-50 rounded-lg border border-dashed border-gray-300">
|
||||
<label className="block text-xs font-semibold text-gray-600 mb-2">Update PDF:</label>
|
||||
<div className="flex items-center gap-3">
|
||||
{editPdfUrl ? (
|
||||
<div className="flex items-center gap-2 text-sm text-green-700 bg-green-50 px-2 py-1 rounded border border-green-200">
|
||||
<Check className="w-3 h-3" />
|
||||
<span className="truncate max-w-[150px]">{editPdfUrl.split('/').pop()}</span>
|
||||
<button
|
||||
onClick={() => setEditPdfUrl('')}
|
||||
className="text-rose-500 hover:text-rose-700 ml-2"
|
||||
title="Remove/Change"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative overflow-hidden group">
|
||||
<button className="flex items-center gap-1 bg-white border border-gray-300 px-3 py-1.5 rounded text-xs font-medium text-gray-700 hover:bg-gray-50">
|
||||
<Upload className="w-3 h-3" />
|
||||
Upload New PDF
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Re-use logic or inline upload for editing
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await api.post('/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
setEditPdfUrl(res.data.url);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Upload failed');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{editPdfUrl && (
|
||||
<a href={getPdfUrl(editPdfUrl)} target="_blank" rel="noreferrer" className="text-xs text-blue-600 underline">
|
||||
Preview
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{quote.pdfUrl && editingId !== quote.id && ( // Hide view button if editing to reduce clutter or keep it? Keeping it but conditionally
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const url = getPdfUrl(quote.pdfUrl || '');
|
||||
console.log('Opening PDF URL:', url, 'Original Path:', quote.pdfUrl);
|
||||
window.open(url, '_blank');
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm font-medium"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
View PDF
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleSendAction(quote, 'whatsapp')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-odoo-secondary/10 text-odoo-secondary rounded-lg hover:bg-odoo-secondary/20 border border-odoo-secondary/20 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
WhatsApp
|
||||
</button>
|
||||
|
||||
{quote.enquiry?.client?.email && (
|
||||
<button
|
||||
onClick={() => handleSendAction(quote, 'email')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-odoo-primary/10 text-odoo-primary rounded-lg hover:bg-odoo-primary/20 border border-odoo-primary/20 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
Email
|
||||
</button>
|
||||
)}
|
||||
|
||||
{editingId === quote.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={editStatus}
|
||||
onChange={e => setEditStatus(e.target.value)}
|
||||
className="border rounded px-2 py-2 text-sm"
|
||||
>
|
||||
<option value="DRAFT">Draft</option>
|
||||
<option value="SENT">Sent</option>
|
||||
<option value="ACCEPTED">Accepted</option>
|
||||
</select>
|
||||
<button onClick={() => saveEdit(quote.id)} className="p-2 bg-odoo-secondary text-white rounded-lg hover:bg-odoo-secondary/90">
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setEditingId(null)} className="p-2 bg-gray-400 text-white rounded-lg hover:bg-gray-500">
|
||||
<ExternalLink className="w-4 h-4 rotate-180" /> {/* Reuse icon as cancel/back */}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => startEditing(quote)} className="p-2 text-gray-400 hover:text-blue-600 transition-colors">
|
||||
<Edit2 className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{quotes.length === 0 && !loading && (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-xl border border-dashed border-gray-300">
|
||||
<p className="text-gray-500">No quotes found. Create one above.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,354 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
BarChart3,
|
||||
PieChart,
|
||||
TrendingUp,
|
||||
Users,
|
||||
IndianRupee,
|
||||
Calendar,
|
||||
Download,
|
||||
ChevronDown,
|
||||
Search,
|
||||
Filter,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
Loader2,
|
||||
FileText,
|
||||
Target
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler
|
||||
} from 'chart.js';
|
||||
import { Bar, Pie, Line } from 'react-chartjs-2';
|
||||
import api from '../lib/axios';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler
|
||||
);
|
||||
|
||||
export default function Reports() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState<'sales' | 'team' | 'expenses'>('sales');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [reportData, setReportData] = useState<any>(null);
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
|
||||
// Filters
|
||||
const [filters, setFilters] = useState({
|
||||
startDate: format(startOfMonth(new Date()), 'yyyy-MM-dd'),
|
||||
endDate: format(endOfMonth(new Date()), 'yyyy-MM-dd'),
|
||||
userId: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReport();
|
||||
}, [filters, activeTab]);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
let endpoint = '/users';
|
||||
if (currentUser?.role === 'MANAGER') {
|
||||
endpoint = '/users/me/subordinates';
|
||||
}
|
||||
const { data } = await api.get(endpoint);
|
||||
setUsers(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch users', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchReport = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const query = new URLSearchParams(filters as any).toString();
|
||||
const { data } = await api.get(`/reports/summary?${query}`);
|
||||
setReportData(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch reports', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !reportData) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-white">
|
||||
<Loader2 className="w-8 h-8 text-odoo-primary animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary, trends, distribution, teamPerformance } = reportData || {};
|
||||
|
||||
const trendChartData = {
|
||||
labels: trends?.salesTrend?.map((t: any) => t.month) || [],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Revenue',
|
||||
data: trends?.salesTrend?.map((t: any) => t.revenue) || [],
|
||||
borderColor: '#714B67',
|
||||
backgroundColor: 'rgba(113, 75, 103, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const stageChartData = {
|
||||
labels: distribution?.stages?.map((s: any) => s.name) || [],
|
||||
datasets: [{
|
||||
data: distribution?.stages?.map((s: any) => s.count) || [],
|
||||
backgroundColor: ['#714B67', '#00A09D', '#F0698C', '#017E84'],
|
||||
borderWidth: 0,
|
||||
}]
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-white">
|
||||
{/* Control Bar - Odoo Style */}
|
||||
<div className="border-b border-gray-200 px-6 py-4 flex flex-col md:flex-row md:items-center justify-between space-y-4 md:space-y-0 bg-white sticky top-0 z-10">
|
||||
<div className="flex items-center space-x-6">
|
||||
<h2 className="text-xl font-bold text-gray-800">Analytics</h2>
|
||||
<div className="flex bg-gray-100 p-1 rounded-lg">
|
||||
{(['sales', 'team', 'expenses'] as const).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-1.5 text-xs font-bold rounded-md transition-all ${
|
||||
activeTab === tab
|
||||
? 'bg-white text-odoo-primary shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{tab.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-2 bg-gray-50 border border-gray-100 rounded-lg px-3 py-1.5">
|
||||
<Calendar size={14} className="text-gray-400" />
|
||||
<input
|
||||
type="date"
|
||||
className="bg-transparent text-xs font-bold text-gray-600 outline-none"
|
||||
value={filters.startDate}
|
||||
onChange={e => setFilters({...filters, startDate: e.target.value})}
|
||||
/>
|
||||
<span className="text-gray-300">→</span>
|
||||
<input
|
||||
type="date"
|
||||
className="bg-transparent text-xs font-bold text-gray-600 outline-none"
|
||||
value={filters.endDate}
|
||||
onChange={e => setFilters({...filters, endDate: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(currentUser?.role === 'ADMIN' || currentUser?.role === 'MANAGER') && (
|
||||
<select
|
||||
className="bg-gray-50 border border-gray-100 text-xs font-bold text-gray-600 rounded-lg px-3 py-1.5 outline-none"
|
||||
value={filters.userId}
|
||||
onChange={e => setFilters({...filters, userId: e.target.value})}
|
||||
>
|
||||
<option value="">{currentUser?.role === 'ADMIN' ? 'All Company' : 'All Branch'}</option>
|
||||
<option value={currentUser.id}>Only Me</option>
|
||||
{users.filter(u => u.id !== currentUser.id).map(u => (
|
||||
<option key={u.id} value={u.id}>{u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button className="bg-odoo-primary hover:bg-odoo-primary/90 text-white p-2 rounded-lg shadow-lg shadow-odoo-primary/20 transition-all">
|
||||
<Download size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-8 custom-scrollbar">
|
||||
{/* Stats Summary Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div className="odoo-card p-6 border-l-4 border-odoo-primary">
|
||||
<p className="text-[11px] font-bold text-gray-400 uppercase tracking-widest mb-1">Total Revenue</p>
|
||||
<h3 className="text-2xl font-black text-gray-800">₹{(summary?.totalRevenue || 0).toLocaleString()}</h3>
|
||||
<div className="flex items-center text-[10px] font-bold text-emerald-500 mt-2">
|
||||
<ArrowUpRight size={12} className="mr-1" /> 12% vs last period
|
||||
</div>
|
||||
</div>
|
||||
<div className="odoo-card p-6 border-l-4 border-odoo-secondary">
|
||||
<p className="text-[11px] font-bold text-gray-400 uppercase tracking-widest mb-1">Conversions</p>
|
||||
<h3 className="text-2xl font-black text-gray-800">{summary?.conversionCount || 0} Clients</h3>
|
||||
<p className="text-[10px] text-gray-400 font-medium mt-2">From {summary?.totalEnquiries || 0} enquiries</p>
|
||||
</div>
|
||||
<div className="odoo-card p-6 border-l-4 border-amber-400">
|
||||
<p className="text-[11px] font-bold text-gray-400 uppercase tracking-widest mb-1">Pipeline Value</p>
|
||||
<h3 className="text-2xl font-black text-gray-800">₹{(summary?.openPipeline || 0).toLocaleString()}</h3>
|
||||
<p className="text-[10px] text-gray-400 font-medium mt-2">Potential future revenue</p>
|
||||
</div>
|
||||
<div className="odoo-card p-6 border-l-4 border-rose-400">
|
||||
<p className="text-[11px] font-bold text-gray-400 uppercase tracking-widest mb-1">Expenses</p>
|
||||
<h3 className="text-2xl font-black text-gray-800">₹{(summary?.totalExpenses || 0).toLocaleString()}</h3>
|
||||
<p className="text-[10px] text-gray-400 font-medium mt-2">Total logistics & costs</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Charts Column */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
{activeTab === 'sales' && (
|
||||
<>
|
||||
<div className="odoo-card p-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-800">Revenue Velocity</h3>
|
||||
<p className="text-xs text-gray-400 font-medium">Monthly performance overview</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-xs font-bold text-odoo-primary bg-indigo-50 px-3 py-1 rounded-full">
|
||||
<TrendingUp size={14} />
|
||||
<span>Target: ₹5.0L</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[300px]">
|
||||
<Line
|
||||
data={trendChartData}
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
y: { grid: { color: '#f1f5f9' }, border: { display: false } },
|
||||
x: { grid: { display: false }, border: { display: false } }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="odoo-card p-0 overflow-hidden">
|
||||
<div className="px-8 py-4 border-b border-gray-50 flex items-center justify-between bg-gray-50/50">
|
||||
<h3 className="font-bold text-gray-800">Team Performance Rankings</h3>
|
||||
<Users size={16} className="text-gray-400" />
|
||||
</div>
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="text-[11px] font-black text-gray-400 uppercase tracking-wider">
|
||||
<th className="px-8 py-3">User</th>
|
||||
<th className="px-8 py-3 text-right">Enquiries</th>
|
||||
<th className="px-8 py-3 text-right">Revenue</th>
|
||||
<th className="px-8 py-3 text-right">Conversion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{teamPerformance?.map((perf: any, idx: number) => (
|
||||
<tr key={idx} className="hover:bg-gray-50/50 transition-all">
|
||||
<td className="px-8 py-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center font-bold text-gray-500 text-xs shadow-inner">
|
||||
{perf.name.charAt(0)}
|
||||
</div>
|
||||
<span className="text-sm font-bold text-gray-700">{perf.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-4 text-sm font-medium text-gray-600 text-right">{perf.enquiries}</td>
|
||||
<td className="px-8 py-4 text-sm font-black text-gray-800 text-right">₹{perf.revenue.toLocaleString()}</td>
|
||||
<td className="px-8 py-4 text-right">
|
||||
<div className="inline-flex items-center space-x-1 px-2 py-0.5 rounded bg-emerald-50 text-emerald-600 text-[10px] font-bold">
|
||||
<ArrowUpRight size={10} />
|
||||
<span>{perf.enquiries > 0 ? ((perf.revenue / (perf.enquiries * 5000)) * 100).toFixed(0) : 0}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Breakdown Column */}
|
||||
<div className="space-y-8">
|
||||
<div className="odoo-card p-8">
|
||||
<h3 className="font-bold text-gray-800 mb-6">Stage Distribution</h3>
|
||||
<div className="h-[250px] relative">
|
||||
<Pie
|
||||
data={stageChartData}
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'bottom', labels: { usePointStyle: true, font: { size: 11, weight: 'bold' } } } }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="odoo-card p-8 bg-odoo-primary text-white relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10">
|
||||
<Target size={80} />
|
||||
</div>
|
||||
<h4 className="text-xl font-black mb-2">Goal Tracking</h4>
|
||||
<p className="text-white/60 text-xs mb-6 font-medium leading-relaxed">
|
||||
You have achieved 64% of your monthly sales target. Keep focusing on "Proposition" stage deals!
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-[10px] font-black uppercase tracking-widest">
|
||||
<span>Progress</span>
|
||||
<span>₹{summary?.totalRevenue?.toLocaleString()} / ₹5.0L</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-white/10 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-white rounded-full" style={{ width: '64%' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="odoo-card p-6">
|
||||
<h3 className="font-bold text-gray-800 mb-4">Export Reports</h3>
|
||||
<div className="space-y-2">
|
||||
<button className="w-full flex items-center justify-between p-3 rounded-lg border border-gray-100 hover:border-odoo-primary hover:bg-indigo-50 transition-all group">
|
||||
<div className="flex items-center space-x-3">
|
||||
<FileText className="text-gray-400 group-hover:text-odoo-primary" size={18} />
|
||||
<span className="text-sm font-bold text-gray-600">Sales Summary PDF</span>
|
||||
</div>
|
||||
<Download size={14} className="text-gray-300 group-hover:text-odoo-primary" />
|
||||
</button>
|
||||
<button className="w-full flex items-center justify-between p-3 rounded-lg border border-gray-100 hover:border-emerald-500 hover:bg-emerald-50 transition-all group">
|
||||
<div className="flex items-center space-x-3">
|
||||
<FileText className="text-gray-400 group-hover:text-emerald-500" size={18} />
|
||||
<span className="text-sm font-bold text-gray-600">Leads Raw Data (CSV)</span>
|
||||
</div>
|
||||
<Download size={14} className="text-gray-300 group-hover:text-emerald-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import api from '@/lib/axios';
|
||||
import { Key, Save, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
export default function Settings() {
|
||||
const { user } = useAuth();
|
||||
const [oldPassword, setOldPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('New passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError('New password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.post('/users/me/change-password', {
|
||||
oldPassword,
|
||||
newPassword
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || err.message || 'Failed to change password');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl mx-auto">
|
||||
<div className="flex items-center space-x-3 mb-8">
|
||||
<div className="p-2 bg-odoo-primary/10 rounded-lg">
|
||||
<Key className="w-6 h-6 text-odoo-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-extrabold text-odoo-primary tracking-tight">Account Settings</h2>
|
||||
<p className="text-slate-500 text-sm font-medium">Manage your security and profile preferences</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-[32px] border-2 border-slate-100 shadow-[0_8px_30px_rgb(0,0,0,0.04)] overflow-hidden">
|
||||
<div className="p-8">
|
||||
<h3 className="text-lg font-bold text-slate-800 mb-6 flex items-center">
|
||||
<span className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center mr-3 text-sm">01</span>
|
||||
Change Password
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-slate-600 ml-1">Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-2xl bg-slate-50 border-2 border-transparent focus:border-odoo-primary focus:bg-white transition-all outline-none"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-slate-600 ml-1">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-2xl bg-slate-50 border-2 border-transparent focus:border-odoo-primary focus:bg-white transition-all outline-none"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-slate-600 ml-1">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-2xl bg-slate-50 border-2 border-transparent focus:border-odoo-primary focus:bg-white transition-all outline-none"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center space-x-2 p-4 bg-rose-50 text-rose-600 rounded-2xl border border-rose-100 animate-in fade-in slide-in-from-top-2">
|
||||
<AlertCircle size={18} />
|
||||
<p className="text-sm font-bold">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="flex items-center space-x-2 p-4 bg-emerald-50 text-emerald-600 rounded-2xl border border-emerald-100 animate-in fade-in slide-in-from-top-2">
|
||||
<CheckCircle2 size={18} />
|
||||
<p className="text-sm font-bold">Password updated successfully!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full md:w-auto px-8 py-4 bg-odoo-primary text-white rounded-2xl font-bold hover:bg-odoo-primary/90 transition-all flex items-center justify-center space-x-2 disabled:opacity-50 shadow-lg shadow-odoo-primary/20"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||
) : (
|
||||
<>
|
||||
<Save size={18} />
|
||||
<span>Update Password</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 p-8 border-t border-slate-100">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="p-2 bg-amber-100 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-slate-800">Security Recommendation</h4>
|
||||
<p className="text-xs text-slate-500 mt-1 leading-relaxed">
|
||||
Use a strong password that you don't use elsewhere.
|
||||
We recommend a mix of uppercase letters, numbers, and symbols.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,469 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../lib/axios';
|
||||
import {
|
||||
Target,
|
||||
User,
|
||||
TrendingUp,
|
||||
Target as TargetIcon,
|
||||
IndianRupee,
|
||||
Users,
|
||||
Plus,
|
||||
Edit2,
|
||||
CheckCircle2,
|
||||
BarChart3,
|
||||
Activity,
|
||||
Search
|
||||
} from 'lucide-react';
|
||||
|
||||
interface UserData {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface TargetData {
|
||||
id: string;
|
||||
userId: string;
|
||||
month: number;
|
||||
year: number;
|
||||
monthlyTarget: number;
|
||||
minTarget: number;
|
||||
weeklyTarget: number;
|
||||
dailyLeadTarget: number;
|
||||
requiredLeads: number;
|
||||
requiredPotential: number;
|
||||
requiredDemos: number;
|
||||
requiredClosures: number;
|
||||
avgDealValue: number;
|
||||
user?: { name: string };
|
||||
}
|
||||
|
||||
export default function TargetManager() {
|
||||
const [users, setUsers] = useState<UserData[]>([]);
|
||||
const [targets, setTargets] = useState<TargetData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [editingTarget, setEditingTarget] = useState<Partial<TargetData> | null>(null);
|
||||
const [selectedUserId, setSelectedUserId] = useState('');
|
||||
const [monthlyTarget, setMonthlyTarget] = useState(400000);
|
||||
const [minTarget, setMinTarget] = useState(200000);
|
||||
const [avgDealValue, setAvgDealValue] = useState(40000);
|
||||
const [month, setMonth] = useState(new Date().getMonth() + 1);
|
||||
const [year, setYear] = useState(new Date().getFullYear());
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Dynamic Engine State
|
||||
const [requiredClosures, setRequiredClosures] = useState(0);
|
||||
const [requiredDemos, setRequiredDemos] = useState(0);
|
||||
const [requiredPotential, setRequiredPotential] = useState(0);
|
||||
const [requiredLeads, setRequiredLeads] = useState(0);
|
||||
const [dailyLeadTarget, setDailyLeadTarget] = useState(0);
|
||||
|
||||
// Auto-calculate defaults when core values change
|
||||
useEffect(() => {
|
||||
if (!editingTarget) {
|
||||
const calcClosures = Math.ceil(monthlyTarget / avgDealValue);
|
||||
const calcDemos = calcClosures * 3;
|
||||
const calcPotential = calcDemos * 2;
|
||||
const calcLeads = calcPotential * 5 * 2;
|
||||
const calcDaily = Math.ceil(calcLeads / 25);
|
||||
|
||||
setRequiredClosures(calcClosures);
|
||||
setRequiredDemos(calcDemos);
|
||||
setRequiredPotential(calcPotential);
|
||||
setRequiredLeads(calcLeads);
|
||||
setDailyLeadTarget(calcDaily);
|
||||
}
|
||||
}, [monthlyTarget, avgDealValue, editingTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [usersRes, targetsRes] = await Promise.all([
|
||||
api.get('/users'),
|
||||
api.get('/targets')
|
||||
]);
|
||||
setUsers(usersRes.data);
|
||||
setTargets(targetsRes.data);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data', error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const payload = {
|
||||
userId: selectedUserId,
|
||||
month: Number(month),
|
||||
year: Number(year),
|
||||
monthlyTarget: Number(monthlyTarget),
|
||||
minTarget: Number(minTarget),
|
||||
avgDealValue: Number(avgDealValue),
|
||||
requiredClosures: Number(requiredClosures),
|
||||
requiredDemos: Number(requiredDemos),
|
||||
requiredPotential: Number(requiredPotential),
|
||||
requiredLeads: Number(requiredLeads),
|
||||
dailyLeadTarget: Number(dailyLeadTarget)
|
||||
};
|
||||
|
||||
if (editingTarget?.id) {
|
||||
await api.patch(`/targets/${editingTarget.id}`, payload);
|
||||
} else {
|
||||
await api.post('/targets', payload);
|
||||
}
|
||||
|
||||
setIsModalOpen(false);
|
||||
setEditingTarget(null);
|
||||
fetchData();
|
||||
alert('Target saved successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Failed to save target');
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingTarget(null);
|
||||
setSelectedUserId('');
|
||||
setMonthlyTarget(400000);
|
||||
setMinTarget(200000);
|
||||
setAvgDealValue(40000);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (target: TargetData) => {
|
||||
setEditingTarget(target);
|
||||
setSelectedUserId(target.userId);
|
||||
setMonthlyTarget(target.monthlyTarget);
|
||||
setMinTarget(target.minTarget);
|
||||
setAvgDealValue(target.avgDealValue || 40000);
|
||||
setMonth(target.month);
|
||||
setYear(target.year);
|
||||
|
||||
// Load existing benchmarks
|
||||
setRequiredClosures(target.requiredClosures || Math.ceil(target.monthlyTarget / (target.avgDealValue || 40000)));
|
||||
setRequiredDemos(target.requiredDemos || 0);
|
||||
setRequiredPotential(target.requiredPotential || 0);
|
||||
setRequiredLeads(target.requiredLeads || 0);
|
||||
setDailyLeadTarget(target.dailyLeadTarget || 0);
|
||||
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const filteredTargets = targets.filter(t =>
|
||||
t.user?.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-1 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between px-2">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black text-slate-800 tracking-tight flex items-center">
|
||||
<TargetIcon className="mr-3 text-odoo-primary" size={32} />
|
||||
Target Engine
|
||||
</h1>
|
||||
<p className="text-slate-500 font-medium">
|
||||
Configure and monitor sales benchmarks for your team.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 md:mt-0 flex items-center space-x-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search team member..."
|
||||
className="pl-10 pr-4 py-2 bg-white border border-slate-200 rounded-xl text-sm font-medium focus:ring-2 focus:ring-odoo-primary/20 outline-none transition-all w-64 shadow-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="bg-odoo-primary text-white px-6 py-2.5 rounded-xl shadow-lg shadow-odoo-primary/20 hover:scale-105 transition-all flex items-center font-bold"
|
||||
>
|
||||
<Plus size={18} className="mr-2" />
|
||||
Set New Target
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target Cards Grid */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
{loading ? (
|
||||
<div className="col-span-full py-20 text-center">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-odoo-primary mx-auto"></div>
|
||||
</div>
|
||||
) : filteredTargets.length === 0 ? (
|
||||
<div className="col-span-full py-20 text-center odoo-card bg-slate-50 border-dashed border-2">
|
||||
<Activity className="mx-auto text-slate-300 mb-4" size={48} />
|
||||
<p className="text-slate-500 font-bold uppercase tracking-widest text-sm">No targets configured for this period</p>
|
||||
</div>
|
||||
) : filteredTargets.map((target) => (
|
||||
<div key={target.id} className="odoo-card group relative overflow-hidden hover:shadow-2xl hover:shadow-indigo-500/10 transition-all duration-500 border border-slate-100">
|
||||
{/* Glass Overlay Effect */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-odoo-primary opacity-[0.03] rounded-bl-full translate-x-8 -translate-y-8" />
|
||||
|
||||
<div className="p-8">
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-14 h-14 rounded-2xl bg-indigo-50 flex items-center justify-center text-indigo-600 font-black text-xl shadow-inner">
|
||||
{target.user?.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-black text-slate-800">{target.user?.name}</h3>
|
||||
<div className="flex items-center text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">
|
||||
<TrendingUp size={12} className="mr-1 text-emerald-500" />
|
||||
Target Period: {new Date(target.year, target.month-1).toLocaleString('default', { month: 'long', year: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => openEditModal(target)}
|
||||
className="relative z-10 p-2.5 rounded-xl bg-slate-50 text-slate-400 hover:bg-odoo-primary/10 hover:text-odoo-primary transition-all"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Revenue Stats */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-8">
|
||||
<div className="bg-slate-50/50 rounded-[20px] p-5 border border-slate-100">
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">Monthly Target</p>
|
||||
<div className="flex items-baseline space-x-1">
|
||||
<span className="text-2xl font-black text-slate-800">₹{(target.monthlyTarget / 100000).toFixed(1)}</span>
|
||||
<span className="text-sm font-bold text-slate-500 uppercase">Lacs</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-emerald-50/30 rounded-[20px] p-5 border border-emerald-100/50">
|
||||
<p className="text-[10px] font-black text-emerald-600/60 uppercase tracking-widest mb-1">Minimum Goal</p>
|
||||
<div className="flex items-baseline space-x-1">
|
||||
<span className="text-2xl font-black text-emerald-700">₹{(target.minTarget / 100000).toFixed(1)}</span>
|
||||
<span className="text-sm font-bold text-emerald-600/60 uppercase">Lacs</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Intelligent Benchmarks */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-[10px] font-black text-slate-300 uppercase tracking-[0.2em]">Activity Benchmarks</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-3 rounded-2xl hover:bg-slate-50 transition-colors">
|
||||
<p className="text-lg font-black text-slate-700">{target.dailyLeadTarget}</p>
|
||||
<p className="text-[9px] font-bold text-slate-400 uppercase">Daily Leads</p>
|
||||
</div>
|
||||
<div className="text-center p-3 rounded-2xl hover:bg-slate-50 transition-colors border-x border-slate-50">
|
||||
<p className="text-lg font-black text-slate-700">{target.requiredDemos}</p>
|
||||
<p className="text-[9px] font-bold text-slate-400 uppercase">Demos</p>
|
||||
</div>
|
||||
<div className="text-center p-3 rounded-2xl hover:bg-slate-50 transition-colors border-r border-slate-50">
|
||||
<p className="text-lg font-black text-slate-700">{target.requiredPotential}</p>
|
||||
<p className="text-[9px] font-bold text-slate-400 uppercase">Potentials</p>
|
||||
</div>
|
||||
<div className="text-center p-3 rounded-2xl hover:bg-slate-50 transition-colors">
|
||||
<p className="text-lg font-black text-emerald-600">{target.requiredClosures}</p>
|
||||
<p className="text-[9px] font-bold text-slate-400 uppercase">Closures</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Visual */}
|
||||
<div className="mt-8 pt-6 border-t border-slate-50">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider">Growth Path</span>
|
||||
<span className="text-xs font-black text-indigo-600">33% Efficiency Rate</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden flex">
|
||||
<div className="h-full bg-odoo-primary w-[33%] rounded-full shadow-[0_0_10px_rgba(113,75,103,0.3)]" />
|
||||
<div className="h-full bg-indigo-200 w-[40%] opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Config Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={() => setIsModalOpen(false)}></div>
|
||||
<div className="bg-white rounded-[32px] w-full max-w-4xl overflow-hidden shadow-2xl relative z-10 animate-in zoom-in-95 duration-300">
|
||||
<div className="flex h-full flex-col md:flex-row">
|
||||
{/* Left: Form */}
|
||||
<div className="flex-1 p-10">
|
||||
<h3 className="text-2xl font-black text-slate-800 mb-8">
|
||||
{editingTarget ? 'Edit Target' : 'Configure New Target'}
|
||||
</h3>
|
||||
<form onSubmit={handleSave} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">Team Member</label>
|
||||
<select
|
||||
value={selectedUserId}
|
||||
onChange={e => setSelectedUserId(e.target.value)}
|
||||
className="w-full bg-slate-50 border-none rounded-2xl py-4 px-6 text-sm font-bold text-slate-700 outline-none focus:ring-2 focus:ring-odoo-primary/20 transition-all"
|
||||
disabled={!!editingTarget}
|
||||
required
|
||||
>
|
||||
<option value="">Select a user...</option>
|
||||
{users.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.name} ({u.role.replace(/_/g, ' ')})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">Monthly Target (₹)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={monthlyTarget}
|
||||
onChange={e => setMonthlyTarget(Number(e.target.value))}
|
||||
className="w-full bg-slate-50 border-none rounded-2xl py-4 px-6 text-sm font-bold text-slate-700 outline-none focus:ring-2 focus:ring-odoo-primary/20 transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">Minimum Target (₹)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={minTarget}
|
||||
onChange={e => setMinTarget(Number(e.target.value))}
|
||||
className="w-full bg-slate-50 border-none rounded-2xl py-4 px-6 text-sm font-bold text-slate-700 outline-none focus:ring-2 focus:ring-odoo-primary/20 transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">Avg. Deal Value (₹)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={avgDealValue}
|
||||
onChange={e => setAvgDealValue(Number(e.target.value))}
|
||||
className="w-full bg-slate-50 border-none rounded-2xl py-4 px-6 text-sm font-bold text-slate-700 outline-none focus:ring-2 focus:ring-odoo-primary/20 transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">Month</label>
|
||||
<input type="number" min="1" max="12" value={month} onChange={e => setMonth(Number(e.target.value))} className="w-full bg-slate-50 border-none rounded-2xl py-4 px-6 text-sm font-bold text-slate-700 outline-none focus:ring-2 focus:ring-odoo-primary/20 transition-all" required />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">Year</label>
|
||||
<input type="number" value={year} onChange={e => setYear(Number(e.target.value))} className="w-full bg-slate-50 border-none rounded-2xl py-4 px-6 text-sm font-bold text-slate-700 outline-none focus:ring-2 focus:ring-odoo-primary/20 transition-all" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 flex space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="flex-1 bg-slate-100 text-slate-600 py-4 rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-slate-200 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 bg-odoo-primary text-white py-4 rounded-2xl font-black uppercase tracking-widest text-xs shadow-xl shadow-odoo-primary/20 hover:scale-105 transition-all"
|
||||
>
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Right: Live Preview Engine */}
|
||||
<div className="w-full md:w-[320px] bg-indigo-900 text-white p-10 relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-b from-odoo-primary/20 to-transparent" />
|
||||
<div className="relative z-10">
|
||||
<h4 className="text-xs font-black uppercase tracking-widest text-white/40 mb-10">Engine Output</h4>
|
||||
|
||||
<div className="space-y-10">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3 bg-white/10 rounded-2xl backdrop-blur-md">
|
||||
<Activity size={24} className="text-indigo-300" />
|
||||
</div>
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="number"
|
||||
value={dailyLeadTarget}
|
||||
onChange={e => setDailyLeadTarget(Number(e.target.value))}
|
||||
className="w-full bg-transparent text-3xl font-black leading-none outline-none text-white border-b border-white/20 pb-1 focus:border-white transition-all"
|
||||
/>
|
||||
<p className="text-[9px] font-bold text-white/50 uppercase tracking-widest mt-1">Leads / Day</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-6 border-t border-white/10">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] font-bold text-white/40 uppercase tracking-widest flex-1">Required Demos</span>
|
||||
<input
|
||||
type="number"
|
||||
value={requiredDemos}
|
||||
onChange={e => setRequiredDemos(Number(e.target.value))}
|
||||
className="w-20 bg-white/10 text-white text-sm font-black text-right px-2 py-1 rounded outline-none focus:ring-1 focus:ring-white/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] font-bold text-white/40 uppercase tracking-widest flex-1">Pipeline Potentials</span>
|
||||
<input
|
||||
type="number"
|
||||
value={requiredPotential}
|
||||
onChange={e => setRequiredPotential(Number(e.target.value))}
|
||||
className="w-20 bg-white/10 text-white text-sm font-black text-right px-2 py-1 rounded outline-none focus:ring-1 focus:ring-white/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] font-bold text-white/40 uppercase tracking-widest flex-1">Qualified Leads</span>
|
||||
<input
|
||||
type="number"
|
||||
value={requiredLeads}
|
||||
onChange={e => setRequiredLeads(Number(e.target.value))}
|
||||
className="w-20 bg-white/10 text-white text-sm font-black text-right px-2 py-1 rounded outline-none focus:ring-1 focus:ring-white/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] font-bold text-white/40 uppercase tracking-widest flex-1">Closure Goal</span>
|
||||
<input
|
||||
type="number"
|
||||
value={requiredClosures}
|
||||
onChange={e => setRequiredClosures(Number(e.target.value))}
|
||||
className="w-20 bg-emerald-500/20 text-emerald-400 text-sm font-black text-right px-2 py-1 rounded outline-none focus:ring-1 focus:ring-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 p-6 bg-white/5 rounded-3xl border border-white/10 backdrop-blur-sm">
|
||||
<p className="text-[9px] font-bold text-white/40 uppercase tracking-widest mb-4">Logic Assumptions</p>
|
||||
<ul className="space-y-3">
|
||||
<li className="flex items-center text-[10px] font-medium text-white/60">
|
||||
<CheckCircle2 size={12} className="mr-2 text-emerald-500" /> 1:3 Win Ratio (Demos)
|
||||
</li>
|
||||
<li className="flex items-center text-[10px] font-medium text-white/60">
|
||||
<CheckCircle2 size={12} className="mr-2 text-emerald-500" /> 1:5 Quality Lead Ratio
|
||||
</li>
|
||||
<li className="flex items-center text-[10px] font-medium text-white/60">
|
||||
<CheckCircle2 size={12} className="mr-2 text-emerald-500" /> 25 Working Days / Month
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Users, TrendingUp, Award, AlertTriangle, ShieldCheck } from 'lucide-react';
|
||||
import api from '../lib/axios';
|
||||
|
||||
interface TeamMemberPerformance {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
score: number;
|
||||
tag: string;
|
||||
lastUpdated: string | null;
|
||||
}
|
||||
|
||||
export default function TeamPerformance() {
|
||||
const [teamData, setTeamData] = useState<TeamMemberPerformance[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTeamData = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/performance/team');
|
||||
setTeamData(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch team performance', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchTeamData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="h-48 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-odoo-primary"></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="odoo-card overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-50 flex items-center justify-between bg-white">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Users size={18} className="text-odoo-primary" />
|
||||
<h3 className="font-bold text-slate-800 text-[15px]">Team Performance Leaderboard</h3>
|
||||
</div>
|
||||
<Award size={18} className="text-amber-400" />
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="bg-slate-50/50 text-[11px] font-black text-slate-400 uppercase tracking-widest">
|
||||
<th className="px-6 py-3">Member</th>
|
||||
<th className="px-6 py-3">Role</th>
|
||||
<th className="px-6 py-3 text-center">Score</th>
|
||||
<th className="px-6 py-3 text-right">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{teamData.sort((a, b) => b.score - a.score).map((member) => (
|
||||
<tr key={member.id} className="hover:bg-slate-50/30 transition-all group">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-indigo-50 flex items-center justify-center text-indigo-500 font-bold text-xs uppercase group-hover:bg-indigo-500 group-hover:text-white transition-all">
|
||||
{member.name.charAt(0)}
|
||||
</div>
|
||||
<span className="text-sm font-bold text-slate-700">{member.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-tighter bg-slate-100 px-2 py-0.5 rounded">
|
||||
{member.role.replace('_', ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className={`text-sm font-black ${
|
||||
member.score >= 80 ? 'text-emerald-600' :
|
||||
member.score >= 50 ? 'text-amber-600' : 'text-rose-600'
|
||||
}`}>
|
||||
{Math.round(member.score)}
|
||||
</span>
|
||||
<div className="w-16 h-1 bg-slate-100 rounded-full mt-1 overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-1000 ${
|
||||
member.score >= 80 ? 'bg-emerald-500' :
|
||||
member.score >= 50 ? 'bg-amber-500' : 'bg-rose-500'
|
||||
}`}
|
||||
style={{ width: `${member.score}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<span className={`text-[10px] font-black px-2 py-0.5 rounded-full ${
|
||||
member.tag === 'ON_TRACK' ? 'bg-emerald-50 text-emerald-600' :
|
||||
member.tag === 'RISK' ? 'bg-amber-50 text-amber-600' :
|
||||
'bg-rose-50 text-rose-600'
|
||||
}`}>
|
||||
{member.tag.replace('_', ' ')}
|
||||
</span>
|
||||
{member.tag === 'ON_TRACK' ? (
|
||||
<ShieldCheck size={14} className="text-emerald-500" />
|
||||
) : (
|
||||
<AlertTriangle size={14} className={member.tag === 'RISK' ? 'text-amber-500' : 'text-rose-500'} />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{teamData.length === 0 && (
|
||||
<div className="p-12 text-center text-slate-400 text-sm font-medium">
|
||||
No performance data available for this month yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../lib/axios';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { Check, X } from 'lucide-react';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
status: string;
|
||||
managerId?: string;
|
||||
manager?: { name: string };
|
||||
}
|
||||
|
||||
export default function UserManager() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState('TELESALES_EXECUTIVE');
|
||||
const [managerId, setManagerId] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const isAdminOrGM = currentUser?.role === 'ADMIN' || currentUser?.role === 'GENERAL_MANAGER';
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await api.get('/users');
|
||||
setUsers(response.data);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.post('/users', {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
role,
|
||||
managerId: managerId || null
|
||||
});
|
||||
setName('');
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
setManagerId('');
|
||||
fetchUsers();
|
||||
alert(isAdminOrGM ? 'User created successfully' : 'User created and sent for admin approval');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Failed to create user');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatus = async (id: string, status: string) => {
|
||||
if (!confirm(`Are you sure you want to ${status.toLowerCase()} this user?`)) return;
|
||||
try {
|
||||
await api.patch(`/users/${id}/status`, { status });
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert(`Failed to ${status.toLowerCase()} user`);
|
||||
}
|
||||
};
|
||||
|
||||
const pendingUsers = users.filter(u => u.status === 'PENDING');
|
||||
const approvedUsers = users.filter(u => u.status === 'APPROVED');
|
||||
|
||||
return (
|
||||
<div className="odoo-card p-6 mb-8">
|
||||
<h3 className="text-xl font-bold mb-4 text-gray-800">User Management</h3>
|
||||
|
||||
<form onSubmit={handleCreate} className="mb-8 grid grid-cols-1 md:grid-cols-2 gap-6 bg-gray-50 p-6 rounded-xl border border-gray-100">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Name</label>
|
||||
<input type="text" value={name} onChange={e => setName(e.target.value)} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Email</label>
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Password</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Role</label>
|
||||
<select value={role} onChange={e => setRole(e.target.value)} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 font-semibold">
|
||||
<option value="TELESALES_EXECUTIVE">Telesales Executive</option>
|
||||
<option value="OFFICER">Officer</option>
|
||||
<option value="MANAGER">Manager</option>
|
||||
<option value="GENERAL_MANAGER">General Manager</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Reporting To (Manager)</label>
|
||||
<select value={managerId} onChange={e => setManagerId(e.target.value)} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2">
|
||||
<option value="">None / Top Level</option>
|
||||
{users.filter(u => u.role !== 'TELESALES_EXECUTIVE').map(u => (
|
||||
<option key={u.id} value={u.id}>{u.name} ({u.role.replace(/_/g, ' ')})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2 pt-2">
|
||||
<button type="submit" disabled={creating} className="bg-odoo-primary text-white px-6 py-2.5 rounded-lg hover:bg-odoo-primary/90 font-bold shadow-sm transition-all transform hover:-translate-y-0.5">
|
||||
{creating ? 'Saving...' : 'Add User'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Pending Approvals Section */}
|
||||
{isAdminOrGM && pendingUsers.length > 0 && (
|
||||
<div className="mb-10">
|
||||
<h4 className="text-lg font-bold mb-4 text-orange-600 flex items-center">
|
||||
<span className="bg-orange-100 text-orange-600 w-6 h-6 rounded-full flex items-center justify-center mr-2 text-sm">{pendingUsers.length}</span>
|
||||
Pending Approvals
|
||||
</h4>
|
||||
<div className="overflow-x-auto border border-orange-100 rounded-xl">
|
||||
<table className="min-w-full divide-y divide-orange-100">
|
||||
<thead className="bg-orange-50/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-orange-600 uppercase tracking-wider">Name</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-orange-600 uppercase tracking-wider">Email</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-orange-600 uppercase tracking-wider">Role</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-orange-600 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-orange-50">
|
||||
{pendingUsers.map(user => (
|
||||
<tr key={user.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap font-medium">{user.name}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-gray-500">{user.email}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-3 py-1 inline-flex text-[10px] leading-5 font-black rounded-full uppercase tracking-wider bg-orange-100 text-orange-700">
|
||||
{user.role.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button onClick={() => updateStatus(user.id, 'APPROVED')} className="text-emerald-600 bg-emerald-50 p-2 rounded-lg hover:bg-emerald-100 mr-2 transition-colors" title="Approve">
|
||||
<Check size={18} />
|
||||
</button>
|
||||
<button onClick={() => updateStatus(user.id, 'REJECTED')} className="text-rose-600 bg-rose-50 p-2 rounded-lg hover:bg-rose-100 transition-colors" title="Reject">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 border border-gray-100 rounded-xl overflow-hidden">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Reporting To</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{loading ? <tr><td colSpan={5} className="text-center py-4">Loading...</td></tr> :
|
||||
approvedUsers.length === 0 ? <tr><td colSpan={5} className="text-center py-4">No approved users found</td></tr> :
|
||||
approvedUsers.map(user => (
|
||||
<tr key={user.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap font-medium text-gray-900">{user.name}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-gray-500">{user.email}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-3 py-1 inline-flex text-[10px] leading-5 font-black rounded-full uppercase tracking-wider ${
|
||||
user.role === 'ADMIN' ? 'bg-indigo-100 text-indigo-700' :
|
||||
user.role === 'GENERAL_MANAGER' ? 'bg-purple-100 text-purple-700' :
|
||||
user.role === 'MANAGER' ? 'bg-odoo-secondary/10 text-odoo-secondary' :
|
||||
user.role === 'OFFICER' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{user.role.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{users.find(u => u.id === user.managerId)?.name || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2.5 py-1 inline-flex text-[10px] leading-5 font-bold rounded-md bg-emerald-50 text-emerald-600">
|
||||
Active
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Cookies from 'js-cookie';
|
||||
import api from '../lib/axios';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const token = Cookies.get('token');
|
||||
const userData = Cookies.get('user');
|
||||
|
||||
if (token && userData) {
|
||||
setUser(JSON.parse(userData));
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await api.post('/auth/login', { email, password });
|
||||
const { access_token, user } = response.data;
|
||||
|
||||
Cookies.set('token', access_token, { expires: 7 }); // 7 days
|
||||
Cookies.set('user', JSON.stringify(user), { expires: 7 });
|
||||
setUser(user);
|
||||
router.push('/dashboard');
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
Cookies.remove('token');
|
||||
Cookies.remove('user');
|
||||
setUser(null);
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import axios from 'axios';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = Cookies.get('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
export default api;
|
||||
Loading…
Reference in New Issue