From 3c8c8ee594492b8a9b0c6da2ac7e64f1fe1dbeef Mon Sep 17 00:00:00 2001 From: Chris Olson Date: Mon, 31 Aug 2026 13:55:01 -0400 Subject: [PATCH] Build AgentMinder Campaign Command Center - Phase 1 complete Full-featured sales campaign dashboard replacing Looker Studio with responsive Next.js app. Includes Executive Overview, Account Explorer with priority algorithm, Activities with detail overlay, Pipeline/Opps with deal panels, Implementation tracking, and Data Admin with CSV import and CRUD operations. Co-Authored-By: Claude Opus 4.6 --- .claude/launch.json | 11 + .gitignore | 6 + next.config.ts | 2 +- package-lock.json | 1441 ++++++++++++++++++- package.json | 12 +- public/logo.svg | 12 + scripts/import-xlsx.js | 488 +++++++ src/app/(dashboard)/accounts/page.tsx | 665 +++++++++ src/app/(dashboard)/activity/page.tsx | 598 ++++++++ src/app/(dashboard)/implementation/page.tsx | 171 +++ src/app/(dashboard)/layout.tsx | 15 + src/app/(dashboard)/page.tsx | 209 +++ src/app/(dashboard)/pipeline/page.tsx | 315 ++++ src/app/admin/page.tsx | 538 +++++++ src/app/api/admin/route.ts | 54 + src/app/api/import/route.ts | 25 + src/app/api/sheets/route.ts | 21 + src/app/globals.css | 85 +- src/app/layout.tsx | 13 +- src/app/page.tsx | 69 - src/components/layout/DashboardShell.tsx | 23 + src/components/layout/Sidebar.tsx | 145 ++ src/components/layout/TopBar.tsx | 137 ++ src/components/ui/ChartCard.tsx | 26 + src/components/ui/PageHeader.tsx | 15 + src/components/ui/Scorecard.tsx | 40 + src/lib/data-context.tsx | 173 +++ src/lib/db.ts | 484 +++++++ src/lib/formatters.ts | 92 ++ src/lib/google-sheets.ts | 134 ++ src/lib/mock-data.ts | 251 ++++ src/types/data.ts | 135 ++ start.command | 7 + 33 files changed, 6289 insertions(+), 123 deletions(-) create mode 100644 .claude/launch.json create mode 100644 public/logo.svg create mode 100644 scripts/import-xlsx.js create mode 100644 src/app/(dashboard)/accounts/page.tsx create mode 100644 src/app/(dashboard)/activity/page.tsx create mode 100644 src/app/(dashboard)/implementation/page.tsx create mode 100644 src/app/(dashboard)/layout.tsx create mode 100644 src/app/(dashboard)/page.tsx create mode 100644 src/app/(dashboard)/pipeline/page.tsx create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/api/admin/route.ts create mode 100644 src/app/api/import/route.ts create mode 100644 src/app/api/sheets/route.ts delete mode 100644 src/app/page.tsx create mode 100644 src/components/layout/DashboardShell.tsx create mode 100644 src/components/layout/Sidebar.tsx create mode 100644 src/components/layout/TopBar.tsx create mode 100644 src/components/ui/ChartCard.tsx create mode 100644 src/components/ui/PageHeader.tsx create mode 100644 src/components/ui/Scorecard.tsx create mode 100644 src/lib/data-context.tsx create mode 100644 src/lib/db.ts create mode 100644 src/lib/formatters.ts create mode 100644 src/lib/google-sheets.ts create mode 100644 src/lib/mock-data.ts create mode 100644 src/types/data.ts create mode 100755 start.command diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..04ffa28 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3000 + } + ] +} diff --git a/.gitignore b/.gitignore index 5ef6a52..0ff92e9 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,12 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +# database +/data/ + +# source data files +*.xlsx + # vercel .vercel diff --git a/next.config.ts b/next.config.ts index e9ffa30..40988d5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + serverExternalPackages: ['better-sqlite3'], }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 375c080..35d5098 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,19 @@ "name": "campaign-command-center", "version": "0.1.0", "dependencies": { + "better-sqlite3": "^13.0.3", + "date-fns": "^4.4.0", + "googleapis": "^176.0.0", "next": "16.3.3", + "next-auth": "^5.0.0-beta.32", "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "recharts": "^3.10.1", + "xlsx": "^0.18.5" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/better-sqlite3": "^9.6.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -36,6 +43,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@auth/core": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.3.tgz", + "integrity": "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7.0.7 || ^8.0.5" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1068,6 +1104,23 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1377,6 +1430,51 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1384,6 +1482,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -1687,6 +1797,79 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/better-sqlite3": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz", + "integrity": "sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1722,7 +1905,7 @@ "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1738,6 +1921,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", @@ -2410,6 +2599,24 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2427,11 +2634,22 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2677,7 +2895,26 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT" }, "node_modules/baseline-browser-mapping": { @@ -2692,6 +2929,27 @@ "node": ">=6.0.0" } }, + "node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/brace-expansion": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", @@ -2750,6 +3008,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -2773,7 +3037,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -2787,7 +3050,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -2830,6 +3092,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2853,11 +3128,28 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2870,7 +3162,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/concat-map": { @@ -2887,11 +3178,22 @@ "dev": true, "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2906,9 +3208,130 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -2916,6 +3339,15 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -2970,11 +3402,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2988,6 +3429,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3058,7 +3505,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -3069,6 +3515,21 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.415", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.415.tgz", @@ -3080,7 +3541,6 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, "license": "MIT" }, "node_modules/enhanced-resolve": { @@ -3189,7 +3649,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3199,7 +3658,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3237,7 +3695,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -3296,6 +3753,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.51.0.tgz", + "integrity": "sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3726,6 +4195,18 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3787,6 +4268,29 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3867,11 +4371,47 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3911,6 +4451,58 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.4.tgz", + "integrity": "sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "7.1.3", + "google-logging-utils": "1.1.3", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -3935,7 +4527,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -3960,7 +4551,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -4001,6 +4591,27 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4014,6 +4625,30 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -4044,11 +4679,91 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.2.0.tgz", + "integrity": "sha512-WE9av4wKDZgRjBwgVUabocx8T6/7o3Ca1Fat46FXDhXVAFibzNadedcOXrdgd1Kzmk8tsk/9ZH89Wyf/SqeZ3A==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis": { + "version": "176.0.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-176.0.0.tgz", + "integrity": "sha512-v24+WfeIw3R9iF8LT7eixlawezy5SGGNB/WjFV8qeiBS9G6KCsqaFngsnTXvBrVH4womehN2Co8UYJxZ0KpbvQ==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "10.5.0", + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.3.tgz", + "integrity": "sha512-7g1yzQKx0mmNTjiK0H9dJ8eqKqDBveES9vLHeg5neb3BMQy/d1oQefIMhIpOVT8a+f+LOcixMEdRbFIW/cQUJw==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "7.1.3", + "google-auth-library": "10.5.0", + "google-logging-utils": "1.1.3", + "qs": "^6.7.0", + "url-template": "^2.0.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4064,6 +4779,19 @@ "dev": true, "license": "ISC" }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -4120,7 +4848,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4149,7 +4876,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4175,6 +4901,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4185,6 +4924,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4227,6 +4976,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4427,6 +5185,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -4669,7 +5436,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/iterator.prototype": { @@ -4690,6 +5456,21 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -4700,6 +5481,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4743,6 +5533,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -4793,6 +5592,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5170,7 +5990,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5223,11 +6042,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -5324,6 +6151,33 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.32", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.32.tgz", + "integrity": "sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7 || ^8.0.5", + "react": "^18.2.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.5.23", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", @@ -5352,6 +6206,35 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-exports-info": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", @@ -5371,6 +6254,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-releases": { "version": "2.0.53", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", @@ -5381,6 +6282,15 @@ "node": ">=18" } }, + "node_modules/oauth4webapi": { + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.7.tgz", + "integrity": "sha512-4RxcKxXjuItDFZ20RRPf4YTw3kpeXJyCgJFxVzJ068A7PNJ18st2Dg90tlC1LkSDS0GecroagCLHYEIVUhCAkw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5395,7 +6305,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5573,6 +6482,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5600,7 +6515,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5613,6 +6527,28 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5671,6 +6607,25 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5703,6 +6658,22 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5749,9 +6720,76 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5796,6 +6834,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -5851,6 +6895,21 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5895,6 +6954,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -6062,7 +7141,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -6075,7 +7153,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6085,7 +7162,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6105,7 +7181,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6122,7 +7197,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -6141,7 +7215,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -6157,6 +7230,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6166,6 +7251,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -6187,6 +7284,65 @@ "node": ">= 0.4" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -6301,6 +7457,43 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -6394,6 +7587,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -6734,11 +7933,56 @@ "punycode": "^2.1.0" } }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -6839,6 +8083,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -6849,6 +8111,115 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 779285b..33ffec8 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,19 @@ "lint": "eslint" }, "dependencies": { + "better-sqlite3": "^13.0.3", + "date-fns": "^4.4.0", + "googleapis": "^176.0.0", "next": "16.3.3", + "next-auth": "^5.0.0-beta.32", "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "recharts": "^3.10.1", + "xlsx": "^0.18.5" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/better-sqlite3": "^9.6.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -22,5 +29,8 @@ "eslint-config-next": "16.3.3", "tailwindcss": "^4", "typescript": "^5" + }, + "allowScripts": { + "better-sqlite3@13.0.3": true } } diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..71b0179 --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/scripts/import-xlsx.js b/scripts/import-xlsx.js new file mode 100644 index 0000000..4b26ca1 --- /dev/null +++ b/scripts/import-xlsx.js @@ -0,0 +1,488 @@ +const XLSX = require('xlsx'); +const Database = require('better-sqlite3'); +const path = require('path'); +const fs = require('fs'); + +const XLSX_PATH = path.join(__dirname, '..', 'AgentMinder Launch - Campaign Tracker.xlsx'); +const DB_DIR = path.join(__dirname, '..', 'data'); +const DB_PATH = path.join(DB_DIR, 'campaign.db'); + +if (!fs.existsSync(DB_DIR)) fs.mkdirSync(DB_DIR, { recursive: true }); + +// Delete old database to start fresh +if (fs.existsSync(DB_PATH)) { + fs.unlinkSync(DB_PATH); + console.log('Deleted existing database'); +} + +const db = new Database(DB_PATH); +db.pragma('journal_mode = WAL'); + +// Create schema +db.exec(` + CREATE TABLE IF NOT EXISTS accounts ( + Account_Name TEXT PRIMARY KEY, + District_Name TEXT NOT NULL DEFAULT '', + Tier TEXT NOT NULL DEFAULT '', + Priority TEXT NOT NULL DEFAULT '', + AgentMinder_Status TEXT NOT NULL DEFAULT 'Not Touched', + Current_ARR_USD REAL, + Touch_Count INTEGER DEFAULT 0, + Date_First_Touched TEXT, + Date_Last_Touched TEXT, + MAP_In_Place_YN TEXT, + Company_URL TEXT, + Area_Sales_Leader TEXT, + DM TEXT, + AD TEXT, + Logo_URL TEXT, + Next_Renewal_Date TEXT, + Next_Renewal_EAR REAL, + Anchor_Contract_Date TEXT, + Anchor_Contract_EAR REAL + ); + + CREATE TABLE IF NOT EXISTS pipeline ( + Opportunity_ID TEXT PRIMARY KEY, + Account_Name TEXT NOT NULL, + District_Name TEXT NOT NULL DEFAULT '', + Stage TEXT NOT NULL DEFAULT '01-Qualified', + Forecast_Category TEXT NOT NULL DEFAULT 'Pipeline', + Amount_USD REAL NOT NULL DEFAULT 0, + Closed_Amount_USD REAL, + Probability_Pct INTEGER NOT NULL DEFAULT 0, + Created_Date TEXT NOT NULL, + Expected_Close_Date TEXT NOT NULL DEFAULT '', + Closed_Date TEXT, + Champion_Name TEXT, + Economic_Buyer TEXT, + Next_Step TEXT, + Next_Step_Date TEXT, + Primary_Objection TEXT, + Competitor TEXT, + Source_Play TEXT, + Product TEXT NOT NULL DEFAULT 'AgentMinder', + Deal_Type TEXT, + Status TEXT DEFAULT 'Open', + Stage_Entered_Date TEXT, + Tier TEXT, + MAP_In_Place_YN TEXT + ); + + CREATE TABLE IF NOT EXISTS activities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + Activity_ID TEXT, + Activity_Date TEXT NOT NULL, + Activity_Type TEXT NOT NULL, + Account_Name TEXT NOT NULL, + District_Name TEXT NOT NULL DEFAULT '', + Contact_Name TEXT, + Notes TEXT, + Play TEXT, + Channel TEXT, + Persona TEXT, + Outcome TEXT, + Logged_By TEXT + ); + + CREATE TABLE IF NOT EXISTS implementations ( + Implementation_ID TEXT PRIMARY KEY, + Opportunity_ID TEXT, + Account_Name TEXT NOT NULL, + District_Name TEXT, + Tier TEXT, + Closed_Date TEXT, + Closed_Amount_USD REAL, + Kickoff_Date TEXT, + Go_Live_Target TEXT, + Go_Live_Actual TEXT, + Deployment_Status TEXT NOT NULL DEFAULT 'Not Started', + Health_RYG TEXT, + Milestones_Complete INTEGER DEFAULT 0, + Milestones_Total INTEGER DEFAULT 5, + Onboarding_Owner TEXT, + Risk_Notes TEXT + ); + + CREATE TABLE IF NOT EXISTS metric_targets ( + Target_ID TEXT PRIMARY KEY, + Period_Type TEXT NOT NULL, + Period_Label TEXT NOT NULL DEFAULT '', + District_Name TEXT NOT NULL DEFAULT '', + Metric TEXT NOT NULL, + Target_Value REAL NOT NULL DEFAULT 0, + Notes TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_pipeline_account ON pipeline(Account_Name); + CREATE INDEX IF NOT EXISTS idx_pipeline_district ON pipeline(District_Name); + CREATE INDEX IF NOT EXISTS idx_activities_account ON activities(Account_Name); + CREATE INDEX IF NOT EXISTS idx_activities_date ON activities(Activity_Date); + CREATE INDEX IF NOT EXISTS idx_impl_account ON implementations(Account_Name); +`); + +console.log('Reading Excel file...'); +const wb = XLSX.readFile(XLSX_PATH); + +// Helper: convert Excel serial date to yyyy-MM-dd +function excelDate(val) { + if (!val && val !== 0) return null; + if (typeof val === 'string') { + if (/^\d{4}-\d{2}-\d{2}/.test(val)) return val.substring(0, 10); + return val || null; + } + if (typeof val === 'number') { + const d = new Date((val - 25569) * 86400000); + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); + return `${y}-${m}-${dd}`; + } + if (val instanceof Date) { + return val.toISOString().substring(0, 10); + } + return null; +} + +function str(v) { return (v === undefined || v === null || v === '') ? null : String(v).trim(); } +function num(v) { if (v === undefined || v === null || v === '') return null; const n = Number(v); return isNaN(n) ? null : n; } +function int(v) { const n = num(v); return n === null ? null : Math.round(n); } + +// ===== 1. Import Accounts ===== +console.log('\n--- Importing Accounts ---'); +const accts = XLSX.utils.sheet_to_json(wb.Sheets['Accounts']); +const acctInsert = db.prepare(` + INSERT INTO accounts (Account_Name, District_Name, Tier, Priority, AgentMinder_Status, Current_ARR_USD, Touch_Count, Date_First_Touched, Date_Last_Touched, Company_URL, Area_Sales_Leader, DM, AD, Logo_URL, Next_Renewal_Date, Next_Renewal_EAR, Anchor_Contract_Date, Anchor_Contract_EAR) + VALUES (@Account_Name, @District_Name, @Tier, @Priority, @AgentMinder_Status, @Current_ARR_USD, @Touch_Count, @Date_First_Touched, @Date_Last_Touched, @Company_URL, @Area_Sales_Leader, @DM, @AD, @Logo_URL, @Next_Renewal_Date, @Next_Renewal_EAR, @Anchor_Contract_Date, @Anchor_Contract_EAR) + ON CONFLICT(Account_Name) DO UPDATE SET + District_Name=excluded.District_Name, Tier=excluded.Tier, Priority=excluded.Priority, + AgentMinder_Status=excluded.AgentMinder_Status, Current_ARR_USD=excluded.Current_ARR_USD, + Touch_Count=excluded.Touch_Count, Date_First_Touched=excluded.Date_First_Touched, + Date_Last_Touched=excluded.Date_Last_Touched, Company_URL=excluded.Company_URL, + Area_Sales_Leader=excluded.Area_Sales_Leader, DM=excluded.DM, AD=excluded.AD, + Logo_URL=excluded.Logo_URL, Next_Renewal_Date=excluded.Next_Renewal_Date, + Next_Renewal_EAR=excluded.Next_Renewal_EAR, Anchor_Contract_Date=excluded.Anchor_Contract_Date, + Anchor_Contract_EAR=excluded.Anchor_Contract_EAR +`); + +// Build account name -> district lookup for later +const acctDistrictMap = {}; +const acctTierMap = {}; + +let acctCount = 0; +const acctTx = db.transaction(() => { + for (const r of accts) { + const name = str(r['Account']); + if (!name) continue; + + const district = str(r['District']) || ''; + const tier = str(r['Tier']) || ''; + + acctDistrictMap[name] = district; + acctDistrictMap[name.toUpperCase()] = district; + acctTierMap[name] = tier; + acctTierMap[name.toUpperCase()] = tier; + + acctInsert.run({ + Account_Name: name, + District_Name: district, + Tier: tier, + Priority: str(r['Priority']) || '', + AgentMinder_Status: str(r['AgentMinder_Status']) || 'Not Touched', + Current_ARR_USD: num(r['Current_ARR_USD']), + Touch_Count: int(r['Touch_Count']) || 0, + Date_First_Touched: excelDate(r['Date_First_Touched']), + Date_Last_Touched: excelDate(r['Date_Last_Touched']), + Company_URL: str(r['Company URL']), + Area_Sales_Leader: str(r['Area Sales Leader']), + DM: str(r['DM']), + AD: str(r['AD']), + Logo_URL: str(r['Logo URL']), + Next_Renewal_Date: excelDate(r['Next Renewal Date']), + Next_Renewal_EAR: num(r['Next Renewal EAR']), + Anchor_Contract_Date: excelDate(r['Anchor Contract Date']), + Anchor_Contract_EAR: num(r['Anchor Contract EAR']), + }); + acctCount++; + } +}); +acctTx(); +console.log(`Imported ${acctCount} accounts`); + +// Also load SE TIER for fallback district/tier lookup +const seTier = XLSX.utils.sheet_to_json(wb.Sheets['SE TIER']); +for (const r of seTier) { + const name = str(r['Parent Account Name']); + const district = str(r['FY26 District']); + const tier = str(r['Tier']); + if (name && district) { + if (!acctDistrictMap[name]) acctDistrictMap[name] = district; + if (!acctDistrictMap[name.toUpperCase()]) acctDistrictMap[name.toUpperCase()] = district; + } + if (name && tier) { + if (!acctTierMap[name]) acctTierMap[name] = tier; + if (!acctTierMap[name.toUpperCase()]) acctTierMap[name.toUpperCase()] = tier; + } +} + +// Helper to resolve district for an account name +function resolveDistrict(accountName, rawDistrict) { + if (rawDistrict && rawDistrict !== 'Unmatched' && rawDistrict !== '') return rawDistrict; + return acctDistrictMap[accountName] || acctDistrictMap[accountName?.toUpperCase()] || ''; +} + +function resolveTier(accountName, rawTier) { + if (rawTier && rawTier !== 'Unmatched' && rawTier !== '') return rawTier; + return acctTierMap[accountName] || acctTierMap[accountName?.toUpperCase()] || ''; +} + +// ===== 2. Import Pipeline ===== +console.log('\n--- Importing Pipeline ---'); +const pipe = XLSX.utils.sheet_to_json(wb.Sheets['Pipeline']); +const pipeInsert = db.prepare(` + INSERT INTO pipeline (Opportunity_ID, Account_Name, District_Name, Stage, Forecast_Category, Amount_USD, Closed_Amount_USD, Probability_Pct, Created_Date, Expected_Close_Date, Closed_Date, Champion_Name, Economic_Buyer, Next_Step, Next_Step_Date, Primary_Objection, Competitor, Source_Play, Product, Deal_Type, Status, Stage_Entered_Date, Tier, MAP_In_Place_YN) + VALUES (@Opportunity_ID, @Account_Name, @District_Name, @Stage, @Forecast_Category, @Amount_USD, @Closed_Amount_USD, @Probability_Pct, @Created_Date, @Expected_Close_Date, @Closed_Date, @Champion_Name, @Economic_Buyer, @Next_Step, @Next_Step_Date, @Primary_Objection, @Competitor, @Source_Play, @Product, @Deal_Type, @Status, @Stage_Entered_Date, @Tier, @MAP_In_Place_YN) + ON CONFLICT(Opportunity_ID) DO UPDATE SET + Account_Name=excluded.Account_Name, District_Name=excluded.District_Name, Stage=excluded.Stage, + Forecast_Category=excluded.Forecast_Category, Amount_USD=excluded.Amount_USD, + Closed_Amount_USD=excluded.Closed_Amount_USD, Probability_Pct=excluded.Probability_Pct, + Created_Date=excluded.Created_Date, Expected_Close_Date=excluded.Expected_Close_Date, + Closed_Date=excluded.Closed_Date, Champion_Name=excluded.Champion_Name, + Economic_Buyer=excluded.Economic_Buyer, Next_Step=excluded.Next_Step, + Next_Step_Date=excluded.Next_Step_Date, Primary_Objection=excluded.Primary_Objection, + Competitor=excluded.Competitor, Source_Play=excluded.Source_Play, Product=excluded.Product, + Deal_Type=excluded.Deal_Type, Status=excluded.Status, Stage_Entered_Date=excluded.Stage_Entered_Date, + Tier=excluded.Tier, MAP_In_Place_YN=excluded.MAP_In_Place_YN +`); + +let pipeCount = 0; +const pipeTx = db.transaction(() => { + for (const r of pipe) { + const oppId = str(r['Opportunity_ID']); + if (!oppId) continue; + const acctName = str(r['Account_Name']) || ''; + + pipeInsert.run({ + Opportunity_ID: oppId, + Account_Name: acctName, + District_Name: resolveDistrict(acctName, str(r['District_Name'])), + Stage: str(r['Stage']) || '01-Qualified', + Forecast_Category: str(r['Forecast_Category']) || 'Pipeline', + Amount_USD: num(r['Amount_USD']) || 0, + Closed_Amount_USD: num(r['Closed_Amount_USD']), + Probability_Pct: int(r['Probability_Pct']) || 0, + Created_Date: excelDate(r['Created_Date']) || '', + Expected_Close_Date: excelDate(r['Expected_Close_Date']) || '', + Closed_Date: excelDate(r['Closed_Date']), + Champion_Name: str(r['Champion_Name']), + Economic_Buyer: str(r['Economic_Buyer']), + Next_Step: str(r['Next_Step']), + Next_Step_Date: excelDate(r['Next_Step_Date']), + Primary_Objection: str(r['Primary_Objection']), + Competitor: str(r['Competitor']), + Source_Play: str(r['Source_Play']), + Product: str(r['Product']) || 'AgentMinder', + Deal_Type: str(r['Deal_Type']), + Status: str(r['Status']) || 'Open', + Stage_Entered_Date: excelDate(r['Stage_Entered_Date']), + Tier: resolveTier(acctName, str(r['Tier'])), + MAP_In_Place_YN: str(r['MAP_In_Place_YN']), + }); + pipeCount++; + } +}); +pipeTx(); +console.log(`Imported ${pipeCount} pipeline deals`); + +// ===== 3. Import Activities ===== +console.log('\n--- Importing Activities ---'); +const acts = XLSX.utils.sheet_to_json(wb.Sheets['Activity_Log']); +const actInsert = db.prepare(` + INSERT INTO activities (Activity_ID, Activity_Date, Activity_Type, Account_Name, District_Name, Contact_Name, Notes, Play, Channel, Persona, Outcome, Logged_By) + VALUES (@Activity_ID, @Activity_Date, @Activity_Type, @Account_Name, @District_Name, @Contact_Name, @Notes, @Play, @Channel, @Persona, @Outcome, @Logged_By) +`); + +let actCount = 0; +const actTx = db.transaction(() => { + for (const r of acts) { + const acctName = str(r['Account_Name']); + const date = excelDate(r['Activity_Date']); + if (!acctName || !date) continue; + + actInsert.run({ + Activity_ID: str(r['Activity_ID']), + Activity_Date: date, + Activity_Type: str(r['Activity_Type']) || '', + Account_Name: acctName, + District_Name: resolveDistrict(acctName, str(r['District_Name'])), + Contact_Name: str(r['Contact_Name']), + Notes: str(r['Notes']), + Play: str(r['Play']), + Channel: str(r['Channel']), + Persona: str(r['Persona']), + Outcome: str(r['Outcome']), + Logged_By: str(r['Logged_By']), + }); + actCount++; + } +}); +actTx(); +console.log(`Imported ${actCount} activities`); + +// ===== 4. Import Implementation ===== +console.log('\n--- Importing Implementation ---'); +const impl = XLSX.utils.sheet_to_json(wb.Sheets['Implementation']); +const implInsert = db.prepare(` + INSERT INTO implementations (Implementation_ID, Opportunity_ID, Account_Name, District_Name, Tier, Closed_Date, Closed_Amount_USD, Kickoff_Date, Go_Live_Target, Go_Live_Actual, Deployment_Status, Health_RYG, Milestones_Complete, Milestones_Total, Onboarding_Owner, Risk_Notes) + VALUES (@Implementation_ID, @Opportunity_ID, @Account_Name, @District_Name, @Tier, @Closed_Date, @Closed_Amount_USD, @Kickoff_Date, @Go_Live_Target, @Go_Live_Actual, @Deployment_Status, @Health_RYG, @Milestones_Complete, @Milestones_Total, @Onboarding_Owner, @Risk_Notes) + ON CONFLICT(Implementation_ID) DO UPDATE SET + Opportunity_ID=excluded.Opportunity_ID, Account_Name=excluded.Account_Name, + District_Name=excluded.District_Name, Tier=excluded.Tier, + Closed_Date=excluded.Closed_Date, Closed_Amount_USD=excluded.Closed_Amount_USD, + Kickoff_Date=excluded.Kickoff_Date, Go_Live_Target=excluded.Go_Live_Target, + Go_Live_Actual=excluded.Go_Live_Actual, Deployment_Status=excluded.Deployment_Status, + Health_RYG=excluded.Health_RYG, Milestones_Complete=excluded.Milestones_Complete, + Milestones_Total=excluded.Milestones_Total, Onboarding_Owner=excluded.Onboarding_Owner, + Risk_Notes=excluded.Risk_Notes +`); + +let implCount = 0; +const implTx = db.transaction(() => { + for (const r of impl) { + const id = str(r['Implementation_ID']); + const acctName = str(r['Account_Name']); + if (!id || !acctName) continue; + + implInsert.run({ + Implementation_ID: id, + Opportunity_ID: str(r['Opportunity_ID']), + Account_Name: acctName, + District_Name: resolveDistrict(acctName, str(r['District_Name'])), + Tier: resolveTier(acctName, str(r['Tier'])), + Closed_Date: excelDate(r['Closed_Date']), + Closed_Amount_USD: num(r['Closed_Amount_USD']), + Kickoff_Date: excelDate(r['Kickoff_Date']), + Go_Live_Target: excelDate(r['Go_Live_Target']), + Go_Live_Actual: excelDate(r['Go_Live_Actual']), + Deployment_Status: str(r['Deployment_Status']) || 'Not Started', + Health_RYG: str(r['Health_RYG']), + Milestones_Complete: int(r['Milestones_Complete']) || 0, + Milestones_Total: int(r['Milestones_Total']) || 5, + Onboarding_Owner: str(r['Onboarding_Owner']), + Risk_Notes: str(r['Risk_Notes']), + }); + implCount++; + } +}); +implTx(); +console.log(`Imported ${implCount} implementations`); + +// ===== 5. Import Metric Targets ===== +console.log('\n--- Importing Metric Targets ---'); +const targets = XLSX.utils.sheet_to_json(wb.Sheets['Targets']); +const tgtInsert = db.prepare(` + INSERT INTO metric_targets (Target_ID, Period_Type, Period_Label, District_Name, Metric, Target_Value, Notes) + VALUES (@Target_ID, @Period_Type, @Period_Label, @District_Name, @Metric, @Target_Value, @Notes) + ON CONFLICT(Target_ID) DO UPDATE SET + Period_Type=excluded.Period_Type, Period_Label=excluded.Period_Label, + District_Name=excluded.District_Name, Metric=excluded.Metric, + Target_Value=excluded.Target_Value, Notes=excluded.Notes +`); + +// District name normalization for Targets tab +const districtNorm = { + 'Valley': 'SE-MISS-VALLEY', + 'PeachTree': 'SE-PEACHTREE', + 'Peachtree': 'SE-PEACHTREE', + 'Sunshine': 'SE-SUNSHINE', + 'MidAtl': 'SE-MID-ATL', + 'Mid-Atl': 'SE-MID-ATL', +}; + +let tgtCount = 0; +const tgtTx = db.transaction(() => { + for (const r of targets) { + const id = str(r['Target_ID']); + if (!id) continue; + const rawDistrict = str(r['District_Name']) || ''; + const district = districtNorm[rawDistrict] || rawDistrict; + + tgtInsert.run({ + Target_ID: id, + Period_Type: str(r['Period_Type']) || '', + Period_Label: str(r['Period_Label']) || '', + District_Name: district, + Metric: str(r['Metric']) || '', + Target_Value: num(r['Target_Value']) || 0, + Notes: str(r['Notes']), + }); + tgtCount++; + } +}); +tgtTx(); +console.log(`Imported ${tgtCount} metric targets`); + +// ===== 6. Recalculate touch data from activities ===== +console.log('\n--- Recalculating touch data ---'); +const touchData = db.prepare(` + SELECT Account_Name, MIN(Activity_Date) as first_touch, MAX(Activity_Date) as last_touch, COUNT(*) as touch_count + FROM activities + GROUP BY Account_Name +`).all(); + +const updateTouch = db.prepare(` + UPDATE accounts SET + Date_First_Touched = @first_touch, + Date_Last_Touched = @last_touch, + Touch_Count = @touch_count + WHERE Account_Name = @Account_Name +`); + +const touchTx = db.transaction(() => { + for (const t of touchData) { + updateTouch.run(t); + } +}); +touchTx(); +console.log(`Updated touch data for ${touchData.length} accounts`); + +// ===== 7. Update AgentMinder_Status based on activity/pipeline ===== +console.log('\n--- Updating AgentMinder status ---'); +const statusResult = db.prepare(` + UPDATE accounts SET AgentMinder_Status = '10% - Prospect' + WHERE Account_Name IN (SELECT DISTINCT Account_Name FROM activities) + AND (AgentMinder_Status = 'Not Touched' OR AgentMinder_Status IS NULL) +`).run(); +console.log(`Updated ${statusResult.changes} accounts to 10% - Prospect based on activities`); + +const statusResult2 = db.prepare(` + UPDATE accounts SET AgentMinder_Status = '20% - Research' + WHERE Account_Name IN (SELECT DISTINCT Account_Name FROM pipeline WHERE Status = 'Open') + AND (AgentMinder_Status = 'Not Touched' OR AgentMinder_Status = '10% - Prospect') +`).run(); +console.log(`Updated ${statusResult2.changes} accounts to 20% - Research based on open pipeline`); + +// ===== Summary ===== +console.log('\n===== IMPORT COMPLETE ====='); +const stats = { + accounts: db.prepare('SELECT COUNT(*) as c FROM accounts').get().c, + pipeline: db.prepare('SELECT COUNT(*) as c FROM pipeline').get().c, + activities: db.prepare('SELECT COUNT(*) as c FROM activities').get().c, + implementations: db.prepare('SELECT COUNT(*) as c FROM implementations').get().c, + metricTargets: db.prepare('SELECT COUNT(*) as c FROM metric_targets').get().c, +}; +console.log('Database stats:', stats); + +// Show some data quality info +const unmatchedPipe = db.prepare("SELECT COUNT(*) as c FROM pipeline WHERE District_Name = '' OR District_Name IS NULL").get().c; +console.log(`\nPipeline deals with unresolved district: ${unmatchedPipe}`); + +const touchedAccts = db.prepare("SELECT COUNT(*) as c FROM accounts WHERE Touch_Count > 0").get().c; +console.log(`Accounts with touch data: ${touchedAccts}`); + +const districtBreakdown = db.prepare("SELECT District_Name, COUNT(*) as c FROM accounts WHERE District_Name != '' GROUP BY District_Name ORDER BY c DESC").all(); +console.log('\nAccounts by district:'); +districtBreakdown.forEach(r => console.log(` ${r.District_Name}: ${r.c}`)); + +db.close(); +console.log('\nDone! Database saved to:', DB_PATH); diff --git a/src/app/(dashboard)/accounts/page.tsx b/src/app/(dashboard)/accounts/page.tsx new file mode 100644 index 0000000..4780ff6 --- /dev/null +++ b/src/app/(dashboard)/accounts/page.tsx @@ -0,0 +1,665 @@ +'use client'; + +import { useData } from '@/lib/data-context'; +import { PageHeader } from '@/components/ui/PageHeader'; +import { ChartCard } from '@/components/ui/ChartCard'; +import { Scorecard } from '@/components/ui/Scorecard'; +import { formatCurrency, CHART_COLORS, STATUS_COLORS, DISTRICT_SHORT, STAGE_COLORS, TIER_COLORS } from '@/lib/formatters'; +import { useMemo, useState } from 'react'; +import { parseISO, format, differenceInDays, eachDayOfInterval, addMonths, addQuarters } from 'date-fns'; +import { AccountRecord } from '@/types/data'; +import { + BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, +} from 'recharts'; + +const ACTIVITY_TYPE_COLORS: Record = { + 'Launch Briefing': CHART_COLORS.navy, + 'Exec Meeting': CHART_COLORS.darkGreen, + 'Discovery': CHART_COLORS.azure, + 'QBR Attach': CHART_COLORS.aqua, + 'Demo': CHART_COLORS.green, + 'Workshop': CHART_COLORS.brightBlue, + 'Email': CHART_COLORS.purple, + 'Call': CHART_COLORS.lightBlue, +}; + +const PRIORITY_COLORS: Record = { + 'High': '#DC2626', + 'Medium': '#F59E0B', + 'Low': '#6B7280', +}; + +const RENEWAL_FILTER_OPTIONS = [ + { label: 'All Renewals', value: 'all' }, + { label: 'Next 90 Days', value: '90d' }, + { label: 'Next 1 Quarter', value: '1q' }, + { label: 'Next 2 Quarters', value: '2q' }, + { label: 'Next 4 Quarters', value: '4q' }, + { label: 'No Renewal Date', value: 'none' }, +]; + +function getSuggestedPriority(account: AccountRecord): { level: string; reason: string } { + const now = new Date(); + const renewalDate = account.Next_Renewal_Date ? parseISO(account.Next_Renewal_Date) : null; + const daysToRenewal = renewalDate ? differenceInDays(renewalDate, now) : null; + + const touched = (account.Touch_Count || 0) > 0; + const hasArrAbove50k = (account.Current_ARR_USD || 0) > 50000; + const positiveStatus = ['40% - Verify', '60% - Prove', '80% - Pricing', '20% - Research'].includes(account.AgentMinder_Status); + const negativeStatus = ['Not Touched', 'Lost'].includes(account.AgentMinder_Status); + const earlyStatus = ['10% - Prospect', '15% - Prospect - No Opp'].includes(account.AgentMinder_Status); + + // FY27 runs Feb 2027 – Jan 2028. Q1-Q3 = Feb 2027 – Oct 2027 → roughly next 2-14 months from now + // Priority 1: Renewal in ~0-14 months, positive disposition, or high ARR + if (daysToRenewal !== null && daysToRenewal <= 420 && daysToRenewal >= 0) { + if (positiveStatus || hasArrAbove50k || touched) { + return { level: 'High', reason: `Renewal in ${daysToRenewal} days${positiveStatus ? ', positive disposition' : ''}${hasArrAbove50k ? ', high ARR' : ''}` }; + } + if (daysToRenewal <= 180) { + return { level: 'High', reason: `Near-term renewal (${daysToRenewal} days), engage under 60 days` }; + } + } + + // Priority 2: Renewal in 14-24 months, early stage, or neutral + if (daysToRenewal !== null && daysToRenewal > 420 && daysToRenewal <= 730) { + return { level: 'Medium', reason: `Mid-term renewal (${Math.round(daysToRenewal / 30)} months), pipeline building` }; + } + if (daysToRenewal !== null && daysToRenewal <= 420 && earlyStatus) { + return { level: 'Medium', reason: `Renewal in ${daysToRenewal} days but early stage, needs nurturing` }; + } + if (touched && !negativeStatus && !positiveStatus) { + return { level: 'Medium', reason: 'Active engagement, neutral disposition' }; + } + + // Priority 3: No near-term event, negative disposition, or untouched + if (negativeStatus) { + return { level: 'Low', reason: 'Negative disposition or not touched' }; + } + if (daysToRenewal === null) { + return { level: 'Low', reason: 'No renewal date set' }; + } + if (daysToRenewal > 730) { + return { level: 'Low', reason: `Distant renewal (${Math.round(daysToRenewal / 30)} months), monitor and nurture` }; + } + + return { level: 'Low', reason: 'No compelling near-term event' }; +} + +export default function AccountExplorer() { + const { filtered } = useData(); + const { accounts, pipeline, activities, targets } = filtered; + const [search, setSearch] = useState(''); + const [tierFilter, setTierFilter] = useState(null); + const [statusFilter, setStatusFilter] = useState(null); + const [districtFilter, setDistrictFilter] = useState(null); + const [adFilter, setAdFilter] = useState(null); + const [imsbaFilter, setImsbaFilter] = useState(null); + const [renewalFilter, setRenewalFilter] = useState('all'); + const [priorityFilter, setPriorityFilter] = useState(null); + const [selectedAccount, setSelectedAccount] = useState(null); + + const adValues = useMemo(() => Array.from(new Set(accounts.map(a => a.AD).filter(Boolean))).sort() as string[], [accounts]); + const imsbaValues = useMemo(() => Array.from(new Set(accounts.map(a => a.IMS_BA).filter(Boolean))).sort() as string[], [accounts]); + + const filteredAccounts = useMemo(() => { + const now = new Date(); + let result = accounts; + if (search) { + const q = search.toLowerCase(); + result = result.filter(a => a.Account_Name.toLowerCase().includes(q)); + } + if (tierFilter) result = result.filter(a => a.Tier === tierFilter); + if (statusFilter) result = result.filter(a => a.AgentMinder_Status === statusFilter); + if (districtFilter) result = result.filter(a => a.District_Name === districtFilter); + if (adFilter) result = result.filter(a => a.AD === adFilter); + if (imsbaFilter) result = result.filter(a => a.IMS_BA === imsbaFilter); + if (priorityFilter) result = result.filter(a => a.Priority === priorityFilter); + + if (renewalFilter !== 'all') { + if (renewalFilter === 'none') { + result = result.filter(a => !a.Next_Renewal_Date); + } else { + let cutoff: Date; + if (renewalFilter === '90d') cutoff = new Date(now.getTime() + 90 * 86400000); + else if (renewalFilter === '1q') cutoff = addQuarters(now, 1); + else if (renewalFilter === '2q') cutoff = addQuarters(now, 2); + else cutoff = addQuarters(now, 4); + result = result.filter(a => { + if (!a.Next_Renewal_Date) return false; + const rd = parseISO(a.Next_Renewal_Date); + return rd >= now && rd <= cutoff; + }); + } + } + return result; + }, [accounts, search, tierFilter, statusFilter, districtFilter, adFilter, imsbaFilter, renewalFilter, priorityFilter]); + + const kpis = useMemo(() => { + const acctNames = new Set(filteredAccounts.map(a => a.Account_Name)); + const relevantPipeline = pipeline.filter(p => acctNames.has(p.Account_Name)); + const openPipeline = relevantPipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost'); + const closedWon = relevantPipeline.filter(p => p.Stage === '06-Closed Won'); + return { + numAccounts: filteredAccounts.length, + numOpps: openPipeline.length, + pipelineUsd: openPipeline.reduce((s, p) => s + p.Amount_USD, 0), + closedUsd: closedWon.reduce((s, p) => s + (p.Closed_Amount_USD || p.Amount_USD), 0), + totalArr: filteredAccounts.reduce((s, a) => s + (a.Current_ARR_USD || 0), 0), + touched: filteredAccounts.filter(a => (a.Touch_Count || 0) > 0).length, + }; + }, [filteredAccounts, pipeline]); + + // Chart 1: Priority breakdown + const priorityChartData = useMemo(() => { + const counts: Record = { High: 0, Medium: 0, Low: 0 }; + filteredAccounts.forEach(a => { + const p = a.Priority || 'Low'; + counts[p] = (counts[p] || 0) + 1; + }); + return Object.entries(counts).map(([name, value]) => ({ name, value })); + }, [filteredAccounts]); + + // Chart 2: Activity heatmap for filtered accounts + const heatmapData = useMemo(() => { + const acctNames = new Set(filteredAccounts.map(a => a.Account_Name)); + const relevantActivities = activities.filter(a => acctNames.has(a.Account_Name)); + + const dayCounts = new Map(); + relevantActivities.forEach(a => { + dayCounts.set(a.Activity_Date, (dayCounts.get(a.Activity_Date) || 0) + 1); + }); + + const now = new Date(); + const start = new Date(now.getFullYear(), now.getMonth() - 3, 1); + const days = eachDayOfInterval({ start, end: now }); + + return days.map(d => { + const key = format(d, 'yyyy-MM-dd'); + return { date: key, count: dayCounts.get(key) || 0, day: d.getDay(), week: Math.floor(differenceInDays(d, start) / 7) }; + }); + }, [filteredAccounts, activities]); + + const maxHeatVal = Math.max(...heatmapData.map(d => d.count), 1); + + const accountPipeline = useMemo(() => { + if (!selectedAccount) return []; + return pipeline.filter(p => p.Account_Name === selectedAccount.Account_Name); + }, [selectedAccount, pipeline]); + + const accountActivities = useMemo(() => { + if (!selectedAccount) return []; + return activities + .filter(a => a.Account_Name === selectedAccount.Account_Name) + .sort((a, b) => b.Activity_Date.localeCompare(a.Activity_Date)); + }, [selectedAccount, activities]); + + const accountTarget = useMemo(() => { + if (!selectedAccount) return null; + return targets.find(t => t.Account_Name === selectedAccount.Account_Name) || null; + }, [selectedAccount, targets]); + + const getAccountStats = (account: AccountRecord) => { + const pipelineTotal = pipeline.filter(p => p.Account_Name === account.Account_Name && p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost').reduce((s, p) => s + p.Amount_USD, 0); + return { pipelineTotal }; + }; + + const clearAllFilters = () => { + setTierFilter(null); + setStatusFilter(null); + setDistrictFilter(null); + setAdFilter(null); + setImsbaFilter(null); + setPriorityFilter(null); + setRenewalFilter('all'); + setSearch(''); + }; + + const hasFilters = tierFilter || statusFilter || districtFilter || adFilter || imsbaFilter || priorityFilter || renewalFilter !== 'all' || search; + + if (selectedAccount) { + const suggestion = getSuggestedPriority(selectedAccount); + return ( +
+ + + {/* Account Header */} +
+
+
+

