first commit
parent
6009ec4ce9
commit
3e8d86c980
|
|
@ -8,12 +8,27 @@
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"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",
|
"next": "16.1.1",
|
||||||
"react": "19.2.3",
|
"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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|
@ -277,6 +292,60 @@
|
||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
|
||||||
|
|
@ -1022,6 +1091,12 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||||
|
|
@ -1227,6 +1302,17 @@
|
||||||
"node": ">=12.4.0"
|
"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": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
|
|
@ -1532,6 +1618,20 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
|
|
@ -1546,6 +1646,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.27",
|
"version": "20.19.27",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
|
||||||
|
|
@ -2367,6 +2477,12 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/available-typed-arrays": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||||
|
|
@ -2393,6 +2509,17 @@
|
||||||
"node": ">=4"
|
"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": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
|
|
@ -2501,7 +2628,6 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
|
|
@ -2575,12 +2701,34 @@
|
||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"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": {
|
"node_modules/client-only": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
|
@ -2601,6 +2749,18 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
|
|
@ -2698,6 +2858,16 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|
@ -2759,6 +2929,15 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
|
@ -2786,7 +2965,6 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
|
@ -2898,7 +3076,6 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -2908,7 +3085,6 @@
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -2946,7 +3122,6 @@
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
|
|
@ -2959,7 +3134,6 @@
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
|
|
@ -3217,6 +3391,7 @@
|
||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
|
|
@ -3575,6 +3750,26 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
|
|
@ -3591,11 +3786,26 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
|
@ -3656,7 +3866,6 @@
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
|
@ -3681,7 +3890,6 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dunder-proto": "^1.0.1",
|
"dunder-proto": "^1.0.1",
|
||||||
|
|
@ -3769,7 +3977,6 @@
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -3841,7 +4048,6 @@
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -3854,7 +4060,6 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-symbols": "^1.0.3"
|
"has-symbols": "^1.0.3"
|
||||||
|
|
@ -3870,7 +4075,6 @@
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
|
|
@ -4405,6 +4609,15 @@
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|
@ -4518,6 +4731,13 @@
|
||||||
"node": ">=0.10"
|
"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": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
|
|
@ -4839,6 +5059,15 @@
|
||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
|
@ -4853,7 +5082,6 @@
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -4883,6 +5111,27 @@
|
||||||
"node": ">=8.6"
|
"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": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
|
|
@ -5353,6 +5602,12 @@
|
||||||
"react-is": "^16.13.1"
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
|
@ -5394,6 +5649,16 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
|
|
@ -5414,6 +5679,20 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
|
|
@ -6026,6 +6305,16 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.1.18",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
"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",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev -p 3001",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start -p 3001",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"next": "16.1.1",
|
||||||
"react": "19.2.3",
|
"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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^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";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #f8f9fa;
|
||||||
--foreground: #171717;
|
--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 {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--color-odoo-primary: var(--odoo-primary);
|
||||||
--font-mono: var(--font-geist-mono);
|
--color-odoo-secondary: var(--odoo-secondary);
|
||||||
}
|
--color-odoo-accent: var(--odoo-accent);
|
||||||
|
--font-sans: "Inter", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
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 type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
|
|
@ -13,8 +14,13 @@ const geistMono = Geist_Mono({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "IgCRM",
|
||||||
description: "Generated by create next app",
|
description: "Advanced Sales & Client Relationship Management",
|
||||||
|
icons: {
|
||||||
|
icon: "/ignosilogo.png",
|
||||||
|
shortcut: "/ignosilogo.png",
|
||||||
|
apple: "/ignosilogo.png",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|
@ -27,7 +33,9 @@ export default function RootLayout({
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
{children}
|
<AuthProvider>
|
||||||
|
{children}
|
||||||
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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() {
|
export default function Home() {
|
||||||
return (
|
redirect("/dashboard");
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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