first commit

main
Manu Krishna 2026-05-05 14:57:45 +05:30
parent 6009ec4ce9
commit 3e8d86c980
35 changed files with 6763 additions and 95 deletions

319
package-lock.json generated
View File

@ -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",

View File

@ -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",

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

BIN
public/ignosi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
public/ignosilogo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

View File

@ -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 });
}
}

View File

@ -0,0 +1,9 @@
import OpportunityBoard from '@/components/OpportunityBoard';
export default function OpportunitiesPage() {
return (
<div className="p-6 h-full">
<OpportunityBoard />
</div>
);
}

223
src/app/dashboard/page.tsx Normal file
View File

@ -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

View File

@ -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;
} }

BIN
src/app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

View File

@ -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`}
> >
<AuthProvider>
{children} {children}
</AuthProvider>
</body> </body>
</html> </html>
); );

195
src/app/login/page.tsx Normal file
View File

@ -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>
);
}

View File

@ -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>
);
} }

207
src/components/CallLogs.tsx Normal file
View File

@ -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>
);
}

View File

@ -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 &rarr;</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">&larr;</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">
📍 &nbsp;
{/* 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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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} &bull; {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> &bull; {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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

373
src/components/LiveMap.tsx Normal file
View File

@ -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='&copy; 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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

354
src/components/Reports.tsx Normal file
View File

@ -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>
);
}

156
src/components/Settings.tsx Normal file
View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;
};

19
src/lib/axios.ts Normal file
View File

@ -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;