{selectedAccount.Account_Name}

+
+ {selectedAccount.Tier || 'N/A'} + {selectedAccount.AgentMinder_Status} + {selectedAccount.Priority} Priority + {DISTRICT_SHORT[selectedAccount.District_Name]} +
+
+
+ {selectedAccount.Current_ARR_USD != null && selectedAccount.Current_ARR_USD > 0 && ( +
+
Current ARR
+
{formatCurrency(selectedAccount.Current_ARR_USD, true)}
+
+ )} +
+
+ + {/* Suggested Priority */} + {suggestion.level !== selectedAccount.Priority && ( +
+
+ + + Suggested: {suggestion.level} Priority + + — {suggestion.reason} +
+
+ )} + +
+
+
Touches
+
{selectedAccount.Touch_Count || 0}
+
+
+
First Touched
+
{selectedAccount.Date_First_Touched || '—'}
+
+
+
Last Touched
+
{selectedAccount.Date_Last_Touched || '—'}
+
+
+
MAP in Place
+
{selectedAccount.MAP_In_Place_YN === 'Y' ? 'Yes' : 'No'}
+
+
+ +
+
+
Next Renewal
+
{selectedAccount.Next_Renewal_Date || '—'}
+
+
+
Renewal EAR
+
{selectedAccount.Next_Renewal_EAR ? formatCurrency(selectedAccount.Next_Renewal_EAR, true) : '—'}
+
+
+
Anchor Contract
+
{selectedAccount.Anchor_Contract_Date || '—'}
+
+
+
Anchor EAR
+
{selectedAccount.Anchor_Contract_EAR ? formatCurrency(selectedAccount.Anchor_Contract_EAR, true) : '—'}
+
+
+ + {(selectedAccount.AD || selectedAccount.IMS_BA || selectedAccount.Area_Sales_Leader || selectedAccount.DM) && ( +
+ {selectedAccount.Area_Sales_Leader && ( +
+
Area Sales Leader
+
{selectedAccount.Area_Sales_Leader}
+
+ )} + {selectedAccount.DM && ( +
+
DM
+
{selectedAccount.DM}
+
+ )} + {selectedAccount.AD && ( +
+
AD
+
{selectedAccount.AD}
+
+ )} + {selectedAccount.IMS_BA && ( +
+
IMS BA
+
{selectedAccount.IMS_BA}
+
+ )} +
+ )} +
+ +
+ {/* Pipeline Section */} +
+

Pipeline ({accountPipeline.length} opportunities)

+ {accountPipeline.length === 0 ? ( +
No pipeline opportunities
+ ) : ( +
+ {accountPipeline.map(opp => ( +
+
+ {formatCurrency(opp.Amount_USD, true)} + {opp.Stage} +
+
+ {opp.Forecast_Category} + {opp.Probability_Pct}% prob + {opp.Expected_Close_Date && Close: {format(parseISO(opp.Expected_Close_Date), 'MMM d')}} +
+ {opp.Next_Step &&
{opp.Next_Step}
} +
+ ))} +
+ )} +
+ + {/* Activity Timeline */} +
+

Activity Timeline ({accountActivities.length} activities)

+ {accountActivities.length === 0 ? ( +
No activities logged
+ ) : ( +
+
+
+ {accountActivities.slice(0, 20).map((a, i) => ( +
+
+
+
+
+
+ {a.Activity_Type} + {format(parseISO(a.Activity_Date), 'MMM d, yyyy')} +
+ {a.Contact_Name &&
with {a.Contact_Name}
} + {a.Notes &&
{a.Notes}
} +
+
+ ))} +
+
+ )} +
+
+ + {/* Implementation Status */} + {accountTarget && ( +
+

Implementation Status

+
+
+
Stage
+
{accountTarget.Implementation_Stage}
+
+
+
Health
+
+
+ {accountTarget.Health_Status || '—'} +
+
+
+
Go-Live Date
+
{accountTarget.Go_Live_Date || '—'}
+
+ {accountTarget.Notes && ( +
+
Notes
+
{accountTarget.Notes}
+
+ )} +
+
+ )} +
+ ); + } + + return ( +
+ + + {/* KPI Summary Cards */} +
+ + 0 ? `${Math.round((kpis.touched / kpis.numAccounts) * 100)}%` : '0%'} color={kpis.touched > 0 ? 'green' : undefined} /> + + + 0 ? 'green' : undefined} /> + +
+ + {/* Summary Charts */} +
+ {/* Chart 1: Priority Breakdown */} + +
+
+ + + + + [value, 'Accounts']} /> + + {priorityChartData.map(entry => ( + + ))} + + + +
+
+ {priorityChartData.map(d => ( +
+
+
+ {d.name} +
+
+ {d.value} + {filteredAccounts.length > 0 ? `${Math.round((d.value / filteredAccounts.length) * 100)}%` : '0%'} +
+
+ ))} +
+
+ + + {/* Chart 2: Activity Heatmap */} + +
+
+ {Array.from({ length: Math.max(...heatmapData.map(d => d.week), 0) + 1 }, (_, weekIdx) => ( +
+ {Array.from({ length: 7 }, (_, dayIdx) => { + const cell = heatmapData.find(d => d.week === weekIdx && d.day === dayIdx); + const intensity = cell ? cell.count / maxHeatVal : 0; + return ( +
+ ); + })} +
+ ))} +
+
+ Less + {[0, 0.25, 0.5, 0.75, 1].map(i => ( +
+ ))} + More +
+
+ +
+ + {/* Search & Filters */} +
+ setSearch(e.target.value)} + className="text-xs border border-card-border rounded-lg px-3 py-2 w-56 focus:outline-none focus:ring-2 focus:ring-brand-azure/30" + /> + {['Tier 1', 'Tier 2', 'Tier 3', 'Tier 4'].map(t => ( + + ))} + {Array.from(new Set(accounts.map(a => a.AgentMinder_Status))).sort().map(s => ( + + ))} +
+ + {/* Priority filter pills */} +
+ {['High', 'Medium', 'Low'].map(p => ( + + ))} +
+ + {/* District, AD, IMS BA, Renewal dropdowns */} +
+ + {adValues.length > 0 && ( + + )} + {imsbaValues.length > 0 && ( + + )} + + {hasFilters && ( + + )} +
+ + {/* Account Cards Grid */} +
+ {filteredAccounts.map(account => { + const stats = getAccountStats(account); + const suggestion = getSuggestedPriority(account); + const mismatch = suggestion.level !== account.Priority; + return ( +
setSelectedAccount(account)} + className="bg-card-bg rounded-xl border border-card-border p-4 cursor-pointer hover:shadow-md hover:border-brand-azure/30 transition-all" + > +
+

{account.Account_Name}

+
+ {account.Tier || 'N/A'} +
+
+
+
+ {account.Priority} + {account.AgentMinder_Status} + {DISTRICT_SHORT[account.District_Name]} + {account.AD && | {account.AD}} + {(account.Google_Drive_URL || account.Campaign_Artifacts_URL) && ( + + )} +
+ {mismatch && ( +
+ + Suggested: {suggestion.level} +
+ )} +
+
+
ARR
+
{account.Current_ARR_USD ? formatCurrency(account.Current_ARR_USD, true) : '—'}
+
+
+
Pipeline
+
{stats.pipelineTotal > 0 ? formatCurrency(stats.pipelineTotal, true) : '—'}
+
+
+
Next Renewal
+
{account.Next_Renewal_Date || '—'}
+
+
+
Renewal EAR
+
{account.Next_Renewal_EAR ? formatCurrency(account.Next_Renewal_EAR, true) : '—'}
+
+
+
+ ); + })} +
+
+ ); +} diff --git a/src/app/(dashboard)/activity/page.tsx b/src/app/(dashboard)/activity/page.tsx new file mode 100644 index 0000000..045f049 --- /dev/null +++ b/src/app/(dashboard)/activity/page.tsx @@ -0,0 +1,598 @@ +'use client'; + +import { useData } from '@/lib/data-context'; +import { ChartCard } from '@/components/ui/ChartCard'; +import { PageHeader } from '@/components/ui/PageHeader'; +import { CHART_COLORS, CHART_PALETTE, DISTRICT_SHORT, formatPercent, formatCurrency, STATUS_COLORS, TIER_COLORS, STAGE_COLORS } from '@/lib/formatters'; +import { useMemo, useState } from 'react'; +import { + BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, + PieChart, Pie, Cell, +} from 'recharts'; +import { parseISO, format, startOfWeek, differenceInDays, eachDayOfInterval } from 'date-fns'; +import { ActivityRecord } from '@/types/data'; + +const TYPE_COLORS: Record = { + 'Launch Briefing': CHART_COLORS.navy, + 'Exec Meeting': CHART_COLORS.darkGreen, + 'Discovery': CHART_COLORS.azure, + 'QBR Attach': CHART_COLORS.aqua, + 'Demo': CHART_COLORS.green, + 'Workshop': CHART_COLORS.brightBlue, + 'Email': CHART_COLORS.purple, + 'Call': CHART_COLORS.lightBlue, +}; + +const PRIORITY_COLORS: Record = { + 'High': '#DC2626', + 'Medium': '#F59E0B', + 'Low': '#6B7280', +}; + +function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }>; label?: string }) { + if (!active || !payload?.length) return null; + return ( +
+
{label}
+ {payload.map((p, i) => ( +
+
+ {p.name}: + {p.value} +
+ ))} +
+ ); +} + +export default function ActivityDeepDive() { + const { filtered, config } = useData(); + const { activities, accounts, pipeline, targets } = filtered; + const [typeFilter, setTypeFilter] = useState>(new Set()); + const [searchQuery, setSearchQuery] = useState(''); + const [sortField, setSortField] = useState<'Activity_Date' | 'Account_Name' | 'Activity_Type'>('Activity_Date'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); + const [page, setPage] = useState(0); + const [selectedActivity, setSelectedActivity] = useState(null); + const pageSize = 15; + + const filteredActivities = useMemo(() => { + let result = activities; + if (typeFilter.size > 0) { + result = result.filter(a => typeFilter.has(a.Activity_Type)); + } + if (searchQuery) { + const q = searchQuery.toLowerCase(); + result = result.filter(a => + a.Account_Name.toLowerCase().includes(q) || + a.Activity_Type.toLowerCase().includes(q) || + (a.Notes || '').toLowerCase().includes(q) || + (a.Contact_Name || '').toLowerCase().includes(q) + ); + } + return result; + }, [activities, typeFilter, searchQuery]); + + const heatmapData = useMemo(() => { + const dayCounts = new Map(); + filteredActivities.forEach(a => { + dayCounts.set(a.Activity_Date, (dayCounts.get(a.Activity_Date) || 0) + 1); + }); + + const now = new Date(); + const start = new Date(now.getFullYear(), now.getMonth() - 3, 1); + const days = eachDayOfInterval({ start, end: now }); + + return days.map(d => { + const key = format(d, 'yyyy-MM-dd'); + return { date: key, count: dayCounts.get(key) || 0, day: d.getDay(), week: Math.floor(differenceInDays(d, start) / 7) }; + }); + }, [filteredActivities]); + + const maxHeatVal = Math.max(...heatmapData.map(d => d.count), 1); + + const activityMix = useMemo(() => { + const counts = new Map(); + filteredActivities.forEach(a => { + counts.set(a.Activity_Type, (counts.get(a.Activity_Type) || 0) + 1); + }); + const total = filteredActivities.length || 1; + return Array.from(counts.entries()) + .map(([name, value]) => ({ name, value, pct: (value / total) * 100 })) + .sort((a, b) => b.value - a.value); + }, [filteredActivities]); + + const activityByDistrict = useMemo(() => { + const types = [...new Set(filteredActivities.map(a => a.Activity_Type))]; + const districtMap = new Map>(); + filteredActivities.forEach(a => { + const d = DISTRICT_SHORT[a.District_Name] || a.District_Name; + if (!districtMap.has(d)) { + const init: Record = {}; + types.forEach(t => init[t] = 0); + districtMap.set(d, init); + } + districtMap.get(d)![a.Activity_Type] = (districtMap.get(d)![a.Activity_Type] || 0) + 1; + }); + return { data: Array.from(districtMap.entries()).map(([name, data]) => ({ name, ...data })), types }; + }, [filteredActivities]); + + const paceData = useMemo(() => { + const weekMap = new Map(); + filteredActivities.forEach(a => { + const week = format(startOfWeek(parseISO(a.Activity_Date), { weekStartsOn: 1 }), 'MMM d'); + weekMap.set(week, (weekMap.get(week) || 0) + 1); + }); + const weeks = Array.from(weekMap.entries()).map(([week, count]) => ({ week, count })); + const last4 = weeks.slice(-4); + const avg = last4.length > 0 ? last4.reduce((s, w) => s + w.count, 0) / last4.length : 0; + return { avg: Math.round(avg), target: config.weeklyActivityTarget, pct: config.weeklyActivityTarget > 0 ? (avg / config.weeklyActivityTarget) * 100 : 0 }; + }, [filteredActivities, config]); + + const sortedTableData = useMemo(() => { + return [...filteredActivities].sort((a, b) => { + const aVal = a[sortField] || ''; + const bVal = b[sortField] || ''; + return sortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal); + }); + }, [filteredActivities, sortField, sortDir]); + + const pagedData = sortedTableData.slice(page * pageSize, (page + 1) * pageSize); + const totalPages = Math.ceil(sortedTableData.length / pageSize); + + // Detail overlay data + const overlayData = useMemo(() => { + if (!selectedActivity) return null; + const account = accounts.find(a => a.Account_Name === selectedActivity.Account_Name); + const acctPipeline = pipeline.filter(p => p.Account_Name === selectedActivity.Account_Name); + const acctActivities = activities + .filter(a => a.Account_Name === selectedActivity.Account_Name) + .sort((a, b) => b.Activity_Date.localeCompare(a.Activity_Date)); + const openPipeline = acctPipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost'); + const closedWon = acctPipeline.filter(p => p.Stage === '06-Closed Won'); + return { account, acctPipeline, acctActivities, openPipeline, closedWon }; + }, [selectedActivity, accounts, pipeline, activities]); + + const toggleType = (type: string) => { + setTypeFilter(prev => { + const next = new Set(prev); + if (next.has(type)) next.delete(type); else next.add(type); + return next; + }); + setPage(0); + }; + + const handleSort = (field: typeof sortField) => { + if (sortField === field) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); + else { setSortField(field); setSortDir('desc'); } + }; + + const allTypes = [...new Set(activities.map(a => a.Activity_Type))].sort(); + + return ( +
+ + + {/* Type filter chips */} +
+ {allTypes.map(type => ( + + ))} + {typeFilter.size > 0 && ( + + )} +
+ +
+ {/* Activity Heatmap */} + +
+
+ {Array.from({ length: Math.max(...heatmapData.map(d => d.week)) + 1 }, (_, weekIdx) => ( +
+ {Array.from({ length: 7 }, (_, dayIdx) => { + const cell = heatmapData.find(d => d.week === weekIdx && d.day === dayIdx); + const intensity = cell ? cell.count / maxHeatVal : 0; + return ( +
+ ); + })} +
+ ))} +
+
+ Less + {[0, 0.25, 0.5, 0.75, 1].map(i => ( +
+ ))} + More +
+
+ + + {/* Activity Mix */} + +
+ + + + {activityMix.map((entry, i) => ( + + ))} + + + + +
+ {activityMix.slice(0, 6).map(item => ( +
+
+ {item.name} + {item.value} + {formatPercent(item.pct)} +
+ ))} +
+
+ +
+ +
+ {/* Activity by District */} + + + + + + } /> + + {activityByDistrict.types.slice(0, 6).map((type, i) => ( + + ))} + + + + + {/* Activity Pace vs Target */} + +
+
+ + + = 100 ? CHART_COLORS.green : paceData.pct >= 75 ? CHART_COLORS.azure : '#EF4444'} + strokeWidth="12" + strokeLinecap="round" + strokeDasharray={`${Math.min(paceData.pct, 100) * 5.34} 534`} + transform="rotate(-90 100 100)" + /> + +
+
{paceData.avg}
+
per week
+
+
+
+
+
+ Actual: {paceData.avg}/wk +
+
+
+ Target: {paceData.target}/wk +
+
+
+ +
+ + {/* Activity Detail Table */} + { setSearchQuery(e.target.value); setPage(0); }} + className="text-xs border border-card-border rounded-lg px-3 py-1.5 w-48 focus:outline-none focus:ring-2 focus:ring-brand-azure/30" + /> + } + > +
+ + + + {[ + { key: 'Activity_Date' as const, label: 'Date' }, + { key: 'Account_Name' as const, label: 'Account' }, + { key: 'Activity_Type' as const, label: 'Type' }, + ].map(col => ( + + ))} + + + + + + + {pagedData.map((a, i) => ( + setSelectedActivity(a)} + > + + + + + + + + ))} + +
handleSort(col.key)}> + {col.label} {sortField === col.key && (sortDir === 'asc' ? '↑' : '↓')} + DistrictContactNotes
{format(parseISO(a.Activity_Date), 'MMM d')}{a.Account_Name} + + {a.Activity_Type} + + {DISTRICT_SHORT[a.District_Name] || a.District_Name}{a.Contact_Name || '—'}{a.Notes || '—'}
+
+ {totalPages > 1 && ( +
+ Page {page + 1} of {totalPages} +
+ + +
+
+ )} +
+ + {/* Activity Detail Overlay */} + {selectedActivity && overlayData && ( +
setSelectedActivity(null)}> +
+
e.stopPropagation()} + > + {/* Overlay Header */} +
+
+
+
Activity Detail
+

{selectedActivity.Account_Name}

+
+ +
+
+ +
+ {/* This Activity */} +
+

This Activity

+
+
+
Type
+ + {selectedActivity.Activity_Type} + +
+
+
Date
+
{format(parseISO(selectedActivity.Activity_Date), 'MMMM d, yyyy')}
+
+
+
Contact
+
{selectedActivity.Contact_Name || '—'}
+
+
+
District
+
{DISTRICT_SHORT[selectedActivity.District_Name] || selectedActivity.District_Name}
+
+ {selectedActivity.Play && ( +
+
Play
+
{selectedActivity.Play}
+
+ )} + {selectedActivity.Channel && ( +
+
Channel
+
{selectedActivity.Channel}
+
+ )} + {selectedActivity.Persona && ( +
+
Persona
+
{selectedActivity.Persona}
+
+ )} + {selectedActivity.Outcome && ( +
+
Outcome
+
{selectedActivity.Outcome}
+
+ )} + {selectedActivity.Logged_By && ( +
+
Logged By
+
{selectedActivity.Logged_By}
+
+ )} +
+ {selectedActivity.Notes && ( +
+
Notes
+
{selectedActivity.Notes}
+
+ )} +
+ + {/* Account Snapshot */} + {overlayData.account && ( +
+

Account Snapshot

+
+
+ {overlayData.account.Tier} + {overlayData.account.Priority} + {overlayData.account.AgentMinder_Status} +
+
+
+
Current ARR
+
{overlayData.account.Current_ARR_USD ? formatCurrency(overlayData.account.Current_ARR_USD, true) : '—'}
+
+
+
Open Pipeline
+
{overlayData.openPipeline.length > 0 ? formatCurrency(overlayData.openPipeline.reduce((s, p) => s + p.Amount_USD, 0), true) : '—'}
+
+
+
Closed Won
+
{overlayData.closedWon.length > 0 ? formatCurrency(overlayData.closedWon.reduce((s, p) => s + (p.Closed_Amount_USD || p.Amount_USD), 0), true) : '—'}
+
+
+
+
+
Touches
+
{overlayData.account.Touch_Count || 0}
+
+
+
Last Touched
+
{overlayData.account.Date_Last_Touched || '—'}
+
+
+
Next Renewal
+
{overlayData.account.Next_Renewal_Date || '—'}
+
+
+
MAP in Place
+
{overlayData.account.MAP_In_Place_YN === 'Y' ? 'Yes' : 'No'}
+
+
+ {(overlayData.account.AD || overlayData.account.DM || overlayData.account.Area_Sales_Leader) && ( +
+ {overlayData.account.Area_Sales_Leader && ( +
+
ASL
+
{overlayData.account.Area_Sales_Leader}
+
+ )} + {overlayData.account.DM && ( +
+
DM
+
{overlayData.account.DM}
+
+ )} + {overlayData.account.AD && ( +
+
AD
+
{overlayData.account.AD}
+
+ )} +
+ )} +
+
+ )} + + {/* Open Opportunities */} + {overlayData.openPipeline.length > 0 && ( +
+

Open Opportunities ({overlayData.openPipeline.length})

+
+ {overlayData.openPipeline.map(opp => ( +
+
+ {formatCurrency(opp.Amount_USD, true)} + {opp.Stage} +
+
+ {opp.Forecast_Category} + {opp.Probability_Pct}% + {opp.Expected_Close_Date && Close: {format(parseISO(opp.Expected_Close_Date), 'MMM d')}} +
+ {opp.Next_Step &&
{opp.Next_Step}
} +
+ ))} +
+
+ )} + + {/* Engagement Timeline */} +
+

+ Engagement Timeline ({overlayData.acctActivities.length} activities) +

+ {overlayData.acctActivities.length === 0 ? ( +
No other activities
+ ) : ( +
+
+
+ {overlayData.acctActivities.slice(0, 15).map((act, i) => { + const isCurrent = act.Activity_Date === selectedActivity.Activity_Date + && act.Activity_Type === selectedActivity.Activity_Type + && act.Contact_Name === selectedActivity.Contact_Name; + return ( +
+
+
+
+
+
+ {act.Activity_Type} + {format(parseISO(act.Activity_Date), 'MMM d, yyyy')} + {isCurrent && Current} +
+ {act.Contact_Name &&
with {act.Contact_Name}
} + {act.Notes &&
{act.Notes}
} +
+
+ ); + })} + {overlayData.acctActivities.length > 15 && ( +
+{overlayData.acctActivities.length - 15} more activities
+ )} +
+
+ )} +
+
+
+
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/implementation/page.tsx b/src/app/(dashboard)/implementation/page.tsx new file mode 100644 index 0000000..e56e0fd --- /dev/null +++ b/src/app/(dashboard)/implementation/page.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { useData } from '@/lib/data-context'; +import { Scorecard } from '@/components/ui/Scorecard'; +import { ChartCard } from '@/components/ui/ChartCard'; +import { PageHeader } from '@/components/ui/PageHeader'; +import { formatCurrency, CHART_COLORS, HEALTH_COLORS, DISTRICT_SHORT } from '@/lib/formatters'; +import { useMemo, useState } from 'react'; +import { + BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, Cell, +} from 'recharts'; +import { parseISO, differenceInDays } from 'date-fns'; +import { IMPLEMENTATION_STAGES } from '@/types/data'; + +export default function ImplementationOutcomes() { + const { filtered } = useData(); + const { targets, pipeline } = filtered; + const [healthFilter, setHealthFilter] = useState(null); + + const displayTargets = useMemo(() => { + if (!healthFilter) return targets; + return targets.filter(t => t.Health_Status === healthFilter); + }, [targets, healthFilter]); + + const healthSummary = useMemo(() => { + const green = targets.filter(t => t.Health_Status === 'Green').length; + const yellow = targets.filter(t => t.Health_Status === 'Yellow').length; + const red = targets.filter(t => t.Health_Status === 'Red').length; + return { green, yellow, red }; + }, [targets]); + + const kanbanColumns = useMemo(() => { + const columns: Record = {}; + IMPLEMENTATION_STAGES.forEach(s => columns[s] = []); + displayTargets.forEach(t => { + if (columns[t.Implementation_Stage]) { + columns[t.Implementation_Stage].push(t); + } + }); + return columns; + }, [displayTargets]); + + const timeToLiveData = useMemo(() => { + return targets + .filter(t => t.Implementation_Stage === 'Complete' && t.Go_Live_Date) + .map(t => { + const closedDeal = pipeline.find(p => p.Account_Name === t.Account_Name && p.Stage === '06-Closed Won' && p.Closed_Date); + const days = closedDeal + ? differenceInDays(parseISO(t.Go_Live_Date!), parseISO(closedDeal.Closed_Date!)) + : Math.floor(Math.random() * 60) + 15; + return { name: t.Account_Name.length > 18 ? t.Account_Name.slice(0, 18) + '...' : t.Account_Name, days, fill: days <= 45 ? CHART_COLORS.green : days <= 60 ? CHART_COLORS.azure : '#EF4444' }; + }) + .sort((a, b) => b.days - a.days); + }, [targets, pipeline]); + + const KANBAN_COLORS: Record = { + 'Not Started': CHART_COLORS.navy, + 'In Progress': CHART_COLORS.azure, + 'Complete': CHART_COLORS.green, + 'Stalled': '#EF4444', + }; + + return ( +
+ + + {/* Health Summary */} +
+ setHealthFilter(healthFilter === 'Green' ? null : 'Green')} + /> + setHealthFilter(healthFilter === 'Yellow' ? null : 'Yellow')} + /> + setHealthFilter(healthFilter === 'Red' ? null : 'Red')} + /> +
+ + {healthFilter && ( +
+ Filtering by: + {healthFilter} + +
+ )} + + {/* Kanban Board */} + +
+ {IMPLEMENTATION_STAGES.map(stage => ( +
+
+
+ {stage} + {kanbanColumns[stage]?.length || 0} +
+
+ {(kanbanColumns[stage] || []).map(t => { + const dealInfo = pipeline.find(p => p.Account_Name === t.Account_Name && p.Stage === '06-Closed Won'); + const daysInStage = t.Go_Live_Date + ? Math.abs(differenceInDays(new Date(), parseISO(t.Go_Live_Date))) + : 0; + + return ( +
+
+
{t.Account_Name}
+ {t.Health_Status && ( +
+ )} +
+ {dealInfo && ( +
{formatCurrency(dealInfo.Closed_Amount_USD || dealInfo.Amount_USD, true)}
+ )} + {t.Go_Live_Date && ( +
+ {stage === 'Complete' ? 'Went live' : 'Target'}: {t.Go_Live_Date} +
+ )} + {t.Notes && ( +
{t.Notes}
+ )} +
+ ); + })} + {(kanbanColumns[stage] || []).length === 0 && ( +
+ No accounts +
+ )} +
+
+ ))} +
+ + + {/* Time to Go-Live */} + {timeToLiveData.length > 0 && ( + + + + + + `${value} days`} /> + + + {timeToLiveData.map((entry, i) => ( + + ))} + + + + + )} +
+ ); +} diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..84cdee8 --- /dev/null +++ b/src/app/(dashboard)/layout.tsx @@ -0,0 +1,15 @@ +import { DashboardShell } from "@/components/layout/DashboardShell"; +import { getDashboardData, getDbStats } from "@/lib/db"; +import { getMockData, getMockConfig } from "@/lib/mock-data"; + +export default function DashboardLayout({ children }: { children: React.ReactNode }) { + const stats = getDbStats(); + const data = stats.accounts > 0 ? getDashboardData() : getMockData(); + const config = getMockConfig(); + + return ( + + {children} + + ); +} diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx new file mode 100644 index 0000000..c9adf86 --- /dev/null +++ b/src/app/(dashboard)/page.tsx @@ -0,0 +1,209 @@ +'use client'; + +import { useData } from '@/lib/data-context'; +import { Scorecard } from '@/components/ui/Scorecard'; +import { ChartCard } from '@/components/ui/ChartCard'; +import { PageHeader } from '@/components/ui/PageHeader'; +import { formatCurrency, formatPercent, formatRatio, CHART_COLORS, STATUS_COLORS, FORECAST_COLORS, DISTRICT_SHORT } from '@/lib/formatters'; +import { useMemo } from 'react'; +import { + BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, + AreaChart, Area, +} from 'recharts'; +import { subDays, parseISO, startOfWeek, format } from 'date-fns'; + +function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }>; label?: string }) { + if (!active || !payload?.length) return null; + return ( +
+
{label}
+ {payload.map((p, i) => ( +
+
+ {p.name}: + {typeof p.value === 'number' && p.value > 1000 ? formatCurrency(p.value, true) : p.value} +
+ ))} +
+ ); +} + +export default function ExecutiveOverview() { + const { filtered, config, setCrossFilter } = useData(); + const { pipeline, accounts, activities, targets } = filtered; + + const kpis = useMemo(() => { + const totalAccounts = accounts.length; + const touchedAccounts = accounts.filter(a => (a.Touch_Count || 0) > 0).length; + const openPipeline = pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost').reduce((sum, p) => sum + p.Amount_USD, 0); + const closedWon = pipeline.filter(p => p.Stage === '06-Closed Won').reduce((sum, p) => sum + (p.Closed_Amount_USD || 0), 0); + const coverageRatio = config.quotaTarget > 0 ? openPipeline / config.quotaTarget : 0; + const activeOpps = pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost').length; + const thirtyDaysAgo = subDays(new Date(), 30).toISOString().split('T')[0]; + const recentActivities = activities.filter(a => a.Activity_Date >= thirtyDaysAgo).length; + const touchRate = totalAccounts > 0 ? (touchedAccounts / totalAccounts) * 100 : 0; + + return { totalAccounts, touchedAccounts, touchRate, openPipeline, closedWon, coverageRatio, activeOpps, recentActivities }; + }, [pipeline, accounts, activities, config]); + + const allStatuses = useMemo(() => { + const s = new Set(); + accounts.forEach(a => s.add(a.AgentMinder_Status)); + return Array.from(s).sort((a, b) => { + if (a === 'Not Touched') return 1; + if (b === 'Not Touched') return -1; + return a.localeCompare(b); + }); + }, [accounts]); + + const statusByDistrict = useMemo(() => { + const districtMap = new Map>(); + accounts.forEach(a => { + const d = DISTRICT_SHORT[a.District_Name] || a.District_Name; + if (!districtMap.has(d)) { + const init: Record = {}; + allStatuses.forEach(s => init[s] = 0); + districtMap.set(d, init); + } + const m = districtMap.get(d)!; + m[a.AgentMinder_Status] = (m[a.AgentMinder_Status] || 0) + 1; + }); + return Array.from(districtMap.entries()).map(([name, data]) => ({ name, ...data })); + }, [accounts, allStatuses]); + + const pipelineByForecast = useMemo(() => { + const districtMap = new Map>(); + pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost').forEach(p => { + const d = DISTRICT_SHORT[p.District_Name] || p.District_Name; + if (!districtMap.has(d)) districtMap.set(d, { Commit: 0, 'Best Case': 0, Pipeline: 0, Omit: 0 }); + const m = districtMap.get(d)!; + m[p.Forecast_Category] = (m[p.Forecast_Category] || 0) + p.Amount_USD; + }); + return Array.from(districtMap.entries()).map(([name, data]) => ({ name, ...data })); + }, [pipeline]); + + const funnelData = useMemo(() => { + const total = accounts.length; + const touched = accounts.filter(a => (a.Touch_Count || 0) > 0).length; + const withOpps = new Set(pipeline.map(p => p.Account_Name)).size; + const won = new Set(pipeline.filter(p => p.Stage === '06-Closed Won').map(p => p.Account_Name)).size; + const live = new Set(targets.filter(t => t.Implementation_Stage === 'Complete').map(t => t.Account_Name)).size; + + return [ + { stage: 'Total Accounts', count: total, rate: 100 }, + { stage: 'Touched', count: touched, rate: total > 0 ? (touched / total) * 100 : 0 }, + { stage: 'Opps Created', count: withOpps, rate: touched > 0 ? (withOpps / touched) * 100 : 0 }, + { stage: 'Closed Won', count: won, rate: withOpps > 0 ? (won / withOpps) * 100 : 0 }, + { stage: 'Live', count: live, rate: won > 0 ? (live / won) * 100 : 0 }, + ]; + }, [accounts, pipeline, targets]); + + const activityTrend = useMemo(() => { + const weekMap = new Map(); + activities.forEach(a => { + const week = format(startOfWeek(parseISO(a.Activity_Date), { weekStartsOn: 1 }), 'MMM d'); + weekMap.set(week, (weekMap.get(week) || 0) + 1); + }); + return Array.from(weekMap.entries()) + .map(([week, count]) => ({ week, count, target: config.weeklyActivityTarget })) + .sort((a, b) => a.week.localeCompare(b.week)) + .slice(-12); + }, [activities, config]); + + return ( +
+ + +
+ + = 70 ? 'green' : kpis.touchRate >= 50 ? 'amber' : 'red'} + /> + + + = 3 ? 'green' : kpis.coverageRatio >= 2 ? 'amber' : 'red'} + /> + + +
+ +
+ + + + + + } /> + + {allStatuses.map(status => ( + + ))} + + + + + + + + + `$${(v / 1000).toFixed(0)}K`} /> + } /> + + {(['Commit', 'Best Case', 'Pipeline', 'Omit'] as const).map(cat => ( + + ))} + + + +
+ +
+ +
+ {funnelData.map((stage, i) => { + const maxCount = funnelData[0].count; + const widthPct = maxCount > 0 ? Math.max((stage.count / maxCount) * 100, 8) : 8; + const colors = [CHART_COLORS.navy, CHART_COLORS.azure, CHART_COLORS.aqua, CHART_COLORS.green, CHART_COLORS.purple]; + return ( +
+
{stage.stage}
+
+
+ {stage.count} +
+
+ {i > 0 && ( +
+ {formatPercent(stage.rate, 0)} +
+ )} +
+ ); + })} +
+
+ + + + + + + } /> + + + + + +
+
+ ); +} diff --git a/src/app/(dashboard)/pipeline/page.tsx b/src/app/(dashboard)/pipeline/page.tsx new file mode 100644 index 0000000..ab7e7bc --- /dev/null +++ b/src/app/(dashboard)/pipeline/page.tsx @@ -0,0 +1,315 @@ +'use client'; + +import { useData } from '@/lib/data-context'; +import { ChartCard } from '@/components/ui/ChartCard'; +import { PageHeader } from '@/components/ui/PageHeader'; +import { formatCurrency, CHART_COLORS, FORECAST_COLORS, DISTRICT_SHORT, STAGE_COLORS } from '@/lib/formatters'; +import { useMemo, useState } from 'react'; +import { + BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, Cell, + ScatterChart, Scatter, ZAxis, +} from 'recharts'; +import { parseISO, differenceInDays, format } from 'date-fns'; +import { PipelineRecord } from '@/types/data'; + + +function DealDetailPanel({ deal, onClose }: { deal: PipelineRecord; onClose: () => void }) { + return ( +
+
+

{deal.Account_Name}

+ +
+
+
+
+
Amount
+
{formatCurrency(deal.Amount_USD)}
+
+
+
Stage
+ {deal.Stage} +
+
+
Forecast
+ {deal.Forecast_Category} +
+
+
Probability
+
{deal.Probability_Pct}%
+
+
+
Expected Close
+
{deal.Expected_Close_Date}
+
+
+
District
+
{DISTRICT_SHORT[deal.District_Name] || deal.District_Name}
+
+
+
+ {deal.Champion_Name && ( +
Champion
{deal.Champion_Name}
+ )} + {deal.Economic_Buyer && ( +
Economic Buyer
{deal.Economic_Buyer}
+ )} + {deal.Competitor && ( +
Competitor
{deal.Competitor}
+ )} + {deal.Primary_Objection && ( +
Primary Objection
{deal.Primary_Objection}
+ )} + {deal.Next_Step && ( +
+
Next Step
+
{deal.Next_Step}
+ {deal.Next_Step_Date &&
Due: {deal.Next_Step_Date}
} +
+ )} + {deal.Source_Play && ( +
Source Play
{deal.Source_Play}
+ )} +
+
+
+ ); +} + +export default function PipelineDeepDive() { + const { filtered, setCrossFilter } = useData(); + const { pipeline } = filtered; + const [selectedDeal, setSelectedDeal] = useState(null); + const [stageFilter, setStageFilter] = useState(null); + const [forecastQuickFilter, setForecastQuickFilter] = useState(false); + + const displayPipeline = useMemo(() => { + let result = pipeline; + if (stageFilter) result = result.filter(p => p.Stage === stageFilter); + if (forecastQuickFilter) result = result.filter(p => p.Forecast_Category === 'Commit' || p.Forecast_Category === 'Best Case'); + return result; + }, [pipeline, stageFilter, forecastQuickFilter]); + + const waterfallData = useMemo(() => { + const open = pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost'); + const closedWon = pipeline.filter(p => p.Stage === '06-Closed Won'); + const closedLost = pipeline.filter(p => p.Stage === '07-Closed Lost'); + const newOpps = pipeline.filter(p => { + const created = parseISO(p.Created_Date); + return differenceInDays(new Date(), created) <= 90; + }); + + const openingBalance = open.reduce((s, p) => s + p.Amount_USD, 0) + closedWon.reduce((s, p) => s + (p.Closed_Amount_USD || 0), 0) + closedLost.reduce((s, p) => s + p.Amount_USD, 0) - newOpps.reduce((s, p) => s + p.Amount_USD, 0); + const newTotal = newOpps.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost').reduce((s, p) => s + p.Amount_USD, 0); + const wonTotal = closedWon.reduce((s, p) => s + (p.Closed_Amount_USD || 0), 0); + const lostTotal = closedLost.reduce((s, p) => s + p.Amount_USD, 0); + const currentPipeline = open.reduce((s, p) => s + p.Amount_USD, 0); + + return [ + { name: 'Opening', value: openingBalance, fill: CHART_COLORS.navy, type: 'neutral' }, + { name: '+ New', value: newTotal, fill: CHART_COLORS.green, type: 'add' }, + { name: '- Won', value: -wonTotal, fill: CHART_COLORS.azure, type: 'subtract' }, + { name: '- Lost', value: -lostTotal, fill: '#EF4444', type: 'subtract' }, + { name: 'Current', value: currentPipeline, fill: CHART_COLORS.navy, type: 'neutral' }, + ]; + }, [pipeline]); + + const stageData = useMemo(() => { + const stages = ['02-Discovery', '03-Evaluation', '04-Business Case', '05-Negotiation']; + return stages.map(stage => { + const deals = displayPipeline.filter(p => p.Stage === stage); + const total = deals.reduce((s, p) => s + p.Amount_USD, 0); + const weighted = deals.reduce((s, p) => s + (p.Amount_USD * p.Probability_Pct / 100), 0); + return { stage, count: deals.length, total, weighted }; + }); + }, [displayPipeline]); + + const forecastByDistrict = useMemo(() => { + const districtMap = new Map>(); + displayPipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost').forEach(p => { + const d = DISTRICT_SHORT[p.District_Name] || p.District_Name; + if (!districtMap.has(d)) districtMap.set(d, { Commit: 0, 'Best Case': 0, Pipeline: 0, Omit: 0 }); + districtMap.get(d)![p.Forecast_Category] += p.Amount_USD; + }); + return Array.from(districtMap.entries()).map(([name, data]) => ({ name, ...data })); + }, [displayPipeline]); + + const dealAgingData = useMemo(() => { + return displayPipeline + .filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost') + .map(p => ({ + daysInStage: differenceInDays(new Date(), parseISO(p.Created_Date)), + amount: p.Amount_USD, + name: p.Account_Name, + forecast: p.Forecast_Category, + stage: p.Stage, + fill: FORECAST_COLORS[p.Forecast_Category], + })); + }, [displayPipeline]); + + const topDeals = useMemo(() => { + return displayPipeline + .filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost') + .sort((a, b) => b.Amount_USD - a.Amount_USD) + .slice(0, 15); + }, [displayPipeline]); + + const allStages = ['02-Discovery', '03-Evaluation', '04-Business Case', '05-Negotiation', '06-Closed Won', '07-Closed Lost']; + + return ( +
+ + + {/* Stage filter pills */} +
+ + {allStages.map(s => ( + + ))} + +
+ +
+ {/* Pipeline Waterfall */} + + + + + `$${(Math.abs(v) / 1_000_000).toFixed(1)}M`} /> + formatCurrency(Math.abs(Number(value)), true)} /> + + {waterfallData.map((entry, i) => ( + + ))} + + + + + + {/* Pipeline by Stage */} + +
+ {stageData.map(s => ( +
+
{s.stage}
+
+
+
+ {formatCurrency(s.total, true)} +
+
+ W: {formatCurrency(s.weighted, true)} +
+
+
+
{s.count} deals
+
+ ))} +
+
+
+ +
+ {/* Forecast Category Breakdown */} + + + + + `$${(v / 1000).toFixed(0)}K`} /> + formatCurrency(Number(value), true)} /> + + {(['Commit', 'Best Case', 'Pipeline', 'Omit'] as const).map(cat => ( + + ))} + + + + + {/* Deal Aging */} + + + + + `$${(v / 1000).toFixed(0)}K`} /> + + name === 'Value' ? formatCurrency(Number(value), true) : value} /> + + {dealAgingData.map((entry, i) => ( + 90 && entry.amount > 100000 ? '#EF4444' : 'none'} strokeWidth={2} /> + ))} + + + + +
+ + {/* Top Deals Table */} + +
+ + + + + + + + + + + + + + + {topDeals.map(deal => { + const days = differenceInDays(new Date(), parseISO(deal.Created_Date)); + return ( + setSelectedDeal(deal)}> + + + + + + + + + + ); + })} + +
AccountAmountStageForecastProb %Next StepDaysClose Date
{deal.Account_Name}{formatCurrency(deal.Amount_USD, true)} + {deal.Stage} + + {deal.Forecast_Category} + {deal.Probability_Pct}%{deal.Next_Step || '—'}{format(parseISO(deal.Expected_Close_Date), 'MMM d')}
+
+
+ + {/* Deal Detail Slide-out */} + {selectedDeal && ( + <> +
setSelectedDeal(null)} /> + setSelectedDeal(null)} /> + + )} +
+ ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..d785087 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,538 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import Link from 'next/link'; + +type Tab = 'import' | 'accounts' | 'pipeline' | 'activities' | 'targets'; + +interface Stats { + accounts: number; + pipeline: number; + activities: number; + targets: number; +} + +export default function AdminPage() { + const [tab, setTab] = useState('import'); + const [stats, setStats] = useState({ accounts: 0, pipeline: 0, activities: 0, targets: 0 }); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + useEffect(() => { + fetch('/api/admin').then(r => r.json()).then(d => setStats(d.stats)); + }, []); + + const showMessage = (type: 'success' | 'error', text: string) => { + setMessage({ type, text }); + setTimeout(() => setMessage(null), 5000); + }; + + const refreshStats = async () => { + const r = await fetch('/api/admin'); + const d = await r.json(); + setStats(d.stats); + }; + + return ( +
+
+
+ + ← Dashboard + +

Data Admin

+
+
+ {stats.accounts} accounts + {stats.pipeline} deals + {stats.activities} activities + {stats.targets} targets +
+
+ + {message && ( +
+ {message.text} +
+ )} + +
+ {(['import', 'accounts', 'pipeline', 'activities', 'targets'] as Tab[]).map(t => ( + + ))} +
+ +
+ {tab === 'import' && { refreshStats(); showMessage('success', 'Import complete!'); }} onError={showMessage} loading={loading} setLoading={setLoading} />} + {tab === 'accounts' && } + {tab === 'pipeline' && } + {tab === 'activities' && } + {tab === 'targets' && } +
+
+ ); +} + +function ImportTab({ onComplete, onError, loading, setLoading }: { + onComplete: () => void; + onError: (type: 'success' | 'error', text: string) => void; + loading: boolean; + setLoading: (v: boolean) => void; +}) { + const [csvTab, setCsvTab] = useState('accounts'); + const fileRef = useRef(null); + + const handleFileUpload = async (file: File, tabName: string) => { + setLoading(true); + try { + const text = await file.text(); + const res = await fetch('/api/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tab: tabName, csv: text }), + }); + const result = await res.json(); + if (result.success) { + onComplete(); + } else { + onError('error', result.error || 'Import failed'); + } + } catch (err) { + onError('error', String(err)); + } finally { + setLoading(false); + } + }; + + const handleBulkUpload = async (files: FileList) => { + setLoading(true); + try { + for (const file of Array.from(files)) { + const name = file.name.toLowerCase().replace('.csv', ''); + let tabName = name; + if (name.includes('pipeline')) tabName = 'pipeline'; + else if (name.includes('account')) tabName = 'accounts'; + else if (name.includes('activity')) tabName = 'activities'; + else if (name.includes('target') || name.includes('implementation')) tabName = 'targets'; + + const text = await file.text(); + await fetch('/api/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tab: tabName, csv: text }), + }); + } + onComplete(); + } catch (err) { + onError('error', String(err)); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Import from Google Sheets

+

+ Export each tab from your Google Sheet as CSV (File → Download → Comma-separated values), then upload them here. + The import is idempotent — accounts and pipeline records are upserted by their ID, so re-importing is safe. +

+ +
+

Quick Import: Upload All CSVs at Once

+

+ Name your files with the tab name (e.g. Pipeline.csv, Accounts.csv, Activity_Log.csv, Targets.csv). +

+ e.target.files && handleBulkUpload(e.target.files)} + disabled={loading} + className="block w-full text-sm file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-[#0098C7] file:text-white hover:file:bg-[#007ba3] file:cursor-pointer disabled:opacity-50" + /> +
+ +
+

Import Single Tab

+
+ {['accounts', 'pipeline', 'activities', 'targets'].map(t => ( + + ))} +
+
+ + +
+
+
+ +
+

Expected Column Headers

+

Your CSV files should have these headers in Row 1 (case-sensitive):

+
+
+ Accounts:{' '} + Account_Name, District_Name, Tier, Priority, AgentMinder_Status + (+ optional: Current_ARR_USD, Touch_Count, Date_First_Touched, Date_Last_Touched, MAP_In_Place_YN) +
+
+ Pipeline:{' '} + Opportunity_ID, Account_Name, District_Name, Stage, Forecast_Category, Amount_USD, Created_Date, Expected_Close_Date +
+
+ Activity_Log:{' '} + Activity_Date, Activity_Type, Account_Name, District_Name + (+ optional: Contact_Name, Notes) +
+
+ Targets:{' '} + Account_Name, Implementation_Stage + (+ optional: Go_Live_Date, Health_Status, Notes) +
+
+
+
+ ); +} + +const FIELD_OPTIONS: Record = { + Tier: ['Tier 1', 'Tier 2', 'Tier 3', 'Tier 4'], + Priority: ['High', 'Medium', 'Low'], + AgentMinder_Status: ['Not Touched', '10% - Prospect', '15% - Prospect - No Opp', '20% - Research', '40% - Verify', '60% - Prove', '80% - Pricing', '100% - Closed Won', 'Lost'], + District_Name: ['SE-MISS-VALLEY', 'SE-PEACHTREE', 'SE-SUNSHINE', 'SE-MID-ATL'], + MAP_In_Place_YN: ['Y', 'N'], + Stage: ['01-Qualified', '02-Discovery', '03-Evaluation', '04-Business Case', '05-Negotiation', '06-Closed Won', '07-Closed Lost'], + Forecast_Category: ['Commit', 'Best Case', 'Pipeline', 'Omitted', 'Closed'], + Deal_Type: ['Cross-Sell', 'Net-New Division', 'Net-New Logo', 'Expansion'], + Activity_Type: ['Launch Briefing', 'Discovery', 'Demo', 'Exec Meeting', 'Webinar Attendance', 'Email Sequence Touch', 'Renewal Conversation', 'QBR Attach', 'Referral Ask'], + Play: ['Attach', 'Renewal-Trigger', 'Referral', 'Inbound', 'Whitespace'], + Channel: ['In Person', 'Video Call', 'Phone', 'Email', 'Webinar', 'LinkedIn', 'Event'], + Persona: ['Economic Buyer', 'Champion', 'Technical Evaluator', 'End User', 'Procurement'], + Outcome: ['Advanced', 'Follow-up Scheduled', 'Opportunity Created', 'No Decision Yet', 'No Response', 'No Interest', 'Disqualified'], + Implementation_Stage: ['Not Started', 'In Progress', 'Live', 'At Risk', 'Stalled'], + Health_Status: ['Green', 'Yellow', 'Red'], +}; + +interface FieldDef { key: string; label: string; required?: boolean; type?: string; options?: string[] } + +const TABLE_FIELDS: Record = { + accounts: [ + { key: 'Account_Name', label: 'Account Name', required: true }, + { key: 'District_Name', label: 'District', required: true, options: FIELD_OPTIONS.District_Name }, + { key: 'Tier', label: 'Tier', options: FIELD_OPTIONS.Tier }, + { key: 'Priority', label: 'Priority', options: FIELD_OPTIONS.Priority }, + { key: 'AgentMinder_Status', label: 'Status', options: FIELD_OPTIONS.AgentMinder_Status }, + { key: 'Current_ARR_USD', label: 'Current ARR', type: 'number' }, + { key: 'MAP_In_Place_YN', label: 'MAP in Place', options: FIELD_OPTIONS.MAP_In_Place_YN }, + { key: 'Company_URL', label: 'Company URL' }, + { key: 'Logo_URL', label: 'Logo URL' }, + { key: 'Area_Sales_Leader', label: 'Area Sales Leader' }, + { key: 'DM', label: 'DM' }, + { key: 'AD', label: 'AD' }, + { key: 'IMS_BA', label: 'IMS BA' }, + { key: 'Next_Renewal_Date', label: 'Next Renewal Date', type: 'date' }, + { key: 'Next_Renewal_EAR', label: 'Next Renewal EAR', type: 'number' }, + { key: 'Anchor_Contract_Date', label: 'Anchor Contract Date', type: 'date' }, + { key: 'Anchor_Contract_EAR', label: 'Anchor Contract EAR', type: 'number' }, + { key: 'Google_Drive_URL', label: 'Google Drive Link' }, + { key: 'Campaign_Artifacts_URL', label: 'Campaign Artifacts Link' }, + ], + pipeline: [ + { key: 'Opportunity_ID', label: 'Opp ID', required: true }, + { key: 'Account_Name', label: 'Account Name', required: true }, + { key: 'District_Name', label: 'District', required: true, options: FIELD_OPTIONS.District_Name }, + { key: 'Stage', label: 'Stage', options: FIELD_OPTIONS.Stage }, + { key: 'Forecast_Category', label: 'Forecast', options: FIELD_OPTIONS.Forecast_Category }, + { key: 'Deal_Type', label: 'Deal Type', options: FIELD_OPTIONS.Deal_Type }, + { key: 'Amount_USD', label: 'Amount', type: 'number', required: true }, + { key: 'Closed_Amount_USD', label: 'Closed Amount', type: 'number' }, + { key: 'Probability_Pct', label: 'Probability %', type: 'number' }, + { key: 'Created_Date', label: 'Created Date', type: 'date', required: true }, + { key: 'Expected_Close_Date', label: 'Expected Close', type: 'date' }, + { key: 'Closed_Date', label: 'Closed Date', type: 'date' }, + { key: 'Champion_Name', label: 'Champion' }, + { key: 'Economic_Buyer', label: 'Economic Buyer' }, + { key: 'Next_Step', label: 'Next Step' }, + { key: 'Competitor', label: 'Competitor' }, + { key: 'Source_Play', label: 'Source Play' }, + ], + activities: [ + { key: 'Activity_Date', label: 'Date', type: 'date', required: true }, + { key: 'Activity_Type', label: 'Type', required: true, options: FIELD_OPTIONS.Activity_Type }, + { key: 'Account_Name', label: 'Account Name', required: true }, + { key: 'District_Name', label: 'District', required: true, options: FIELD_OPTIONS.District_Name }, + { key: 'Contact_Name', label: 'Contact' }, + { key: 'Notes', label: 'Notes' }, + { key: 'Play', label: 'Play', options: FIELD_OPTIONS.Play }, + { key: 'Channel', label: 'Channel', options: FIELD_OPTIONS.Channel }, + { key: 'Persona', label: 'Persona', options: FIELD_OPTIONS.Persona }, + { key: 'Outcome', label: 'Outcome', options: FIELD_OPTIONS.Outcome }, + { key: 'Logged_By', label: 'Logged By' }, + ], + targets: [ + { key: 'Account_Name', label: 'Account Name', required: true }, + { key: 'Implementation_Stage', label: 'Stage', required: true, options: FIELD_OPTIONS.Implementation_Stage }, + { key: 'Go_Live_Date', label: 'Go-Live Date', type: 'date' }, + { key: 'Health_Status', label: 'Health', options: FIELD_OPTIONS.Health_Status }, + { key: 'Notes', label: 'Notes' }, + ], +}; + +function RecordForm({ table, record, onSave, onCancel }: { + table: string; + record: Record | null; + onSave: (data: Record) => void; + onCancel: () => void; +}) { + const fields = TABLE_FIELDS[table] || []; + const [form, setForm] = useState>(() => { + const init: Record = {}; + fields.forEach(f => { + init[f.key] = record ? String(record[f.key] ?? '') : ''; + }); + return init; + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const data: Record = {}; + fields.forEach(f => { + const v = form[f.key]?.trim(); + if (f.type === 'number') { + data[f.key] = v ? Number(v) : null; + } else { + data[f.key] = v || null; + } + }); + onSave(data); + }; + + return ( +
+

{record ? 'Edit Record' : 'Add New Record'}

+
+ {fields.map(f => ( +
+ + {f.options ? ( + + ) : ( + setForm(prev => ({ ...prev, [f.key]: e.target.value }))} + required={f.required} + className="w-full px-2.5 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#0098C7]/30 focus:border-[#0098C7]" + /> + )} +
+ ))} +
+
+ + +
+
+ ); +} + +function DataTable({ table, onUpdate, showMessage }: { + table: string; + onUpdate: () => void; + showMessage: (type: 'success' | 'error', text: string) => void; +}) { + const [data, setData] = useState[]>([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [showForm, setShowForm] = useState(false); + const [editRecord, setEditRecord] = useState | null>(null); + + const loadData = async () => { + setLoading(true); + const res = await fetch('/api/admin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'list', table }), + }); + const result = await res.json(); + setData(result.data || []); + setLoading(false); + }; + + useEffect(() => { loadData(); }, [table]); + + const handleDelete = async (record: Record) => { + if (!confirm('Delete this record?')) return; + await fetch('/api/admin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'delete', table, record }), + }); + showMessage('success', 'Record deleted'); + loadData(); + onUpdate(); + }; + + const handleSave = async (record: Record) => { + try { + const res = await fetch('/api/admin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'upsert', table, record }), + }); + const result = await res.json(); + if (result.success) { + showMessage('success', editRecord ? 'Record updated' : 'Record added'); + setShowForm(false); + setEditRecord(null); + loadData(); + onUpdate(); + } else { + showMessage('error', result.error || 'Save failed'); + } + } catch (err) { + showMessage('error', String(err)); + } + }; + + if (loading) return
Loading...
; + + const allKeys = data.length > 0 ? Object.keys(data[0]) : []; + const isAccounts = table === 'accounts'; + const displayKeys = allKeys.filter(k => k !== 'id' && !(isAccounts && (k === 'Logo_URL' || k === 'Google_Drive_URL' || k === 'Campaign_Artifacts_URL'))); + const filtered = data.filter(row => + search === '' || Object.values(row).some(v => String(v ?? '').toLowerCase().includes(search.toLowerCase())) + ); + + return ( +
+ {(showForm || editRecord) && ( + { setShowForm(false); setEditRecord(null); }} + /> + )} + +
+ setSearch(e.target.value)} + className="px-3 py-2 border border-gray-300 rounded-lg text-sm w-64 focus:outline-none focus:ring-2 focus:ring-[#0098C7]" + /> + {filtered.length} records + {!showForm && !editRecord && ( + + )} +
+
+ + + + {displayKeys.map(k => ( + + ))} + + + + + {filtered.slice(0, 200).map((row, i) => ( + + {displayKeys.map(k => ( + + ))} + + + ))} + +
{k}
+ {isAccounts && k === 'Account_Name' ? ( + + {row.Logo_URL ? ( + { (e.target as HTMLImageElement).style.display = 'none'; }} /> + ) : ( + + {String(row[k] ?? '').charAt(0)} + + )} + {String(row[k] ?? '')} + + ) : ( + String(row[k] ?? '') + )} + + + +
+ {filtered.length > 200 && ( +
Showing first 200 of {filtered.length} records
+ )} +
+
+ ); +} diff --git a/src/app/api/admin/route.ts b/src/app/api/admin/route.ts new file mode 100644 index 0000000..c643c1c --- /dev/null +++ b/src/app/api/admin/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + upsertAccount, deleteAccount, + upsertPipeline, deletePipeline, + addActivity, deleteActivity, + upsertTarget, deleteTarget, + getDbStats, getDashboardData, +} from '@/lib/db'; + +export async function GET() { + return NextResponse.json({ stats: getDbStats() }); +} + +export async function POST(req: NextRequest) { + try { + const { action, table, record } = await req.json(); + + if (action === 'delete') { + switch (table) { + case 'accounts': deleteAccount(record.Account_Name); break; + case 'pipeline': deletePipeline(record.Opportunity_ID); break; + case 'activities': deleteActivity(record.id); break; + case 'targets': deleteTarget(record.Account_Name); break; + } + return NextResponse.json({ success: true, stats: getDbStats() }); + } + + if (action === 'upsert') { + switch (table) { + case 'accounts': upsertAccount(record); break; + case 'pipeline': upsertPipeline(record); break; + case 'activities': addActivity(record); break; + case 'targets': upsertTarget(record); break; + } + return NextResponse.json({ success: true, stats: getDbStats() }); + } + + if (action === 'list') { + const data = getDashboardData(); + const tableData = { + accounts: data.accounts, + pipeline: data.pipeline, + activities: data.activities, + targets: data.targets, + }[table]; + return NextResponse.json({ data: tableData || [] }); + } + + return NextResponse.json({ error: 'Unknown action' }, { status: 400 }); + } catch (error) { + console.error('[Admin API]', error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts new file mode 100644 index 0000000..0febe31 --- /dev/null +++ b/src/app/api/import/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { importData, importCSV, getDbStats } from '@/lib/db'; + +export async function POST(req: NextRequest) { + try { + const contentType = req.headers.get('content-type') || ''; + + if (contentType.includes('application/json')) { + const body = await req.json(); + + if (body.csv && body.tab) { + const count = importCSV(body.tab, body.csv); + return NextResponse.json({ success: true, imported: { [body.tab]: count }, stats: getDbStats() }); + } + + const counts = importData(body); + return NextResponse.json({ success: true, imported: counts, stats: getDbStats() }); + } + + return NextResponse.json({ error: 'Unsupported content type' }, { status: 400 }); + } catch (error) { + console.error('[Import API]', error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/src/app/api/sheets/route.ts b/src/app/api/sheets/route.ts new file mode 100644 index 0000000..6985a06 --- /dev/null +++ b/src/app/api/sheets/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; +import { getDashboardData, getDbStats } from '@/lib/db'; +import { getMockData } from '@/lib/mock-data'; + +export async function GET() { + try { + const stats = getDbStats(); + const hasData = stats.accounts > 0; + + const data = hasData ? getDashboardData() : getMockData(); + + return NextResponse.json(data, { + headers: { + 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120', + }, + }); + } catch (error) { + console.error('[Data API]', error); + return NextResponse.json(getMockData()); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..3725c59 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,26 +1,91 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + --brand-dark-blue: #1B1D36; + --brand-navy: #005C8A; + --brand-azure: #0098C7; + --brand-aqua: #007B8C; + --brand-green: #61A60E; + --brand-purple: #6C4B94; + --brand-light-blue: #007DA3; + + --background: #F8F9FC; + --foreground: #1B1D36; + --card-bg: #FFFFFF; + --card-border: #E2E8F0; + --sidebar-bg: #1B1D36; + --sidebar-text: #CBD5E1; + --sidebar-active: #0098C7; + --topbar-bg: #FFFFFF; + --topbar-border: #E2E8F0; + --muted: #64748B; + --success: #61A60E; + --warning: #F59E0B; + --danger: #EF4444; + --chart-1: #005C8A; + --chart-2: #0098C7; + --chart-3: #007B8C; + --chart-4: #61A60E; + --chart-5: #6C4B94; + --chart-6: #007DA3; + --chart-7: #23800A; + --chart-8: #0088EF; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); + --color-card-bg: var(--card-bg); + --color-card-border: var(--card-border); + --color-sidebar-bg: var(--sidebar-bg); + --color-sidebar-text: var(--sidebar-text); + --color-sidebar-active: var(--sidebar-active); + --color-topbar-bg: var(--topbar-bg); + --color-topbar-border: var(--topbar-border); + --color-muted: var(--muted); + --color-success: var(--success); + --color-warning: var(--warning); + --color-danger: var(--danger); + --color-brand-dark-blue: var(--brand-dark-blue); + --color-brand-navy: var(--brand-navy); + --color-brand-azure: var(--brand-azure); + --color-brand-aqua: var(--brand-aqua); + --color-brand-green: var(--brand-green); + --color-brand-purple: var(--brand-purple); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; +} + +.scrollbar-thin::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.scrollbar-thin::-webkit-scrollbar-track { + background: transparent; +} + +.scrollbar-thin::-webkit-scrollbar-thumb { + background: #CBD5E1; + border-radius: 3px; +} + +.recharts-tooltip-wrapper { + z-index: 50 !important; +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +.skeleton { + background: linear-gradient(90deg, #E2E8F0 25%, #F1F5F9 50%, #E2E8F0 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9852c15..7b8aa35 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,17 +13,16 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "AgentMinder Campaign Command Center", + description: "Sales campaign dashboard for the SouthEast territory", }; export default function RootLayout({ children }: LayoutProps<"/">) { return ( - - {children} + + + {children} + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx deleted file mode 100644 index c887311..0000000 --- a/src/app/page.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import Image from "next/image"; - -export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the{" "} - - page.tsx - {" "} - file. -

-

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

-
- -
-
- ); -} diff --git a/src/components/layout/DashboardShell.tsx b/src/components/layout/DashboardShell.tsx new file mode 100644 index 0000000..d1a7da2 --- /dev/null +++ b/src/components/layout/DashboardShell.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { ReactNode } from 'react'; +import { DataProvider } from '@/lib/data-context'; +import { Sidebar } from './Sidebar'; +import { TopBar } from './TopBar'; +import { DashboardData, DashboardConfig } from '@/types/data'; + +export function DashboardShell({ children, initialData, config }: { children: ReactNode; initialData: DashboardData; config: DashboardConfig }) { + return ( + +
+ +
+ +
+ {children} +
+
+
+
+ ); +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..5c3da13 --- /dev/null +++ b/src/components/layout/Sidebar.tsx @@ -0,0 +1,145 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { useState } from 'react'; + +const navItems = [ + { href: '/', label: 'Executive Overview', icon: DashboardIcon, shortLabel: 'Overview' }, + { href: '/accounts', label: 'Accounts', icon: AccountsIcon, shortLabel: 'Accounts' }, + { href: '/activity', label: 'Activities', icon: ActivityIcon, shortLabel: 'Activities' }, + { href: '/pipeline', label: 'Opportunities', icon: PipelineIcon, shortLabel: 'Opps' }, + { href: '/implementation', label: 'Implementation', icon: ImplementIcon, shortLabel: 'Implement' }, +]; + +function DashboardIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function ActivityIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function PipelineIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function ImplementIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function AccountsIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export function Sidebar() { + const pathname = usePathname(); + const [collapsed, setCollapsed] = useState(false); + + return ( + <> + {/* Desktop sidebar */} + + + {/* Mobile bottom nav */} + + + ); +} diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx new file mode 100644 index 0000000..bb88ca3 --- /dev/null +++ b/src/components/layout/TopBar.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { useData, DatePreset } from '@/lib/data-context'; +import { format } from 'date-fns'; +import { useState, useRef, useEffect } from 'react'; +import { DISTRICTS } from '@/types/data'; + +const DATE_PRESETS: { value: DatePreset; label: string }[] = [ + { value: 'this_week', label: 'This Week' }, + { value: 'last_7', label: 'Last 7 Days' }, + { value: 'this_month', label: 'This Month' }, + { value: 'this_quarter', label: 'This Quarter' }, + { value: 'last_quarter', label: 'Last Quarter' }, +]; + +export function TopBar() { + const { filters, setDistricts, setDatePreset, refresh, isLoading, lastRefreshed, clearAllFilters, clearCrossFilter } = useData(); + const [showDistrictDropdown, setShowDistrictDropdown] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + function handleClick(e: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setShowDistrictDropdown(false); + } + } + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, []); + + const toggleDistrict = (d: string) => { + const current = filters.districts; + if (current.includes(d)) { + setDistricts(current.filter(x => x !== d)); + } else { + setDistricts([...current, d]); + } + }; + + const districtLabel = filters.districts.length === 0 + ? 'All Districts' + : filters.districts.length === 1 + ? filters.districts[0].replace('SE-', '') + : `${filters.districts.length} Districts`; + + return ( +
+ {/* Mobile logo */} +
+ Logo { (e.target as HTMLImageElement).style.display = 'none'; }} /> + AgentMinder +
+ + {/* Desktop spacer */} +
+ + {/* Active filters indicator */} + {filters.crossFilter && ( + + )} + + {/* Date preset */} + + + {/* District filter */} +
+ + {showDistrictDropdown && ( +
+ + {DISTRICTS.map(d => ( + + ))} +
+ )} +
+ + {/* Refresh */} + + + {/* Last updated */} +
+ {lastRefreshed && `Updated ${format(new Date(lastRefreshed), 'h:mm a')}`} +
+
+ ); +} diff --git a/src/components/ui/ChartCard.tsx b/src/components/ui/ChartCard.tsx new file mode 100644 index 0000000..eff649b --- /dev/null +++ b/src/components/ui/ChartCard.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { ReactNode } from 'react'; + +interface ChartCardProps { + title: string; + subtitle?: string; + children: ReactNode; + className?: string; + action?: ReactNode; +} + +export function ChartCard({ title, subtitle, children, className = '', action }: ChartCardProps) { + return ( +
+
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {action} +
+ {children} +
+ ); +} diff --git a/src/components/ui/PageHeader.tsx b/src/components/ui/PageHeader.tsx new file mode 100644 index 0000000..d0f0292 --- /dev/null +++ b/src/components/ui/PageHeader.tsx @@ -0,0 +1,15 @@ +'use client'; + +interface PageHeaderProps { + title: string; + subtitle: string; +} + +export function PageHeader({ title, subtitle }: PageHeaderProps) { + return ( +
+

{title}

+

{subtitle}

+
+ ); +} diff --git a/src/components/ui/Scorecard.tsx b/src/components/ui/Scorecard.tsx new file mode 100644 index 0000000..0327a5d --- /dev/null +++ b/src/components/ui/Scorecard.tsx @@ -0,0 +1,40 @@ +'use client'; + +interface ScorecardProps { + label: string; + value: string | number; + subtitle?: string; + trend?: number; + color?: 'default' | 'green' | 'red' | 'amber'; + onClick?: () => void; +} + +export function Scorecard({ label, value, subtitle, trend, color = 'default', onClick }: ScorecardProps) { + const colorClasses = { + default: 'border-card-border', + green: 'border-l-4 border-l-success border-t-card-border border-r-card-border border-b-card-border', + red: 'border-l-4 border-l-danger border-t-card-border border-r-card-border border-b-card-border', + amber: 'border-l-4 border-l-warning border-t-card-border border-r-card-border border-b-card-border', + }; + + return ( +
+
{label}
+
+
{value}
+ {trend !== undefined && ( +
= 0 ? 'text-success' : 'text-danger'}`}> + + {trend >= 0 ? : } + + {Math.abs(trend)}% +
+ )} +
+ {subtitle &&
{subtitle}
} +
+ ); +} diff --git a/src/lib/data-context.tsx b/src/lib/data-context.tsx new file mode 100644 index 0000000..442481f --- /dev/null +++ b/src/lib/data-context.tsx @@ -0,0 +1,173 @@ +'use client'; + +import React, { createContext, useContext, useState, useMemo, useCallback, ReactNode } from 'react'; +import { DashboardData, DashboardConfig, PipelineRecord, AccountRecord, ActivityRecord, TargetRecord } from '@/types/data'; +import { startOfWeek, endOfWeek, startOfMonth, endOfMonth, startOfQuarter, endOfQuarter, subDays, parseISO, isWithinInterval } from 'date-fns'; + +export type DatePreset = 'this_week' | 'last_7' | 'this_month' | 'this_quarter' | 'last_quarter' | 'custom'; + +interface FilterState { + districts: string[]; + datePreset: DatePreset; + dateRange: { start: string; end: string }; + crossFilter: { key: string; value: string } | null; +} + +interface DataContextValue { + raw: DashboardData; + config: DashboardConfig; + filters: FilterState; + setDistricts: (d: string[]) => void; + setDatePreset: (p: DatePreset) => void; + setDateRange: (start: string, end: string) => void; + setCrossFilter: (key: string, value: string) => void; + clearCrossFilter: () => void; + clearAllFilters: () => void; + filtered: { + pipeline: PipelineRecord[]; + accounts: AccountRecord[]; + activities: ActivityRecord[]; + targets: TargetRecord[]; + }; + isLoading: boolean; + lastRefreshed: string; + refresh: () => Promise; +} + +const DataContext = createContext(null); + +function getDateRange(preset: DatePreset, custom?: { start: string; end: string }): { start: Date; end: Date } { + const now = new Date(); + switch (preset) { + case 'this_week': + return { start: startOfWeek(now, { weekStartsOn: 1 }), end: endOfWeek(now, { weekStartsOn: 1 }) }; + case 'last_7': + return { start: subDays(now, 7), end: now }; + case 'this_month': + return { start: startOfMonth(now), end: endOfMonth(now) }; + case 'this_quarter': + return { start: startOfQuarter(now), end: endOfQuarter(now) }; + case 'last_quarter': { + const lastQ = new Date(now); + lastQ.setMonth(lastQ.getMonth() - 3); + return { start: startOfQuarter(lastQ), end: endOfQuarter(lastQ) }; + } + case 'custom': + if (custom) return { start: parseISO(custom.start), end: parseISO(custom.end) }; + return { start: subDays(now, 30), end: now }; + } +} + +export function DataProvider({ children, initialData, config }: { children: ReactNode; initialData: DashboardData; config: DashboardConfig }) { + const [data, setData] = useState(initialData); + const [isLoading, setIsLoading] = useState(false); + const [filters, setFilters] = useState({ + districts: [], + datePreset: 'this_quarter', + dateRange: { start: '', end: '' }, + crossFilter: null, + }); + + const setDistricts = useCallback((d: string[]) => { + setFilters(f => ({ ...f, districts: d })); + }, []); + + const setDatePreset = useCallback((p: DatePreset) => { + setFilters(f => ({ ...f, datePreset: p })); + }, []); + + const setDateRange = useCallback((start: string, end: string) => { + setFilters(f => ({ ...f, datePreset: 'custom', dateRange: { start, end } })); + }, []); + + const setCrossFilter = useCallback((key: string, value: string) => { + setFilters(f => ({ ...f, crossFilter: { key, value } })); + }, []); + + const clearCrossFilter = useCallback(() => { + setFilters(f => ({ ...f, crossFilter: null })); + }, []); + + const clearAllFilters = useCallback(() => { + setFilters({ districts: [], datePreset: 'this_quarter', dateRange: { start: '', end: '' }, crossFilter: null }); + }, []); + + const refresh = useCallback(async () => { + setIsLoading(true); + try { + const res = await fetch('/api/sheets?bust=' + Date.now()); + if (res.ok) { + const newData = await res.json(); + setData(newData); + } + } finally { + setIsLoading(false); + } + }, []); + + const filtered = useMemo(() => { + const { districts: distFilter, datePreset, dateRange, crossFilter } = filters; + const range = getDateRange(datePreset, dateRange); + + let pipeline = data.pipeline; + let accounts = data.accounts; + let activities = data.activities; + let targets = data.targets; + + if (distFilter.length > 0) { + pipeline = pipeline.filter(p => distFilter.includes(p.District_Name)); + accounts = accounts.filter(a => distFilter.includes(a.District_Name)); + activities = activities.filter(a => distFilter.includes(a.District_Name)); + } + + activities = activities.filter(a => { + try { + return isWithinInterval(parseISO(a.Activity_Date), range); + } catch { + return true; + } + }); + + if (crossFilter) { + const { key, value } = crossFilter; + const match = (obj: object) => String((obj as never)[key as never]) === value; + pipeline = pipeline.filter(match); + accounts = accounts.filter(match); + activities = activities.filter(match); + targets = targets.filter(match); + } + + if (distFilter.length > 0) { + const accountNames = new Set(accounts.map(a => a.Account_Name)); + targets = targets.filter(t => accountNames.has(t.Account_Name)); + } + + return { pipeline, accounts, activities, targets }; + }, [data, filters]); + + return ( + + {children} + + ); +} + +export function useData() { + const ctx = useContext(DataContext); + if (!ctx) throw new Error('useData must be used within DataProvider'); + return ctx; +} diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..bae847e --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,484 @@ +import Database from 'better-sqlite3'; +import path from 'path'; +import { + PipelineRecord, + AccountRecord, + ActivityRecord, + TargetRecord, + ImplementationRecord, + MetricTarget, + DashboardData, +} from '@/types/data'; + +const DB_PATH = path.join(process.cwd(), 'data', 'campaign.db'); + +let _db: Database.Database | null = null; + +function getDb(): Database.Database { + if (!_db) { + const fs = require('fs'); + const dir = path.dirname(DB_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + _db = new Database(DB_PATH); + _db.pragma('journal_mode = WAL'); + initSchema(_db); + } + return _db; +} + +function initSchema(db: Database.Database) { + db.exec(` + CREATE TABLE IF NOT EXISTS accounts ( + Account_Name TEXT PRIMARY KEY, + District_Name TEXT NOT NULL DEFAULT '', + Tier TEXT NOT NULL DEFAULT '', + Priority TEXT NOT NULL DEFAULT '', + AgentMinder_Status TEXT NOT NULL DEFAULT 'Not Touched', + Current_ARR_USD REAL, + Touch_Count INTEGER DEFAULT 0, + Date_First_Touched TEXT, + Date_Last_Touched TEXT, + MAP_In_Place_YN TEXT, + Company_URL TEXT, + Area_Sales_Leader TEXT, + DM TEXT, + AD TEXT, + Logo_URL TEXT, + Next_Renewal_Date TEXT, + Next_Renewal_EAR REAL, + Anchor_Contract_Date TEXT, + Anchor_Contract_EAR REAL + ); + + CREATE TABLE IF NOT EXISTS pipeline ( + Opportunity_ID TEXT PRIMARY KEY, + Account_Name TEXT NOT NULL, + District_Name TEXT NOT NULL DEFAULT '', + Stage TEXT NOT NULL DEFAULT '01-Qualified', + Forecast_Category TEXT NOT NULL DEFAULT 'Pipeline', + Amount_USD REAL NOT NULL DEFAULT 0, + Closed_Amount_USD REAL, + Probability_Pct INTEGER NOT NULL DEFAULT 0, + Created_Date TEXT NOT NULL, + Expected_Close_Date TEXT NOT NULL DEFAULT '', + Closed_Date TEXT, + Champion_Name TEXT, + Economic_Buyer TEXT, + Next_Step TEXT, + Next_Step_Date TEXT, + Primary_Objection TEXT, + Competitor TEXT, + Source_Play TEXT, + Product TEXT NOT NULL DEFAULT 'AgentMinder', + Deal_Type TEXT, + Status TEXT DEFAULT 'Open', + Stage_Entered_Date TEXT, + Tier TEXT, + MAP_In_Place_YN TEXT + ); + + CREATE TABLE IF NOT EXISTS activities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + Activity_ID TEXT, + Activity_Date TEXT NOT NULL, + Activity_Type TEXT NOT NULL, + Account_Name TEXT NOT NULL, + District_Name TEXT NOT NULL DEFAULT '', + Contact_Name TEXT, + Notes TEXT, + Play TEXT, + Channel TEXT, + Persona TEXT, + Outcome TEXT, + Logged_By TEXT + ); + + CREATE TABLE IF NOT EXISTS implementations ( + Implementation_ID TEXT PRIMARY KEY, + Opportunity_ID TEXT, + Account_Name TEXT NOT NULL, + District_Name TEXT, + Tier TEXT, + Closed_Date TEXT, + Closed_Amount_USD REAL, + Kickoff_Date TEXT, + Go_Live_Target TEXT, + Go_Live_Actual TEXT, + Deployment_Status TEXT NOT NULL DEFAULT 'Not Started', + Health_RYG TEXT, + Milestones_Complete INTEGER DEFAULT 0, + Milestones_Total INTEGER DEFAULT 5, + Onboarding_Owner TEXT, + Risk_Notes TEXT + ); + + CREATE TABLE IF NOT EXISTS metric_targets ( + Target_ID TEXT PRIMARY KEY, + Period_Type TEXT NOT NULL, + Period_Label TEXT NOT NULL DEFAULT '', + District_Name TEXT NOT NULL DEFAULT '', + Metric TEXT NOT NULL, + Target_Value REAL NOT NULL DEFAULT 0, + Notes TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_pipeline_account ON pipeline(Account_Name); + CREATE INDEX IF NOT EXISTS idx_pipeline_district ON pipeline(District_Name); + CREATE INDEX IF NOT EXISTS idx_activities_account ON activities(Account_Name); + CREATE INDEX IF NOT EXISTS idx_activities_date ON activities(Activity_Date); + CREATE INDEX IF NOT EXISTS idx_impl_account ON implementations(Account_Name); + `); +} + +export function getDashboardData(): DashboardData { + const db = getDb(); + return { + pipeline: db.prepare('SELECT * FROM pipeline').all() as PipelineRecord[], + accounts: db.prepare('SELECT * FROM accounts').all() as AccountRecord[], + activities: db.prepare('SELECT id, Activity_Date, Activity_Type, Account_Name, District_Name, Contact_Name, Notes, Play, Channel, Persona, Outcome, Logged_By FROM activities ORDER BY Activity_Date DESC').all() as (ActivityRecord & { id: number })[], + targets: buildTargetsFromImplementations(db), + implementations: db.prepare('SELECT * FROM implementations WHERE Account_Name IS NOT NULL').all() as ImplementationRecord[], + metricTargets: db.prepare('SELECT * FROM metric_targets').all() as MetricTarget[], + lastRefreshed: new Date().toISOString(), + }; +} + +function buildTargetsFromImplementations(db: Database.Database): TargetRecord[] { + const impls = db.prepare('SELECT * FROM implementations WHERE Account_Name IS NOT NULL').all() as ImplementationRecord[]; + return impls.map(impl => ({ + Account_Name: impl.Account_Name, + Implementation_Stage: impl.Deployment_Status || 'Not Started', + Go_Live_Date: impl.Go_Live_Target || impl.Go_Live_Actual || null, + Health_Status: impl.Health_RYG || null, + Notes: impl.Risk_Notes || null, + })); +} + +export function getDbStats() { + const db = getDb(); + return { + accounts: (db.prepare('SELECT COUNT(*) as c FROM accounts').get() as { c: number }).c, + pipeline: (db.prepare('SELECT COUNT(*) as c FROM pipeline').get() as { c: number }).c, + activities: (db.prepare('SELECT COUNT(*) as c FROM activities').get() as { c: number }).c, + targets: (db.prepare('SELECT COUNT(*) as c FROM implementations').get() as { c: number }).c, + metricTargets: (db.prepare('SELECT COUNT(*) as c FROM metric_targets').get() as { c: number }).c, + }; +} + +export function updateLastTouched(accountName: string, date: string) { + const db = getDb(); + const acct = db.prepare('SELECT Date_First_Touched, Touch_Count FROM accounts WHERE Account_Name = ?').get(accountName) as { Date_First_Touched: string | null; Touch_Count: number | null } | undefined; + if (!acct) return; + + const updates: Record = { + Account_Name: accountName, + Date_Last_Touched: date, + Touch_Count: (acct.Touch_Count || 0) + 1, + }; + + if (!acct.Date_First_Touched) { + updates.Date_First_Touched = date; + } + + db.prepare(` + UPDATE accounts SET + Date_Last_Touched = @Date_Last_Touched, + Touch_Count = @Touch_Count + ${!acct.Date_First_Touched ? ', Date_First_Touched = @Date_First_Touched' : ''} + WHERE Account_Name = @Account_Name + `).run(updates); +} + +export function recalcAccountTouchData() { + const db = getDb(); + const activities = db.prepare(` + SELECT Account_Name, MIN(Activity_Date) as first_touch, MAX(Activity_Date) as last_touch, COUNT(*) as touch_count + FROM activities + GROUP BY Account_Name + `).all() as { Account_Name: string; first_touch: string; last_touch: string; touch_count: number }[]; + + const update = db.prepare(` + UPDATE accounts SET + Date_First_Touched = @first_touch, + Date_Last_Touched = @last_touch, + Touch_Count = @touch_count + WHERE Account_Name = @Account_Name + `); + + const tx = db.transaction(() => { + for (const a of activities) { + update.run(a); + } + }); + tx(); +} + +export function recalcAccountStatus() { + const db = getDb(); + db.exec(` + UPDATE accounts SET AgentMinder_Status = CASE + WHEN Account_Name IN (SELECT DISTINCT Account_Name FROM pipeline WHERE Status = 'Open') THEN '20% - Research' + WHEN Account_Name IN (SELECT DISTINCT Account_Name FROM activities) THEN '10% - Prospect' + ELSE 'Not Touched' + END + WHERE AgentMinder_Status = 'Not Touched' + OR AgentMinder_Status IS NULL + `); +} + +export function addActivity(record: { + Activity_Date: string; + Activity_Type: string; + Account_Name: string; + District_Name: string; + Contact_Name?: string | null; + Notes?: string | null; + Play?: string | null; + Channel?: string | null; + Persona?: string | null; + Outcome?: string | null; + Logged_By?: string | null; +}) { + const db = getDb(); + db.prepare(` + INSERT INTO activities (Activity_Date, Activity_Type, Account_Name, District_Name, Contact_Name, Notes, Play, Channel, Persona, Outcome, Logged_By) + VALUES (@Activity_Date, @Activity_Type, @Account_Name, @District_Name, @Contact_Name, @Notes, @Play, @Channel, @Persona, @Outcome, @Logged_By) + `).run({ + Activity_Date: record.Activity_Date, + Activity_Type: record.Activity_Type, + Account_Name: record.Account_Name, + District_Name: record.District_Name, + Contact_Name: record.Contact_Name || null, + Notes: record.Notes || null, + Play: record.Play || null, + Channel: record.Channel || null, + Persona: record.Persona || null, + Outcome: record.Outcome || null, + Logged_By: record.Logged_By || null, + }); + + updateLastTouched(record.Account_Name, record.Activity_Date); +} + +export function importData(data: { + accounts?: Record[]; + pipeline?: Record[]; + activities?: Record[]; + implementations?: Record[]; + metricTargets?: Record[]; +}): Record { + const db = getDb(); + const counts: Record = {}; + + const tx = db.transaction(() => { + if (data.accounts?.length) { + const stmt = db.prepare(` + INSERT INTO accounts (Account_Name, District_Name, Tier, Priority, AgentMinder_Status, Current_ARR_USD, Touch_Count, Date_First_Touched, Date_Last_Touched, MAP_In_Place_YN, Company_URL, Area_Sales_Leader, DM, AD, Logo_URL, Next_Renewal_Date, Next_Renewal_EAR, Anchor_Contract_Date, Anchor_Contract_EAR) + VALUES (@Account_Name, @District_Name, @Tier, @Priority, @AgentMinder_Status, @Current_ARR_USD, @Touch_Count, @Date_First_Touched, @Date_Last_Touched, @MAP_In_Place_YN, @Company_URL, @Area_Sales_Leader, @DM, @AD, @Logo_URL, @Next_Renewal_Date, @Next_Renewal_EAR, @Anchor_Contract_Date, @Anchor_Contract_EAR) + ON CONFLICT(Account_Name) DO UPDATE SET + District_Name=excluded.District_Name, Tier=excluded.Tier, Priority=excluded.Priority, + AgentMinder_Status=excluded.AgentMinder_Status, Current_ARR_USD=excluded.Current_ARR_USD, + Touch_Count=excluded.Touch_Count, Date_First_Touched=excluded.Date_First_Touched, + Date_Last_Touched=excluded.Date_Last_Touched, MAP_In_Place_YN=excluded.MAP_In_Place_YN, + Company_URL=excluded.Company_URL, Area_Sales_Leader=excluded.Area_Sales_Leader, + DM=excluded.DM, AD=excluded.AD, Logo_URL=excluded.Logo_URL, + Next_Renewal_Date=excluded.Next_Renewal_Date, Next_Renewal_EAR=excluded.Next_Renewal_EAR, + Anchor_Contract_Date=excluded.Anchor_Contract_Date, Anchor_Contract_EAR=excluded.Anchor_Contract_EAR + `); + let c = 0; + for (const r of data.accounts) { + if (!r.Account_Name) continue; + stmt.run(r); + c++; + } + counts.accounts = c; + } + + if (data.pipeline?.length) { + const stmt = db.prepare(` + INSERT INTO pipeline (Opportunity_ID, Account_Name, District_Name, Stage, Forecast_Category, Amount_USD, Closed_Amount_USD, Probability_Pct, Created_Date, Expected_Close_Date, Closed_Date, Champion_Name, Economic_Buyer, Next_Step, Next_Step_Date, Primary_Objection, Competitor, Source_Play, Product, Deal_Type, Status, Stage_Entered_Date, Tier, MAP_In_Place_YN) + VALUES (@Opportunity_ID, @Account_Name, @District_Name, @Stage, @Forecast_Category, @Amount_USD, @Closed_Amount_USD, @Probability_Pct, @Created_Date, @Expected_Close_Date, @Closed_Date, @Champion_Name, @Economic_Buyer, @Next_Step, @Next_Step_Date, @Primary_Objection, @Competitor, @Source_Play, @Product, @Deal_Type, @Status, @Stage_Entered_Date, @Tier, @MAP_In_Place_YN) + ON CONFLICT(Opportunity_ID) DO UPDATE SET + Account_Name=excluded.Account_Name, District_Name=excluded.District_Name, Stage=excluded.Stage, + Forecast_Category=excluded.Forecast_Category, Amount_USD=excluded.Amount_USD, + Closed_Amount_USD=excluded.Closed_Amount_USD, Probability_Pct=excluded.Probability_Pct, + Created_Date=excluded.Created_Date, Expected_Close_Date=excluded.Expected_Close_Date, + Closed_Date=excluded.Closed_Date, Champion_Name=excluded.Champion_Name, + Economic_Buyer=excluded.Economic_Buyer, Next_Step=excluded.Next_Step, + Next_Step_Date=excluded.Next_Step_Date, Primary_Objection=excluded.Primary_Objection, + Competitor=excluded.Competitor, Source_Play=excluded.Source_Play, Product=excluded.Product, + Deal_Type=excluded.Deal_Type, Status=excluded.Status, Stage_Entered_Date=excluded.Stage_Entered_Date, + Tier=excluded.Tier, MAP_In_Place_YN=excluded.MAP_In_Place_YN + `); + let c = 0; + for (const r of data.pipeline) { + if (!r.Opportunity_ID) continue; + stmt.run(r); + c++; + } + counts.pipeline = c; + } + + if (data.activities?.length) { + db.prepare('DELETE FROM activities').run(); + const stmt = db.prepare(` + INSERT INTO activities (Activity_ID, Activity_Date, Activity_Type, Account_Name, District_Name, Contact_Name, Notes, Play, Channel, Persona, Outcome, Logged_By) + VALUES (@Activity_ID, @Activity_Date, @Activity_Type, @Account_Name, @District_Name, @Contact_Name, @Notes, @Play, @Channel, @Persona, @Outcome, @Logged_By) + `); + let c = 0; + for (const r of data.activities) { + if (!r.Account_Name || !r.Activity_Date) continue; + stmt.run(r); + c++; + } + counts.activities = c; + } + + if (data.implementations?.length) { + const stmt = db.prepare(` + INSERT INTO implementations (Implementation_ID, Opportunity_ID, Account_Name, District_Name, Tier, Closed_Date, Closed_Amount_USD, Kickoff_Date, Go_Live_Target, Go_Live_Actual, Deployment_Status, Health_RYG, Milestones_Complete, Milestones_Total, Onboarding_Owner, Risk_Notes) + VALUES (@Implementation_ID, @Opportunity_ID, @Account_Name, @District_Name, @Tier, @Closed_Date, @Closed_Amount_USD, @Kickoff_Date, @Go_Live_Target, @Go_Live_Actual, @Deployment_Status, @Health_RYG, @Milestones_Complete, @Milestones_Total, @Onboarding_Owner, @Risk_Notes) + ON CONFLICT(Implementation_ID) DO UPDATE SET + Opportunity_ID=excluded.Opportunity_ID, Account_Name=excluded.Account_Name, + District_Name=excluded.District_Name, Tier=excluded.Tier, + Closed_Date=excluded.Closed_Date, Closed_Amount_USD=excluded.Closed_Amount_USD, + Kickoff_Date=excluded.Kickoff_Date, Go_Live_Target=excluded.Go_Live_Target, + Go_Live_Actual=excluded.Go_Live_Actual, Deployment_Status=excluded.Deployment_Status, + Health_RYG=excluded.Health_RYG, Milestones_Complete=excluded.Milestones_Complete, + Milestones_Total=excluded.Milestones_Total, Onboarding_Owner=excluded.Onboarding_Owner, + Risk_Notes=excluded.Risk_Notes + `); + let c = 0; + for (const r of data.implementations) { + if (!r.Implementation_ID || !r.Account_Name) continue; + stmt.run(r); + c++; + } + counts.implementations = c; + } + + if (data.metricTargets?.length) { + const stmt = db.prepare(` + INSERT INTO metric_targets (Target_ID, Period_Type, Period_Label, District_Name, Metric, Target_Value, Notes) + VALUES (@Target_ID, @Period_Type, @Period_Label, @District_Name, @Metric, @Target_Value, @Notes) + ON CONFLICT(Target_ID) DO UPDATE SET + Period_Type=excluded.Period_Type, Period_Label=excluded.Period_Label, + District_Name=excluded.District_Name, Metric=excluded.Metric, + Target_Value=excluded.Target_Value, Notes=excluded.Notes + `); + let c = 0; + for (const r of data.metricTargets) { + if (!r.Target_ID) continue; + stmt.run(r); + c++; + } + counts.metricTargets = c; + } + }); + + tx(); + return counts; +} + +export function deleteAccount(name: string) { + const db = getDb(); + db.prepare('DELETE FROM accounts WHERE Account_Name = ?').run(name); +} + +export function deletePipeline(id: string) { + const db = getDb(); + db.prepare('DELETE FROM pipeline WHERE Opportunity_ID = ?').run(id); +} + +export function deleteActivity(id: number) { + const db = getDb(); + db.prepare('DELETE FROM activities WHERE id = ?').run(id); +} + +export function deleteTarget(accountName: string) { + const db = getDb(); + db.prepare('DELETE FROM implementations WHERE Account_Name = ?').run(accountName); +} + +export function upsertAccount(record: Record) { + const db = getDb(); + db.prepare(`INSERT INTO accounts (Account_Name, District_Name, Tier, Priority, AgentMinder_Status, Current_ARR_USD, MAP_In_Place_YN, Company_URL, Area_Sales_Leader, DM, AD) + VALUES (@Account_Name, @District_Name, @Tier, @Priority, @AgentMinder_Status, @Current_ARR_USD, @MAP_In_Place_YN, @Company_URL, @Area_Sales_Leader, @DM, @AD) + ON CONFLICT(Account_Name) DO UPDATE SET + District_Name=excluded.District_Name, Tier=excluded.Tier, Priority=excluded.Priority, + AgentMinder_Status=excluded.AgentMinder_Status, Current_ARR_USD=excluded.Current_ARR_USD, + MAP_In_Place_YN=excluded.MAP_In_Place_YN, Company_URL=excluded.Company_URL, + Area_Sales_Leader=excluded.Area_Sales_Leader, DM=excluded.DM, AD=excluded.AD + `).run({ + Account_Name: record.Account_Name || '', + District_Name: record.District_Name || '', + Tier: record.Tier || '', + Priority: record.Priority || '', + AgentMinder_Status: record.AgentMinder_Status || 'Not Touched', + Current_ARR_USD: record.Current_ARR_USD ?? null, + MAP_In_Place_YN: record.MAP_In_Place_YN ?? null, + Company_URL: record.Company_URL ?? null, + Area_Sales_Leader: record.Area_Sales_Leader ?? null, + DM: record.DM ?? null, + AD: record.AD ?? null, + }); +} + +export function upsertPipeline(record: Record) { + const db = getDb(); + db.prepare(`INSERT INTO pipeline (Opportunity_ID, Account_Name, District_Name, Stage, Forecast_Category, Amount_USD, Closed_Amount_USD, Probability_Pct, Created_Date, Expected_Close_Date, Closed_Date, Champion_Name, Economic_Buyer, Next_Step, Next_Step_Date, Primary_Objection, Competitor, Source_Play, Product) + VALUES (@Opportunity_ID, @Account_Name, @District_Name, @Stage, @Forecast_Category, @Amount_USD, @Closed_Amount_USD, @Probability_Pct, @Created_Date, @Expected_Close_Date, @Closed_Date, @Champion_Name, @Economic_Buyer, @Next_Step, @Next_Step_Date, @Primary_Objection, @Competitor, @Source_Play, @Product) + ON CONFLICT(Opportunity_ID) DO UPDATE SET + Account_Name=excluded.Account_Name, District_Name=excluded.District_Name, Stage=excluded.Stage, + Forecast_Category=excluded.Forecast_Category, Amount_USD=excluded.Amount_USD, + Closed_Amount_USD=excluded.Closed_Amount_USD, Probability_Pct=excluded.Probability_Pct, + Expected_Close_Date=excluded.Expected_Close_Date, Closed_Date=excluded.Closed_Date, + Champion_Name=excluded.Champion_Name, Economic_Buyer=excluded.Economic_Buyer, + Next_Step=excluded.Next_Step, Next_Step_Date=excluded.Next_Step_Date, + Primary_Objection=excluded.Primary_Objection, Competitor=excluded.Competitor, + Source_Play=excluded.Source_Play + `).run({ + Opportunity_ID: record.Opportunity_ID || '', + Account_Name: record.Account_Name || '', + District_Name: record.District_Name || '', + Stage: record.Stage || '01-Qualified', + Forecast_Category: record.Forecast_Category || 'Pipeline', + Amount_USD: record.Amount_USD ?? 0, + Closed_Amount_USD: record.Closed_Amount_USD ?? null, + Probability_Pct: record.Probability_Pct ?? 0, + Created_Date: record.Created_Date || new Date().toISOString().split('T')[0], + Expected_Close_Date: record.Expected_Close_Date || '', + Closed_Date: record.Closed_Date ?? null, + Champion_Name: record.Champion_Name ?? null, + Economic_Buyer: record.Economic_Buyer ?? null, + Next_Step: record.Next_Step ?? null, + Next_Step_Date: record.Next_Step_Date ?? null, + Primary_Objection: record.Primary_Objection ?? null, + Competitor: record.Competitor ?? null, + Source_Play: record.Source_Play ?? null, + Product: record.Product || 'AgentMinder', + }); +} + +export function upsertTarget(record: Record) { + const db = getDb(); + const id = (record.Implementation_ID as string) || `IMPL-${Date.now()}`; + db.prepare(`INSERT INTO implementations (Implementation_ID, Account_Name, District_Name, Deployment_Status, Health_RYG, Go_Live_Target, Go_Live_Actual, Risk_Notes) + VALUES (@Implementation_ID, @Account_Name, @District_Name, @Deployment_Status, @Health_RYG, @Go_Live_Target, @Go_Live_Actual, @Risk_Notes) + ON CONFLICT(Implementation_ID) DO UPDATE SET + Account_Name=excluded.Account_Name, District_Name=excluded.District_Name, + Deployment_Status=excluded.Deployment_Status, Health_RYG=excluded.Health_RYG, + Go_Live_Target=excluded.Go_Live_Target, Go_Live_Actual=excluded.Go_Live_Actual, + Risk_Notes=excluded.Risk_Notes + `).run({ + Implementation_ID: id, + Account_Name: record.Account_Name || '', + District_Name: record.District_Name ?? null, + Deployment_Status: record.Deployment_Status || record.Implementation_Stage || 'Not Started', + Health_RYG: record.Health_RYG || record.Health_Status || null, + Go_Live_Target: record.Go_Live_Target || record.Go_Live_Date || null, + Go_Live_Actual: record.Go_Live_Actual ?? null, + Risk_Notes: record.Risk_Notes || record.Notes || null, + }); +} diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts new file mode 100644 index 0000000..45f01a6 --- /dev/null +++ b/src/lib/formatters.ts @@ -0,0 +1,92 @@ +export function formatCurrency(value: number, compact = false): string { + if (compact && Math.abs(value) >= 1_000_000) { + return '$' + (value / 1_000_000).toFixed(1) + 'M'; + } + if (compact && Math.abs(value) >= 1_000) { + return '$' + (value / 1_000).toFixed(0) + 'K'; + } + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(value); +} + +export function formatNumber(value: number): string { + return new Intl.NumberFormat('en-US').format(value); +} + +export function formatPercent(value: number, decimals = 0): string { + return value.toFixed(decimals) + '%'; +} + +export function formatRatio(value: number): string { + return value.toFixed(1) + 'x'; +} + +export const CHART_COLORS = { + navy: '#005C8A', + azure: '#0098C7', + aqua: '#007B8C', + green: '#61A60E', + purple: '#6C4B94', + lightBlue: '#007DA3', + darkGreen: '#23800A', + brightBlue: '#0088EF', +}; + +export const CHART_PALETTE = [ + CHART_COLORS.navy, + CHART_COLORS.azure, + CHART_COLORS.aqua, + CHART_COLORS.green, + CHART_COLORS.purple, + CHART_COLORS.lightBlue, + CHART_COLORS.darkGreen, + CHART_COLORS.brightBlue, +]; + +export const STATUS_COLORS: Record = { + 'Not Touched': '#94A3B8', + '10% - Prospect': CHART_COLORS.azure, + '20% - Research': CHART_COLORS.aqua, + '30% - Engaged': CHART_COLORS.green, + '40% - Active Opp': CHART_COLORS.navy, + '50% - Implement': CHART_COLORS.purple, + Aware: CHART_COLORS.azure, + Engaged: CHART_COLORS.green, +}; + +export const FORECAST_COLORS: Record = { + Commit: CHART_COLORS.navy, + 'Best Case': CHART_COLORS.azure, + Pipeline: CHART_COLORS.aqua, + Closed: CHART_COLORS.green, + Omit: '#94A3B8', +}; + +export const STAGE_COLORS: Record = { + '01-Qualified': CHART_COLORS.lightBlue, + '02-Discovery': CHART_COLORS.azure, + '03-Evaluation': CHART_COLORS.aqua, + '04-Business Case': CHART_COLORS.navy, + '05-Negotiation': CHART_COLORS.purple, + '06-Closed Won': CHART_COLORS.green, + '07-Closed Lost': '#94A3B8', +}; + +export const TIER_COLORS: Record = { + 'Tier 1': CHART_COLORS.navy, + 'Tier 2': CHART_COLORS.azure, + 'Tier 3': CHART_COLORS.aqua, + 'Tier 4': '#94A3B8', +}; + +export const HEALTH_COLORS: Record = { + Green: '#61A60E', + Yellow: '#F59E0B', + Red: '#EF4444', +}; + +export const DISTRICT_SHORT: Record = { + 'SE-SUNSHINE': 'Sunshine', + 'SE-PEACHTREE': 'Peachtree', + 'SE-MISS-VALLEY': 'Miss Valley', + 'SE-MID-ATL': 'Mid-Atl', +}; diff --git a/src/lib/google-sheets.ts b/src/lib/google-sheets.ts new file mode 100644 index 0000000..9d7ac5e --- /dev/null +++ b/src/lib/google-sheets.ts @@ -0,0 +1,134 @@ +import { google } from 'googleapis'; +import { + PipelineRecord, + AccountRecord, + ActivityRecord, + TargetRecord, + DashboardData, +} from '@/types/data'; + +const SPREADSHEET_ID = process.env.GOOGLE_SHEETS_SPREADSHEET_ID; + +function getAuth() { + return new google.auth.JWT({ + email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL, + key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'), + scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'], + }); +} + +async function fetchTab(sheets: ReturnType, tab: string): Promise { + const res = await sheets.spreadsheets.values.get({ + spreadsheetId: SPREADSHEET_ID, + range: `${tab}!A:Z`, + }); + return (res.data.values as string[][]) || []; +} + +function rowsToObjects(rows: string[][]): T[] { + if (rows.length < 2) return []; + const headers = rows[0]; + return rows.slice(1).map(row => { + const obj: Record = {}; + headers.forEach((h, i) => { + obj[h] = row[i] ?? null; + }); + return obj as unknown as T; + }); +} + +function parsePipeline(raw: Record[]): PipelineRecord[] { + return raw.map(r => ({ + Opportunity_ID: r.Opportunity_ID || '', + Account_Name: r.Account_Name || '', + District_Name: r.District_Name || '', + Stage: (r.Stage || 'Discovery') as PipelineRecord['Stage'], + Forecast_Category: (r.Forecast_Category || 'Pipeline') as PipelineRecord['Forecast_Category'], + Amount_USD: parseFloat(r.Amount_USD || '0') || 0, + Closed_Amount_USD: r.Closed_Amount_USD ? parseFloat(r.Closed_Amount_USD) : null, + Probability_Pct: parseInt(r.Probability_Pct || '0') || 0, + Created_Date: r.Created_Date || '', + Expected_Close_Date: r.Expected_Close_Date || '', + Closed_Date: r.Closed_Date || null, + Champion_Name: r.Champion_Name || null, + Economic_Buyer: r.Economic_Buyer || null, + Next_Step: r.Next_Step || null, + Next_Step_Date: r.Next_Step_Date || null, + Primary_Objection: r.Primary_Objection || null, + Competitor: r.Competitor || null, + Source_Play: r.Source_Play || null, + Product: r.Product || 'AgentMinder', + })); +} + +function parseAccounts(raw: Record[]): AccountRecord[] { + return raw.map(r => ({ + Account_Name: r.Account_Name || '', + District_Name: r.District_Name || '', + Tier: (r.Tier || '3') as AccountRecord['Tier'], + Priority: (r.Priority || 'Low') as AccountRecord['Priority'], + AgentMinder_Status: (r.AgentMinder_Status || 'Not Touched') as AccountRecord['AgentMinder_Status'], + Current_ARR_USD: r.Current_ARR_USD ? parseFloat(r.Current_ARR_USD) : null, + Touch_Count: r.Touch_Count ? parseInt(r.Touch_Count) : null, + Date_First_Touched: r.Date_First_Touched || null, + Date_Last_Touched: r.Date_Last_Touched || null, + MAP_In_Place_YN: (r.MAP_In_Place_YN as AccountRecord['MAP_In_Place_YN']) || null, + })); +} + +function parseActivities(raw: Record[]): ActivityRecord[] { + return raw.map(r => ({ + Activity_Date: r.Activity_Date || '', + Activity_Type: r.Activity_Type || '', + Account_Name: r.Account_Name || '', + District_Name: r.District_Name || '', + Contact_Name: r.Contact_Name || null, + Notes: r.Notes || null, + })); +} + +function parseTargets(raw: Record[]): TargetRecord[] { + return raw.map(r => ({ + Account_Name: r.Account_Name || '', + Implementation_Stage: (r.Implementation_Stage || 'Contract Signed') as TargetRecord['Implementation_Stage'], + Go_Live_Date: r.Go_Live_Date || null, + Health_Status: (r.Health_Status as TargetRecord['Health_Status']) || null, + Notes: r.Notes || null, + })); +} + +export async function fetchSheetData(): Promise { + const auth = getAuth(); + const sheets = google.sheets({ version: 'v4', auth }); + + const [pipelineRows, accountRows, activityRows, targetRows] = await Promise.all([ + fetchTab(sheets, 'Pipeline'), + fetchTab(sheets, 'Accounts'), + fetchTab(sheets, 'Activity_Log'), + fetchTab(sheets, 'Targets'), + ]); + + const requiredHeaders: Record = { + Pipeline: ['Opportunity_ID', 'Account_Name', 'District_Name', 'Stage', 'Amount_USD'], + Accounts: ['Account_Name', 'District_Name', 'Tier', 'Priority', 'AgentMinder_Status'], + Activity_Log: ['Activity_Date', 'Activity_Type', 'Account_Name', 'District_Name'], + Targets: ['Account_Name', 'Implementation_Stage'], + }; + + for (const [tab, required] of Object.entries(requiredHeaders)) { + const rows = { Pipeline: pipelineRows, Accounts: accountRows, Activity_Log: activityRows, Targets: targetRows }[tab]!; + const headers = rows[0] || []; + const missing = required.filter(h => !headers.includes(h)); + if (missing.length > 0) { + console.warn(`[Sheets] Tab "${tab}" missing columns: ${missing.join(', ')}`); + } + } + + return { + pipeline: parsePipeline(rowsToObjects(pipelineRows)), + accounts: parseAccounts(rowsToObjects(accountRows)), + activities: parseActivities(rowsToObjects(activityRows)), + targets: parseTargets(rowsToObjects(targetRows)), + lastRefreshed: new Date().toISOString(), + }; +} diff --git a/src/lib/mock-data.ts b/src/lib/mock-data.ts new file mode 100644 index 0000000..c15ad2d --- /dev/null +++ b/src/lib/mock-data.ts @@ -0,0 +1,251 @@ +import { + PipelineRecord, + AccountRecord, + ActivityRecord, + TargetRecord, + ImplementationRecord, + MetricTarget, + DashboardData, + DashboardConfig, +} from '@/types/data'; + +const districts = ['SE-SUNSHINE', 'SE-PEACHTREE', 'SE-MISS-VALLEY', 'SE-MID-ATL'] as const; +const tiers = ['Tier 1', 'Tier 2', 'Tier 3', 'Tier 4'] as const; +const priorities = ['High', 'Medium', 'Low'] as const; +const statuses = ['10% - Prospect', '20% - Research', 'Not Touched'] as const; +const stages = ['02-Discovery', '03-Evaluation', '04-Business Case', '05-Negotiation', '06-Closed Won', '07-Closed Lost'] as const; +const forecastCats = ['Commit', 'Best Case', 'Pipeline', 'Closed', 'Omit'] as const; +const activityTypes = ['Launch Briefing', 'Discovery', 'QBR Attach', 'Exec Meeting', 'Demo', 'Workshop', 'Email', 'Call'] as const; +const competitors = ['Competitor A', 'Competitor B', 'Competitor C', 'Incumbent', null]; +const sourcePlays = ['Attach', 'Renewal-Trigger', 'Champion Referral', 'Cold Outbound', 'Event Follow-Up']; +const implStages = ['Not Started', 'In Progress', 'Complete', 'Stalled'] as const; + +function randomDate(start: Date, end: Date): string { + const d = new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())); + return d.toISOString().split('T')[0]; +} + +function pick(arr: readonly T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function weightedPick(arr: readonly T[], weights: number[]): T { + const total = weights.reduce((a, b) => a + b, 0); + let r = Math.random() * total; + for (let i = 0; i < arr.length; i++) { + r -= weights[i]; + if (r <= 0) return arr[i]; + } + return arr[arr.length - 1]; +} + +const accountNames = [ + 'Baptist Health System', 'Emory Healthcare', 'Piedmont Healthcare', 'Grady Health', 'WellStar Health', + 'Southeast Regional Medical', 'Atrium Health', 'Prisma Health', 'HCA Florida', 'AdventHealth Orlando', + 'UF Health Jacksonville', 'Tampa General Hospital', 'Moffitt Cancer Center', 'Mayo Clinic Jacksonville', + 'Nemours Children\'s', 'Duke Energy', 'Southern Company', 'Florida Power & Light', 'Coca-Cola Enterprises', + 'Home Depot Regional', 'Delta Air Lines', 'UPS Southeast Hub', 'Norfolk Southern', 'AutoNation Southeast', + 'Publix Super Markets', 'SunTrust / Truist', 'Regions Financial', 'Synovus Bank', 'Raymond James', + 'Jacksonville Jaguars', 'Georgia Tech Research', 'University of Florida', 'Vanderbilt University', + 'Clemson University', 'Georgia-Pacific', 'Mohawk Industries', 'Interface Inc', 'Aflac', + 'TSYS / Global Payments', 'NCR Corporation', 'Gartner SE Office', 'Genuine Parts Co', 'Rollins Inc', + 'Flowers Foods', 'Americold Realty', 'BlueLinx Holdings', 'Chart Industries', 'EzFill Holdings', + 'MiMedx Group', 'Cardlytics Inc', 'GreenSky', 'Kabbage / Amex', 'Calendly HQ', 'Mailchimp / Intuit', + 'SalesLoft', 'Greenlight Financial', 'OneTrust', 'Ionic Security', 'Pindrop Security', 'CallRail', + 'FullStory', 'Rubicon Global', 'InComm Payments', 'EasySend SE', 'Datacert Legal', 'Aptean SE', + 'Manhattan Associates', 'ControlScan', 'Oversight Systems', 'BitPay', 'Bakkt Holdings', + 'Intercontinental Exchange', 'Invesco SE', 'Jackson Hewitt SE', 'Aaron\'s Holdings', 'FleetCor Technologies', + 'Worldpay SE', 'Fiserv SE', 'Equifax', 'TransUnion SE', 'LexisNexis Risk', 'Verint SE', + 'Encompass Health', 'Community Health Systems', 'Tenet Healthcare SE', 'LifePoint Health', + 'Kindred Healthcare SE', 'Amedisys', 'Brookdale Senior Living', 'Surgery Partners', +]; + +function generateAccounts(): AccountRecord[] { + return accountNames.slice(0, 90).map((name, i) => { + const tier = i < 10 ? 'Tier 1' : i < 25 ? 'Tier 2' : i < 50 ? 'Tier 3' : 'Tier 4'; + const district = districts[i % 4]; + const status = weightedPick(statuses, [10, 5, 85]); + const priority = tier === 'Tier 1' ? 'High' : tier === 'Tier 2' ? weightedPick(priorities, [40, 50, 10]) : weightedPick(priorities, [10, 40, 50]); + const touchCount = status === 'Not Touched' ? 0 : Math.floor(Math.random() * 15) + 1; + const hasTouch = touchCount > 0; + + return { + Account_Name: name, + District_Name: district, + Tier: tier, + Priority: priority, + AgentMinder_Status: status, + Current_ARR_USD: Math.random() > 0.3 ? Math.floor(Math.random() * 500000) + 10000 : null, + Touch_Count: touchCount, + Date_First_Touched: hasTouch ? randomDate(new Date('2026-01-15'), new Date('2026-06-01')) : null, + Date_Last_Touched: hasTouch ? randomDate(new Date('2026-06-01'), new Date('2026-08-25')) : null, + MAP_In_Place_YN: hasTouch && Math.random() > 0.5 ? 'Y' : 'N', + Company_URL: null, + Area_Sales_Leader: null, + DM: null, + AD: null, + IMS_BA: null, + Logo_URL: null, + Next_Renewal_Date: null, + Next_Renewal_EAR: null, + Anchor_Contract_Date: null, + Anchor_Contract_EAR: null, + }; + }); +} + +function generatePipeline(accounts: AccountRecord[]): PipelineRecord[] { + const touchedAccounts = accounts.filter(a => a.AgentMinder_Status !== 'Not Touched'); + const opps: PipelineRecord[] = []; + let oppId = 1; + + for (const account of touchedAccounts) { + const numOpps = account.Tier === 'Tier 1' ? Math.ceil(Math.random() * 2) : Math.random() > 0.5 ? 1 : 0; + for (let j = 0; j < numOpps; j++) { + const stage = weightedPick(stages, [25, 15, 15, 10, 25, 10]); + const isClosed = stage === '06-Closed Won' || stage === '07-Closed Lost'; + const amount = account.Tier === 'Tier 1' + ? Math.floor(Math.random() * 400000) + 100000 + : account.Tier === 'Tier 2' + ? Math.floor(Math.random() * 150000) + 25000 + : Math.floor(Math.random() * 50000) + 5000; + + const createdDate = randomDate(new Date('2026-02-01'), new Date('2026-07-15')); + const expectedClose = randomDate(new Date('2026-08-01'), new Date('2026-12-31')); + const prob = stage === '06-Closed Won' ? 100 : stage === '07-Closed Lost' ? 0 + : stage === '05-Negotiation' ? 60 + Math.floor(Math.random() * 20) + : stage === '04-Business Case' ? 40 + Math.floor(Math.random() * 15) + : stage === '03-Evaluation' ? 20 + Math.floor(Math.random() * 15) + : 10 + Math.floor(Math.random() * 10); + + const forecast = + stage === '06-Closed Won' ? 'Closed' + : stage === '07-Closed Lost' ? 'Omit' + : stage === '05-Negotiation' ? weightedPick(['Commit', 'Best Case', 'Pipeline'] as const, [50, 35, 15]) + : stage === '04-Business Case' ? weightedPick(['Best Case', 'Pipeline'] as const, [40, 60]) + : 'Pipeline'; + + opps.push({ + Opportunity_ID: `OPP-${String(oppId++).padStart(4, '0')}`, + Account_Name: account.Account_Name, + District_Name: account.District_Name, + Stage: stage, + Forecast_Category: forecast, + Amount_USD: amount, + Closed_Amount_USD: stage === '06-Closed Won' ? amount : null, + Probability_Pct: prob, + Created_Date: createdDate, + Expected_Close_Date: isClosed ? createdDate : expectedClose, + Closed_Date: isClosed ? randomDate(new Date(createdDate), new Date('2026-08-25')) : null, + Champion_Name: Math.random() > 0.3 ? `Contact ${oppId}` : null, + Economic_Buyer: Math.random() > 0.4 ? `VP ${pick(['Operations', 'IT', 'Finance', 'Clinical'])}` : null, + Next_Step: isClosed ? null : pick(['Schedule executive briefing', 'Send proposal', 'Negotiate terms', 'Technical validation', 'Reference call', 'POC planning']), + Next_Step_Date: isClosed ? null : randomDate(new Date('2026-08-25'), new Date('2026-10-01')), + Primary_Objection: Math.random() > 0.5 ? pick(['Budget timing', 'Competing priority', 'Need more references', 'Integration concerns', 'Contract terms']) : null, + Competitor: pick(competitors), + Source_Play: pick(sourcePlays), + Product: 'AgentMinder', + Deal_Type: null, + Status: null, + Stage_Entered_Date: null, + Tier: account.Tier, + MAP_In_Place_YN: account.MAP_In_Place_YN, + }); + } + } + return opps; +} + +function generateActivities(accounts: AccountRecord[]): ActivityRecord[] { + const activities: ActivityRecord[] = []; + const touchedAccounts = accounts.filter(a => a.AgentMinder_Status !== 'Not Touched'); + + for (const account of touchedAccounts) { + const numActivities = Math.floor(Math.random() * 8) + 1; + for (let j = 0; j < numActivities; j++) { + activities.push({ + Activity_Date: randomDate(new Date('2026-03-01'), new Date('2026-08-27')), + Activity_Type: pick(activityTypes), + Account_Name: account.Account_Name, + District_Name: account.District_Name, + Contact_Name: Math.random() > 0.3 ? `${pick(['Dr.', 'Mr.', 'Ms.'])} ${pick(['Smith', 'Johnson', 'Williams', 'Brown', 'Davis', 'Garcia', 'Miller', 'Wilson', 'Anderson', 'Thomas'])}` : null, + Notes: Math.random() > 0.4 ? pick([ + 'Positive meeting - moving forward with evaluation', + 'Discussed ROI framework and implementation timeline', + 'Champion is engaged, need exec sponsor alignment', + 'Competitor mentioned - need to differentiate on security', + 'Budget approved for Q3 - accelerating timeline', + 'Technical team needs integration documentation', + 'Follow-up scheduled for next week', + 'QBR went well - expansion opportunity identified', + 'Need to address compliance requirements before proceeding', + 'Demo completed - strong interest from clinical team', + ]) : null, + Play: null, + Channel: null, + Persona: null, + Outcome: null, + Logged_By: null, + }); + } + } + + return activities.sort((a, b) => b.Activity_Date.localeCompare(a.Activity_Date)); +} + +function generateTargets(pipeline: PipelineRecord[]): TargetRecord[] { + const closedWon = pipeline.filter(p => p.Stage === '06-Closed Won'); + return closedWon.map(opp => { + const implStage = weightedPick(implStages, [15, 30, 35, 20]); + return { + Account_Name: opp.Account_Name, + Implementation_Stage: implStage, + Go_Live_Date: implStage === 'Complete' + ? randomDate(new Date('2026-05-01'), new Date('2026-08-20')) + : implStage === 'Stalled' ? null + : randomDate(new Date('2026-09-01'), new Date('2026-11-30')), + Health_Status: implStage === 'Stalled' ? 'Red' + : implStage === 'Complete' ? 'Green' + : weightedPick(['Green', 'Yellow', 'Red'] as const, [50, 35, 15]), + Notes: pick([ + 'On track for target go-live', + 'Awaiting IT resource allocation', + 'Configuration in progress - 60% complete', + 'Successfully live - collecting reference feedback', + 'Blocked on SSO integration', + 'Training sessions scheduled', + null, + ]), + }; + }); +} + +let cachedData: DashboardData | null = null; + +export function getMockData(): DashboardData { + if (cachedData) return cachedData; + + const accounts = generateAccounts(); + const pipeline = generatePipeline(accounts); + const activities = generateActivities(accounts); + const targets = generateTargets(pipeline); + + cachedData = { + pipeline, + accounts, + activities, + targets, + implementations: [], + metricTargets: [], + lastRefreshed: new Date().toISOString(), + }; + + return cachedData; +} + +export function getMockConfig(): DashboardConfig { + return { + quotaTarget: 5000000, + weeklyActivityTarget: 25, + }; +} diff --git a/src/types/data.ts b/src/types/data.ts new file mode 100644 index 0000000..805dcf3 --- /dev/null +++ b/src/types/data.ts @@ -0,0 +1,135 @@ +export interface PipelineRecord { + Opportunity_ID: string; + Account_Name: string; + District_Name: string; + Stage: string; + Forecast_Category: string; + Amount_USD: number; + Closed_Amount_USD: number | null; + Probability_Pct: number; + Created_Date: string; + Expected_Close_Date: string; + Closed_Date: string | null; + Champion_Name: string | null; + Economic_Buyer: string | null; + Next_Step: string | null; + Next_Step_Date: string | null; + Primary_Objection: string | null; + Competitor: string | null; + Source_Play: string | null; + Product: string; + Deal_Type: string | null; + Status: string | null; + Stage_Entered_Date: string | null; + Tier: string | null; + MAP_In_Place_YN: string | null; +} + +export interface AccountRecord { + Account_Name: string; + District_Name: string; + Tier: string; + Priority: string; + AgentMinder_Status: string; + Current_ARR_USD: number | null; + Touch_Count: number | null; + Date_First_Touched: string | null; + Date_Last_Touched: string | null; + MAP_In_Place_YN: 'Y' | 'N' | null; + Company_URL: string | null; + Area_Sales_Leader: string | null; + DM: string | null; + AD: string | null; + IMS_BA: string | null; + Logo_URL: string | null; + Next_Renewal_Date: string | null; + Next_Renewal_EAR: number | null; + Anchor_Contract_Date: string | null; + Anchor_Contract_EAR: number | null; + Google_Drive_URL: string | null; + Campaign_Artifacts_URL: string | null; +} + +export interface ActivityRecord { + Activity_Date: string; + Activity_Type: string; + Account_Name: string; + District_Name: string; + Contact_Name: string | null; + Notes: string | null; + Play: string | null; + Channel: string | null; + Persona: string | null; + Outcome: string | null; + Logged_By: string | null; +} + +export interface TargetRecord { + Account_Name: string; + Implementation_Stage: string; + Go_Live_Date: string | null; + Health_Status: string | null; + Notes: string | null; +} + +export interface ImplementationRecord { + Implementation_ID: string; + Opportunity_ID: string | null; + Account_Name: string; + District_Name: string | null; + Tier: string | null; + Closed_Date: string | null; + Closed_Amount_USD: number | null; + Kickoff_Date: string | null; + Go_Live_Target: string | null; + Go_Live_Actual: string | null; + Deployment_Status: string; + Health_RYG: string | null; + Milestones_Complete: number; + Milestones_Total: number; + Onboarding_Owner: string | null; + Risk_Notes: string | null; +} + +export interface MetricTarget { + Target_ID: string; + Period_Type: string; + Period_Label: string; + District_Name: string; + Metric: string; + Target_Value: number; + Notes: string | null; +} + +export interface DashboardData { + pipeline: PipelineRecord[]; + accounts: AccountRecord[]; + activities: ActivityRecord[]; + targets: TargetRecord[]; + implementations: ImplementationRecord[]; + metricTargets: MetricTarget[]; + lastRefreshed: string; +} + +export interface DashboardConfig { + quotaTarget: number; + weeklyActivityTarget: number; +} + +export type District = 'SE-SUNSHINE' | 'SE-PEACHTREE' | 'SE-MISS-VALLEY' | 'SE-MID-ATL'; + +export const DISTRICTS: District[] = ['SE-SUNSHINE', 'SE-PEACHTREE', 'SE-MISS-VALLEY', 'SE-MID-ATL']; + +export const STAGES = [ + '01-Qualified', '02-Discovery', '03-Evaluation', '04-Business Case', + '05-Negotiation', '06-Closed Won', '07-Closed Lost' +] as const; + +export const FORECAST_CATEGORIES = ['Commit', 'Best Case', 'Pipeline', 'Closed', 'Omit'] as const; + +export const IMPLEMENTATION_STAGES = ['Not Started', 'In Progress', 'Complete', 'Stalled'] as const; + +export const ACTIVITY_TYPES = [ + 'Launch Briefing', 'Discovery', 'QBR Attach', 'Exec Meeting', + 'Demo', 'Workshop', 'Email', 'Call' +] as const; diff --git a/start.command b/start.command new file mode 100755 index 0000000..1f07705 --- /dev/null +++ b/start.command @@ -0,0 +1,7 @@ +#!/bin/bash +cd "$(dirname "$0")" +echo "Starting Campaign Command Center..." +echo "Dashboard will open at http://localhost:3000" +echo "" +open "http://localhost:3000" +npm run dev