Add dynamic fields, people directory, analytics, security hardening, and UX polish
Phase 1 - Security & Bug Fixes: - Add requireApiAuth helper and protect all 25 unprotected API routes - Add org-tenant scoping to all card, job, stats, and notification queries - Fix SSRF in ai-test, mask secrets in settings API, fix middleware bypass - Fix cards pagination routing, stat filter sync, drag-drop file passing - Add PUT /api/auth/me for profile persistence, stuck job recovery - Fix email watcher MIME type detection Phase 2 - Dynamic Fields & Digital Survey: - Add FormTemplate, FormField, Person, PasswordResetToken models to schema - Add fieldData, formTemplateId, firstName, lastName, personId to ResponseCard - Build FormTemplate CRUD API with field management and org scoping - Build Form Builder UI with field ordering, type config, and section management - Refactor card detail page to render fields dynamically from templates - Add dynamic OCR prompt/schema generation from template fields - Build public survey page at /s/[orgSlug]/[formSlug] with branding - Add QR code generation API and share section component Phase 3 - People & Analytics: - Build People CRUD API with merge and batch auto-link endpoints - Build People list and detail pages with search, merge dialog - Add auto-link logic in OCR completion to match/create Person records - Add /api/stats/trends endpoint with time series and team activity - Build Reports page with Recharts (area charts, bar charts, pipeline) - Upgrade dashboard with sparklines and People stat card Phase 4 - UX Polish: - Replace silent error handling with toast notifications across all pages - Add loading skeletons, differentiated empty states - Add ARIA labels, skip-to-content link, accessible column toggle - Add forgot password flow, Cmd+K command palette, Collection Days pages - Unify Echo branding and theme toggle consistency Made-with: Cursor
This commit is contained in:
parent
1bceeb0418
commit
f53b08f99f
91 changed files with 7301 additions and 450 deletions
601
package-lock.json
generated
601
package-lock.json
generated
|
|
@ -20,6 +20,7 @@
|
||||||
"@prisma/adapter-pg": "^7.4.2",
|
"@prisma/adapter-pg": "^7.4.2",
|
||||||
"@prisma/client": "^7.4.2",
|
"@prisma/client": "^7.4.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@upstash/qstash": "^2.10.1",
|
"@upstash/qstash": "^2.10.1",
|
||||||
"ai": "^6.0.116",
|
"ai": "^6.0.116",
|
||||||
"basic-ftp": "^5.3.0",
|
"basic-ftp": "^5.3.0",
|
||||||
|
|
@ -39,8 +40,10 @@
|
||||||
"pdfjs-dist": "^5.6.205",
|
"pdfjs-dist": "^5.6.205",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"prisma": "^7.4.2",
|
"prisma": "^7.4.2",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"rrule": "^2.8.1",
|
"rrule": "^2.8.1",
|
||||||
"shadcn": "^4.0.2",
|
"shadcn": "^4.0.2",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
|
|
@ -3969,6 +3972,42 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||||
|
"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/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||||
|
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
|
|
@ -4745,6 +4784,12 @@
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
|
|
@ -5151,6 +5196,69 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"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.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
|
|
@ -5200,7 +5308,6 @@
|
||||||
"version": "25.4.0",
|
"version": "25.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz",
|
||||||
"integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==",
|
"integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
|
|
@ -5218,6 +5325,15 @@
|
||||||
"pg-types": "^2.2.0"
|
"pg-types": "^2.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/qrcode": {
|
||||||
|
"version": "1.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
|
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
|
|
@ -5243,6 +5359,12 @@
|
||||||
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
|
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"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/@types/validate-npm-package-name": {
|
"node_modules/@types/validate-npm-package-name": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||||
|
|
@ -6551,6 +6673,15 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001777",
|
"version": "1.0.30001777",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
|
||||||
|
|
@ -6944,6 +7075,127 @@
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
|
|
@ -7041,6 +7293,21 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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/dedent": {
|
"node_modules/dedent": {
|
||||||
"version": "1.7.2",
|
"version": "1.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||||
|
|
@ -7210,6 +7477,12 @@
|
||||||
"node": ">=0.3.1"
|
"node": ">=0.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/doctrine": {
|
"node_modules/doctrine": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||||
|
|
@ -7595,6 +7868,16 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.45.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||||
|
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
|
@ -8075,6 +8358,12 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/eventsource": {
|
"node_modules/eventsource": {
|
||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||||
|
|
@ -9093,6 +9382,16 @@
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
|
|
@ -9140,6 +9439,15 @@
|
||||||
"node": ">= 0.4"
|
"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/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||||
|
|
@ -11287,6 +11595,15 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/package-manager-detector": {
|
"node_modules/package-manager-detector": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
|
||||||
|
|
@ -11373,7 +11690,6 @@
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
|
|
@ -11626,6 +11942,15 @@
|
||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
|
|
@ -11933,6 +12258,168 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/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/qrcode/node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/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/qrcode/node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/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/qrcode/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/qrcode/node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||||
|
|
@ -12033,9 +12520,31 @@
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+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/react-remove-scroll": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
|
|
@ -12130,6 +12639,51 @@
|
||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||||
|
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
||||||
|
"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": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"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": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
|
|
@ -12207,6 +12761,12 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/reselect": {
|
"node_modules/reselect": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
|
@ -12511,6 +13071,12 @@
|
||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/set-function-length": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
|
|
@ -13661,7 +14227,6 @@
|
||||||
"version": "7.18.2",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unicorn-magic": {
|
"node_modules/unicorn-magic": {
|
||||||
|
|
@ -13868,6 +14433,28 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/web-streams-polyfill": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||||
|
|
@ -13959,6 +14546,12 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/which-typed-array": {
|
"node_modules/which-typed-array": {
|
||||||
"version": "1.1.20",
|
"version": "1.1.20",
|
||||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"@prisma/adapter-pg": "^7.4.2",
|
"@prisma/adapter-pg": "^7.4.2",
|
||||||
"@prisma/client": "^7.4.2",
|
"@prisma/client": "^7.4.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@upstash/qstash": "^2.10.1",
|
"@upstash/qstash": "^2.10.1",
|
||||||
"ai": "^6.0.116",
|
"ai": "^6.0.116",
|
||||||
"basic-ftp": "^5.3.0",
|
"basic-ftp": "^5.3.0",
|
||||||
|
|
@ -43,8 +44,10 @@
|
||||||
"pdfjs-dist": "^5.6.205",
|
"pdfjs-dist": "^5.6.205",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"prisma": "^7.4.2",
|
"prisma": "^7.4.2",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"rrule": "^2.8.1",
|
"rrule": "^2.8.1",
|
||||||
"shadcn": "^4.0.2",
|
"shadcn": "^4.0.2",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,8 @@ model Organization {
|
||||||
cards ResponseCard[]
|
cards ResponseCard[]
|
||||||
jobs ProcessingJob[]
|
jobs ProcessingJob[]
|
||||||
integrations Integration[]
|
integrations Integration[]
|
||||||
|
formTemplates FormTemplate[]
|
||||||
|
persons Person[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model OrgMember {
|
model OrgMember {
|
||||||
|
|
@ -137,6 +139,81 @@ model CollectionDay {
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Form Templates (Dynamic Fields) ────────────────────────
|
||||||
|
|
||||||
|
model FormTemplate {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
|
name String
|
||||||
|
slug String
|
||||||
|
description String?
|
||||||
|
isDefault Boolean @default(false)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
version Int @default(1)
|
||||||
|
branding Json?
|
||||||
|
settings Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
fields FormField[]
|
||||||
|
cards ResponseCard[]
|
||||||
|
|
||||||
|
@@unique([organizationId, slug])
|
||||||
|
@@index([organizationId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model FormField {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
formTemplateId String
|
||||||
|
key String
|
||||||
|
label String
|
||||||
|
type String
|
||||||
|
section String @default("personal")
|
||||||
|
required Boolean @default(false)
|
||||||
|
removable Boolean @default(true)
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
options Json?
|
||||||
|
placeholder String?
|
||||||
|
helpText String?
|
||||||
|
validation Json?
|
||||||
|
visibleOnCard Boolean @default(true)
|
||||||
|
visibleOnSurvey Boolean @default(true)
|
||||||
|
isCore Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
formTemplate FormTemplate @relation(fields: [formTemplateId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([formTemplateId, key])
|
||||||
|
@@index([formTemplateId, sortOrder])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── People (Contact Directory) ─────────────────────────────
|
||||||
|
|
||||||
|
model Person {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
email String?
|
||||||
|
cellPhone String?
|
||||||
|
fieldData Json?
|
||||||
|
mergedIntoId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
cards ResponseCard[]
|
||||||
|
mergedInto Person? @relation("PersonMerge", fields: [mergedIntoId], references: [id])
|
||||||
|
mergedFrom Person[] @relation("PersonMerge")
|
||||||
|
|
||||||
|
@@index([organizationId])
|
||||||
|
@@index([email])
|
||||||
|
@@index([organizationId, firstName, lastName])
|
||||||
|
@@index([mergedIntoId])
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Integrations ────────────────────────────────────────────
|
// ─── Integrations ────────────────────────────────────────────
|
||||||
|
|
||||||
model Integration {
|
model Integration {
|
||||||
|
|
@ -174,6 +251,18 @@ model Invitation {
|
||||||
organization Organization @relation(fields: [organizationId], references: [id])
|
organization Organization @relation(fields: [organizationId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Password Reset ─────────────────────────────────────────
|
||||||
|
|
||||||
|
model PasswordResetToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String
|
||||||
|
token String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([email])
|
||||||
|
}
|
||||||
|
|
||||||
// ─── System config ───────────────────────────────────────────
|
// ─── System config ───────────────────────────────────────────
|
||||||
|
|
||||||
model SystemConfig {
|
model SystemConfig {
|
||||||
|
|
@ -191,6 +280,8 @@ model ResponseCard {
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
firstName String?
|
||||||
|
lastName String?
|
||||||
name String?
|
name String?
|
||||||
gender String?
|
gender String?
|
||||||
dateOfBirth String?
|
dateOfBirth String?
|
||||||
|
|
@ -250,6 +341,11 @@ model ResponseCard {
|
||||||
|
|
||||||
mondayItemId String?
|
mondayItemId String?
|
||||||
|
|
||||||
|
fieldData Json?
|
||||||
|
formTemplateId String?
|
||||||
|
personId String?
|
||||||
|
submissionSource String?
|
||||||
|
|
||||||
organizationId String?
|
organizationId String?
|
||||||
locationId String?
|
locationId String?
|
||||||
collectionDayId String?
|
collectionDayId String?
|
||||||
|
|
@ -258,10 +354,13 @@ model ResponseCard {
|
||||||
organization Organization? @relation(fields: [organizationId], references: [id])
|
organization Organization? @relation(fields: [organizationId], references: [id])
|
||||||
location Location? @relation(fields: [locationId], references: [id])
|
location Location? @relation(fields: [locationId], references: [id])
|
||||||
collectionDay CollectionDay? @relation(fields: [collectionDayId], references: [id])
|
collectionDay CollectionDay? @relation(fields: [collectionDayId], references: [id])
|
||||||
|
formTemplate FormTemplate? @relation(fields: [formTemplateId], references: [id])
|
||||||
|
person Person? @relation(fields: [personId], references: [id])
|
||||||
|
|
||||||
@@index([ocrStatus])
|
@@index([ocrStatus])
|
||||||
@@index([reviewStatus])
|
@@index([reviewStatus])
|
||||||
@@index([name])
|
@@index([name])
|
||||||
|
@@index([firstName, lastName])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([mondayItemId])
|
@@index([mondayItemId])
|
||||||
@@index([assignedToId])
|
@@index([assignedToId])
|
||||||
|
|
@ -269,6 +368,8 @@ model ResponseCard {
|
||||||
@@index([organizationId])
|
@@index([organizationId])
|
||||||
@@index([locationId])
|
@@index([locationId])
|
||||||
@@index([collectionDayId])
|
@@index([collectionDayId])
|
||||||
|
@@index([formTemplateId])
|
||||||
|
@@index([personId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ProcessingJob {
|
model ProcessingJob {
|
||||||
|
|
|
||||||
92
src/app/(auth)/forgot-password/page.tsx
Normal file
92
src/app/(auth)/forgot-password/page.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ScanLine, Mail, Loader2, ArrowLeft } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
await fetch("/api/auth/forgot-password", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
|
||||||
|
setSent(true);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card mx-auto w-full max-w-md rounded-2xl p-8">
|
||||||
|
<div className="mb-8 flex flex-col items-center">
|
||||||
|
<div className="mb-4 flex size-14 items-center justify-center rounded-2xl gradient-banner shadow-lg">
|
||||||
|
<ScanLine className="size-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Reset your password</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Enter your email and we'll send you a reset link
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sent ? (
|
||||||
|
<div className="space-y-4 text-center">
|
||||||
|
<div className="rounded-lg bg-primary/10 p-4 text-sm text-foreground">
|
||||||
|
If an account with that email exists, we've sent a password
|
||||||
|
reset link. Check your inbox.
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-3.5" />
|
||||||
|
Back to login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@church.org"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full rounded-xl" disabled={loading}>
|
||||||
|
{loading ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||||
|
Send Reset Link
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-center">
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-3" />
|
||||||
|
Back to login
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -60,7 +60,7 @@ function LoginForm() {
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Welcome back</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Welcome back</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Sign in to your Echo OCR account
|
Sign in to your Echo account
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -89,7 +89,12 @@ function LoginForm() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Link href="/forgot-password" className="text-xs text-primary hover:underline">
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
<Lock className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -202,7 +202,7 @@ export default function OnboardingPage() {
|
||||||
Set up your workspace
|
Set up your workspace
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="mt-2 text-muted-foreground">
|
||||||
Get Echo OCR ready in just a few steps
|
Get Echo ready in just a few steps
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -587,7 +587,7 @@ export default function OnboardingPage() {
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold">Upload Source</h2>
|
<h2 className="text-xl font-semibold">Upload Source</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
How will response cards get into Echo OCR?
|
How will response cards get into Echo?
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -746,7 +746,7 @@ export default function OnboardingPage() {
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold">AI Provider</h2>
|
<h2 className="text-xl font-semibold">AI Provider</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Choose how Echo OCR processes your scanned cards.
|
Choose how Echo processes your scanned cards.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
168
src/app/(auth)/reset-password/page.tsx
Normal file
168
src/app/(auth)/reset-password/page.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, useState } from "react";
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ScanLine, Lock, Loader2, ArrowLeft, CheckCircle2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<ResetForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResetForm() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const token = searchParams.get("token");
|
||||||
|
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError("Password must be at least 8 characters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== confirm) {
|
||||||
|
setError("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const res = await fetch("/api/auth/reset-password", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || "Something went wrong. Please try again.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccess(true);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="glass-card mx-auto w-full max-w-md rounded-2xl p-8 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Invalid or missing reset token.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/forgot-password"
|
||||||
|
className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Request a new reset link
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card mx-auto w-full max-w-md rounded-2xl p-8">
|
||||||
|
<div className="mb-8 flex flex-col items-center">
|
||||||
|
<div className="mb-4 flex size-14 items-center justify-center rounded-2xl gradient-banner shadow-lg">
|
||||||
|
<ScanLine className="size-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Choose a new password</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Enter your new password below
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{success ? (
|
||||||
|
<div className="space-y-4 text-center">
|
||||||
|
<div className="flex flex-col items-center gap-2 rounded-lg bg-primary/10 p-4">
|
||||||
|
<CheckCircle2 className="size-6 text-primary" />
|
||||||
|
<p className="text-sm font-medium">Password updated successfully!</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="w-full rounded-xl"
|
||||||
|
onClick={() => router.push("/login")}
|
||||||
|
>
|
||||||
|
Sign in with new password
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg bg-destructive/10 p-3 text-center text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">New password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirm">Confirm password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="confirm"
|
||||||
|
type="password"
|
||||||
|
placeholder="Re-enter your password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full rounded-xl" disabled={loading}>
|
||||||
|
{loading ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||||
|
Reset Password
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-center">
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-3" />
|
||||||
|
Back to login
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -122,7 +122,7 @@ export default function SetupPage() {
|
||||||
<div className="mb-4 flex size-14 items-center justify-center rounded-2xl gradient-banner shadow-lg">
|
<div className="mb-4 flex size-14 items-center justify-center rounded-2xl gradient-banner shadow-lg">
|
||||||
<ScanLine className="size-7 text-white" />
|
<ScanLine className="size-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Set up Echo OCR</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Set up Echo</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Configure your instance in a few steps
|
Configure your instance in a few steps
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ function SignupForm() {
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{token
|
{token
|
||||||
? "Complete your account setup"
|
? "Complete your account setup"
|
||||||
: "Get started with Echo OCR"}
|
: "Get started with Echo"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,27 @@ import {
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useUserProfile } from "@/lib/user-profile";
|
import { useUserProfile } from "@/lib/user-profile";
|
||||||
|
import { DynamicField, type FormFieldDef } from "@/components/cards/dynamic-field";
|
||||||
|
|
||||||
|
const SECTION_LABELS: Record<string, string> = {
|
||||||
|
personal: "Personal Information",
|
||||||
|
contact: "Contact Details",
|
||||||
|
address: "Address",
|
||||||
|
survey: "Survey Responses",
|
||||||
|
followup: "Follow-Up",
|
||||||
|
custom: "Custom Fields",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECTION_ORDER = ["personal", "contact", "address", "survey", "followup", "custom"];
|
||||||
|
|
||||||
|
const SECTION_ICONS: Record<string, string> = {
|
||||||
|
personal: "user",
|
||||||
|
contact: "user",
|
||||||
|
address: "user",
|
||||||
|
survey: "clipboardList",
|
||||||
|
followup: "clipboardCheck",
|
||||||
|
custom: "clipboardList",
|
||||||
|
};
|
||||||
|
|
||||||
const MESSAGE_TOPIC_OPTIONS = [
|
const MESSAGE_TOPIC_OPTIONS = [
|
||||||
"Stress/Anxiety", "Marriage", "Hearing God's Voice", "Dealing With Doubt",
|
"Stress/Anxiety", "Marriage", "Hearing God's Voice", "Dealing With Doubt",
|
||||||
|
|
@ -64,8 +85,16 @@ const HOW_HEARD_OPTIONS = [
|
||||||
"Social Media", "Google", "Personal Invite",
|
"Social Media", "Google", "Personal Invite",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type TemplateData = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fields: FormFieldDef[];
|
||||||
|
};
|
||||||
|
|
||||||
type CardData = {
|
type CardData = {
|
||||||
id: string;
|
id: string;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
gender: string | null;
|
gender: string | null;
|
||||||
dateOfBirth: string | null;
|
dateOfBirth: string | null;
|
||||||
|
|
@ -114,6 +143,9 @@ type CardData = {
|
||||||
mondayItemId: string | null;
|
mondayItemId: string | null;
|
||||||
frontImageUrl: string | null;
|
frontImageUrl: string | null;
|
||||||
backImageUrl: string | null;
|
backImageUrl: string | null;
|
||||||
|
fieldData: Record<string, unknown> | null;
|
||||||
|
formTemplateId: string | null;
|
||||||
|
formTemplate?: TemplateData | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ActivityEntry = {
|
type ActivityEntry = {
|
||||||
|
|
@ -137,6 +169,7 @@ export default function CardDetailPage() {
|
||||||
const isViewer = role === "viewer";
|
const isViewer = role === "viewer";
|
||||||
|
|
||||||
const [card, setCard] = React.useState<CardData | null>(null);
|
const [card, setCard] = React.useState<CardData | null>(null);
|
||||||
|
const [loadError, setLoadError] = React.useState(false);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [saving, setSaving] = React.useState(false);
|
const [saving, setSaving] = React.useState(false);
|
||||||
const [reprocessing, setReprocessing] = React.useState(false);
|
const [reprocessing, setReprocessing] = React.useState(false);
|
||||||
|
|
@ -149,6 +182,8 @@ export default function CardDetailPage() {
|
||||||
const [pushingToMonday, setPushingToMonday] = React.useState(false);
|
const [pushingToMonday, setPushingToMonday] = React.useState(false);
|
||||||
const [users, setUsers] = React.useState<AssignableUser[]>([]);
|
const [users, setUsers] = React.useState<AssignableUser[]>([]);
|
||||||
const [prevNextIds, setPrevNextIds] = React.useState<{ prev: string | null; next: string | null }>({ prev: null, next: null });
|
const [prevNextIds, setPrevNextIds] = React.useState<{ prev: string | null; next: string | null }>({ prev: null, next: null });
|
||||||
|
const [templateFields, setTemplateFields] = React.useState<FormFieldDef[] | null>(null);
|
||||||
|
const [fieldDataEdits, setFieldDataEdits] = React.useState<Record<string, unknown>>({});
|
||||||
|
|
||||||
const isAssignedToMe = card?.assignedToId && userId === card.assignedToId;
|
const isAssignedToMe = card?.assignedToId && userId === card.assignedToId;
|
||||||
const canEdit = isAdmin || (isReviewer && isAssignedToMe);
|
const canEdit = isAdmin || (isReviewer && isAssignedToMe);
|
||||||
|
|
@ -156,13 +191,27 @@ export default function CardDetailPage() {
|
||||||
|
|
||||||
const fetchCard = React.useCallback(async () => {
|
const fetchCard = React.useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setLoadError(false);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/cards/${id}`);
|
const res = await fetch(`/api/cards/${id}`);
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setCard(data);
|
setCard(data);
|
||||||
setEdits({});
|
setEdits({});
|
||||||
|
setFieldDataEdits({});
|
||||||
|
|
||||||
|
if (data.formTemplate?.fields) {
|
||||||
|
setTemplateFields(data.formTemplate.fields);
|
||||||
|
} else if (!data.formTemplateId) {
|
||||||
|
fetch("/api/form-templates?default=true")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => {
|
||||||
|
if (d.template?.fields) setTemplateFields(d.template.fields);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
setLoadError(true);
|
||||||
toast.error("Failed to load card");
|
toast.error("Failed to load card");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -253,11 +302,62 @@ export default function CardDetailPage() {
|
||||||
setEdits((prev) => ({ ...prev, [field]: value }));
|
setEdits((prev) => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDynamicValue = React.useCallback(
|
||||||
|
(field: FormFieldDef): unknown => {
|
||||||
|
if (field.isCore) {
|
||||||
|
const key = field.key;
|
||||||
|
if (key in edits) return edits[key];
|
||||||
|
return (card as unknown as Record<string, unknown>)?.[key] ?? null;
|
||||||
|
}
|
||||||
|
if (field.key in fieldDataEdits) return fieldDataEdits[field.key];
|
||||||
|
const fd = card?.fieldData as Record<string, unknown> | null;
|
||||||
|
if (fd && field.key in fd) return fd[field.key];
|
||||||
|
if (card && field.key in (card as unknown as Record<string, unknown>)) {
|
||||||
|
return (card as unknown as Record<string, unknown>)[field.key];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[card, edits, fieldDataEdits]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setDynamicValue = React.useCallback(
|
||||||
|
(field: FormFieldDef, value: unknown) => {
|
||||||
|
if (field.isCore) {
|
||||||
|
setEdits((prev) => ({ ...prev, [field.key]: value }));
|
||||||
|
} else {
|
||||||
|
setFieldDataEdits((prev) => ({ ...prev, [field.key]: value }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dynamicSections = React.useMemo(() => {
|
||||||
|
if (!templateFields) return null;
|
||||||
|
const grouped: Record<string, FormFieldDef[]> = {};
|
||||||
|
for (const field of templateFields) {
|
||||||
|
if (!grouped[field.section]) grouped[field.section] = [];
|
||||||
|
grouped[field.section].push(field);
|
||||||
|
}
|
||||||
|
return SECTION_ORDER
|
||||||
|
.filter((s) => grouped[s]?.length > 0)
|
||||||
|
.map((s) => ({
|
||||||
|
key: s,
|
||||||
|
label: SECTION_LABELS[s] || s,
|
||||||
|
iconKey: SECTION_ICONS[s] || "clipboardList",
|
||||||
|
fields: grouped[s],
|
||||||
|
}));
|
||||||
|
}, [templateFields]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (Object.keys(edits).length === 0) return;
|
const hasFieldDataChanges = Object.keys(fieldDataEdits).length > 0;
|
||||||
|
if (Object.keys(edits).length === 0 && !hasFieldDataChanges) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload: Record<string, unknown> = { ...edits };
|
const payload: Record<string, unknown> = { ...edits };
|
||||||
|
if (hasFieldDataChanges) {
|
||||||
|
const currentFieldData = (card?.fieldData as Record<string, unknown>) || {};
|
||||||
|
payload.fieldData = { ...currentFieldData, ...fieldDataEdits };
|
||||||
|
}
|
||||||
if (card?.reviewStatus === "assigned") {
|
if (card?.reviewStatus === "assigned") {
|
||||||
payload.reviewStatus = "in_review";
|
payload.reviewStatus = "in_review";
|
||||||
}
|
}
|
||||||
|
|
@ -286,6 +386,10 @@ export default function CardDetailPage() {
|
||||||
...edits,
|
...edits,
|
||||||
reviewStatus: "reviewed",
|
reviewStatus: "reviewed",
|
||||||
};
|
};
|
||||||
|
if (Object.keys(fieldDataEdits).length > 0) {
|
||||||
|
const currentFieldData = (card?.fieldData as Record<string, unknown>) || {};
|
||||||
|
payload.fieldData = { ...currentFieldData, ...fieldDataEdits };
|
||||||
|
}
|
||||||
const res = await fetch(`/api/cards/${id}`, {
|
const res = await fetch(`/api/cards/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -399,10 +503,28 @@ export default function CardDetailPage() {
|
||||||
if (!card) {
|
if (!card) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Header title="Card Not Found" icon={ScanLine} />
|
<Header title={loadError ? "Failed to Load Card" : "Card Not Found"} icon={loadError ? AlertCircle : ScanLine} />
|
||||||
<Button variant="outline" className="rounded-xl" onClick={() => router.push("/")}>
|
<div className="glass-card flex flex-col items-center justify-center rounded-2xl p-12 text-center">
|
||||||
<ArrowLeft className="mr-2 size-4" /> Back to Dashboard
|
<AlertCircle className="mb-4 size-12 text-muted-foreground/40" />
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
{loadError ? "Something went wrong" : "This card doesn't exist"}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||||
|
{loadError
|
||||||
|
? "We couldn't load this card. Check your connection and try again."
|
||||||
|
: "The card you're looking for may have been deleted or the link is incorrect."}
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex gap-3">
|
||||||
|
{loadError && (
|
||||||
|
<Button variant="outline" className="rounded-xl" onClick={fetchCard}>
|
||||||
|
<RefreshCw className="mr-2 size-4" /> Retry
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" className="rounded-xl" onClick={() => router.push("/cards")}>
|
||||||
|
<ArrowLeft className="mr-2 size-4" /> Go back to cards
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -410,7 +532,7 @@ export default function CardDetailPage() {
|
||||||
const confidence = card.ocrConfidence;
|
const confidence = card.ocrConfidence;
|
||||||
const ocrStatus = card.ocrStatus;
|
const ocrStatus = card.ocrStatus;
|
||||||
const reviewStatus = card.reviewStatus;
|
const reviewStatus = card.reviewStatus;
|
||||||
const hasEdits = Object.keys(edits).length > 0;
|
const hasEdits = Object.keys(edits).length > 0 || Object.keys(fieldDataEdits).length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -552,6 +674,42 @@ export default function CardDetailPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{dynamicSections ? (
|
||||||
|
dynamicSections.map((section) => {
|
||||||
|
const IconComp =
|
||||||
|
section.iconKey === "user" ? User :
|
||||||
|
section.iconKey === "clipboardCheck" ? ClipboardCheck :
|
||||||
|
ClipboardList;
|
||||||
|
const isWide = section.key === "survey" || section.key === "custom";
|
||||||
|
return (
|
||||||
|
<Card key={section.key} variant="glass" className={isWide ? "xl:col-span-2" : ""}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<IconComp className="size-4 text-primary" />
|
||||||
|
{section.label}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className={cn(
|
||||||
|
"grid gap-4 grid-cols-1 sm:grid-cols-2",
|
||||||
|
isWide && "lg:grid-cols-3"
|
||||||
|
)}>
|
||||||
|
{section.fields.map((field) => (
|
||||||
|
<DynamicField
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
value={getDynamicValue(field)}
|
||||||
|
onChange={(v) => setDynamicValue(field, v)}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Card variant="glass">
|
<Card variant="glass">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
|
@ -638,6 +796,8 @@ export default function CardDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card variant="glass" className="xl:col-span-2">
|
<Card variant="glass" className="xl:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,163 @@
|
||||||
import { CalendarDays } from "lucide-react";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
CalendarDays,
|
||||||
|
MapPin,
|
||||||
|
CreditCard,
|
||||||
|
Clock,
|
||||||
|
ArrowLeft,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
import { Header } from "@/components/layout/header";
|
import { Header } from "@/components/layout/header";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type CollectionDay = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
dayOfWeek: number | null;
|
||||||
|
timeStart: string | null;
|
||||||
|
timeEnd: string | null;
|
||||||
|
isRecurring: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
date: string | null;
|
||||||
|
rrule: string | null;
|
||||||
|
location: { id: string; name: string };
|
||||||
|
_count: { cards: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
|
|
||||||
export default function EventDetailPage() {
|
export default function EventDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [day, setDay] = useState<CollectionDay | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [notFound, setNotFound] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/collection-days/${id}`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error("not found");
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(setDay)
|
||||||
|
.catch(() => setNotFound(true))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notFound || !day) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Header
|
<Header
|
||||||
title="Event Detail"
|
title="Not Found"
|
||||||
description="View cards assigned to this event"
|
description="This collection day doesn't exist or you don't have access."
|
||||||
icon={CalendarDays}
|
icon={CalendarDays}
|
||||||
/>
|
/>
|
||||||
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
|
<Button render={<Link href="/events" />} variant="outline" size="sm" className="rounded-xl">
|
||||||
Event detail view coming soon.
|
<ArrowLeft className="size-4" />
|
||||||
|
Back to Collection Days
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Header
|
||||||
|
title={day.name}
|
||||||
|
description={day.description || "Collection day details"}
|
||||||
|
icon={CalendarDays}
|
||||||
|
>
|
||||||
|
<Button render={<Link href="/events" />} variant="outline" size="sm" className="rounded-xl">
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
All Collection Days
|
||||||
|
</Button>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<InfoCard
|
||||||
|
icon={MapPin}
|
||||||
|
label="Location"
|
||||||
|
value={day.location.name}
|
||||||
|
/>
|
||||||
|
<InfoCard
|
||||||
|
icon={CalendarDays}
|
||||||
|
label="Day"
|
||||||
|
value={
|
||||||
|
day.dayOfWeek != null
|
||||||
|
? DAY_NAMES[day.dayOfWeek]
|
||||||
|
: day.date
|
||||||
|
? new Date(day.date).toLocaleDateString()
|
||||||
|
: "Not set"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoCard
|
||||||
|
icon={Clock}
|
||||||
|
label="Time"
|
||||||
|
value={
|
||||||
|
day.timeStart
|
||||||
|
? `${day.timeStart}${day.timeEnd ? ` – ${day.timeEnd}` : ""}`
|
||||||
|
: "Not set"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoCard
|
||||||
|
icon={CreditCard}
|
||||||
|
label="Linked Cards"
|
||||||
|
value={String(day._count.cards)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-card rounded-2xl p-5">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Badge variant={day.isActive ? "default" : "secondary"}>
|
||||||
|
{day.isActive ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
{day.isRecurring && (
|
||||||
|
<Badge variant="outline">
|
||||||
|
<RefreshCw className="size-3" />
|
||||||
|
Recurring
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{day.rrule && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Rule: {day.rrule}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InfoCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="glass-card rounded-2xl p-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Icon className="size-3.5" />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-lg font-semibold tracking-tight">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,130 @@
|
||||||
import { CalendarDays } from "lucide-react";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
CalendarDays,
|
||||||
|
MapPin,
|
||||||
|
CreditCard,
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
Settings,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Header } from "@/components/layout/header";
|
import { Header } from "@/components/layout/header";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type CollectionDay = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
dayOfWeek: number | null;
|
||||||
|
timeStart: string | null;
|
||||||
|
timeEnd: string | null;
|
||||||
|
isRecurring: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
location: { id: string; name: string };
|
||||||
|
_count: { cards: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
|
|
||||||
export default function EventsPage() {
|
export default function EventsPage() {
|
||||||
|
const [days, setDays] = useState<CollectionDay[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/collection-days")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
setDays(Array.isArray(data) ? data : []);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Failed to load collection days");
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Header
|
<Header
|
||||||
title="Collection Days"
|
title="Collection Days"
|
||||||
description="Manage recurring events and service times"
|
description="Manage recurring events and service times"
|
||||||
icon={CalendarDays}
|
icon={CalendarDays}
|
||||||
/>
|
>
|
||||||
|
<Button render={<Link href="/settings/locations" />} size="sm" className="rounded-xl">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Create Collection Day
|
||||||
|
</Button>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : days.length === 0 ? (
|
||||||
<div className="glass-card flex flex-col items-center justify-center rounded-2xl p-12 text-center">
|
<div className="glass-card flex flex-col items-center justify-center rounded-2xl p-12 text-center">
|
||||||
<CalendarDays className="mb-4 size-12 text-muted-foreground/40" />
|
<CalendarDays className="mb-4 size-12 text-muted-foreground/40" />
|
||||||
<h2 className="text-lg font-semibold">No collection days yet</h2>
|
<h2 className="text-lg font-semibold">No collection days configured</h2>
|
||||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||||
Collection days will be available once organizations and locations are
|
Set up your locations and service schedule to start organizing
|
||||||
configured. Check back after setup is complete.
|
response cards by collection day.
|
||||||
</p>
|
</p>
|
||||||
|
<Button
|
||||||
|
render={<Link href="/settings/locations" />}
|
||||||
|
size="sm"
|
||||||
|
className="mt-6 rounded-xl"
|
||||||
|
>
|
||||||
|
<Settings className="mr-1 size-4" />
|
||||||
|
Configure Locations
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{days.map((day) => (
|
||||||
|
<Link
|
||||||
|
key={day.id}
|
||||||
|
href={`/events/${day.id}`}
|
||||||
|
className="glass-card group rounded-2xl p-5 transition-shadow hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<h3 className="font-semibold tracking-tight group-hover:text-primary transition-colors">
|
||||||
|
{day.name}
|
||||||
|
</h3>
|
||||||
|
<Badge variant={day.isActive ? "default" : "secondary"}>
|
||||||
|
{day.isActive ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{day.description && (
|
||||||
|
<p className="mt-1.5 line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{day.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<MapPin className="size-3" />
|
||||||
|
{day.location.name}
|
||||||
|
</span>
|
||||||
|
{day.dayOfWeek != null && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<CalendarDays className="size-3" />
|
||||||
|
{DAY_NAMES[day.dayOfWeek]}
|
||||||
|
{day.timeStart && ` at ${day.timeStart}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<CreditCard className="size-3" />
|
||||||
|
{day._count.cards} card{day._count.cards !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ import {
|
||||||
UserPlus,
|
UserPlus,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
ResponsiveContainer,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
} from "recharts";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Header } from "@/components/layout/header";
|
import { Header } from "@/components/layout/header";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -25,6 +31,14 @@ type Stats = {
|
||||||
byReviewStatus: Record<string, number>;
|
byReviewStatus: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TrendPoint = { date: string; total: number; scanned: number; survey: number };
|
||||||
|
|
||||||
|
type TrendsData = {
|
||||||
|
series: TrendPoint[];
|
||||||
|
peopleCount: number;
|
||||||
|
newPeopleThisPeriod: number;
|
||||||
|
};
|
||||||
|
|
||||||
const HINTS_STORAGE_KEY = "echo-ocr-dismissed-hints";
|
const HINTS_STORAGE_KEY = "echo-ocr-dismissed-hints";
|
||||||
|
|
||||||
function loadDismissedHints(): string[] {
|
function loadDismissedHints(): string[] {
|
||||||
|
|
@ -49,6 +63,7 @@ function dismissHint(id: string) {
|
||||||
|
|
||||||
export default function DashboardHomePage() {
|
export default function DashboardHomePage() {
|
||||||
const [stats, setStats] = useState<Stats | null>(null);
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [trends, setTrends] = useState<TrendsData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [dismissedHints, setDismissedHints] = useState<string[]>([]);
|
const [dismissedHints, setDismissedHints] = useState<string[]>([]);
|
||||||
|
|
||||||
|
|
@ -62,10 +77,17 @@ export default function DashboardHomePage() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/stats")
|
Promise.all([
|
||||||
.then((r) => r.json())
|
fetch("/api/stats").then((r) => r.json()),
|
||||||
.then(setStats)
|
fetch("/api/stats/trends?period=7&groupBy=day")
|
||||||
.catch(() => {})
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.catch(() => null),
|
||||||
|
])
|
||||||
|
.then(([statsData, trendsData]) => {
|
||||||
|
setStats(statsData);
|
||||||
|
setTrends(trendsData);
|
||||||
|
})
|
||||||
|
.catch(() => { toast.error("Failed to load dashboard stats"); })
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -74,30 +96,44 @@ export default function DashboardHomePage() {
|
||||||
const pending = (stats?.byOcrStatus?.pending ?? 0) + (stats?.byOcrStatus?.processing ?? 0);
|
const pending = (stats?.byOcrStatus?.pending ?? 0) + (stats?.byOcrStatus?.processing ?? 0);
|
||||||
const unreviewed = stats?.byReviewStatus?.unreviewed ?? 0;
|
const unreviewed = stats?.byReviewStatus?.unreviewed ?? 0;
|
||||||
|
|
||||||
|
const sparkData = trends?.series ?? [];
|
||||||
|
|
||||||
const summaryCards = [
|
const summaryCards = [
|
||||||
{
|
{
|
||||||
label: "Total Cards",
|
label: "Total Cards",
|
||||||
value: stats?.total ?? 0,
|
value: stats?.total ?? 0,
|
||||||
icon: CreditCard,
|
icon: CreditCard,
|
||||||
href: "/cards",
|
href: "/cards",
|
||||||
|
sparkKey: "total" as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "OCR Complete",
|
label: "OCR Complete",
|
||||||
value: completed,
|
value: completed,
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
href: "/cards?ocrStatus=complete",
|
href: "/cards?ocrStatus=complete",
|
||||||
|
sparkKey: "scanned" as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Needs Review",
|
label: "Needs Review",
|
||||||
value: unreviewed,
|
value: unreviewed,
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
href: "/cards?reviewStatus=unreviewed",
|
href: "/cards?reviewStatus=unreviewed",
|
||||||
|
sparkKey: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Errors",
|
label: "Errors",
|
||||||
value: errors,
|
value: errors,
|
||||||
icon: AlertCircle,
|
icon: AlertCircle,
|
||||||
href: "/cards?ocrStatus=error",
|
href: "/cards?ocrStatus=error",
|
||||||
|
sparkKey: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "People",
|
||||||
|
value: trends?.peopleCount ?? 0,
|
||||||
|
icon: Users,
|
||||||
|
href: "/people",
|
||||||
|
sparkKey: null,
|
||||||
|
sub: trends ? `+${trends.newPeopleThisPeriod} this week` : undefined,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -136,7 +172,7 @@ export default function DashboardHomePage() {
|
||||||
icon={CreditCard}
|
icon={CreditCard}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
{summaryCards.map((card) =>
|
{summaryCards.map((card) =>
|
||||||
loading ? (
|
loading ? (
|
||||||
<Skeleton key={card.label} className="h-28 rounded-2xl" />
|
<Skeleton key={card.label} className="h-28 rounded-2xl" />
|
||||||
|
|
@ -155,6 +191,30 @@ export default function DashboardHomePage() {
|
||||||
<p className="mt-2 text-3xl font-bold tracking-tight">
|
<p className="mt-2 text-3xl font-bold tracking-tight">
|
||||||
{card.value.toLocaleString()}
|
{card.value.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
|
{"sub" in card && card.sub && (
|
||||||
|
<p className="text-xs text-muted-foreground">{card.sub}</p>
|
||||||
|
)}
|
||||||
|
{card.sparkKey && sparkData.length > 1 && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<ResponsiveContainer width="100%" height={40}>
|
||||||
|
<AreaChart data={sparkData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`spark-${card.sparkKey}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#6366f1" stopOpacity={0.15} />
|
||||||
|
<stop offset="100%" stopColor="#6366f1" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey={card.sparkKey}
|
||||||
|
stroke="#6366f1"
|
||||||
|
fill={`url(#spark-${card.sparkKey})`}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
690
src/app/(dashboard)/people/[id]/page.tsx
Normal file
690
src/app/(dashboard)/people/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,690 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
User,
|
||||||
|
Edit2,
|
||||||
|
Save,
|
||||||
|
X,
|
||||||
|
Merge,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
FileText,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { formatDistanceToNow, differenceInDays, format } from "date-fns";
|
||||||
|
|
||||||
|
import { Header } from "@/components/layout/header";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type LinkedCard = {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
ocrStatus: string;
|
||||||
|
reviewStatus: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PersonData = {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string | null;
|
||||||
|
cellPhone: string | null;
|
||||||
|
fieldData: Record<string, unknown> | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
cards: LinkedCard[];
|
||||||
|
_count: { cards: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
type PersonSearchResult = {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string | null;
|
||||||
|
_count: { cards: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
if (differenceInDays(new Date(), d) < 30) {
|
||||||
|
return formatDistanceToNow(d, { addSuffix: true });
|
||||||
|
}
|
||||||
|
return format(d, "MMM d, yyyy");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PersonDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const id = params.id as string;
|
||||||
|
|
||||||
|
const [person, setPerson] = React.useState<PersonData | null>(null);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [notFound, setNotFound] = React.useState(false);
|
||||||
|
|
||||||
|
const [editing, setEditing] = React.useState(false);
|
||||||
|
const [editFields, setEditFields] = React.useState({
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
email: "",
|
||||||
|
cellPhone: "",
|
||||||
|
});
|
||||||
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
|
||||||
|
const [mergeOpen, setMergeOpen] = React.useState(false);
|
||||||
|
const [mergeSearch, setMergeSearch] = React.useState("");
|
||||||
|
const [mergeResults, setMergeResults] = React.useState<PersonSearchResult[]>([]);
|
||||||
|
const [mergeSearching, setMergeSearching] = React.useState(false);
|
||||||
|
const [mergeTarget, setMergeTarget] = React.useState<PersonSearchResult | null>(null);
|
||||||
|
const [merging, setMerging] = React.useState(false);
|
||||||
|
|
||||||
|
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
||||||
|
const [deleting, setDeleting] = React.useState(false);
|
||||||
|
|
||||||
|
const fetchPerson = React.useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setNotFound(false);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/people/${id}`);
|
||||||
|
if (res.status === 404) {
|
||||||
|
setNotFound(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data = await res.json();
|
||||||
|
setPerson(data);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to load person");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetchPerson();
|
||||||
|
}, [fetchPerson]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (searchParams.get("action") === "merge") {
|
||||||
|
setMergeOpen(true);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const startEditing = () => {
|
||||||
|
if (!person) return;
|
||||||
|
setEditFields({
|
||||||
|
firstName: person.firstName,
|
||||||
|
lastName: person.lastName,
|
||||||
|
email: person.email ?? "",
|
||||||
|
cellPhone: person.cellPhone ?? "",
|
||||||
|
});
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEditing = () => {
|
||||||
|
setEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/people/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(editFields),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error || "Failed to save");
|
||||||
|
}
|
||||||
|
toast.success("Person updated");
|
||||||
|
setEditing(false);
|
||||||
|
fetchPerson();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to save");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!mergeOpen) {
|
||||||
|
setMergeSearch("");
|
||||||
|
setMergeResults([]);
|
||||||
|
setMergeTarget(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [mergeOpen]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!mergeSearch.trim()) {
|
||||||
|
setMergeResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
setMergeSearching(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/people?search=${encodeURIComponent(mergeSearch)}&limit=10`
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data = await res.json();
|
||||||
|
setMergeResults(
|
||||||
|
(data.people as PersonSearchResult[]).filter((p) => p.id !== id)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore search errors
|
||||||
|
} finally {
|
||||||
|
setMergeSearching(false);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [mergeSearch, id]);
|
||||||
|
|
||||||
|
const handleMerge = async () => {
|
||||||
|
if (!mergeTarget) return;
|
||||||
|
setMerging(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/people/${id}/merge`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ targetPersonId: mergeTarget.id }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error || "Merge failed");
|
||||||
|
}
|
||||||
|
toast.success("People merged successfully");
|
||||||
|
setMergeOpen(false);
|
||||||
|
router.push(`/people/${mergeTarget.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Merge failed");
|
||||||
|
} finally {
|
||||||
|
setMerging(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/people/${id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
toast.success("Person deleted");
|
||||||
|
setDeleteOpen(false);
|
||||||
|
router.push("/people");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to delete person");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-8 w-24 rounded-xl" />
|
||||||
|
<Skeleton className="h-10 w-64 rounded-xl" />
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<Skeleton className="h-80 rounded-2xl" />
|
||||||
|
<Skeleton className="h-80 rounded-2xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notFound || !person) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Header title="Person Not Found" icon={User} />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
The person you're looking for doesn't exist or has been
|
||||||
|
removed.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-xl"
|
||||||
|
onClick={() => router.push("/people")}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-1 size-4" /> Back to People
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldDataEntries = person.fieldData
|
||||||
|
? Object.entries(person.fieldData).filter(
|
||||||
|
([, v]) => v !== null && v !== undefined && v !== ""
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-fit rounded-xl"
|
||||||
|
onClick={() => router.push("/people")}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-1 size-4" /> Back to People
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Header
|
||||||
|
title={`${person.firstName} ${person.lastName}`}
|
||||||
|
description="Contact Details"
|
||||||
|
icon={User}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl"
|
||||||
|
onClick={() => setMergeOpen(true)}
|
||||||
|
>
|
||||||
|
<Merge className="mr-1 size-4" /> Merge with...
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1 size-4" /> Delete Person
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* Contact info card */}
|
||||||
|
<Card variant="glass">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between text-base">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<User className="size-4 text-primary" />
|
||||||
|
Contact Information
|
||||||
|
</span>
|
||||||
|
{!editing ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={startEditing}
|
||||||
|
>
|
||||||
|
<Edit2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={cancelEditing}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="size-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{editing ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
||||||
|
First Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={editFields.firstName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditFields((f) => ({
|
||||||
|
...f,
|
||||||
|
firstName: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
||||||
|
Last Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={editFields.lastName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditFields((f) => ({
|
||||||
|
...f,
|
||||||
|
lastName: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
||||||
|
Email
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={editFields.email}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditFields((f) => ({ ...f, email: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
||||||
|
Cell Phone
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={editFields.cellPhone}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditFields((f) => ({
|
||||||
|
...f,
|
||||||
|
cellPhone: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<InfoRow label="First Name" value={person.firstName} />
|
||||||
|
<InfoRow label="Last Name" value={person.lastName} />
|
||||||
|
<InfoRow label="Email" value={person.email} />
|
||||||
|
<InfoRow label="Cell Phone" value={person.cellPhone} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fieldDataEntries.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator className="opacity-50" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
Additional Data
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{fieldDataEntries.map(([key, value]) => (
|
||||||
|
<InfoRow
|
||||||
|
key={key}
|
||||||
|
label={key
|
||||||
|
.replace(/([A-Z])/g, " $1")
|
||||||
|
.replace(/^./, (s) => s.toUpperCase())
|
||||||
|
.trim()}
|
||||||
|
value={String(value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator className="opacity-50" />
|
||||||
|
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||||
|
<span>Created {formatDate(person.createdAt)}</span>
|
||||||
|
<span>Updated {formatDate(person.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Linked cards */}
|
||||||
|
<Card variant="glass">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<FileText className="size-4 text-primary" />
|
||||||
|
Linked Response Cards
|
||||||
|
<Badge variant="secondary" className="ml-1">
|
||||||
|
{person._count.cards}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{person.cards.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<FileText className="mb-3 size-10 text-muted-foreground/30" />
|
||||||
|
<p className="text-sm font-medium">No linked cards</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
Cards will appear here when linked to this person
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{person.cards.map((card) => (
|
||||||
|
<button
|
||||||
|
key={card.id}
|
||||||
|
className="flex w-full items-center justify-between gap-3 rounded-xl border border-border/50 bg-muted/20 px-3 py-2.5 text-left transition-colors hover:bg-muted/40"
|
||||||
|
onClick={() => router.push(`/cards/${card.id}`)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">
|
||||||
|
{card.name || "Unnamed Card"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{formatDate(card.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 gap-1.5">
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] capitalize",
|
||||||
|
card.ocrStatus === "complete" &&
|
||||||
|
"bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||||
|
card.ocrStatus === "error" &&
|
||||||
|
"bg-red-500/10 text-red-700 dark:text-red-300",
|
||||||
|
card.ocrStatus === "processing" &&
|
||||||
|
"bg-primary/10 text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{card.ocrStatus}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] capitalize",
|
||||||
|
card.reviewStatus === "reviewed" &&
|
||||||
|
"bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||||
|
card.reviewStatus === "unreviewed" &&
|
||||||
|
"bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{card.reviewStatus}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Merge dialog */}
|
||||||
|
<Dialog open={mergeOpen} onOpenChange={setMergeOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Merge Person</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Search for another person to merge{" "}
|
||||||
|
<strong>
|
||||||
|
{person.firstName} {person.lastName}
|
||||||
|
</strong>{" "}
|
||||||
|
into. Cards and data will be moved to the target person.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search people..."
|
||||||
|
value={mergeSearch}
|
||||||
|
onChange={(e) => {
|
||||||
|
setMergeSearch(e.target.value);
|
||||||
|
setMergeTarget(null);
|
||||||
|
}}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mergeSearching && (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!mergeSearching && mergeResults.length > 0 && (
|
||||||
|
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||||
|
{mergeResults.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors",
|
||||||
|
mergeTarget?.id === p.id
|
||||||
|
? "bg-primary/10 text-primary ring-1 ring-primary/30"
|
||||||
|
: "hover:bg-muted/60"
|
||||||
|
)}
|
||||||
|
onClick={() => setMergeTarget(p)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">
|
||||||
|
{p.firstName} {p.lastName}
|
||||||
|
</span>
|
||||||
|
{p.email && (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
{p.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{p._count.cards} cards
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!mergeSearching &&
|
||||||
|
mergeSearch.trim() &&
|
||||||
|
mergeResults.length === 0 && (
|
||||||
|
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||||
|
No matching people found
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setMergeOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!mergeTarget || merging}
|
||||||
|
onClick={handleMerge}
|
||||||
|
>
|
||||||
|
{merging ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-1 size-4 animate-spin" /> Merging...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Merge className="mr-1 size-4" /> Merge into{" "}
|
||||||
|
{mergeTarget
|
||||||
|
? `${mergeTarget.firstName} ${mergeTarget.lastName}`
|
||||||
|
: "..."}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Person</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete{" "}
|
||||||
|
<strong>
|
||||||
|
{person.firstName} {person.lastName}
|
||||||
|
</strong>
|
||||||
|
? Their linked cards will be unlinked but not deleted.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeleteOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
{deleting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-1 size-4 animate-spin" /> Deleting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 className="mr-1 size-4" /> Delete
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | null | undefined;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||||
|
<p className="mt-0.5 text-sm">{value || "—"}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,404 @@
|
||||||
import { Users } from "lucide-react";
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Search,
|
||||||
|
MoreHorizontal,
|
||||||
|
Eye,
|
||||||
|
Merge,
|
||||||
|
Trash2,
|
||||||
|
Link2,
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
CalendarPlus,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { formatDistanceToNow, differenceInDays, format } from "date-fns";
|
||||||
|
|
||||||
import { Header } from "@/components/layout/header";
|
import { Header } from "@/components/layout/header";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
type PersonRow = {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string | null;
|
||||||
|
cellPhone: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
_count: { cards: number };
|
||||||
|
cards?: { createdAt: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PeopleResponse = {
|
||||||
|
people: PersonRow[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
if (differenceInDays(new Date(), d) < 30) {
|
||||||
|
return formatDistanceToNow(d, { addSuffix: true });
|
||||||
|
}
|
||||||
|
return format(d, "MMM d, yyyy");
|
||||||
|
}
|
||||||
|
|
||||||
export default function PeoplePage() {
|
export default function PeoplePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [data, setData] = React.useState<PeopleResponse | null>(null);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [search, setSearch] = React.useState("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
||||||
|
const [page, setPage] = React.useState(1);
|
||||||
|
const [linking, setLinking] = React.useState(false);
|
||||||
|
const [deletingId, setDeletingId] = React.useState<string | null>(null);
|
||||||
|
const limit = 20;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedSearch(search);
|
||||||
|
setPage(1);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const fetchPeople = React.useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
limit: String(limit),
|
||||||
|
});
|
||||||
|
if (debouncedSearch) params.set("search", debouncedSearch);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/people?${params.toString()}`);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const json = await res.json();
|
||||||
|
setData(json);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to load people");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, debouncedSearch]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetchPeople();
|
||||||
|
}, [fetchPeople]);
|
||||||
|
|
||||||
|
const totalPages = data ? Math.max(1, Math.ceil(data.total / limit)) : 1;
|
||||||
|
|
||||||
|
const newThisMonth = React.useMemo(() => {
|
||||||
|
if (!data) return 0;
|
||||||
|
const now = new Date();
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
return data.people.filter(
|
||||||
|
(p) => new Date(p.createdAt) >= startOfMonth
|
||||||
|
).length;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const handleLinkCards = async () => {
|
||||||
|
setLinking(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/people/link", { method: "POST" });
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const result = await res.json();
|
||||||
|
toast.success(
|
||||||
|
`Linked ${result.linked} card${result.linked !== 1 ? "s" : ""}, created ${result.created} new ${result.created !== 1 ? "people" : "person"}`
|
||||||
|
);
|
||||||
|
fetchPeople();
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to link cards");
|
||||||
|
} finally {
|
||||||
|
setLinking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (personId: string) => {
|
||||||
|
setDeletingId(personId);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/people/${personId}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
toast.success("Person deleted");
|
||||||
|
fetchPeople();
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to delete person");
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !data) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-10 w-64 rounded-xl" />
|
||||||
|
<Skeleton className="h-4 w-80 rounded-lg" />
|
||||||
|
<Skeleton className="h-10 w-full rounded-xl" />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Skeleton className="h-20 rounded-2xl" />
|
||||||
|
<Skeleton className="h-20 rounded-2xl" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const people = data?.people ?? [];
|
||||||
|
const isEmpty = !loading && people.length === 0 && !debouncedSearch;
|
||||||
|
const noResults = !loading && people.length === 0 && !!debouncedSearch;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Header
|
<Header
|
||||||
title="People"
|
title="People"
|
||||||
description="Contact directory from scanned response cards"
|
description="Contact directory from scanned response cards"
|
||||||
icon={Users}
|
icon={Users}
|
||||||
/>
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl"
|
||||||
|
onClick={handleLinkCards}
|
||||||
|
disabled={linking}
|
||||||
|
>
|
||||||
|
{linking ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-1 size-4 animate-spin" /> Linking...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link2 className="mr-1 size-4" /> Link Unlinked Cards
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
{isEmpty ? (
|
||||||
<div className="glass-card flex flex-col items-center justify-center rounded-2xl p-12 text-center">
|
<div className="glass-card flex flex-col items-center justify-center rounded-2xl p-12 text-center">
|
||||||
<Users className="mb-4 size-12 text-muted-foreground/40" />
|
<Users className="mb-4 size-12 text-muted-foreground/40" />
|
||||||
<h2 className="text-lg font-semibold">People directory</h2>
|
<h2 className="text-lg font-semibold">No people yet</h2>
|
||||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||||
A deduplicated contact list aggregated from your response cards will
|
People are automatically created when response cards are processed.
|
||||||
appear here once the people module is built out.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by name or email..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="gradient-stat flex flex-col gap-1 rounded-2xl p-4">
|
||||||
|
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||||
|
<Users className="size-5" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-bold tracking-tight">
|
||||||
|
{data?.total.toLocaleString() ?? "—"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Total People</p>
|
||||||
|
</div>
|
||||||
|
<div className="gradient-stat flex flex-col gap-1 rounded-2xl p-4">
|
||||||
|
<div className="flex size-9 items-center justify-center rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||||
|
<CalendarPlus className="size-5" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-bold tracking-tight">
|
||||||
|
{newThisMonth}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">New This Month</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{noResults ? (
|
||||||
|
<div className="glass-card flex flex-col items-center justify-center rounded-2xl p-12 text-center">
|
||||||
|
<Search className="mb-4 size-10 text-muted-foreground/40" />
|
||||||
|
<h2 className="text-lg font-semibold">No results</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
No people match “{debouncedSearch}”
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="glass-card overflow-hidden rounded-2xl">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead className="hidden sm:table-cell">
|
||||||
|
Email
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell">
|
||||||
|
Phone
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-center">Cards</TableHead>
|
||||||
|
<TableHead className="hidden lg:table-cell">
|
||||||
|
Last Seen
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-10" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading
|
||||||
|
? Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-5 w-32 rounded" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden sm:table-cell">
|
||||||
|
<Skeleton className="h-4 w-40 rounded" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">
|
||||||
|
<Skeleton className="h-4 w-28 rounded" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Skeleton className="mx-auto h-5 w-8 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell">
|
||||||
|
<Skeleton className="h-4 w-24 rounded" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-6 w-6 rounded" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
: people.map((person) => {
|
||||||
|
const lastSeen =
|
||||||
|
person.cards?.[0]?.createdAt ?? person.updatedAt;
|
||||||
|
return (
|
||||||
|
<TableRow key={person.id}>
|
||||||
|
<TableCell>
|
||||||
|
<button
|
||||||
|
className="font-medium text-foreground hover:text-primary hover:underline"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/people/${person.id}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{person.firstName} {person.lastName}
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden text-muted-foreground sm:table-cell">
|
||||||
|
{person.email || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden text-muted-foreground md:table-cell">
|
||||||
|
{person.cellPhone || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{person._count.cards}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden text-muted-foreground lg:table-cell">
|
||||||
|
{formatDate(lastSeen)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/people/${person.id}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Eye className="mr-2 size-4" /> View
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
router.push(
|
||||||
|
`/people/${person.id}?action=merge`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Merge className="mr-2 size-4" /> Merge
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
disabled={deletingId === person.id}
|
||||||
|
onClick={() => handleDelete(person.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 size-4" /> Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="mr-1 size-4" /> Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
Next <ChevronRight className="ml-1 size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,12 @@ export default function ProfilePage() {
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const [saving, setSaving] = React.useState(false);
|
||||||
updateProfile({
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await updateProfile({
|
||||||
jobTitle: form.jobTitle,
|
jobTitle: form.jobTitle,
|
||||||
company: form.company,
|
company: form.company,
|
||||||
bio: form.bio,
|
bio: form.bio,
|
||||||
|
|
@ -70,6 +74,11 @@ export default function ProfilePage() {
|
||||||
});
|
});
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
toast.success("Profile updated");
|
toast.success("Profile updated");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to save profile");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAvatarUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAvatarUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -108,9 +117,9 @@ export default function ProfilePage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Header title="Profile" description="Manage your personal information" icon={UserCircle}>
|
<Header title="Profile" description="Manage your personal information" icon={UserCircle}>
|
||||||
<Button className="rounded-xl" onClick={handleSave} disabled={!dirty}>
|
<Button className="rounded-xl" onClick={handleSave} disabled={!dirty || saving}>
|
||||||
<Save className="mr-2 size-4" />
|
{saving ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Save className="mr-2 size-4" />}
|
||||||
Save Changes
|
{saving ? "Saving…" : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,367 @@
|
||||||
import { BarChart3 } from "lucide-react";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { BarChart3, Download, Users, Clock, CreditCard, FileText } from "lucide-react";
|
||||||
|
import {
|
||||||
|
ResponsiveContainer,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
import { Header } from "@/components/layout/header";
|
import { Header } from "@/components/layout/header";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type Period = 7 | 30 | 90 | 365;
|
||||||
|
|
||||||
|
type SeriesPoint = {
|
||||||
|
date: string;
|
||||||
|
total: number;
|
||||||
|
scanned: number;
|
||||||
|
survey: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Pipeline = {
|
||||||
|
pending: number;
|
||||||
|
processing: number;
|
||||||
|
complete: number;
|
||||||
|
error: number;
|
||||||
|
unreviewed: number;
|
||||||
|
reviewed: number;
|
||||||
|
exported: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TeamMember = {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
reviewed: number;
|
||||||
|
assigned: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TrendsData = {
|
||||||
|
series: SeriesPoint[];
|
||||||
|
pipeline: Pipeline;
|
||||||
|
teamActivity: TeamMember[];
|
||||||
|
avgReviewTimeHours: number | null;
|
||||||
|
peopleCount: number;
|
||||||
|
newPeopleThisPeriod: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PERIOD_OPTIONS: { label: string; value: Period }[] = [
|
||||||
|
{ label: "7D", value: 7 },
|
||||||
|
{ label: "30D", value: 30 },
|
||||||
|
{ label: "90D", value: 90 },
|
||||||
|
{ label: "1Y", value: 365 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function groupByForPeriod(period: Period): string {
|
||||||
|
if (period <= 30) return "day";
|
||||||
|
if (period <= 90) return "week";
|
||||||
|
return "month";
|
||||||
|
}
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
|
const [period, setPeriod] = useState<Period>(30);
|
||||||
|
const [data, setData] = useState<TrendsData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async (p: Period) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/stats/trends?period=${p}&groupBy=${groupByForPeriod(p)}`
|
||||||
|
);
|
||||||
|
if (res.ok) setData(await res.json());
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData(period);
|
||||||
|
}, [period, fetchData]);
|
||||||
|
|
||||||
|
const handlePeriodChange = (p: Period) => setPeriod(p);
|
||||||
|
|
||||||
|
const downloadCsv = () => {
|
||||||
|
if (!data) return;
|
||||||
|
const header = "date,total,scanned,survey\n";
|
||||||
|
const rows = data.series
|
||||||
|
.map((r) => `${r.date},${r.total},${r.scanned},${r.survey}`)
|
||||||
|
.join("\n");
|
||||||
|
const blob = new Blob([header + rows], { type: "text/csv" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `echo-report-${period}d.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalCards = data?.series.reduce((s, r) => s + r.total, 0) ?? 0;
|
||||||
|
const totalSurvey = data?.series.reduce((s, r) => s + r.survey, 0) ?? 0;
|
||||||
|
|
||||||
|
const pipelineChartData = data
|
||||||
|
? [
|
||||||
|
{ stage: "Pending", count: data.pipeline.pending },
|
||||||
|
{ stage: "Processing", count: data.pipeline.processing },
|
||||||
|
{ stage: "Complete", count: data.pipeline.complete },
|
||||||
|
{ stage: "Unreviewed", count: data.pipeline.unreviewed },
|
||||||
|
{ stage: "Reviewed", count: data.pipeline.reviewed },
|
||||||
|
{ stage: "Exported", count: data.pipeline.exported },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<Header
|
<Header
|
||||||
title="Reports"
|
title="Reports"
|
||||||
description="Analytics, trends, and export center"
|
description="Analytics, trends, and export center"
|
||||||
icon={BarChart3}
|
icon={BarChart3}
|
||||||
/>
|
/>
|
||||||
<div className="glass-card flex flex-col items-center justify-center rounded-2xl p-12 text-center">
|
|
||||||
<BarChart3 className="mb-4 size-12 text-muted-foreground/40" />
|
{/* Period selector + export */}
|
||||||
<h2 className="text-lg font-semibold">Reports & Analytics</h2>
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
<div className="flex gap-1 rounded-lg bg-muted/50 p-1">
|
||||||
Detailed reports, trends over time, and data exports will be available
|
{PERIOD_OPTIONS.map((opt) => (
|
||||||
here in a future update.
|
<button
|
||||||
</p>
|
key={opt.value}
|
||||||
|
onClick={() => handlePeriodChange(opt.value)}
|
||||||
|
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||||
|
period === opt.value
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={downloadCsv} disabled={!data}>
|
||||||
|
<Download className="mr-2 size-4" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary stat cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-24 rounded-2xl" />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<StatCard
|
||||||
|
label="Total Cards"
|
||||||
|
value={totalCards}
|
||||||
|
icon={CreditCard}
|
||||||
|
sub={`in last ${period} days`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="People"
|
||||||
|
value={data?.peopleCount ?? 0}
|
||||||
|
icon={Users}
|
||||||
|
sub={`+${data?.newPeopleThisPeriod ?? 0} this period`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Avg Review Time"
|
||||||
|
value={
|
||||||
|
data?.avgReviewTimeHours != null
|
||||||
|
? `${data.avgReviewTimeHours}h`
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
icon={Clock}
|
||||||
|
sub="hours to review"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Survey Submissions"
|
||||||
|
value={totalSurvey}
|
||||||
|
icon={FileText}
|
||||||
|
sub={`of ${totalCards} total`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts grid */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
{/* Cards Over Time */}
|
||||||
|
<div className="glass-card rounded-2xl p-5">
|
||||||
|
<h3 className="mb-4 text-sm font-semibold">Cards Over Time</h3>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-64 rounded-xl" />
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<AreaChart data={data?.series ?? []}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradScanned" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#6366f1" stopOpacity={0.3} />
|
||||||
|
<stop offset="100%" stopColor="#6366f1" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradSurvey" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#10b981" stopOpacity={0.3} />
|
||||||
|
<stop offset="100%" stopColor="#10b981" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
stroke="hsl(var(--muted-foreground))"
|
||||||
|
tickFormatter={(v: string) =>
|
||||||
|
new Date(v + "T00:00:00").toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
stroke="hsl(var(--muted-foreground))"
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: "hsl(var(--popover))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="scanned"
|
||||||
|
stroke="#6366f1"
|
||||||
|
fill="url(#gradScanned)"
|
||||||
|
strokeWidth={2}
|
||||||
|
name="Scanned"
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="survey"
|
||||||
|
stroke="#10b981"
|
||||||
|
fill="url(#gradSurvey)"
|
||||||
|
strokeWidth={2}
|
||||||
|
name="Survey"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Processing Pipeline */}
|
||||||
|
<div className="glass-card rounded-2xl p-5">
|
||||||
|
<h3 className="mb-4 text-sm font-semibold">Processing Pipeline</h3>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-64 rounded-xl" />
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<BarChart data={pipelineChartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="stage"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
stroke="hsl(var(--muted-foreground))"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
stroke="hsl(var(--muted-foreground))"
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: "hsl(var(--popover))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="count" fill="#6366f1" radius={[6, 6, 0, 0]} name="Cards" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Team Activity */}
|
||||||
|
<div className="glass-card col-span-1 rounded-2xl p-5 lg:col-span-2">
|
||||||
|
<h3 className="mb-4 text-sm font-semibold">Team Activity</h3>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-64 rounded-xl" />
|
||||||
|
) : data?.teamActivity.length === 0 ? (
|
||||||
|
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
No team activity yet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={Math.max(200, (data?.teamActivity.length ?? 0) * 50)}>
|
||||||
|
<BarChart
|
||||||
|
data={data?.teamActivity ?? []}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ left: 20 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
stroke="hsl(var(--muted-foreground))"
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
stroke="hsl(var(--muted-foreground))"
|
||||||
|
width={100}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: "hsl(var(--popover))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
|
<Bar dataKey="reviewed" fill="#6366f1" radius={[0, 6, 6, 0]} name="Reviewed" />
|
||||||
|
<Bar dataKey="assigned" fill="#a78bfa" radius={[0, 6, 6, 0]} name="Assigned" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
sub,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
sub: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="gradient-stat flex flex-col justify-between rounded-2xl p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">{label}</span>
|
||||||
|
<Icon className="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-3xl font-bold tracking-tight">
|
||||||
|
{typeof value === "number" ? value.toLocaleString() : value}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{sub}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
867
src/app/(dashboard)/settings/forms/[id]/page.tsx
Normal file
867
src/app/(dashboard)/settings/forms/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,867 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
|
FileText,
|
||||||
|
GripVertical,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
Asterisk,
|
||||||
|
Lock,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Header } from "@/components/layout/header";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
const FIELD_TYPES = [
|
||||||
|
{ value: "text", label: "Text" },
|
||||||
|
{ value: "textarea", label: "Text Area" },
|
||||||
|
{ value: "email", label: "Email" },
|
||||||
|
{ value: "phone", label: "Phone" },
|
||||||
|
{ value: "date", label: "Date" },
|
||||||
|
{ value: "select", label: "Select" },
|
||||||
|
{ value: "multiselect", label: "Multi-Select" },
|
||||||
|
{ value: "checkbox", label: "Checkbox" },
|
||||||
|
{ value: "radio", label: "Radio" },
|
||||||
|
{ value: "number", label: "Number" },
|
||||||
|
{ value: "url", label: "URL" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{ value: "personal", label: "Personal" },
|
||||||
|
{ value: "contact", label: "Contact" },
|
||||||
|
{ value: "address", label: "Address" },
|
||||||
|
{ value: "survey", label: "Survey" },
|
||||||
|
{ value: "followup", label: "Follow-up" },
|
||||||
|
{ value: "custom", label: "Custom" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const HAS_OPTIONS = new Set(["select", "multiselect", "radio"]);
|
||||||
|
|
||||||
|
type TemplateField = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
key: string;
|
||||||
|
type: string;
|
||||||
|
section: string;
|
||||||
|
isRequired: boolean;
|
||||||
|
isCore: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
options: string[] | null;
|
||||||
|
placeholder: string | null;
|
||||||
|
helpText: string | null;
|
||||||
|
visibleOnCard: boolean;
|
||||||
|
visibleOnSurvey: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Template = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
isDefault: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
fields: TemplateField[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TemplateEditorPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const id = params.id as string;
|
||||||
|
|
||||||
|
const [template, setTemplate] = React.useState<Template | null>(null);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [selectedFieldId, setSelectedFieldId] = React.useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [savingTemplate, setSavingTemplate] = React.useState(false);
|
||||||
|
const [savingField, setSavingField] = React.useState(false);
|
||||||
|
const [addingField, setAddingField] = React.useState(false);
|
||||||
|
const [showAddForm, setShowAddForm] = React.useState(false);
|
||||||
|
|
||||||
|
const [newField, setNewField] = React.useState({
|
||||||
|
label: "",
|
||||||
|
key: "",
|
||||||
|
type: "text",
|
||||||
|
section: "custom",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchTemplate = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/form-templates/${id}`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch");
|
||||||
|
const data = await res.json();
|
||||||
|
setTemplate(data.template ?? data);
|
||||||
|
setError(null);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load template.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetchTemplate();
|
||||||
|
}, [fetchTemplate]);
|
||||||
|
|
||||||
|
const selectedField = template?.fields.find(
|
||||||
|
(f) => f.id === selectedFieldId
|
||||||
|
);
|
||||||
|
|
||||||
|
const fieldsBySection = React.useMemo(() => {
|
||||||
|
if (!template) return {};
|
||||||
|
const grouped: Record<string, TemplateField[]> = {};
|
||||||
|
for (const field of template.fields) {
|
||||||
|
const section = field.section || "custom";
|
||||||
|
if (!grouped[section]) grouped[section] = [];
|
||||||
|
grouped[section].push(field);
|
||||||
|
}
|
||||||
|
for (const section of Object.keys(grouped)) {
|
||||||
|
grouped[section].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}, [template]);
|
||||||
|
|
||||||
|
const handleSaveTemplate = async () => {
|
||||||
|
if (!template) return;
|
||||||
|
setSavingTemplate(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/form-templates/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: template.name,
|
||||||
|
description: template.description,
|
||||||
|
isActive: template.isActive,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to update");
|
||||||
|
toast.success("Template updated");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to update template");
|
||||||
|
} finally {
|
||||||
|
setSavingTemplate(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveField = async () => {
|
||||||
|
if (!selectedField) return;
|
||||||
|
setSavingField(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/form-templates/${id}/fields/${selectedField.id}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
label: selectedField.label,
|
||||||
|
type: selectedField.type,
|
||||||
|
section: selectedField.section,
|
||||||
|
isRequired: selectedField.isRequired,
|
||||||
|
options: selectedField.options,
|
||||||
|
placeholder: selectedField.placeholder,
|
||||||
|
helpText: selectedField.helpText,
|
||||||
|
visibleOnCard: selectedField.visibleOnCard,
|
||||||
|
visibleOnSurvey: selectedField.visibleOnSurvey,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("Failed to update");
|
||||||
|
toast.success("Field updated");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to update field");
|
||||||
|
} finally {
|
||||||
|
setSavingField(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddField = async () => {
|
||||||
|
if (!newField.label.trim() || !newField.key.trim()) return;
|
||||||
|
setAddingField(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/form-templates/${id}/fields`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
label: newField.label.trim(),
|
||||||
|
key: newField.key.trim(),
|
||||||
|
type: newField.type,
|
||||||
|
section: newField.section,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to add field");
|
||||||
|
toast.success("Field added");
|
||||||
|
setNewField({ label: "", key: "", type: "text", section: "custom" });
|
||||||
|
setShowAddForm(false);
|
||||||
|
fetchTemplate();
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to add field");
|
||||||
|
} finally {
|
||||||
|
setAddingField(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteField = async (field: TemplateField) => {
|
||||||
|
if (field.isCore) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/form-templates/${id}/fields/${field.id}`,
|
||||||
|
{ method: "DELETE" }
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("Failed to delete");
|
||||||
|
setTemplate((prev) =>
|
||||||
|
prev
|
||||||
|
? { ...prev, fields: prev.fields.filter((f) => f.id !== field.id) }
|
||||||
|
: prev
|
||||||
|
);
|
||||||
|
if (selectedFieldId === field.id) setSelectedFieldId(null);
|
||||||
|
toast.success("Field deleted");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to delete field");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReorder = async (
|
||||||
|
field: TemplateField,
|
||||||
|
direction: "up" | "down"
|
||||||
|
) => {
|
||||||
|
if (!template) return;
|
||||||
|
const sectionFields = template.fields
|
||||||
|
.filter((f) => f.section === field.section)
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
|
||||||
|
const idx = sectionFields.findIndex((f) => f.id === field.id);
|
||||||
|
if (
|
||||||
|
(direction === "up" && idx === 0) ||
|
||||||
|
(direction === "down" && idx === sectionFields.length - 1)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const swapWith =
|
||||||
|
direction === "up" ? sectionFields[idx - 1] : sectionFields[idx + 1];
|
||||||
|
|
||||||
|
const newFields = template.fields.map((f) => {
|
||||||
|
if (f.id === field.id) return { ...f, sortOrder: swapWith.sortOrder };
|
||||||
|
if (f.id === swapWith.id) return { ...f, sortOrder: field.sortOrder };
|
||||||
|
return f;
|
||||||
|
});
|
||||||
|
|
||||||
|
setTemplate({ ...template, fields: newFields });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/form-templates/${id}/fields/${field.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ sortOrder: swapWith.sortOrder }),
|
||||||
|
});
|
||||||
|
await fetch(`/api/form-templates/${id}/fields/${swapWith.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ sortOrder: field.sortOrder }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to reorder fields");
|
||||||
|
fetchTemplate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSelectedField = (updates: Partial<TemplateField>) => {
|
||||||
|
if (!selectedFieldId || !template) return;
|
||||||
|
setTemplate({
|
||||||
|
...template,
|
||||||
|
fields: template.fields.map((f) =>
|
||||||
|
f.id === selectedFieldId ? { ...f, ...updates } : f
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !template) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
render={<Link href="/settings/forms" />}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
Back to templates
|
||||||
|
</Button>
|
||||||
|
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
|
||||||
|
{error ?? "Template not found."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
render={<Link href="/settings/forms" />}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Header title={template.name} icon={FileText} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Template Settings */}
|
||||||
|
<Card className="glass-card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Template Settings</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Update the template name, description, and status.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input
|
||||||
|
value={template.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTemplate({ ...template, name: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Input
|
||||||
|
value={template.description ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTemplate({
|
||||||
|
...template,
|
||||||
|
description: e.target.value || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Optional description..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>Active</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Inactive templates won't appear when creating new cards.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={template.isActive}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setTemplate({ ...template, isActive: !!checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSaveTemplate} disabled={savingTemplate}>
|
||||||
|
{savingTemplate ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
Save Settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Field Editor Layout */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||||
|
{/* Left Panel - Field List */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">Fields</h2>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => setShowAddForm((v) => !v)}
|
||||||
|
>
|
||||||
|
{showAddForm ? (
|
||||||
|
<>
|
||||||
|
<X className="size-3" />
|
||||||
|
Cancel
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="size-3" />
|
||||||
|
Add Field
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAddForm && (
|
||||||
|
<Card className="glass-card">
|
||||||
|
<CardContent className="space-y-3 pt-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Label</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. Phone Number"
|
||||||
|
value={newField.label}
|
||||||
|
onChange={(e) => {
|
||||||
|
const label = e.target.value;
|
||||||
|
setNewField({
|
||||||
|
...newField,
|
||||||
|
label,
|
||||||
|
key: label
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "_")
|
||||||
|
.replace(/^_|_$/g, ""),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Key</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="phone_number"
|
||||||
|
value={newField.key}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewField({ ...newField, key: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Type</Label>
|
||||||
|
<Select
|
||||||
|
value={newField.type}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setNewField({ ...newField, type: v as string })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FIELD_TYPES.map((ft) => (
|
||||||
|
<SelectItem key={ft.value} value={ft.value}>
|
||||||
|
{ft.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Section</Label>
|
||||||
|
<Select
|
||||||
|
value={newField.section}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setNewField({ ...newField, section: v as string })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SECTIONS.map((s) => (
|
||||||
|
<SelectItem key={s.value} value={s.value}>
|
||||||
|
{s.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddField}
|
||||||
|
disabled={
|
||||||
|
addingField ||
|
||||||
|
!newField.label.trim() ||
|
||||||
|
!newField.key.trim()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{addingField && (
|
||||||
|
<Loader2 className="mr-1 size-3 animate-spin" />
|
||||||
|
)}
|
||||||
|
Add Field
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Object.keys(fieldsBySection).length === 0 ? (
|
||||||
|
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
|
||||||
|
No fields yet. Add your first field to get started.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{SECTIONS.map((section) => {
|
||||||
|
const fields = fieldsBySection[section.value];
|
||||||
|
if (!fields || fields.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={section.value}>
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{section.label}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{fields.map((field, idx) => (
|
||||||
|
<button
|
||||||
|
key={field.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedFieldId(field.id)}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
selectedFieldId === field.id
|
||||||
|
? "bg-primary/10 text-primary ring-1 ring-primary/20"
|
||||||
|
: "hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<GripVertical className="size-3.5 shrink-0 text-muted-foreground/50" />
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium">
|
||||||
|
{field.label}
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{field.isRequired && (
|
||||||
|
<Asterisk className="size-3 text-destructive" />
|
||||||
|
)}
|
||||||
|
{field.isCore && (
|
||||||
|
<Lock className="size-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{field.type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleReorder(field, "up");
|
||||||
|
}}
|
||||||
|
disabled={idx === 0}
|
||||||
|
className="size-5"
|
||||||
|
>
|
||||||
|
<ChevronUp className="size-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleReorder(field, "down");
|
||||||
|
}}
|
||||||
|
disabled={idx === fields.length - 1}
|
||||||
|
className="size-5"
|
||||||
|
>
|
||||||
|
<ChevronDown className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Panel - Field Editor */}
|
||||||
|
<div>
|
||||||
|
{selectedField ? (
|
||||||
|
<FieldEditor
|
||||||
|
field={selectedField}
|
||||||
|
isCore={selectedField.isCore}
|
||||||
|
onChange={updateSelectedField}
|
||||||
|
onSave={handleSaveField}
|
||||||
|
onDelete={() => handleDeleteField(selectedField)}
|
||||||
|
saving={savingField}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="glass-card flex flex-col items-center gap-2 rounded-2xl p-12 text-center">
|
||||||
|
<FileText className="size-8 text-muted-foreground/30" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Select a field to edit its properties
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldEditor({
|
||||||
|
field,
|
||||||
|
isCore,
|
||||||
|
onChange,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
saving,
|
||||||
|
}: {
|
||||||
|
field: TemplateField;
|
||||||
|
isCore: boolean;
|
||||||
|
onChange: (updates: Partial<TemplateField>) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
saving: boolean;
|
||||||
|
}) {
|
||||||
|
const [optionInput, setOptionInput] = React.useState("");
|
||||||
|
|
||||||
|
const addOption = () => {
|
||||||
|
const val = optionInput.trim();
|
||||||
|
if (!val) return;
|
||||||
|
const current = field.options ?? [];
|
||||||
|
if (!current.includes(val)) {
|
||||||
|
onChange({ options: [...current, val] });
|
||||||
|
}
|
||||||
|
setOptionInput("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeOption = (opt: string) => {
|
||||||
|
onChange({ options: (field.options ?? []).filter((o) => o !== opt) });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="glass-card sticky top-4">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">Edit Field</CardTitle>
|
||||||
|
{!isCore && (
|
||||||
|
<Button variant="destructive" size="xs" onClick={onDelete}>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
{isCore
|
||||||
|
? "Core fields cannot be deleted or have their key changed."
|
||||||
|
: "Configure this field's properties."}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Label</Label>
|
||||||
|
<Input
|
||||||
|
value={field.label}
|
||||||
|
onChange={(e) => onChange({ label: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Key</Label>
|
||||||
|
<Input value={field.key} disabled={isCore} className={isCore ? "opacity-60" : ""} onChange={(e) => !isCore && onChange({ key: e.target.value })} />
|
||||||
|
{isCore && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Core field keys cannot be changed.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select
|
||||||
|
value={field.type}
|
||||||
|
onValueChange={(v) => onChange({ type: v as string })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FIELD_TYPES.map((ft) => (
|
||||||
|
<SelectItem key={ft.value} value={ft.value}>
|
||||||
|
{ft.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Section</Label>
|
||||||
|
<Select
|
||||||
|
value={field.section}
|
||||||
|
onValueChange={(v) => onChange({ section: v as string })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SECTIONS.map((s) => (
|
||||||
|
<SelectItem key={s.value} value={s.value}>
|
||||||
|
{s.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Placeholder</Label>
|
||||||
|
<Input
|
||||||
|
value={field.placeholder ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ placeholder: e.target.value || null })
|
||||||
|
}
|
||||||
|
placeholder="Optional placeholder text..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Help Text</Label>
|
||||||
|
<Textarea
|
||||||
|
value={field.helpText ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ helpText: e.target.value || null })
|
||||||
|
}
|
||||||
|
placeholder="Optional help text shown below the field..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{HAS_OPTIONS.has(field.type) && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Options</Label>
|
||||||
|
{field.options && field.options.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{field.options.map((opt) => (
|
||||||
|
<span
|
||||||
|
key={opt}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full bg-secondary px-2.5 py-0.5 text-xs font-medium text-secondary-foreground"
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeOption(opt)}
|
||||||
|
className="rounded-full p-0.5 hover:bg-foreground/10"
|
||||||
|
>
|
||||||
|
<X className="size-2.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Add an option..."
|
||||||
|
value={optionInput}
|
||||||
|
onChange={(e) => setOptionInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
addOption();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addOption}
|
||||||
|
disabled={!optionInput.trim()}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>Required</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Field must be filled when submitting
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
checked={field.isRequired}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onChange({ isRequired: !!checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>Visible on Card</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Show this field on the connect card
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
checked={field.visibleOnCard}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onChange({ visibleOnCard: !!checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>Visible on Survey</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Show this field on the digital survey
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
checked={field.visibleOnSurvey}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onChange({ visibleOnSurvey: !!checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<Button onClick={onSave} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
Save Field
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
347
src/app/(dashboard)/settings/forms/page.tsx
Normal file
347
src/app/(dashboard)/settings/forms/page.tsx
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
Copy,
|
||||||
|
Trash2,
|
||||||
|
Pencil,
|
||||||
|
LayoutGrid,
|
||||||
|
ListChecks,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Header } from "@/components/layout/header";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
type FormTemplate = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
isDefault: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
_count?: { fields: number; cards: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FormTemplatesPage() {
|
||||||
|
const [templates, setTemplates] = React.useState<FormTemplate[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||||
|
const [creating, setCreating] = React.useState(false);
|
||||||
|
const [newName, setNewName] = React.useState("");
|
||||||
|
const [newDescription, setNewDescription] = React.useState("");
|
||||||
|
const [togglingId, setTogglingId] = React.useState<string | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = React.useState<string | null>(null);
|
||||||
|
const [duplicatingId, setDuplicatingId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchTemplates = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/form-templates");
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch");
|
||||||
|
const data = await res.json();
|
||||||
|
setTemplates(data.templates ?? []);
|
||||||
|
setError(null);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load form templates.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetchTemplates();
|
||||||
|
}, [fetchTemplates]);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/form-templates", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: newName.trim(),
|
||||||
|
description: newDescription.trim() || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to create");
|
||||||
|
toast.success("Template created");
|
||||||
|
setNewName("");
|
||||||
|
setNewDescription("");
|
||||||
|
setDialogOpen(false);
|
||||||
|
fetchTemplates();
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to create template");
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleActive = async (template: FormTemplate) => {
|
||||||
|
setTogglingId(template.id);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/form-templates/${template.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ isActive: !template.isActive }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to update");
|
||||||
|
setTemplates((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === template.id ? { ...t, isActive: !t.isActive } : t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
toast.success(
|
||||||
|
`Template ${!template.isActive ? "activated" : "deactivated"}`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to update template");
|
||||||
|
} finally {
|
||||||
|
setTogglingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = async (template: FormTemplate) => {
|
||||||
|
setDuplicatingId(template.id);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/form-templates/${template.id}/duplicate`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to duplicate");
|
||||||
|
toast.success("Template duplicated");
|
||||||
|
fetchTemplates();
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to duplicate template");
|
||||||
|
} finally {
|
||||||
|
setDuplicatingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (template: FormTemplate) => {
|
||||||
|
if (template.isDefault) {
|
||||||
|
toast.error("Cannot delete the default template");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeletingId(template.id);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/form-templates/${template.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to delete");
|
||||||
|
setTemplates((prev) => prev.filter((t) => t.id !== template.id));
|
||||||
|
toast.success("Template deleted");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to delete template");
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Header
|
||||||
|
title="Form Templates"
|
||||||
|
description="Define the fields for your connect cards and surveys"
|
||||||
|
icon={FileText}
|
||||||
|
>
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogTrigger
|
||||||
|
render={
|
||||||
|
<Button size="sm">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Create Template
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Template</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new form template with custom fields.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. Sunday Service Card"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCreate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Optional description..."
|
||||||
|
value={newDescription}
|
||||||
|
onChange={(e) => setNewDescription(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={creating || !newName.trim()}
|
||||||
|
>
|
||||||
|
{creating && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
{templates.length === 0 ? (
|
||||||
|
<div className="glass-card flex flex-col items-center gap-3 rounded-2xl p-12 text-center">
|
||||||
|
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||||
|
<FileText className="size-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">No templates yet</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Create your first form template to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="mt-2"
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Create Template
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{templates.map((template) => (
|
||||||
|
<Card key={template.id} className="glass-card">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<span className="truncate">{template.name}</span>
|
||||||
|
{template.isDefault && (
|
||||||
|
<Badge variant="secondary" className="shrink-0">
|
||||||
|
Default
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
{template.description && (
|
||||||
|
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{template.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
checked={template.isActive}
|
||||||
|
onCheckedChange={() => handleToggleActive(template)}
|
||||||
|
disabled={togglingId === template.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<ListChecks className="size-3.5" />
|
||||||
|
{template._count?.fields ?? 0} fields
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<LayoutGrid className="size-3.5" />
|
||||||
|
{template._count?.cards ?? 0} cards
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
render={<Link href={`/settings/forms/${template.id}`} />}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => handleDuplicate(template)}
|
||||||
|
disabled={duplicatingId === template.id}
|
||||||
|
>
|
||||||
|
{duplicatingId === template.id ? (
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-3" />
|
||||||
|
)}
|
||||||
|
Duplicate
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => handleDelete(template)}
|
||||||
|
disabled={
|
||||||
|
template.isDefault || deletingId === template.id
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{deletingId === template.id ? (
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
)}
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -62,6 +62,7 @@ export default function GeneralSettingsPage() {
|
||||||
imagesEligible: number;
|
imagesEligible: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [cleaning, setCleaning] = React.useState(false);
|
const [cleaning, setCleaning] = React.useState(false);
|
||||||
|
const [fetchError, setFetchError] = React.useState(false);
|
||||||
const [notifPrefs, setNotifPrefs] = React.useState<NotificationPrefs>(
|
const [notifPrefs, setNotifPrefs] = React.useState<NotificationPrefs>(
|
||||||
DEFAULT_NOTIFICATION_PREFS
|
DEFAULT_NOTIFICATION_PREFS
|
||||||
);
|
);
|
||||||
|
|
@ -78,14 +79,20 @@ export default function GeneralSettingsPage() {
|
||||||
setImageRetention(data.imageRetentionDays || 180);
|
setImageRetention(data.imageRetentionDays || 180);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch(() => setLoading(false));
|
.catch(() => {
|
||||||
|
setFetchError(true);
|
||||||
|
toast.error("Failed to load settings");
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
fetch("/api/cleanup")
|
fetch("/api/cleanup")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.sourcesEligible !== undefined) setCleanupStatus(data);
|
if (data.sourcesEligible !== undefined) setCleanupStatus(data);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {
|
||||||
|
toast.error("Failed to load cleanup status");
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateNotifPref = (key: keyof NotificationPrefs, value: boolean) => {
|
const updateNotifPref = (key: keyof NotificationPrefs, value: boolean) => {
|
||||||
|
|
@ -265,8 +272,11 @@ export default function GeneralSettingsPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex items-center justify-end gap-3">
|
||||||
<Button onClick={handleSave} disabled={saving}>
|
{fetchError && (
|
||||||
|
<p className="text-sm text-destructive">Settings failed to load — saving is disabled</p>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleSave} disabled={saving || fetchError}>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Users,
|
Users,
|
||||||
MapPin,
|
MapPin,
|
||||||
Building2,
|
Building2,
|
||||||
|
FileText,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Header } from "@/components/layout/header";
|
import { Header } from "@/components/layout/header";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -22,6 +23,7 @@ const SECTIONS = [
|
||||||
{ href: "/settings/users", label: "Team Members", icon: Users },
|
{ href: "/settings/users", label: "Team Members", icon: Users },
|
||||||
{ href: "/settings/locations", label: "Locations", icon: MapPin },
|
{ href: "/settings/locations", label: "Locations", icon: MapPin },
|
||||||
{ href: "/settings/organization", label: "Organization", icon: Building2 },
|
{ href: "/settings/organization", label: "Organization", icon: Building2 },
|
||||||
|
{ href: "/settings/forms", label: "Form Templates", icon: FileText },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsLayout({
|
export default function SettingsLayout({
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { generateText } from "ai";
|
||||||
import { gateway } from "@ai-sdk/gateway";
|
import { gateway } from "@ai-sdk/gateway";
|
||||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||||
import type { LanguageModel } from "ai";
|
import type { LanguageModel } from "ai";
|
||||||
|
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
function getTestModel(provider: string, modelId: string, ollamaUrl: string): LanguageModel {
|
function getTestModel(provider: string, modelId: string, ollamaUrl: string): LanguageModel {
|
||||||
if (provider === "ollama") {
|
if (provider === "ollama") {
|
||||||
|
|
@ -16,10 +18,13 @@ function getTestModel(provider: string, modelId: string, ollamaUrl: string): Lan
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
await requireApiAuth();
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const provider = body.provider || "gateway";
|
const provider = body.provider || "gateway";
|
||||||
const modelId = body.model || "";
|
const modelId = body.model || "";
|
||||||
const ollamaUrl = body.ollamaUrl || "";
|
|
||||||
|
const settings = await prisma.appSettings.findUnique({ where: { id: "singleton" } });
|
||||||
|
const ollamaUrl = settings?.ollamaUrl || "";
|
||||||
|
|
||||||
const model = getTestModel(provider, modelId, ollamaUrl);
|
const model = getTestModel(provider, modelId, ollamaUrl);
|
||||||
|
|
||||||
|
|
@ -30,9 +35,7 @@ export async function POST(request: NextRequest) {
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, response: text.trim() });
|
return NextResponse.json({ ok: true, response: text.trim() });
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
const message = err instanceof Error ? err.message : "Unknown error";
|
return handleApiError(error);
|
||||||
console.error("[ai-test]", message);
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
90
src/app/api/auth/forgot-password/route.ts
Normal file
90
src/app/api/auth/forgot-password/route.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { sendEmail } from "@/lib/email-sender";
|
||||||
|
|
||||||
|
const SITE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_SITE_URL || "https://echoocr.com";
|
||||||
|
|
||||||
|
const BRAND = {
|
||||||
|
dark: "#2d2b28",
|
||||||
|
accent: "#3d3a36",
|
||||||
|
muted: "#8a8580",
|
||||||
|
bg: "#f7f5f2",
|
||||||
|
cardBg: "#ffffff",
|
||||||
|
border: "#e8e4df",
|
||||||
|
cta: "#2d2b28",
|
||||||
|
ctaText: "#fafaf8",
|
||||||
|
link: "#5c5650",
|
||||||
|
font: "'Quicksand', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
|
||||||
|
};
|
||||||
|
|
||||||
|
function resetEmailHtml(resetUrl: string) {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Quicksand:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
||||||
|
<title>Reset your password</title></head>
|
||||||
|
<body style="margin:0;padding:0;background-color:${BRAND.bg};font-family:${BRAND.font};-webkit-font-smoothing:antialiased;">
|
||||||
|
<div style="display:none;max-height:0;overflow:hidden;">Reset your Echo password͏‌ </div>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color:${BRAND.bg};">
|
||||||
|
<tr><td align="center" style="padding:40px 16px 32px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:520px;">
|
||||||
|
<tr><td align="center" style="padding-bottom:32px;">
|
||||||
|
<span style="font-family:${BRAND.font};font-size:20px;font-weight:700;color:${BRAND.dark};">Echo</span>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:520px;background-color:${BRAND.cardBg};border-radius:16px;border:1px solid ${BRAND.border};box-shadow:0 1px 3px rgba(0,0,0,0.04);">
|
||||||
|
<tr><td style="padding:48px 40px 44px;">
|
||||||
|
<h1 style="margin:0 0 16px;font-family:${BRAND.font};font-size:24px;font-weight:700;color:${BRAND.dark};line-height:1.3;">Reset your password</h1>
|
||||||
|
<div style="margin:0 0 32px;font-family:${BRAND.font};font-size:15px;color:${BRAND.accent};line-height:1.65;">
|
||||||
|
<p style="margin:0 0 16px;">We received a request to reset the password for your Echo account. Click the button below to choose a new password.</p>
|
||||||
|
<p style="margin:0;">If you didn't request this, you can safely ignore this email.</p>
|
||||||
|
</div>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" width="100%"><tr><td>
|
||||||
|
<a href="${resetUrl}" target="_blank" style="display:inline-block;padding:14px 32px;background-color:${BRAND.cta};color:${BRAND.ctaText};font-family:${BRAND.font};font-size:15px;font-weight:600;text-decoration:none;border-radius:10px;">Reset Password</a>
|
||||||
|
</td></tr></table>
|
||||||
|
<p style="margin:28px 0 0;font-family:${BRAND.font};font-size:13px;color:${BRAND.muted};line-height:1.55;">This link expires in 1 hour. If you didn't request a password reset, no action is needed.</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:520px;">
|
||||||
|
<tr><td align="center" style="padding:28px 0 8px;">
|
||||||
|
<p style="margin:0;font-family:${BRAND.font};font-size:12px;color:${BRAND.muted};">AI-powered response card scanning for churches</p>
|
||||||
|
<p style="margin:6px 0 0;font-family:${BRAND.font};font-size:12px;"><a href="${SITE_URL}" style="color:${BRAND.link};text-decoration:underline;">echoocr.com</a></p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr></table>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { email } = await req.json();
|
||||||
|
|
||||||
|
if (!email || typeof email !== "string") {
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { email: email.toLowerCase().trim() } });
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
await prisma.passwordResetToken.deleteMany({ where: { email: user.email } });
|
||||||
|
|
||||||
|
const token = crypto.randomUUID();
|
||||||
|
await prisma.passwordResetToken.create({
|
||||||
|
data: {
|
||||||
|
email: user.email,
|
||||||
|
token,
|
||||||
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetUrl = `${SITE_URL}/reset-password?token=${token}`;
|
||||||
|
await sendEmail(user.email, "Reset your password — Echo", resetEmailHtml(resetUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[forgot-password] Error:", error);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
import { getOrCreateUser, type AppUser } from "@/lib/auth";
|
import { getOrCreateUser, type AppUser } from "@/lib/auth";
|
||||||
|
|
||||||
export type AuthentikUser = {
|
export type AuthentikUser = {
|
||||||
|
|
@ -74,3 +76,22 @@ export async function GET(req: NextRequest) {
|
||||||
|
|
||||||
return NextResponse.json({ authenticated: true, user, dbUser });
|
return NextResponse.json({ authenticated: true, user, dbUser });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { displayName, avatarUrl } = await req.json();
|
||||||
|
const data: Record<string, string> = {};
|
||||||
|
if (typeof displayName === "string") data.displayName = displayName;
|
||||||
|
if (typeof avatarUrl === "string") data.avatarUrl = avatarUrl;
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,12 @@ export async function POST(req: NextRequest) {
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (invitation.email.toLowerCase() !== email.toLowerCase()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Email does not match invitation" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
inviteRole = invitation.role;
|
inviteRole = invitation.role;
|
||||||
inviteOrgId = invitation.organizationId;
|
inviteOrgId = invitation.organizationId;
|
||||||
|
|
|
||||||
38
src/app/api/auth/reset-password/route.ts
Normal file
38
src/app/api/auth/reset-password/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { token, password } = await req.json();
|
||||||
|
|
||||||
|
if (!token || !password || typeof password !== "string" || password.length < 8) {
|
||||||
|
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetToken = await prisma.passwordResetToken.findUnique({
|
||||||
|
where: { token },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resetToken || resetToken.expiresAt < new Date()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "This reset link has expired or is invalid. Please request a new one." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { email: resetToken.email },
|
||||||
|
data: { hashedPassword },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.passwordResetToken.delete({ where: { id: resetToken.id } });
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[reset-password] Error:", error);
|
||||||
|
return NextResponse.json({ error: "Something went wrong" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,24 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
import { getCardActivity } from "@/lib/activity-log";
|
import { getCardActivity } from "@/lib/activity-log";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
|
const card = await prisma.responseCard.findUnique({ where: { id }, select: { organizationId: true } });
|
||||||
|
if (!card || card.organizationId !== session.user.orgId) {
|
||||||
|
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
const activity = await getCardActivity(id);
|
const activity = await getCardActivity(id);
|
||||||
return NextResponse.json(activity);
|
return NextResponse.json(activity);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[cards/[id]/activity GET]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch activity" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { fireIntegrationEvent } from "@/lib/integrations";
|
import { fireIntegrationEvent } from "@/lib/integrations";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const card = await prisma.responseCard.findUnique({
|
const card = await prisma.responseCard.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!card) {
|
if (!card || card.organizationId !== session.user.orgId) {
|
||||||
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,10 +29,6 @@ export async function POST(
|
||||||
|
|
||||||
return NextResponse.json(updated);
|
return NextResponse.json(updated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[cards/[id]/export POST]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to mark card as exported" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { reprocessCard } from "@/lib/ocr";
|
import { reprocessCard } from "@/lib/ocr";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const card = await prisma.responseCard.findUnique({ where: { id } });
|
const card = await prisma.responseCard.findUnique({ where: { id } });
|
||||||
|
|
||||||
if (!card) {
|
if (!card || card.organizationId !== session.user.orgId) {
|
||||||
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,10 +29,6 @@ export async function POST(
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[cards/[id]/reprocess POST]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to start reprocessing" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,26 @@ import { prisma } from "@/lib/db";
|
||||||
import { deleteObject } from "@/lib/storage";
|
import { deleteObject } from "@/lib/storage";
|
||||||
import { fireIntegrationEvent } from "@/lib/integrations";
|
import { fireIntegrationEvent } from "@/lib/integrations";
|
||||||
import { logActivity, diffCardFields } from "@/lib/activity-log";
|
import { logActivity, diffCardFields } from "@/lib/activity-log";
|
||||||
import { getOrCreateUser, RoleError } from "@/lib/auth";
|
import { RoleError } from "@/lib/auth";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const card = await prisma.responseCard.findUnique({
|
const card = await prisma.responseCard.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
include: {
|
||||||
|
formTemplate: {
|
||||||
|
include: { fields: { orderBy: { sortOrder: "asc" } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!card) {
|
if (!card || card.organizationId !== session.user.orgId) {
|
||||||
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,11 +39,7 @@ export async function GET(
|
||||||
backImageUrl,
|
backImageUrl,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[cards/[id] GET]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch card" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,31 +48,32 @@ export async function PUT(
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const user = await getOrCreateUser(request.headers);
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const user = session.user;
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const card = await prisma.responseCard.findUnique({
|
const card = await prisma.responseCard.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!card) {
|
if (!card || card.organizationId !== session.user.orgId) {
|
||||||
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user) {
|
|
||||||
if (user.role === "viewer") {
|
if (user.role === "viewer") {
|
||||||
return NextResponse.json({ error: "Viewers cannot edit cards" }, { status: 403 });
|
return NextResponse.json({ error: "Viewers cannot edit cards" }, { status: 403 });
|
||||||
}
|
}
|
||||||
if (user.role === "reviewer" && card.assignedToId !== user.id) {
|
if (user.role === "reviewer" && card.assignedToId !== user.id) {
|
||||||
return NextResponse.json({ error: "You can only edit cards assigned to you" }, { status: 403 });
|
return NextResponse.json({ error: "You can only edit cards assigned to you" }, { status: 403 });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const data: Record<string, unknown> = {};
|
const data: Record<string, unknown> = {};
|
||||||
|
|
||||||
const stringFields = [
|
const stringFields = [
|
||||||
"name",
|
"name",
|
||||||
|
"firstName",
|
||||||
|
"lastName",
|
||||||
"gender",
|
"gender",
|
||||||
"dateOfBirth",
|
"dateOfBirth",
|
||||||
"maritalStatus",
|
"maritalStatus",
|
||||||
|
|
@ -118,6 +122,7 @@ export async function PUT(
|
||||||
if (body.campusPreference != null) data.campusPreference = body.campusPreference;
|
if (body.campusPreference != null) data.campusPreference = body.campusPreference;
|
||||||
if (body.howHeard != null) data.howHeard = body.howHeard;
|
if (body.howHeard != null) data.howHeard = body.howHeard;
|
||||||
if (body.rawOcrResponse != null) data.rawOcrResponse = body.rawOcrResponse;
|
if (body.rawOcrResponse != null) data.rawOcrResponse = body.rawOcrResponse;
|
||||||
|
if (body.fieldData !== undefined) data.fieldData = body.fieldData;
|
||||||
|
|
||||||
for (const assignField of ["assignedToId", "assignedById", "reviewedById"] as const) {
|
for (const assignField of ["assignedToId", "assignedById", "reviewedById"] as const) {
|
||||||
if (body[assignField] !== undefined) {
|
if (body[assignField] !== undefined) {
|
||||||
|
|
@ -129,7 +134,7 @@ export async function PUT(
|
||||||
data.reviewStatus = "in_review";
|
data.reviewStatus = "in_review";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.reviewStatus === "reviewed" && user) {
|
if (body.reviewStatus === "reviewed") {
|
||||||
data.reviewedById = user.id;
|
data.reviewedById = user.id;
|
||||||
data.reviewedAt = new Date();
|
data.reviewedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
@ -150,7 +155,7 @@ export async function PUT(
|
||||||
"user",
|
"user",
|
||||||
`${changes.length} field(s) updated manually`,
|
`${changes.length} field(s) updated manually`,
|
||||||
changes,
|
changes,
|
||||||
user?.id
|
user.id
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,11 +174,7 @@ export async function PUT(
|
||||||
if (error instanceof RoleError) {
|
if (error instanceof RoleError) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 403 });
|
return NextResponse.json({ error: error.message }, { status: 403 });
|
||||||
}
|
}
|
||||||
console.error("[cards/[id] PUT]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to update card" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,8 +183,8 @@ export async function DELETE(
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const user = await getOrCreateUser(request.headers);
|
const session = await requireApiAuthWithOrg();
|
||||||
if (user && user.role !== "admin" && user.role !== "owner") {
|
if (session.user.role !== "admin" && session.user.role !== "owner") {
|
||||||
return NextResponse.json({ error: "Only admins can delete cards" }, { status: 403 });
|
return NextResponse.json({ error: "Only admins can delete cards" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,7 +193,7 @@ export async function DELETE(
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!card) {
|
if (!card || card.organizationId !== session.user.orgId) {
|
||||||
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,10 +210,6 @@ export async function DELETE(
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[cards/[id] DELETE]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to delete card" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { getOrCreateUser, requireRole, RoleError } from "@/lib/auth";
|
|
||||||
import { createNotification } from "@/lib/notifications";
|
import { createNotification } from "@/lib/notifications";
|
||||||
import { logActivity } from "@/lib/activity-log";
|
import { logActivity } from "@/lib/activity-log";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getOrCreateUser(req.headers);
|
const session = await requireApiAuthWithOrg();
|
||||||
requireRole(user, "admin");
|
const role = session.user.role;
|
||||||
|
if (!role || !["admin", "owner"].includes(role)) {
|
||||||
|
return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({ where: { id: session.user.id } });
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { cardIds, assignToUserId } = body as {
|
const { cardIds, assignToUserId } = body as {
|
||||||
|
|
@ -30,10 +34,10 @@ export async function POST(req: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.responseCard.updateMany({
|
await prisma.responseCard.updateMany({
|
||||||
where: { id: { in: cardIds } },
|
where: { id: { in: cardIds }, organizationId: session.user.orgId },
|
||||||
data: {
|
data: {
|
||||||
assignedToId: assignToUserId,
|
assignedToId: assignToUserId,
|
||||||
assignedById: user!.id,
|
assignedById: user.id,
|
||||||
assignedAt: new Date(),
|
assignedAt: new Date(),
|
||||||
reviewStatus: "assigned",
|
reviewStatus: "assigned",
|
||||||
},
|
},
|
||||||
|
|
@ -44,16 +48,16 @@ export async function POST(req: NextRequest) {
|
||||||
cardId,
|
cardId,
|
||||||
"assignment",
|
"assignment",
|
||||||
"user",
|
"user",
|
||||||
`Assigned to ${assignee.displayName} by ${user!.displayName}`,
|
`Assigned to ${assignee.displayName} by ${user.displayName}`,
|
||||||
[{ field: "assignedToId", from: null, to: assignToUserId }],
|
[{ field: "assignedToId", from: null, to: assignToUserId }],
|
||||||
user!.id
|
user.id
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
await createNotification({
|
await createNotification({
|
||||||
type: "card_assigned",
|
type: "card_assigned",
|
||||||
title: "Cards Assigned to You",
|
title: "Cards Assigned to You",
|
||||||
message: `${cardIds.length} card(s) assigned by ${user!.displayName}`,
|
message: `${cardIds.length} card(s) assigned by ${user.displayName}`,
|
||||||
actionUrl: "/?assignedToId=me",
|
actionUrl: "/?assignedToId=me",
|
||||||
userId: assignToUserId,
|
userId: assignToUserId,
|
||||||
});
|
});
|
||||||
|
|
@ -64,10 +68,6 @@ export async function POST(req: NextRequest) {
|
||||||
assignee: assignee.displayName,
|
assignee: assignee.displayName,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof RoleError) {
|
return handleApiError(error);
|
||||||
return NextResponse.json({ error: error.message }, { status: 403 });
|
|
||||||
}
|
|
||||||
console.error("[cards/assign POST]", error);
|
|
||||||
return NextResponse.json({ error: "Assignment failed" }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,27 @@ import { prisma } from "@/lib/db";
|
||||||
import { getBuffer, uploadBuffer } from "@/lib/storage";
|
import { getBuffer, uploadBuffer } from "@/lib/storage";
|
||||||
import { pdfToImages, imageToBase64 } from "@/lib/pdf";
|
import { pdfToImages, imageToBase64 } from "@/lib/pdf";
|
||||||
import { ocrImage } from "@/lib/ai-ocr";
|
import { ocrImage } from "@/lib/ai-ocr";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
const DELAY_BETWEEN_OCR_MS = 3_000;
|
const DELAY_BETWEEN_OCR_MS = 3_000;
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
const missing = await prisma.responseCard.count({
|
const missing = await prisma.responseCard.count({
|
||||||
where: { frontImagePath: null, backImagePath: { not: null } },
|
where: { frontImagePath: null, backImagePath: { not: null }, organizationId: session.user.orgId },
|
||||||
});
|
});
|
||||||
return NextResponse.json({ missingCount: missing });
|
return NextResponse.json({ missingCount: missing });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
try {
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
const cards = await prisma.responseCard.findMany({
|
const cards = await prisma.responseCard.findMany({
|
||||||
where: { frontImagePath: null, backImagePath: { not: null } },
|
where: { frontImagePath: null, backImagePath: { not: null }, organizationId: session.user.orgId },
|
||||||
select: { id: true, sourceFile: true, name: true },
|
select: { id: true, sourceFile: true, name: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -134,8 +141,7 @@ export async function POST() {
|
||||||
message: `Recovery started for ${cards.length} card(s) from ${sourceFiles.length} PDF(s)`,
|
message: `Recovery started for ${cards.length} card(s) from ${sourceFiles.length} PDF(s)`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[recover-survey POST]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json({ error: "Recovery failed" }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { reprocessCard } from "@/lib/ocr";
|
import { reprocessCard } from "@/lib/ocr";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
const DELAY_BETWEEN_CARDS_MS = 3_000;
|
const DELAY_BETWEEN_CARDS_MS = 3_000;
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const ids = body.ids as string[] | undefined;
|
const ids = body.ids as string[] | undefined;
|
||||||
|
|
||||||
|
|
@ -14,7 +16,7 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const cards = await prisma.responseCard.findMany({
|
const cards = await prisma.responseCard.findMany({
|
||||||
where: { id: { in: ids } },
|
where: { id: { in: ids }, organizationId: session.user.orgId },
|
||||||
select: { id: true, ocrStatus: true, backImagePath: true, frontImagePath: true },
|
select: { id: true, ocrStatus: true, backImagePath: true, frontImagePath: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -30,7 +32,7 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.responseCard.updateMany({
|
await prisma.responseCard.updateMany({
|
||||||
where: { id: { in: eligible.map((c) => c.id) } },
|
where: { id: { in: eligible.map((c) => c.id) }, organizationId: session.user.orgId },
|
||||||
data: { ocrStatus: "processing", ocrError: null },
|
data: { ocrStatus: "processing", ocrError: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -56,7 +58,6 @@ export async function POST(request: NextRequest) {
|
||||||
skipped: ids.length - eligible.length,
|
skipped: ids.length - eligible.length,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[reprocess-batch POST]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json({ error: "Failed to start batch reprocessing" }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { getOrCreateUser } from "@/lib/auth";
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
|
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
|
||||||
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10)));
|
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10)));
|
||||||
|
|
@ -17,8 +19,6 @@ export async function GET(request: NextRequest) {
|
||||||
const sortBy = searchParams.get("sortBy") ?? "createdAt";
|
const sortBy = searchParams.get("sortBy") ?? "createdAt";
|
||||||
const sortOrder = searchParams.get("sortOrder") ?? "desc";
|
const sortOrder = searchParams.get("sortOrder") ?? "desc";
|
||||||
|
|
||||||
const user = await getOrCreateUser(request.headers);
|
|
||||||
|
|
||||||
const validSortFields = [
|
const validSortFields = [
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"updatedAt",
|
"updatedAt",
|
||||||
|
|
@ -33,7 +33,9 @@ export async function GET(request: NextRequest) {
|
||||||
const orderByField = validSortFields.includes(sortBy) ? sortBy : "createdAt";
|
const orderByField = validSortFields.includes(sortBy) ? sortBy : "createdAt";
|
||||||
const order = sortOrder === "asc" ? "asc" : "desc";
|
const order = sortOrder === "asc" ? "asc" : "desc";
|
||||||
|
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {
|
||||||
|
organizationId: session.user.orgId,
|
||||||
|
};
|
||||||
|
|
||||||
if (ocrStatus) where.ocrStatus = ocrStatus;
|
if (ocrStatus) where.ocrStatus = ocrStatus;
|
||||||
if (reviewStatus) where.reviewStatus = reviewStatus;
|
if (reviewStatus) where.reviewStatus = reviewStatus;
|
||||||
|
|
@ -41,8 +43,8 @@ export async function GET(request: NextRequest) {
|
||||||
if (visitType) where.visitType = visitType;
|
if (visitType) where.visitType = visitType;
|
||||||
if (serviceAttended) where.serviceAttended = serviceAttended;
|
if (serviceAttended) where.serviceAttended = serviceAttended;
|
||||||
|
|
||||||
if (assignedToId === "me" && user) {
|
if (assignedToId === "me") {
|
||||||
where.assignedToId = user.id;
|
where.assignedToId = session.user.id;
|
||||||
} else if (assignedToId === "unassigned") {
|
} else if (assignedToId === "unassigned") {
|
||||||
where.assignedToId = null;
|
where.assignedToId = null;
|
||||||
} else if (assignedToId) {
|
} else if (assignedToId) {
|
||||||
|
|
@ -74,18 +76,18 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
return NextResponse.json({ cards, total, page, limit });
|
return NextResponse.json({ cards, total, page, limit });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[cards GET]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch cards" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const data: Record<string, unknown> = {};
|
const data: Record<string, unknown> = {
|
||||||
|
organizationId: session.user.orgId,
|
||||||
|
};
|
||||||
|
|
||||||
const stringFields = [
|
const stringFields = [
|
||||||
"name",
|
"name",
|
||||||
|
|
@ -134,10 +136,6 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
return NextResponse.json(card, { status: 201 });
|
return NextResponse.json(card, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[cards POST]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to create card" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { deleteObject, listObjects } from "@/lib/storage";
|
import { deleteObject, listObjects } from "@/lib/storage";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const orgId = session.user.orgId;
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const dryRun = body.dryRun === true;
|
const dryRun = body.dryRun === true;
|
||||||
|
|
||||||
|
|
@ -31,7 +34,7 @@ export async function POST(request: NextRequest) {
|
||||||
// 1. Purge source PDFs older than sourceRetentionDays
|
// 1. Purge source PDFs older than sourceRetentionDays
|
||||||
const sourceKeys = await listObjects("sources/");
|
const sourceKeys = await listObjects("sources/");
|
||||||
const jobsWithSources = await prisma.processingJob.findMany({
|
const jobsWithSources = await prisma.processingJob.findMany({
|
||||||
where: { createdAt: { lt: sourcesCutoff }, status: "complete" },
|
where: { createdAt: { lt: sourcesCutoff }, status: "complete", organizationId: orgId },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
const oldJobIds = new Set(jobsWithSources.map((j) => j.id));
|
const oldJobIds = new Set(jobsWithSources.map((j) => j.id));
|
||||||
|
|
@ -46,7 +49,7 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
// 2. Purge card images older than imageRetentionDays
|
// 2. Purge card images older than imageRetentionDays
|
||||||
const oldCards = await prisma.responseCard.findMany({
|
const oldCards = await prisma.responseCard.findMany({
|
||||||
where: { createdAt: { lt: imagesCutoff } },
|
where: { createdAt: { lt: imagesCutoff }, organizationId: orgId },
|
||||||
select: { id: true, frontImagePath: true, backImagePath: true },
|
select: { id: true, frontImagePath: true, backImagePath: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -71,27 +74,25 @@ export async function POST(request: NextRequest) {
|
||||||
// 3. Purge completed processing jobs older than sourceRetentionDays
|
// 3. Purge completed processing jobs older than sourceRetentionDays
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
const deleted = await prisma.processingJob.deleteMany({
|
const deleted = await prisma.processingJob.deleteMany({
|
||||||
where: { createdAt: { lt: sourcesCutoff }, status: { in: ["complete", "error"] } },
|
where: { createdAt: { lt: sourcesCutoff }, status: { in: ["complete", "error"] }, organizationId: orgId },
|
||||||
});
|
});
|
||||||
results.jobsPurged = deleted.count;
|
results.jobsPurged = deleted.count;
|
||||||
} else {
|
} else {
|
||||||
results.jobsPurged = await prisma.processingJob.count({
|
results.jobsPurged = await prisma.processingJob.count({
|
||||||
where: { createdAt: { lt: sourcesCutoff }, status: { in: ["complete", "error"] } },
|
where: { createdAt: { lt: sourcesCutoff }, status: { in: ["complete", "error"] }, organizationId: orgId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(results);
|
return NextResponse.json(results);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[cleanup POST]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Cleanup failed" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const orgId = session.user.orgId;
|
||||||
let settings = await prisma.appSettings.findUnique({
|
let settings = await prisma.appSettings.findUnique({
|
||||||
where: { id: "singleton" },
|
where: { id: "singleton" },
|
||||||
});
|
});
|
||||||
|
|
@ -106,11 +107,12 @@ export async function GET() {
|
||||||
const imagesCutoff = new Date(now.getTime() - settings.imageRetentionDays * 86400000);
|
const imagesCutoff = new Date(now.getTime() - settings.imageRetentionDays * 86400000);
|
||||||
|
|
||||||
const sourcesEligible = await prisma.processingJob.count({
|
const sourcesEligible = await prisma.processingJob.count({
|
||||||
where: { createdAt: { lt: sourcesCutoff }, status: "complete" },
|
where: { createdAt: { lt: sourcesCutoff }, status: "complete", organizationId: orgId },
|
||||||
});
|
});
|
||||||
const imagesEligible = await prisma.responseCard.count({
|
const imagesEligible = await prisma.responseCard.count({
|
||||||
where: {
|
where: {
|
||||||
createdAt: { lt: imagesCutoff },
|
createdAt: { lt: imagesCutoff },
|
||||||
|
organizationId: orgId,
|
||||||
OR: [
|
OR: [
|
||||||
{ frontImagePath: { not: null } },
|
{ frontImagePath: { not: null } },
|
||||||
{ backImagePath: { not: null } },
|
{ backImagePath: { not: null } },
|
||||||
|
|
@ -125,10 +127,6 @@ export async function GET() {
|
||||||
imagesEligible,
|
imagesEligible,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[cleanup GET]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to check cleanup status" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
36
src/app/api/collection-days/[id]/route.ts
Normal file
36
src/app/api/collection-days/[id]/route.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireAuth } from "@/lib/auth";
|
||||||
|
import { PermissionError } from "@/lib/permissions";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth();
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const collectionDay = await prisma.collectionDay.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
location: { organizationId: user.orgId },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
location: { select: { id: true, name: true } },
|
||||||
|
_count: { select: { cards: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!collectionDay) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(collectionDay);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PermissionError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "Failed to fetch collection day" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
|
||||||
import { scanInbox } from "@/lib/email-watcher";
|
import { scanInbox } from "@/lib/email-watcher";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
if (!process.env.CRON_SECRET) {
|
||||||
|
return NextResponse.json({ error: "CRON_SECRET not configured" }, { status: 500 });
|
||||||
|
}
|
||||||
const authHeader = request.headers.get("authorization");
|
const authHeader = request.headers.get("authorization");
|
||||||
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
|
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { scanInbox } from "@/lib/email-watcher";
|
import { scanInbox } from "@/lib/email-watcher";
|
||||||
|
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
await requireApiAuth();
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const action = body.action as string | undefined;
|
const action = body.action as string | undefined;
|
||||||
|
|
||||||
|
|
@ -55,9 +57,6 @@ export async function POST(request: NextRequest) {
|
||||||
const updated = await prisma.appSettings.findUnique({ where: { id: "singleton" } });
|
const updated = await prisma.appSettings.findUnique({ where: { id: "singleton" } });
|
||||||
return NextResponse.json({ emailWatching: updated?.emailWatching ?? false });
|
return NextResponse.json({ emailWatching: updated?.emailWatching ?? false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[email-watch POST]", error);
|
return handleApiError(error);
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : "Failed to update email watch";
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { testEmailConnection } from "@/lib/email-watcher";
|
import { testEmailConnection } from "@/lib/email-watcher";
|
||||||
|
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
await requireApiAuth();
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
|
|
||||||
const host = String(body.host || "");
|
const host = String(body.host || "");
|
||||||
|
|
@ -33,9 +35,6 @@ export async function POST(request: NextRequest) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[email-watch/test POST]", error);
|
return handleApiError(error);
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : "Connection test failed";
|
|
||||||
return NextResponse.json({ ok: false, error: message }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
102
src/app/api/form-templates/[id]/fields/[fieldId]/route.ts
Normal file
102
src/app/api/form-templates/[id]/fields/[fieldId]/route.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ id: string; fieldId: string }> };
|
||||||
|
|
||||||
|
async function getOwnedField(templateId: string, fieldId: string, orgId: string) {
|
||||||
|
const template = await prisma.formTemplate.findUnique({
|
||||||
|
where: { id: templateId },
|
||||||
|
select: { organizationId: true },
|
||||||
|
});
|
||||||
|
if (!template || template.organizationId !== orgId) return null;
|
||||||
|
|
||||||
|
const field = await prisma.formField.findUnique({
|
||||||
|
where: { id: fieldId },
|
||||||
|
});
|
||||||
|
if (!field || field.formTemplateId !== templateId) return null;
|
||||||
|
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, ctx: RouteContext) {
|
||||||
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const { id, fieldId } = await ctx.params;
|
||||||
|
|
||||||
|
const field = await getOwnedField(id, fieldId, session.user.orgId!);
|
||||||
|
if (!field) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Field not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const {
|
||||||
|
label, type, section, required, removable, options,
|
||||||
|
placeholder, helpText, validation, visibleOnCard, visibleOnSurvey,
|
||||||
|
} = body as {
|
||||||
|
label?: string;
|
||||||
|
type?: string;
|
||||||
|
section?: string;
|
||||||
|
required?: boolean;
|
||||||
|
removable?: boolean;
|
||||||
|
options?: unknown;
|
||||||
|
placeholder?: string;
|
||||||
|
helpText?: string;
|
||||||
|
validation?: unknown;
|
||||||
|
visibleOnCard?: boolean;
|
||||||
|
visibleOnSurvey?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
if (label !== undefined) data.label = label.trim();
|
||||||
|
if (type !== undefined) data.type = type.trim();
|
||||||
|
if (section !== undefined) data.section = section.trim();
|
||||||
|
if (required !== undefined) data.required = Boolean(required);
|
||||||
|
if (removable !== undefined) data.removable = Boolean(removable);
|
||||||
|
if (options !== undefined) data.options = options;
|
||||||
|
if (placeholder !== undefined) data.placeholder = placeholder?.trim() || null;
|
||||||
|
if (helpText !== undefined) data.helpText = helpText?.trim() || null;
|
||||||
|
if (validation !== undefined) data.validation = validation;
|
||||||
|
if (visibleOnCard !== undefined) data.visibleOnCard = Boolean(visibleOnCard);
|
||||||
|
if (visibleOnSurvey !== undefined) data.visibleOnSurvey = Boolean(visibleOnSurvey);
|
||||||
|
|
||||||
|
const updated = await prisma.formField.update({
|
||||||
|
where: { id: fieldId },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: NextRequest, ctx: RouteContext) {
|
||||||
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const { id, fieldId } = await ctx.params;
|
||||||
|
|
||||||
|
const field = await getOwnedField(id, fieldId, session.user.orgId!);
|
||||||
|
if (!field) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Field not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.isCore) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Core fields cannot be deleted" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.formField.delete({ where: { id: fieldId } });
|
||||||
|
return NextResponse.json({ deleted: true });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
144
src/app/api/form-templates/[id]/fields/route.ts
Normal file
144
src/app/api/form-templates/[id]/fields/route.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
async function verifyTemplateOwnership(templateId: string, orgId: string) {
|
||||||
|
const template = await prisma.formTemplate.findUnique({
|
||||||
|
where: { id: templateId },
|
||||||
|
select: { id: true, organizationId: true },
|
||||||
|
});
|
||||||
|
if (!template || template.organizationId !== orgId) return null;
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, ctx: RouteContext) {
|
||||||
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
|
||||||
|
if (!(await verifyTemplateOwnership(id, session.user.orgId!))) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Template not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = await prisma.formField.findMany({
|
||||||
|
where: { formTemplateId: id },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ fields });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, ctx: RouteContext) {
|
||||||
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
|
||||||
|
if (!(await verifyTemplateOwnership(id, session.user.orgId!))) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Template not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { key, label, type, section, options, required, placeholder, helpText, validation, visibleOnCard, visibleOnSurvey } = body as {
|
||||||
|
key?: string;
|
||||||
|
label?: string;
|
||||||
|
type?: string;
|
||||||
|
section?: string;
|
||||||
|
options?: unknown;
|
||||||
|
required?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
helpText?: string;
|
||||||
|
validation?: unknown;
|
||||||
|
visibleOnCard?: boolean;
|
||||||
|
visibleOnSurvey?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!key?.trim() || !label?.trim() || !type?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "key, label, and type are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSort = await prisma.formField.aggregate({
|
||||||
|
where: { formTemplateId: id },
|
||||||
|
_max: { sortOrder: true },
|
||||||
|
});
|
||||||
|
const nextSort = (maxSort._max.sortOrder ?? -1) + 1;
|
||||||
|
|
||||||
|
const field = await prisma.formField.create({
|
||||||
|
data: {
|
||||||
|
formTemplateId: id,
|
||||||
|
key: key.trim(),
|
||||||
|
label: label.trim(),
|
||||||
|
type: type.trim(),
|
||||||
|
section: section?.trim() || "personal",
|
||||||
|
required: required ?? false,
|
||||||
|
sortOrder: nextSort,
|
||||||
|
options: options ?? Prisma.JsonNull,
|
||||||
|
placeholder: placeholder?.trim() || null,
|
||||||
|
helpText: helpText?.trim() || null,
|
||||||
|
validation: validation ?? Prisma.JsonNull,
|
||||||
|
visibleOnCard: visibleOnCard ?? true,
|
||||||
|
visibleOnSurvey: visibleOnSurvey ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(field, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, ctx: RouteContext) {
|
||||||
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
|
||||||
|
if (!(await verifyTemplateOwnership(id, session.user.orgId!))) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Template not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const reorders = body as Array<{ id: string; sortOrder: number }>;
|
||||||
|
|
||||||
|
if (!Array.isArray(reorders) || reorders.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Expected array of { id, sortOrder }" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(
|
||||||
|
reorders.map((item) =>
|
||||||
|
prisma.formField.update({
|
||||||
|
where: { id: item.id },
|
||||||
|
data: { sortOrder: item.sortOrder },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const fields = await prisma.formField.findMany({
|
||||||
|
where: { formTemplateId: id },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ fields });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/app/api/form-templates/[id]/qr/route.ts
Normal file
46
src/app/api/form-templates/[id]/qr/route.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
import QRCode from "qrcode";
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, ctx: RouteContext) {
|
||||||
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
|
||||||
|
const template = await prisma.formTemplate.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
organization: { select: { slug: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!template || template.organizationId !== session.user.orgId) {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl =
|
||||||
|
process.env.NEXTAUTH_URL ||
|
||||||
|
process.env.AUTH_URL ||
|
||||||
|
"https://echoocr.app";
|
||||||
|
|
||||||
|
const surveyUrl = `${baseUrl}/s/${template.organization.slug}/${template.slug}`;
|
||||||
|
|
||||||
|
const pngBuffer = await QRCode.toBuffer(surveyUrl, {
|
||||||
|
type: "png",
|
||||||
|
width: 400,
|
||||||
|
margin: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(new Uint8Array(pngBuffer), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "image/png",
|
||||||
|
"Cache-Control": "public, max-age=3600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/app/api/form-templates/[id]/route.ts
Normal file
112
src/app/api/form-templates/[id]/route.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
async function getOwnedTemplate(templateId: string, orgId: string) {
|
||||||
|
const template = await prisma.formTemplate.findUnique({
|
||||||
|
where: { id: templateId },
|
||||||
|
include: {
|
||||||
|
fields: { orderBy: { sortOrder: "asc" } },
|
||||||
|
_count: { select: { cards: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!template || template.organizationId !== orgId) return null;
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, ctx: RouteContext) {
|
||||||
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
|
||||||
|
const template = await getOwnedTemplate(id, session.user.orgId!);
|
||||||
|
if (!template) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Template not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(template);
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, ctx: RouteContext) {
|
||||||
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
|
||||||
|
const existing = await getOwnedTemplate(id, session.user.orgId!);
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Template not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, description, isActive, branding, settings } = body as {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
branding?: Record<string, unknown>;
|
||||||
|
settings?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
if (name !== undefined) data.name = name.trim();
|
||||||
|
if (description !== undefined) data.description = description?.trim() || null;
|
||||||
|
if (isActive !== undefined) data.isActive = Boolean(isActive);
|
||||||
|
if (branding !== undefined) data.branding = branding;
|
||||||
|
if (settings !== undefined) data.settings = settings;
|
||||||
|
|
||||||
|
const updated = await prisma.formTemplate.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
fields: { orderBy: { sortOrder: "asc" } },
|
||||||
|
_count: { select: { cards: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: NextRequest, ctx: RouteContext) {
|
||||||
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
|
||||||
|
const existing = await getOwnedTemplate(id, session.user.orgId!);
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Template not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing._count.cards > 0) {
|
||||||
|
const deactivated = await prisma.formTemplate.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
...deactivated,
|
||||||
|
_deactivated: true,
|
||||||
|
_reason: "Template has associated cards and was deactivated instead of deleted",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.formTemplate.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ deleted: true });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/app/api/form-templates/route.ts
Normal file
131
src/app/api/form-templates/route.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
if (searchParams.get("default") === "true") {
|
||||||
|
const template = await prisma.formTemplate.findFirst({
|
||||||
|
where: {
|
||||||
|
organizationId: session.user.orgId,
|
||||||
|
isDefault: true,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
include: { fields: { orderBy: { sortOrder: "asc" } } },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ template });
|
||||||
|
}
|
||||||
|
|
||||||
|
const templates = await prisma.formTemplate.findMany({
|
||||||
|
where: { organizationId: session.user.orgId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
_count: { select: { fields: true, cards: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ templates });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const { name, description, duplicateFrom } = body as {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
duplicateFrom?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!name?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Name is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseSlug = slugify(name);
|
||||||
|
let slug = baseSlug;
|
||||||
|
let suffix = 1;
|
||||||
|
while (
|
||||||
|
await prisma.formTemplate.findUnique({
|
||||||
|
where: {
|
||||||
|
organizationId_slug: {
|
||||||
|
organizationId: session.user.orgId!,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
slug = `${baseSlug}-${suffix++}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fieldsToCreate: Prisma.FormFieldCreateWithoutFormTemplateInput[] = [];
|
||||||
|
|
||||||
|
if (duplicateFrom) {
|
||||||
|
const source = await prisma.formTemplate.findFirst({
|
||||||
|
where: { id: duplicateFrom, organizationId: session.user.orgId },
|
||||||
|
include: { fields: { orderBy: { sortOrder: "asc" } } },
|
||||||
|
});
|
||||||
|
if (!source) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Source template not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fieldsToCreate = source.fields.map((f) => ({
|
||||||
|
key: f.key,
|
||||||
|
label: f.label,
|
||||||
|
type: f.type,
|
||||||
|
section: f.section,
|
||||||
|
required: f.required,
|
||||||
|
removable: f.removable,
|
||||||
|
isCore: f.isCore,
|
||||||
|
sortOrder: f.sortOrder,
|
||||||
|
options: (f.options ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||||
|
placeholder: f.placeholder,
|
||||||
|
helpText: f.helpText,
|
||||||
|
validation: (f.validation ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||||
|
visibleOnCard: f.visibleOnCard,
|
||||||
|
visibleOnSurvey: f.visibleOnSurvey,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = await prisma.formTemplate.create({
|
||||||
|
data: {
|
||||||
|
organizationId: session.user.orgId!,
|
||||||
|
name: name.trim(),
|
||||||
|
slug,
|
||||||
|
description: description?.trim() || null,
|
||||||
|
isDefault: false,
|
||||||
|
isActive: true,
|
||||||
|
...(fieldsToCreate.length > 0 && {
|
||||||
|
fields: { create: fieldsToCreate },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
fields: { orderBy: { sortOrder: "asc" } },
|
||||||
|
_count: { select: { cards: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(template, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
|
||||||
import { pollFtp } from "@/lib/ftp-watcher";
|
import { pollFtp } from "@/lib/ftp-watcher";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
if (!process.env.CRON_SECRET) {
|
||||||
|
return NextResponse.json({ error: "CRON_SECRET not configured" }, { status: 500 });
|
||||||
|
}
|
||||||
const authHeader = request.headers.get("authorization");
|
const authHeader = request.headers.get("authorization");
|
||||||
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
|
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { pollFtp } from "@/lib/ftp-watcher";
|
import { pollFtp } from "@/lib/ftp-watcher";
|
||||||
|
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
await requireApiAuth();
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const action = body.action as string | undefined;
|
const action = body.action as string | undefined;
|
||||||
|
|
||||||
|
|
@ -55,9 +57,6 @@ export async function POST(request: NextRequest) {
|
||||||
const updated = await prisma.appSettings.findUnique({ where: { id: "singleton" } });
|
const updated = await prisma.appSettings.findUnique({ where: { id: "singleton" } });
|
||||||
return NextResponse.json({ ftpEnabled: updated?.ftpEnabled ?? false });
|
return NextResponse.json({ ftpEnabled: updated?.ftpEnabled ?? false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ftp-watch POST]", error);
|
return handleApiError(error);
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : "Failed to update FTP watch";
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { testFtpConnection } from "@/lib/ftp-watcher";
|
import { testFtpConnection } from "@/lib/ftp-watcher";
|
||||||
|
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
await requireApiAuth();
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const result = await testFtpConnection({
|
const result = await testFtpConnection({
|
||||||
host: body.host,
|
host: body.host,
|
||||||
|
|
@ -14,10 +16,6 @@ export async function POST(request: NextRequest) {
|
||||||
});
|
});
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ftp-watch/test POST]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ ok: false, error: "Test failed" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getBuffer } from "@/lib/storage";
|
import { getBuffer } from "@/lib/storage";
|
||||||
|
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
const EXT_TO_CONTENT_TYPE: Record<string, string> = {
|
const EXT_TO_CONTENT_TYPE: Record<string, string> = {
|
||||||
jpg: "image/jpeg",
|
jpg: "image/jpeg",
|
||||||
|
|
@ -15,6 +16,7 @@ export async function GET(
|
||||||
{ params }: { params: Promise<{ path: string[] }> }
|
{ params }: { params: Promise<{ path: string[] }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
await requireApiAuth();
|
||||||
const { path: pathSegments } = await params;
|
const { path: pathSegments } = await params;
|
||||||
const key = pathSegments.join("/");
|
const key = pathSegments.join("/");
|
||||||
|
|
||||||
|
|
@ -34,10 +36,6 @@ export async function GET(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[images GET]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to get image" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { fetchBoardColumns } from "@/lib/monday";
|
import { fetchBoardColumns } from "@/lib/monday";
|
||||||
|
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
await requireApiAuth();
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const token = String(body.token || "");
|
|
||||||
const boardId = String(body.boardId || "");
|
const boardId = String(body.boardId || "");
|
||||||
|
|
||||||
|
const settings = await prisma.appSettings.findUnique({ where: { id: "singleton" } });
|
||||||
|
const token = settings?.mondayApiToken || "";
|
||||||
|
|
||||||
if (!token || !boardId) {
|
if (!token || !boardId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "API token and board ID are required" },
|
{ error: "API token and board ID are required" },
|
||||||
|
|
@ -17,8 +22,6 @@ export async function POST(request: NextRequest) {
|
||||||
const columns = await fetchBoardColumns(token, boardId);
|
const columns = await fetchBoardColumns(token, boardId);
|
||||||
return NextResponse.json({ columns });
|
return NextResponse.json({ columns });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[monday/columns POST]", error);
|
return handleApiError(error);
|
||||||
const message = error instanceof Error ? error.message : "Failed to fetch columns";
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { pushCardToMonday } from "@/lib/integrations";
|
import { pushCardToMonday } from "@/lib/integrations";
|
||||||
|
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
await requireApiAuth();
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const result = await pushCardToMonday(id);
|
const result = await pushCardToMonday(id);
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|
@ -14,8 +16,6 @@ export async function POST(
|
||||||
mondayItemId: result.mondayItemId,
|
mondayItemId: result.mondayItemId,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[monday/push POST]", error);
|
return handleApiError(error);
|
||||||
const message = error instanceof Error ? error.message : "Push failed";
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { createWebhookSubscription, deleteWebhookSubscription } from "@/lib/monday";
|
import { createWebhookSubscription, deleteWebhookSubscription } from "@/lib/monday";
|
||||||
|
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
await requireApiAuth();
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const action = body.action as string;
|
const action = body.action as string;
|
||||||
|
|
||||||
|
|
@ -61,8 +63,6 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[monday/subscribe POST]", error);
|
return handleApiError(error);
|
||||||
const message = error instanceof Error ? error.message : "Failed";
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { pushCardToMonday } from "@/lib/integrations";
|
import { pushCardToMonday } from "@/lib/integrations";
|
||||||
import { createNotification } from "@/lib/notifications";
|
import { createNotification } from "@/lib/notifications";
|
||||||
|
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
await requireApiAuth();
|
||||||
const settings = await prisma.appSettings.findUnique({
|
const settings = await prisma.appSettings.findUnique({
|
||||||
where: { id: "singleton" },
|
where: { id: "singleton" },
|
||||||
});
|
});
|
||||||
|
|
@ -80,8 +82,6 @@ export async function POST(req: NextRequest) {
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, synced, created, updated, failed, total: cards.length, errors: errors.slice(0, 10) });
|
return NextResponse.json({ ok: true, synced, created, updated, failed, total: cards.length, errors: errors.slice(0, 10) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[monday/sync-all POST]", error);
|
return handleApiError(error);
|
||||||
const message = error instanceof Error ? error.message : "Sync failed";
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { sendWebhook } from "@/lib/webhook";
|
import { sendWebhook } from "@/lib/webhook";
|
||||||
|
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
try {
|
try {
|
||||||
|
await requireApiAuth();
|
||||||
const settings = await prisma.appSettings.findUnique({
|
const settings = await prisma.appSettings.findUnique({
|
||||||
where: { id: "singleton" },
|
where: { id: "singleton" },
|
||||||
});
|
});
|
||||||
|
|
@ -40,8 +42,6 @@ export async function POST() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[webhook/test POST]", error);
|
return handleApiError(error);
|
||||||
const message = error instanceof Error ? error.message : "Test failed";
|
|
||||||
return NextResponse.json({ ok: false, error: message }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
|
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
|
||||||
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10)));
|
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10)));
|
||||||
const status = searchParams.get("status") || undefined;
|
const status = searchParams.get("status") || undefined;
|
||||||
|
|
||||||
const where = status ? { status } : {};
|
const where: Record<string, unknown> = { organizationId: session.user.orgId };
|
||||||
|
if (status) where.status = status;
|
||||||
|
|
||||||
const [jobs, total] = await Promise.all([
|
const [jobs, total] = await Promise.all([
|
||||||
prisma.processingJob.findMany({
|
prisma.processingJob.findMany({
|
||||||
|
|
@ -22,10 +25,6 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
return NextResponse.json({ jobs, total, page, limit });
|
return NextResponse.json({ jobs, total, page, limit });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[jobs GET]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch jobs" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,50 +6,47 @@ import {
|
||||||
markAllRead,
|
markAllRead,
|
||||||
dismissNotification,
|
dismissNotification,
|
||||||
} from "@/lib/notifications";
|
} from "@/lib/notifications";
|
||||||
|
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const session = await requireApiAuth();
|
||||||
|
const userId = session.user.id!;
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const unreadOnly = searchParams.get("unreadOnly") === "true";
|
const unreadOnly = searchParams.get("unreadOnly") === "true";
|
||||||
const limit = Math.min(100, parseInt(searchParams.get("limit") || "50"));
|
const limit = Math.min(100, parseInt(searchParams.get("limit") || "50"));
|
||||||
|
|
||||||
const [notifications, unreadCount] = await Promise.all([
|
const [notifications, unreadCount] = await Promise.all([
|
||||||
getNotifications({ unreadOnly, limit }),
|
getNotifications({ userId, unreadOnly, limit }),
|
||||||
getUnreadCount(),
|
getUnreadCount(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return NextResponse.json({ notifications, unreadCount });
|
return NextResponse.json({ notifications, unreadCount });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[notifications GET]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch notifications" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const session = await requireApiAuth();
|
||||||
|
const userId = session.user.id!;
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const action = body.action as string;
|
const action = body.action as string;
|
||||||
|
|
||||||
if (action === "mark_read" && body.id) {
|
if (action === "mark_read" && body.id) {
|
||||||
await markRead(body.id);
|
await markRead(body.id, userId);
|
||||||
} else if (action === "mark_all_read") {
|
} else if (action === "mark_all_read") {
|
||||||
await markAllRead();
|
await markAllRead(userId);
|
||||||
} else if (action === "dismiss" && body.id) {
|
} else if (action === "dismiss" && body.id) {
|
||||||
await dismissNotification(body.id);
|
await dismissNotification(body.id, userId);
|
||||||
} else {
|
} else {
|
||||||
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const unreadCount = await getUnreadCount();
|
const unreadCount = await getUnreadCount(userId);
|
||||||
return NextResponse.json({ ok: true, unreadCount });
|
return NextResponse.json({ ok: true, unreadCount });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[notifications PUT]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to update notification" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
70
src/app/api/people/[id]/merge/route.ts
Normal file
70
src/app/api/people/[id]/merge/route.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
type Ctx = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, ctx: Ctx) {
|
||||||
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const orgId = session.user.orgId!;
|
||||||
|
const { id: sourceId } = await ctx.params;
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const { targetPersonId } = body;
|
||||||
|
|
||||||
|
if (!targetPersonId) {
|
||||||
|
return NextResponse.json({ error: "targetPersonId is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (sourceId === targetPersonId) {
|
||||||
|
return NextResponse.json({ error: "Cannot merge a person into themselves" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [source, target] = await Promise.all([
|
||||||
|
prisma.person.findUnique({ where: { id: sourceId } }),
|
||||||
|
prisma.person.findUnique({ where: { id: targetPersonId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!source || source.organizationId !== orgId) {
|
||||||
|
return NextResponse.json({ error: "Source person not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!target || target.organizationId !== orgId) {
|
||||||
|
return NextResponse.json({ error: "Target person not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Move all cards from source to target
|
||||||
|
await prisma.responseCard.updateMany({
|
||||||
|
where: { personId: sourceId },
|
||||||
|
data: { personId: targetPersonId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Merge fieldData: target values take priority, fill gaps from source
|
||||||
|
const mergedFieldData = {
|
||||||
|
...((source.fieldData as object) ?? {}),
|
||||||
|
...((target.fieldData as object) ?? {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Update target's core fields if target's are null but source's are not
|
||||||
|
const updates: Record<string, unknown> = {
|
||||||
|
fieldData: Object.keys(mergedFieldData).length > 0 ? mergedFieldData : null,
|
||||||
|
};
|
||||||
|
if (!target.email && source.email) updates.email = source.email;
|
||||||
|
if (!target.cellPhone && source.cellPhone) updates.cellPhone = source.cellPhone;
|
||||||
|
|
||||||
|
const targetPerson = await prisma.person.update({
|
||||||
|
where: { id: targetPersonId },
|
||||||
|
data: updates,
|
||||||
|
include: { _count: { select: { cards: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Mark source as merged
|
||||||
|
await prisma.person.update({
|
||||||
|
where: { id: sourceId },
|
||||||
|
data: { mergedIntoId: targetPersonId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, targetPerson });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/app/api/people/[id]/route.ts
Normal file
93
src/app/api/people/[id]/route.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
type Ctx = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, ctx: Ctx) {
|
||||||
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
|
||||||
|
const person = await prisma.person.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
cards: {
|
||||||
|
take: 20,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
ocrStatus: true,
|
||||||
|
reviewStatus: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: { select: { cards: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!person || person.organizationId !== session.user.orgId) {
|
||||||
|
return NextResponse.json({ error: "Person not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(person);
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, ctx: Ctx) {
|
||||||
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
|
||||||
|
const existing = await prisma.person.findUnique({ where: { id } });
|
||||||
|
if (!existing || existing.organizationId !== session.user.orgId) {
|
||||||
|
return NextResponse.json({ error: "Person not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (body.firstName != null) data.firstName = String(body.firstName).trim();
|
||||||
|
if (body.lastName != null) data.lastName = String(body.lastName).trim();
|
||||||
|
if (body.email !== undefined) {
|
||||||
|
data.email = body.email ? String(body.email).trim().toLowerCase() : null;
|
||||||
|
}
|
||||||
|
if (body.cellPhone !== undefined) {
|
||||||
|
data.cellPhone = body.cellPhone ? String(body.cellPhone).trim() : null;
|
||||||
|
}
|
||||||
|
if (body.fieldData != null) {
|
||||||
|
data.fieldData = { ...((existing.fieldData as object) ?? {}), ...body.fieldData };
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.person.update({ where: { id }, data });
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: NextRequest, ctx: Ctx) {
|
||||||
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
|
||||||
|
const existing = await prisma.person.findUnique({ where: { id } });
|
||||||
|
if (!existing || existing.organizationId !== session.user.orgId) {
|
||||||
|
return NextResponse.json({ error: "Person not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.responseCard.updateMany({
|
||||||
|
where: { personId: id },
|
||||||
|
data: { personId: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.person.delete({ where: { id } });
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/app/api/people/link/route.ts
Normal file
47
src/app/api/people/link/route.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
import { linkCardToPerson } from "@/lib/person-linker";
|
||||||
|
|
||||||
|
const BATCH_LIMIT = 100;
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const orgId = session.user.orgId!;
|
||||||
|
|
||||||
|
const unlinkedCards = await prisma.responseCard.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId: orgId,
|
||||||
|
personId: null,
|
||||||
|
ocrStatus: "complete",
|
||||||
|
},
|
||||||
|
take: BATCH_LIMIT,
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let linked = 0;
|
||||||
|
let created = 0;
|
||||||
|
|
||||||
|
for (const card of unlinkedCards) {
|
||||||
|
const personCountBefore = await prisma.person.count({ where: { organizationId: orgId } });
|
||||||
|
const result = await linkCardToPerson(card.id, orgId);
|
||||||
|
if (result) {
|
||||||
|
const personCountAfter = await prisma.person.count({ where: { organizationId: orgId } });
|
||||||
|
if (personCountAfter > personCountBefore) {
|
||||||
|
created++;
|
||||||
|
} else {
|
||||||
|
linked++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
linked,
|
||||||
|
created,
|
||||||
|
total: unlinkedCards.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/app/api/people/route.ts
Normal file
81
src/app/api/people/route.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const orgId = session.user.orgId!;
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
|
||||||
|
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10)));
|
||||||
|
const search = searchParams.get("search")?.trim() || undefined;
|
||||||
|
const sortBy = searchParams.get("sortBy") ?? "createdAt";
|
||||||
|
const sortOrder = searchParams.get("sortOrder") ?? "desc";
|
||||||
|
|
||||||
|
const validSortFields = ["createdAt", "updatedAt", "firstName", "lastName", "email"];
|
||||||
|
const orderByField = validSortFields.includes(sortBy) ? sortBy : "createdAt";
|
||||||
|
const order = sortOrder === "asc" ? "asc" : "desc";
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = {
|
||||||
|
organizationId: orgId,
|
||||||
|
mergedIntoId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ firstName: { contains: search, mode: "insensitive" } },
|
||||||
|
{ lastName: { contains: search, mode: "insensitive" } },
|
||||||
|
{ email: { contains: search, mode: "insensitive" } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [people, total] = await Promise.all([
|
||||||
|
prisma.person.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { [orderByField]: order },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
include: { _count: { select: { cards: true } } },
|
||||||
|
}),
|
||||||
|
prisma.person.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({ people, total, page, limit });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const orgId = session.user.orgId!;
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const { firstName, lastName, email, cellPhone, fieldData } = body;
|
||||||
|
|
||||||
|
if (!firstName || !lastName) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "firstName and lastName are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const person = await prisma.person.create({
|
||||||
|
data: {
|
||||||
|
organizationId: orgId,
|
||||||
|
firstName: String(firstName).trim(),
|
||||||
|
lastName: String(lastName).trim(),
|
||||||
|
email: email ? String(email).trim().toLowerCase() : null,
|
||||||
|
cellPhone: cellPhone ? String(cellPhone).trim() : null,
|
||||||
|
fieldData: fieldData ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(person, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
await requireApiAuth();
|
||||||
const settings = await prisma.appSettings.findUnique({
|
const settings = await prisma.appSettings.findUnique({
|
||||||
where: { id: "singleton" },
|
where: { id: "singleton" },
|
||||||
});
|
});
|
||||||
|
|
@ -14,18 +16,20 @@ export async function GET() {
|
||||||
return NextResponse.json(created);
|
return NextResponse.json(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(settings);
|
const masked = { ...settings } as Record<string, unknown>;
|
||||||
|
if (masked.mondayApiToken) masked.mondayApiToken = "••••" + (masked.mondayApiToken as string).slice(-4);
|
||||||
|
if (masked.webhookSecret) masked.webhookSecret = "••••" + (masked.webhookSecret as string).slice(-4);
|
||||||
|
if (masked.emailImapPass) masked.emailImapPass = "••••••••";
|
||||||
|
if (masked.ftpPass) masked.ftpPass = "••••••••";
|
||||||
|
return NextResponse.json(masked);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[settings GET]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch settings" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
await requireApiAuth();
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const data: Record<string, unknown> = {};
|
const data: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
|
@ -81,10 +85,6 @@ export async function PUT(request: NextRequest) {
|
||||||
|
|
||||||
return NextResponse.json(settings);
|
return NextResponse.json(settings);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[settings PUT]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to update settings" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,36 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { getOrCreateUser } from "@/lib/auth";
|
import { recoverStuckJobs } from "@/lib/job-recovery";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
recoverStuckJobs().catch(() => {});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await getOrCreateUser(request.headers);
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const orgId = session.user.orgId;
|
||||||
const userId = request.nextUrl.searchParams.get("userId") || undefined;
|
const userId = request.nextUrl.searchParams.get("userId") || undefined;
|
||||||
const effectiveUserId = userId === "me" && user ? user.id : userId;
|
const effectiveUserId = userId === "me" ? session.user.id : userId;
|
||||||
|
|
||||||
const [total, byOcrStatus, byReviewStatus, myCards] = await Promise.all([
|
const [total, byOcrStatus, byReviewStatus, myCards] = await Promise.all([
|
||||||
prisma.responseCard.count(),
|
prisma.responseCard.count({ where: { organizationId: orgId } }),
|
||||||
prisma.responseCard.groupBy({
|
prisma.responseCard.groupBy({
|
||||||
by: ["ocrStatus"],
|
by: ["ocrStatus"],
|
||||||
|
where: { organizationId: orgId },
|
||||||
_count: { id: true },
|
_count: { id: true },
|
||||||
}),
|
}),
|
||||||
prisma.responseCard.groupBy({
|
prisma.responseCard.groupBy({
|
||||||
by: ["reviewStatus"],
|
by: ["reviewStatus"],
|
||||||
|
where: { organizationId: orgId },
|
||||||
_count: { id: true },
|
_count: { id: true },
|
||||||
}),
|
}),
|
||||||
effectiveUserId
|
effectiveUserId
|
||||||
? prisma.responseCard.count({
|
? prisma.responseCard.count({
|
||||||
where: { assignedToId: effectiveUserId },
|
where: { assignedToId: effectiveUserId, organizationId: orgId },
|
||||||
})
|
})
|
||||||
: user
|
: prisma.responseCard.count({
|
||||||
? prisma.responseCard.count({
|
where: { assignedToId: session.user.id, organizationId: orgId },
|
||||||
where: { assignedToId: user.id },
|
}),
|
||||||
})
|
|
||||||
: Promise.resolve(0),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ocrStatusCounts = Object.fromEntries(
|
const ocrStatusCounts = Object.fromEntries(
|
||||||
|
|
@ -43,10 +47,6 @@ export async function GET(request: NextRequest) {
|
||||||
myCards,
|
myCards,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[stats GET]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch stats" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
197
src/app/api/stats/trends/route.ts
Normal file
197
src/app/api/stats/trends/route.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
const VALID_PERIODS = new Set(["7", "30", "90", "365"]);
|
||||||
|
const VALID_GROUP_BY = new Set(["day", "week", "month"]);
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
|
const orgId = session.user.orgId!;
|
||||||
|
|
||||||
|
const params = request.nextUrl.searchParams;
|
||||||
|
const period = VALID_PERIODS.has(params.get("period") ?? "")
|
||||||
|
? Number(params.get("period"))
|
||||||
|
: 30;
|
||||||
|
const groupBy = VALID_GROUP_BY.has(params.get("groupBy") ?? "")
|
||||||
|
? (params.get("groupBy") as "day" | "week" | "month")
|
||||||
|
: "day";
|
||||||
|
|
||||||
|
const since = new Date();
|
||||||
|
since.setDate(since.getDate() - period);
|
||||||
|
|
||||||
|
const [series, pipeline, teamActivity, avgReview, peopleCount, newPeople] =
|
||||||
|
await Promise.all([
|
||||||
|
buildTimeSeries(orgId, since, groupBy),
|
||||||
|
buildPipeline(orgId),
|
||||||
|
buildTeamActivity(orgId, since),
|
||||||
|
buildAvgReviewTime(orgId, since),
|
||||||
|
prisma.person.count({ where: { organizationId: orgId } }),
|
||||||
|
prisma.person.count({
|
||||||
|
where: { organizationId: orgId, createdAt: { gte: since } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
series,
|
||||||
|
pipeline,
|
||||||
|
teamActivity,
|
||||||
|
avgReviewTimeHours: avgReview,
|
||||||
|
peopleCount,
|
||||||
|
newPeopleThisPeriod: newPeople,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildTimeSeries(
|
||||||
|
orgId: string,
|
||||||
|
since: Date,
|
||||||
|
groupBy: "day" | "week" | "month"
|
||||||
|
) {
|
||||||
|
const cards = await prisma.responseCard.findMany({
|
||||||
|
where: { organizationId: orgId, createdAt: { gte: since } },
|
||||||
|
select: { createdAt: true, submissionSource: true },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const buckets = new Map<string, { total: number; survey: number }>();
|
||||||
|
|
||||||
|
for (const card of cards) {
|
||||||
|
const key = bucketKey(card.createdAt, groupBy);
|
||||||
|
const entry = buckets.get(key) ?? { total: 0, survey: 0 };
|
||||||
|
entry.total++;
|
||||||
|
if (card.submissionSource === "survey") entry.survey++;
|
||||||
|
buckets.set(key, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(buckets.entries())
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([date, counts]) => ({
|
||||||
|
date,
|
||||||
|
total: counts.total,
|
||||||
|
scanned: counts.total - counts.survey,
|
||||||
|
survey: counts.survey,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketKey(date: Date, groupBy: "day" | "week" | "month"): string {
|
||||||
|
const d = new Date(date);
|
||||||
|
if (groupBy === "month") {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-01`;
|
||||||
|
}
|
||||||
|
if (groupBy === "week") {
|
||||||
|
const day = d.getDay();
|
||||||
|
d.setDate(d.getDate() - day);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
}
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildPipeline(orgId: string) {
|
||||||
|
const [byOcr, byReview] = await Promise.all([
|
||||||
|
prisma.responseCard.groupBy({
|
||||||
|
by: ["ocrStatus"],
|
||||||
|
where: { organizationId: orgId },
|
||||||
|
_count: { id: true },
|
||||||
|
}),
|
||||||
|
prisma.responseCard.groupBy({
|
||||||
|
by: ["reviewStatus"],
|
||||||
|
where: { organizationId: orgId },
|
||||||
|
_count: { id: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ocrMap = Object.fromEntries(
|
||||||
|
byOcr.map((r) => [r.ocrStatus, r._count.id])
|
||||||
|
);
|
||||||
|
const reviewMap = Object.fromEntries(
|
||||||
|
byReview.map((r) => [r.reviewStatus, r._count.id])
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pending: ocrMap["pending"] ?? 0,
|
||||||
|
processing: ocrMap["processing"] ?? 0,
|
||||||
|
complete: ocrMap["complete"] ?? 0,
|
||||||
|
error: ocrMap["error"] ?? 0,
|
||||||
|
unreviewed: reviewMap["unreviewed"] ?? 0,
|
||||||
|
reviewed: reviewMap["reviewed"] ?? 0,
|
||||||
|
exported: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildTeamActivity(orgId: string, since: Date) {
|
||||||
|
const [reviewedCards, assignedCards] = await Promise.all([
|
||||||
|
prisma.responseCard.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId: orgId,
|
||||||
|
reviewedById: { not: null },
|
||||||
|
reviewedAt: { gte: since },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
reviewedById: true,
|
||||||
|
reviewedBy: { select: { id: true, displayName: true, username: true, email: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.responseCard.findMany({
|
||||||
|
where: { organizationId: orgId, assignedToId: { not: null } },
|
||||||
|
select: {
|
||||||
|
assignedToId: true,
|
||||||
|
assignedTo: { select: { id: true, displayName: true, username: true, email: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const userMap = new Map<string, { name: string; reviewed: number; assigned: number }>();
|
||||||
|
|
||||||
|
for (const card of reviewedCards) {
|
||||||
|
const u = card.reviewedBy!;
|
||||||
|
const entry = userMap.get(u.id) ?? {
|
||||||
|
name: u.displayName || u.username || u.email,
|
||||||
|
reviewed: 0,
|
||||||
|
assigned: 0,
|
||||||
|
};
|
||||||
|
entry.reviewed++;
|
||||||
|
userMap.set(u.id, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const card of assignedCards) {
|
||||||
|
const u = card.assignedTo!;
|
||||||
|
const existing = userMap.get(u.id);
|
||||||
|
if (existing) {
|
||||||
|
existing.assigned++;
|
||||||
|
} else {
|
||||||
|
userMap.set(u.id, {
|
||||||
|
name: u.displayName || u.username || u.email,
|
||||||
|
reviewed: 0,
|
||||||
|
assigned: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(userMap.entries())
|
||||||
|
.map(([userId, data]) => ({ userId, ...data }))
|
||||||
|
.sort((a, b) => b.reviewed - a.reviewed);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildAvgReviewTime(orgId: string, since: Date) {
|
||||||
|
const cards = await prisma.responseCard.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId: orgId,
|
||||||
|
reviewedAt: { not: null },
|
||||||
|
createdAt: { gte: since },
|
||||||
|
},
|
||||||
|
select: { createdAt: true, reviewedAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cards.length === 0) return null;
|
||||||
|
|
||||||
|
const totalMs = cards.reduce((sum, c) => {
|
||||||
|
return sum + (c.reviewedAt!.getTime() - c.createdAt.getTime());
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const avgHours = totalMs / cards.length / (1000 * 60 * 60);
|
||||||
|
return Math.round(avgHours * 10) / 10;
|
||||||
|
}
|
||||||
74
src/app/api/survey/submit/route.ts
Normal file
74
src/app/api/survey/submit/route.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { formTemplateId, organizationId, firstName, lastName, fieldData } =
|
||||||
|
body as {
|
||||||
|
formTemplateId?: string;
|
||||||
|
organizationId?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
fieldData?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!formTemplateId || !organizationId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "formTemplateId and organizationId are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!firstName?.trim() || !lastName?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "firstName and lastName are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = await prisma.formTemplate.findUnique({
|
||||||
|
where: { id: formTemplateId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
!template ||
|
||||||
|
!template.isActive ||
|
||||||
|
template.organizationId !== organizationId
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Form template not found or inactive" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedFirst = firstName.trim();
|
||||||
|
const trimmedLast = lastName.trim();
|
||||||
|
|
||||||
|
const card = await prisma.responseCard.create({
|
||||||
|
data: {
|
||||||
|
firstName: trimmedFirst,
|
||||||
|
lastName: trimmedLast,
|
||||||
|
name: `${trimmedFirst} ${trimmedLast}`,
|
||||||
|
email: fieldData?.email || null,
|
||||||
|
cellPhone: fieldData?.cellPhone || fieldData?.phone || null,
|
||||||
|
fieldData: fieldData ?? {},
|
||||||
|
formTemplateId,
|
||||||
|
organizationId,
|
||||||
|
ocrStatus: "complete",
|
||||||
|
reviewStatus: "unreviewed",
|
||||||
|
submissionSource: "survey",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Rate limiting — add IP-based or fingerprint-based rate limiting
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, cardId: card.id });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Survey Submit Error]", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { uploadBuffer } from "@/lib/storage";
|
import { uploadBuffer } from "@/lib/storage";
|
||||||
import { enqueueProcessing } from "@/lib/processing-queue";
|
import { enqueueProcessing } from "@/lib/processing-queue";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50 MB
|
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||||
|
|
||||||
|
|
@ -15,6 +16,7 @@ const ALLOWED_TYPES = [
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const session = await requireApiAuthWithOrg();
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const files = [
|
const files = [
|
||||||
...(formData.getAll("files") as File[]),
|
...(formData.getAll("files") as File[]),
|
||||||
|
|
@ -56,6 +58,7 @@ export async function POST(request: NextRequest) {
|
||||||
fileName,
|
fileName,
|
||||||
filePath: `sources/${fileName}`,
|
filePath: `sources/${fileName}`,
|
||||||
status: "queued",
|
status: "queued",
|
||||||
|
organizationId: session.user.orgId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -68,10 +71,6 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
return NextResponse.json({ jobIds });
|
return NextResponse.json({ jobIds });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[upload POST]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to upload files" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,21 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { getOrCreateUser } from "@/lib/auth";
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getOrCreateUser(req.headers);
|
const session = await requireApiAuthWithOrg();
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const role = req.nextUrl.searchParams.get("role") || undefined;
|
const role = req.nextUrl.searchParams.get("role") || undefined;
|
||||||
const where: Record<string, unknown> = {};
|
|
||||||
if (role) where.role = role;
|
|
||||||
|
|
||||||
const users = await prisma.user.findMany({
|
const where: Record<string, unknown> = {
|
||||||
|
organizationId: session.user.orgId,
|
||||||
|
};
|
||||||
|
if (role) where.user = { role };
|
||||||
|
|
||||||
|
const members = await prisma.orgMember.findMany({
|
||||||
where,
|
where,
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
username: true,
|
username: true,
|
||||||
|
|
@ -23,12 +24,15 @@ export async function GET(req: NextRequest) {
|
||||||
avatarUrl: true,
|
avatarUrl: true,
|
||||||
role: true,
|
role: true,
|
||||||
},
|
},
|
||||||
orderBy: { displayName: "asc" },
|
},
|
||||||
|
},
|
||||||
|
orderBy: { user: { displayName: "asc" } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const users = members.map((m) => m.user);
|
||||||
|
|
||||||
return NextResponse.json({ users });
|
return NextResponse.json({ users });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[users GET]", error);
|
return handleApiError(error);
|
||||||
return NextResponse.json({ error: "Failed to fetch users" }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
84
src/app/s/[orgSlug]/[formSlug]/page.tsx
Normal file
84
src/app/s/[orgSlug]/[formSlug]/page.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { SurveyForm } from "@/components/survey/survey-form";
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: Promise<{ orgSlug: string; formSlug: string }>;
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function PublicSurveyPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: PageProps) {
|
||||||
|
const { orgSlug, formSlug } = await params;
|
||||||
|
const query = await searchParams;
|
||||||
|
|
||||||
|
const org = await prisma.organization.findUnique({
|
||||||
|
where: { slug: orgSlug },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!org) notFound();
|
||||||
|
|
||||||
|
const template = await prisma.formTemplate.findUnique({
|
||||||
|
where: {
|
||||||
|
organizationId_slug: {
|
||||||
|
organizationId: org.id,
|
||||||
|
slug: formSlug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
fields: {
|
||||||
|
where: { visibleOnSurvey: true },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!template || !template.isActive) notFound();
|
||||||
|
|
||||||
|
const branding = template.branding as {
|
||||||
|
primaryColor?: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
const prefill: Record<string, string> = {};
|
||||||
|
for (const [key, val] of Object.entries(query)) {
|
||||||
|
if (typeof val === "string") prefill[key] = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
{branding?.logoUrl && (
|
||||||
|
<img
|
||||||
|
src={branding.logoUrl}
|
||||||
|
alt={org.name}
|
||||||
|
className="mx-auto mb-4 h-16 w-auto object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<h1
|
||||||
|
className="text-2xl font-bold tracking-tight"
|
||||||
|
style={branding?.primaryColor ? { color: branding.primaryColor } : undefined}
|
||||||
|
>
|
||||||
|
{template.name}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{org.name}</p>
|
||||||
|
{template.description && (
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{template.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SurveyForm
|
||||||
|
formTemplateId={template.id}
|
||||||
|
organizationId={org.id}
|
||||||
|
fields={template.fields}
|
||||||
|
branding={branding}
|
||||||
|
prefill={prefill}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/app/s/layout.tsx
Normal file
12
src/app/s/layout.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export default function SurveyLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-screen items-center justify-center px-4 py-8">
|
||||||
|
<div className="gradient-mesh pointer-events-none fixed inset-0 z-0" />
|
||||||
|
<div className="relative z-10 w-full max-w-2xl">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { StatCards, type StatFilter } from "./stat-cards";
|
import { StatCards, type StatFilter } from "./stat-cards";
|
||||||
|
|
@ -27,9 +27,18 @@ const ATTENDANCE_OPTIONS = [
|
||||||
|
|
||||||
const SERVICE_OPTIONS = ["A", "B", "C", "D"];
|
const SERVICE_OPTIONS = ["A", "B", "C", "D"];
|
||||||
|
|
||||||
|
function deriveStatFilter(params: URLSearchParams): StatFilter {
|
||||||
|
if (params.get("assignedToId") === "me") return "my_cards";
|
||||||
|
if (params.get("ocrStatus") === "complete") return "complete";
|
||||||
|
if (params.get("ocrStatus") === "error") return "error";
|
||||||
|
if (params.get("reviewStatus") === "unreviewed") return "unreviewed";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardContent() {
|
export function DashboardContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const pathname = usePathname();
|
||||||
const { role } = useUserProfile();
|
const { role } = useUserProfile();
|
||||||
const isAdmin = role === "admin" || role === "owner";
|
const isAdmin = role === "admin" || role === "owner";
|
||||||
|
|
||||||
|
|
@ -45,8 +54,9 @@ export function DashboardContent() {
|
||||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||||
const [uploadModalOpen, setUploadModalOpen] = React.useState(false);
|
const [uploadModalOpen, setUploadModalOpen] = React.useState(false);
|
||||||
const [uploadingRows, setUploadingRows] = React.useState<UploadingFile[]>([]);
|
const [uploadingRows, setUploadingRows] = React.useState<UploadingFile[]>([]);
|
||||||
const [activeStatFilter, setActiveStatFilter] = React.useState<StatFilter>(null);
|
const [activeStatFilter, setActiveStatFilter] = React.useState<StatFilter>(() => deriveStatFilter(searchParams));
|
||||||
const [globalDragOver, setGlobalDragOver] = React.useState(false);
|
const [globalDragOver, setGlobalDragOver] = React.useState(false);
|
||||||
|
const [initialFiles, setInitialFiles] = React.useState<File[]>([]);
|
||||||
|
|
||||||
const fetchCards = React.useCallback(async () => {
|
const fetchCards = React.useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -83,6 +93,10 @@ export function DashboardContent() {
|
||||||
fetchCards();
|
fetchCards();
|
||||||
}, [fetchCards]);
|
}, [fetchCards]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setActiveStatFilter(deriveStatFilter(searchParams));
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
const updateUrl = React.useCallback(
|
const updateUrl = React.useCallback(
|
||||||
(updates: Record<string, string | number | undefined>) => {
|
(updates: Record<string, string | number | undefined>) => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
|
@ -93,9 +107,9 @@ export function DashboardContent() {
|
||||||
params.set(key, String(value));
|
params.set(key, String(value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
router.push(`/?${params.toString()}`);
|
router.push(`${pathname}?${params.toString()}`);
|
||||||
},
|
},
|
||||||
[router, searchParams]
|
[router, searchParams, pathname]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStatFilterChange = React.useCallback(
|
const handleStatFilterChange = React.useCallback(
|
||||||
|
|
@ -118,9 +132,9 @@ export function DashboardContent() {
|
||||||
}
|
}
|
||||||
|
|
||||||
params.set("page", "1");
|
params.set("page", "1");
|
||||||
router.push(`/?${params.toString()}`);
|
router.push(`${pathname}?${params.toString()}`);
|
||||||
},
|
},
|
||||||
[router, searchParams]
|
[router, searchParams, pathname]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBulkAction = async (
|
const handleBulkAction = async (
|
||||||
|
|
@ -398,6 +412,7 @@ export function DashboardContent() {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setGlobalDragOver(false);
|
setGlobalDragOver(false);
|
||||||
if (e.dataTransfer?.files.length) {
|
if (e.dataTransfer?.files.length) {
|
||||||
|
setInitialFiles(Array.from(e.dataTransfer.files));
|
||||||
setUploadModalOpen(true);
|
setUploadModalOpen(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -417,6 +432,12 @@ export function DashboardContent() {
|
||||||
setUploadModalOpen(true);
|
setUploadModalOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const FILTER_PARAM_KEYS = ["search", "ocrStatus", "reviewStatus", "visitType", "assignedToId", "attendanceDuration", "serviceAttended", "eventId", "locationId"];
|
||||||
|
const hasActiveFilters = FILTER_PARAM_KEYS.some((k) => searchParams.has(k));
|
||||||
|
const clearFilters = React.useCallback(() => {
|
||||||
|
router.push(pathname);
|
||||||
|
}, [router, pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Global drag overlay */}
|
{/* Global drag overlay */}
|
||||||
|
|
@ -476,6 +497,9 @@ export function DashboardContent() {
|
||||||
onSortChange={(sb, so) => updateUrl({ sortBy: sb, sortOrder: so })}
|
onSortChange={(sb, so) => updateUrl({ sortBy: sb, sortOrder: so })}
|
||||||
uploadingRows={uploadingRows.filter((f) => f.status === "uploading")}
|
uploadingRows={uploadingRows.filter((f) => f.status === "uploading")}
|
||||||
onSelectionChange={setSelectedIds}
|
onSelectionChange={setSelectedIds}
|
||||||
|
hasActiveFilters={hasActiveFilters}
|
||||||
|
onClearFilters={clearFilters}
|
||||||
|
onUploadClick={isAdmin ? openUpload : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -495,7 +519,11 @@ export function DashboardContent() {
|
||||||
{/* Upload modal */}
|
{/* Upload modal */}
|
||||||
<UploadModal
|
<UploadModal
|
||||||
open={uploadModalOpen}
|
open={uploadModalOpen}
|
||||||
onOpenChange={setUploadModalOpen}
|
onOpenChange={(open) => {
|
||||||
|
setUploadModalOpen(open);
|
||||||
|
if (!open) setInitialFiles([]);
|
||||||
|
}}
|
||||||
|
initialFiles={initialFiles}
|
||||||
onUploadStart={handleUploadStart}
|
onUploadStart={handleUploadStart}
|
||||||
onUploadProgress={handleUploadProgress}
|
onUploadProgress={handleUploadProgress}
|
||||||
onUploadComplete={handleUploadComplete}
|
onUploadComplete={handleUploadComplete}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Loader2,
|
Loader2,
|
||||||
Copy,
|
Copy,
|
||||||
|
Columns3,
|
||||||
|
Upload,
|
||||||
|
SearchX,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
|
@ -32,6 +35,8 @@ import {
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ResponseCard } from "./columns";
|
import type { ResponseCard } from "./columns";
|
||||||
import type { UploadingFile } from "./upload-modal";
|
import type { UploadingFile } from "./upload-modal";
|
||||||
|
|
@ -133,6 +138,9 @@ type DataTableProps<TData> = {
|
||||||
onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void;
|
onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void;
|
||||||
uploadingRows?: UploadingFile[];
|
uploadingRows?: UploadingFile[];
|
||||||
onSelectionChange?: (selectedIds: string[]) => void;
|
onSelectionChange?: (selectedIds: string[]) => void;
|
||||||
|
hasActiveFilters?: boolean;
|
||||||
|
onClearFilters?: () => void;
|
||||||
|
onUploadClick?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_SIZES = [10, 20, 50, 100];
|
const PAGE_SIZES = [10, 20, 50, 100];
|
||||||
|
|
@ -158,6 +166,9 @@ export function DataTable<TData extends ResponseCard>({
|
||||||
onSortChange,
|
onSortChange,
|
||||||
uploadingRows = [],
|
uploadingRows = [],
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
|
hasActiveFilters,
|
||||||
|
onClearFilters,
|
||||||
|
onUploadClick,
|
||||||
}: DataTableProps<TData>) {
|
}: DataTableProps<TData>) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|
@ -333,6 +344,54 @@ export function DataTable<TData extends ResponseCard>({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl"
|
||||||
|
aria-label="Toggle column visibility"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Columns3 className="size-4 mr-1.5" />
|
||||||
|
<span className="hidden sm:inline">Columns</span>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" sideOffset={4} className="w-56 p-0">
|
||||||
|
<div className="px-3 py-2 text-xs font-medium text-muted-foreground border-b border-border/50">
|
||||||
|
Toggle Columns
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="max-h-80">
|
||||||
|
<div className="p-1">
|
||||||
|
{COLUMN_GROUPS.map((group) => (
|
||||||
|
<React.Fragment key={group.label}>
|
||||||
|
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/60">
|
||||||
|
{group.label}
|
||||||
|
</div>
|
||||||
|
{group.columns.map((col) => (
|
||||||
|
<label
|
||||||
|
key={col.id}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isColumnVisible(col.id)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
toggleColumn(col.id, checked !== false)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{col.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Desktop table */}
|
{/* Desktop table */}
|
||||||
<div className="glass-card overflow-hidden rounded-2xl hidden md:block">
|
<div className="glass-card overflow-hidden rounded-2xl hidden md:block">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
|
@ -455,9 +514,31 @@ export function DataTable<TData extends ResponseCard>({
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={columns.length}
|
colSpan={columns.length}
|
||||||
className="h-24 text-center text-muted-foreground"
|
className="h-40 text-center"
|
||||||
>
|
>
|
||||||
No results.
|
{hasActiveFilters ? (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<SearchX className="size-8 text-muted-foreground/30" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">No matching cards</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70">Try adjusting your filters or search terms</p>
|
||||||
|
{onClearFilters && (
|
||||||
|
<Button variant="outline" size="sm" className="mt-1 rounded-xl" onClick={onClearFilters}>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Upload className="size-8 text-muted-foreground/30" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">No cards yet</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70">Upload your first response card to get started</p>
|
||||||
|
{onUploadClick && (
|
||||||
|
<Button size="sm" className="mt-1 rounded-xl" onClick={onUploadClick}>
|
||||||
|
<Upload className="mr-1.5 size-3.5" /> Upload
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
|
|
@ -557,8 +638,30 @@ export function DataTable<TData extends ResponseCard>({
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
uploadingRows.length === 0 && (
|
uploadingRows.length === 0 && (
|
||||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
<div className="flex flex-col items-center py-12 text-center">
|
||||||
No results.
|
{hasActiveFilters ? (
|
||||||
|
<>
|
||||||
|
<SearchX className="size-8 text-muted-foreground/30 mb-2" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">No matching cards</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-0.5">Try adjusting your filters or search terms</p>
|
||||||
|
{onClearFilters && (
|
||||||
|
<Button variant="outline" size="sm" className="mt-3 rounded-xl" onClick={onClearFilters}>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="size-8 text-muted-foreground/30 mb-2" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">No cards yet</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-0.5">Upload your first response card to get started</p>
|
||||||
|
{onUploadClick && (
|
||||||
|
<Button size="sm" className="mt-3 rounded-xl" onClick={onUploadClick}>
|
||||||
|
<Upload className="mr-1.5 size-3.5" /> Upload
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
191
src/components/cards/dynamic-field.tsx
Normal file
191
src/components/cards/dynamic-field.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type FormFieldDef = {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
section: string;
|
||||||
|
required: boolean;
|
||||||
|
options?: string[] | null;
|
||||||
|
placeholder?: string | null;
|
||||||
|
helpText?: string | null;
|
||||||
|
isCore: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DynamicFieldProps {
|
||||||
|
field: FormFieldDef;
|
||||||
|
value: unknown;
|
||||||
|
onChange: (value: unknown) => void;
|
||||||
|
readOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { FormFieldDef };
|
||||||
|
|
||||||
|
export function DynamicField({ field, value, onChange, readOnly }: DynamicFieldProps) {
|
||||||
|
const opts = Array.isArray(field.options) ? (field.options as string[]) : [];
|
||||||
|
|
||||||
|
const renderInput = () => {
|
||||||
|
switch (field.type) {
|
||||||
|
case "text":
|
||||||
|
case "email":
|
||||||
|
case "phone":
|
||||||
|
case "url":
|
||||||
|
case "number": {
|
||||||
|
const inputType = field.type === "phone" ? "tel" : field.type;
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type={inputType}
|
||||||
|
value={String(value ?? "")}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(
|
||||||
|
field.type === "number"
|
||||||
|
? e.target.value === ""
|
||||||
|
? null
|
||||||
|
: Number(e.target.value)
|
||||||
|
: e.target.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
readOnly={readOnly}
|
||||||
|
placeholder={field.placeholder || undefined}
|
||||||
|
className={readOnly ? "opacity-70 cursor-default" : ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "textarea":
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
value={String(value ?? "")}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
readOnly={readOnly}
|
||||||
|
placeholder={field.placeholder || undefined}
|
||||||
|
className={readOnly ? "opacity-70 cursor-default" : ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "date":
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={value ? String(value).slice(0, 10) : ""}
|
||||||
|
onChange={(e) => onChange(e.target.value || null)}
|
||||||
|
readOnly={readOnly}
|
||||||
|
className={readOnly ? "opacity-70 cursor-default" : ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "select":
|
||||||
|
case "radio": {
|
||||||
|
if (readOnly) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={value ? String(value) : "—"}
|
||||||
|
readOnly
|
||||||
|
className="opacity-70 cursor-default"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={String(value ?? "")}
|
||||||
|
onChange={(e) => onChange(e.target.value || null)}
|
||||||
|
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30"
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{opts.map((opt) => (
|
||||||
|
<option key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "multiselect": {
|
||||||
|
const selected = new Set(
|
||||||
|
Array.isArray(value) ? (value as string[]) : []
|
||||||
|
);
|
||||||
|
const toggle = (opt: string) => {
|
||||||
|
if (readOnly) return;
|
||||||
|
const next = new Set(selected);
|
||||||
|
if (next.has(opt)) next.delete(opt);
|
||||||
|
else next.add(opt);
|
||||||
|
onChange(Array.from(next));
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{opts.map((opt) => {
|
||||||
|
const isOn = selected.has(opt);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(opt)}
|
||||||
|
disabled={readOnly}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
isOn
|
||||||
|
? "border-primary/40 bg-primary/10 text-primary hover:bg-primary/20"
|
||||||
|
: "border-border bg-muted/30 text-muted-foreground hover:bg-muted/60 hover:text-foreground",
|
||||||
|
readOnly && "cursor-default opacity-70"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "checkbox":
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<Checkbox
|
||||||
|
checked={Boolean(value)}
|
||||||
|
onCheckedChange={
|
||||||
|
readOnly ? undefined : (checked: boolean) => onChange(checked)
|
||||||
|
}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={String(value ?? "")}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
readOnly={readOnly}
|
||||||
|
placeholder={field.placeholder || undefined}
|
||||||
|
className={readOnly ? "opacity-70 cursor-default" : ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={field.type === "textarea" ? "sm:col-span-2" : ""}>
|
||||||
|
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
||||||
|
{field.label}
|
||||||
|
{field.required && (
|
||||||
|
<span className="text-destructive ml-0.5">*</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
{renderInput()}
|
||||||
|
{field.helpText && (
|
||||||
|
<p className="mt-1 text-[11px] text-muted-foreground/70">
|
||||||
|
{field.helpText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,9 @@ import {
|
||||||
Clock,
|
Clock,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
type Stats = {
|
type Stats = {
|
||||||
total: number;
|
total: number;
|
||||||
|
|
@ -36,11 +38,14 @@ interface StatCardsProps {
|
||||||
export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
|
export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
|
||||||
const [stats, setStats] = React.useState<Stats | null>(null);
|
const [stats, setStats] = React.useState<Stats | null>(null);
|
||||||
|
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
fetch("/api/stats")
|
fetch("/api/stats")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then(setStats)
|
.then(setStats)
|
||||||
.catch(() => {});
|
.catch(() => { toast.error("Failed to load card stats"); })
|
||||||
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cards: StatCardData[] = React.useMemo(() => {
|
const cards: StatCardData[] = React.useMemo(() => {
|
||||||
|
|
@ -105,8 +110,18 @@ export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-2 sm:gap-3 md:grid-cols-5">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 md:grid-cols-5">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-[88px] rounded-xl sm:rounded-2xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 md:grid-cols-5">
|
||||||
{cards.map((card) => {
|
{cards.map((card) => {
|
||||||
const Icon = card.icon;
|
const Icon = card.icon;
|
||||||
const isActive = activeFilter === card.filterKey;
|
const isActive = activeFilter === card.filterKey;
|
||||||
|
|
@ -131,7 +146,7 @@ export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-bold tracking-tight sm:text-2xl">
|
<p className="text-lg font-bold tracking-tight sm:text-2xl">
|
||||||
{stats ? card.value.toLocaleString() : "—"}
|
{card.value.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-muted-foreground sm:text-xs">
|
<p className="text-[11px] text-muted-foreground sm:text-xs">
|
||||||
{card.label}
|
{card.label}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ export type UploadingFile = {
|
||||||
interface UploadModalProps {
|
interface UploadModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
initialFiles?: File[];
|
||||||
onUploadStart?: (files: UploadingFile[]) => void;
|
onUploadStart?: (files: UploadingFile[]) => void;
|
||||||
onUploadProgress?: (fileId: string, progress: number) => void;
|
onUploadProgress?: (fileId: string, progress: number) => void;
|
||||||
onUploadComplete?: (fileId: string) => void;
|
onUploadComplete?: (fileId: string) => void;
|
||||||
|
|
@ -46,6 +47,7 @@ interface UploadModalProps {
|
||||||
export function UploadModal({
|
export function UploadModal({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
initialFiles,
|
||||||
onUploadStart,
|
onUploadStart,
|
||||||
onUploadProgress,
|
onUploadProgress,
|
||||||
onUploadComplete,
|
onUploadComplete,
|
||||||
|
|
@ -56,6 +58,17 @@ export function UploadModal({
|
||||||
const [dragOver, setDragOver] = React.useState(false);
|
const [dragOver, setDragOver] = React.useState(false);
|
||||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (initialFiles && initialFiles.length > 0) {
|
||||||
|
const valid = initialFiles.filter(
|
||||||
|
(f) => f.type === "application/pdf" || f.type.startsWith("image/")
|
||||||
|
);
|
||||||
|
if (valid.length) {
|
||||||
|
setStagedFiles((prev) => [...prev, ...valid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [initialFiles]);
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -95,6 +108,9 @@ export function UploadModal({
|
||||||
const filesToUpload = [...stagedFiles];
|
const filesToUpload = [...stagedFiles];
|
||||||
setStagedFiles([]);
|
setStagedFiles([]);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
for (let i = 0; i < filesToUpload.length; i++) {
|
for (let i = 0; i < filesToUpload.length; i++) {
|
||||||
const file = filesToUpload[i];
|
const file = filesToUpload[i];
|
||||||
const uploadEntry = newUploading[i];
|
const uploadEntry = newUploading[i];
|
||||||
|
|
@ -127,6 +143,7 @@ export function UploadModal({
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
onUploadComplete?.(uploadEntry.id);
|
onUploadComplete?.(uploadEntry.id);
|
||||||
|
successCount++;
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
const errMsg = "Upload failed";
|
const errMsg = "Upload failed";
|
||||||
|
|
@ -138,6 +155,7 @@ export function UploadModal({
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
onUploadError?.(uploadEntry.id, errMsg);
|
onUploadError?.(uploadEntry.id, errMsg);
|
||||||
|
failCount++;
|
||||||
reject(new Error(errMsg));
|
reject(new Error(errMsg));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -152,6 +170,7 @@ export function UploadModal({
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
onUploadError?.(uploadEntry.id, errMsg);
|
onUploadError?.(uploadEntry.id, errMsg);
|
||||||
|
failCount++;
|
||||||
reject(new Error(errMsg));
|
reject(new Error(errMsg));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -163,8 +182,13 @@ export function UploadModal({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const completeCount = filesToUpload.length;
|
if (failCount === 0) {
|
||||||
toast.success(`Uploaded ${completeCount} file(s). Processing started.`);
|
toast.success(`Uploaded ${successCount} file(s). Processing started.`);
|
||||||
|
} else if (successCount === 0) {
|
||||||
|
toast.error(`All ${failCount} file(s) failed to upload`);
|
||||||
|
} else {
|
||||||
|
toast.warning(`Uploaded ${successCount} file(s), ${failCount} failed`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
|
|
||||||
148
src/components/command-palette.tsx
Normal file
148
src/components/command-palette.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Command } from "cmdk";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
CreditCard,
|
||||||
|
Users,
|
||||||
|
CalendarDays,
|
||||||
|
BarChart3,
|
||||||
|
Settings,
|
||||||
|
Upload,
|
||||||
|
UserPlus,
|
||||||
|
FileText,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ label: "Dashboard", href: "/", icon: LayoutDashboard, group: "Navigate" },
|
||||||
|
{ label: "Response Cards", href: "/cards", icon: CreditCard, group: "Navigate" },
|
||||||
|
{ label: "People", href: "/people", icon: Users, group: "Navigate" },
|
||||||
|
{ label: "Collection Days", href: "/events", icon: CalendarDays, group: "Navigate" },
|
||||||
|
{ label: "Reports", href: "/reports", icon: BarChart3, group: "Navigate" },
|
||||||
|
{ label: "Settings", href: "/settings", icon: Settings, group: "Navigate" },
|
||||||
|
{ label: "Form Templates", href: "/settings/form-templates", icon: FileText, group: "Navigate" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const actionItems = [
|
||||||
|
{ label: "Upload Documents", icon: Upload, group: "Actions", action: "upload" },
|
||||||
|
{ label: "Create Person", icon: UserPlus, group: "Actions", action: "create-person" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CommandPalette() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((prev) => !prev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runAction = useCallback(
|
||||||
|
(action: string) => {
|
||||||
|
setOpen(false);
|
||||||
|
if (action === "upload") {
|
||||||
|
window.dispatchEvent(new CustomEvent("open-upload-modal"));
|
||||||
|
} else if (action === "create-person") {
|
||||||
|
router.push("/people?new=true");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const navigate = useCallback(
|
||||||
|
(href: string) => {
|
||||||
|
setOpen(false);
|
||||||
|
router.push(href);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/40 backdrop-blur-sm"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
<Command
|
||||||
|
className="relative w-full max-w-lg overflow-hidden rounded-2xl border border-border/60 bg-background/95 shadow-2xl backdrop-blur-xl"
|
||||||
|
loop
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 border-b border-border/40 px-4">
|
||||||
|
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<Command.Input
|
||||||
|
placeholder="Type a command or search…"
|
||||||
|
className="h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<kbd className="hidden shrink-0 rounded-md border border-border/60 bg-muted/50 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:inline-block">
|
||||||
|
ESC
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Command.List className="max-h-80 overflow-y-auto p-2">
|
||||||
|
<Command.Empty className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No results found.
|
||||||
|
</Command.Empty>
|
||||||
|
|
||||||
|
<Command.Group
|
||||||
|
heading="Navigate"
|
||||||
|
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
|
||||||
|
>
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Command.Item
|
||||||
|
key={item.href}
|
||||||
|
value={item.label}
|
||||||
|
onSelect={() => navigate(item.href)}
|
||||||
|
className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-foreground data-[selected=true]:bg-accent/60"
|
||||||
|
>
|
||||||
|
<item.icon className="size-4 text-muted-foreground" />
|
||||||
|
{item.label}
|
||||||
|
</Command.Item>
|
||||||
|
))}
|
||||||
|
</Command.Group>
|
||||||
|
|
||||||
|
<Command.Separator className="my-1 h-px bg-border/40" />
|
||||||
|
|
||||||
|
<Command.Group
|
||||||
|
heading="Actions"
|
||||||
|
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
|
||||||
|
>
|
||||||
|
{actionItems.map((item) => (
|
||||||
|
<Command.Item
|
||||||
|
key={item.action}
|
||||||
|
value={item.label}
|
||||||
|
onSelect={() => runAction(item.action)}
|
||||||
|
className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-foreground data-[selected=true]:bg-accent/60"
|
||||||
|
>
|
||||||
|
<item.icon className="size-4 text-muted-foreground" />
|
||||||
|
{item.label}
|
||||||
|
</Command.Item>
|
||||||
|
))}
|
||||||
|
</Command.Group>
|
||||||
|
</Command.List>
|
||||||
|
|
||||||
|
<div className="border-t border-border/40 px-4 py-2">
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
<kbd className="rounded border border-border/60 bg-muted/50 px-1 py-0.5 text-[10px] font-medium">↑↓</kbd>{" "}
|
||||||
|
navigate{" "}
|
||||||
|
<kbd className="ml-2 rounded border border-border/60 bg-muted/50 px-1 py-0.5 text-[10px] font-medium">↵</kbd>{" "}
|
||||||
|
select{" "}
|
||||||
|
<kbd className="ml-2 rounded border border-border/60 bg-muted/50 px-1 py-0.5 text-[10px] font-medium">esc</kbd>{" "}
|
||||||
|
close
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Command>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
src/components/forms/share-section.tsx
Normal file
146
src/components/forms/share-section.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Copy, Check, Download, ExternalLink, QrCode } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
type ShareSectionProps = {
|
||||||
|
templateId: string;
|
||||||
|
orgSlug: string;
|
||||||
|
formSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ShareSection({
|
||||||
|
templateId,
|
||||||
|
orgSlug,
|
||||||
|
formSlug,
|
||||||
|
}: ShareSectionProps) {
|
||||||
|
const [copied, setCopied] = React.useState(false);
|
||||||
|
|
||||||
|
const baseUrl =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? window.location.origin
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const surveyUrl = `${baseUrl}/s/${orgSlug}/${formSlug}`;
|
||||||
|
const qrEndpoint = `/api/form-templates/${templateId}/qr`;
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(surveyUrl);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.value = surveyUrl;
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(input);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadQR = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(qrEndpoint);
|
||||||
|
if (!res.ok) throw new Error("Failed to download");
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `qr-${formSlug}.png`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
console.error("Failed to download QR code");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="glass-card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<QrCode className="size-4" />
|
||||||
|
Share & QR Code
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
Public Survey URL
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="glass-input flex-1 truncate rounded-lg px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
{surveyUrl}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="size-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-4" />
|
||||||
|
)}
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
render={
|
||||||
|
<a href={surveyUrl} target="_blank" rel="noopener noreferrer" />
|
||||||
|
}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
QR Code
|
||||||
|
</label>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="overflow-hidden rounded-xl border border-border bg-white p-2">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={qrEndpoint}
|
||||||
|
alt="Survey QR Code"
|
||||||
|
width={160}
|
||||||
|
height={160}
|
||||||
|
className="size-40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Scan this QR code to open the survey on a mobile device. Print
|
||||||
|
it on cards, posters, or slides.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDownloadQR}
|
||||||
|
>
|
||||||
|
<Download className="size-4" />
|
||||||
|
Download QR
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,16 +4,25 @@ import { cn } from "@/lib/utils";
|
||||||
import { TopBar } from "@/components/layout/top-bar";
|
import { TopBar } from "@/components/layout/top-bar";
|
||||||
import { Sidebar, SidebarProvider, useSidebar } from "@/components/layout/sidebar";
|
import { Sidebar, SidebarProvider, useSidebar } from "@/components/layout/sidebar";
|
||||||
import { EmailVerificationBanner } from "@/components/layout/email-verification-banner";
|
import { EmailVerificationBanner } from "@/components/layout/email-verification-banner";
|
||||||
|
import { CommandPalette } from "@/components/command-palette";
|
||||||
|
|
||||||
function ShellContent({ children }: { children: React.ReactNode }) {
|
function ShellContent({ children }: { children: React.ReactNode }) {
|
||||||
const { collapsed } = useSidebar();
|
const { collapsed } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen">
|
<div className="relative min-h-screen">
|
||||||
|
<a
|
||||||
|
href="#main-content"
|
||||||
|
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:rounded-lg focus:bg-background focus:px-4 focus:py-2 focus:text-foreground focus:shadow-lg focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
Skip to main content
|
||||||
|
</a>
|
||||||
<div className="gradient-mesh pointer-events-none fixed inset-0 z-0" />
|
<div className="gradient-mesh pointer-events-none fixed inset-0 z-0" />
|
||||||
<TopBar />
|
<TopBar />
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
<CommandPalette />
|
||||||
<main
|
<main
|
||||||
|
id="main-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-0 pt-16 transition-[margin-left] duration-200 ease-in-out",
|
"relative z-0 pt-16 transition-[margin-left] duration-200 ease-in-out",
|
||||||
collapsed ? "ml-16" : "ml-60"
|
collapsed ? "ml-16" : "ml-60"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Check, ChevronsUpDown, Plus } from "lucide-react";
|
import { Check, ChevronsUpDown, Plus, Loader2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useUserProfile } from "@/lib/user-profile";
|
import { useUserProfile } from "@/lib/user-profile";
|
||||||
import {
|
import {
|
||||||
|
|
@ -31,15 +32,21 @@ export function OrgSwitcher({ collapsed }: { collapsed: boolean }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [orgs, setOrgs] = React.useState<OrgListItem[]>([]);
|
const [orgs, setOrgs] = React.useState<OrgListItem[]>([]);
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [orgsLoading, setOrgsLoading] = React.useState(false);
|
||||||
|
|
||||||
const fetchOrgs = React.useCallback(async () => {
|
const fetchOrgs = React.useCallback(async () => {
|
||||||
|
setOrgsLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/org/list");
|
const res = await fetch("/api/org/list");
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setOrgs(data.orgs ?? []);
|
setOrgs(data.orgs ?? []);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {
|
||||||
|
toast.error("Failed to load workspaces");
|
||||||
|
} finally {
|
||||||
|
setOrgsLoading(false);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const initial = orgName
|
const initial = orgName
|
||||||
|
|
@ -94,7 +101,11 @@ export function OrgSwitcher({ collapsed }: { collapsed: boolean }) {
|
||||||
sideOffset={collapsed ? 8 : 4}
|
sideOffset={collapsed ? 8 : 4}
|
||||||
className="w-64"
|
className="w-64"
|
||||||
>
|
>
|
||||||
{orgs.map((org) => (
|
{orgsLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-3">
|
||||||
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : orgs.map((org) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={org.id}
|
key={org.id}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ function NavItem({
|
||||||
const content = (
|
const content = (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||||
active
|
active
|
||||||
|
|
@ -139,6 +140,7 @@ export function Sidebar() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
|
aria-label="Main navigation"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-0 top-16 z-30 flex h-[calc(100vh-4rem)] flex-col border-r border-sidebar-border bg-sidebar transition-[width] duration-200 ease-in-out",
|
"fixed left-0 top-16 z-30 flex h-[calc(100vh-4rem)] flex-col border-r border-sidebar-border bg-sidebar transition-[width] duration-200 ease-in-out",
|
||||||
collapsed ? "w-16" : "w-60"
|
collapsed ? "w-16" : "w-60"
|
||||||
|
|
@ -182,6 +184,7 @@ export function Sidebar() {
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
|
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-sidebar-foreground/50 transition-colors hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
|
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-sidebar-foreground/50 transition-colors hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
|
||||||
collapsed && "justify-center px-0"
|
collapsed && "justify-center px-0"
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ export function TopBar() {
|
||||||
<div className="flex size-9 items-center justify-center rounded-xl gradient-banner shadow-sm">
|
<div className="flex size-9 items-center justify-center rounded-xl gradient-banner shadow-sm">
|
||||||
<ScanLine className="size-[18px] text-white" />
|
<ScanLine className="size-[18px] text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-base font-bold tracking-tight">Echo OCR</span>
|
<span className="text-base font-bold tracking-tight">Echo</span>
|
||||||
</Link>
|
</Link>
|
||||||
{process.env.NEXT_PUBLIC_ENV === "staging" && (
|
{process.env.NEXT_PUBLIC_ENV === "staging" && (
|
||||||
<span className="rounded-md bg-amber-500/15 px-2 py-0.5 text-xs font-semibold uppercase tracking-wider text-amber-600 ring-1 ring-amber-500/25 dark:text-amber-400">
|
<span className="rounded-md bg-amber-500/15 px-2 py-0.5 text-xs font-semibold uppercase tracking-wider text-amber-600 ring-1 ring-amber-500/25 dark:text-amber-400">
|
||||||
|
|
@ -93,6 +93,7 @@ export function TopBar() {
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-9 rounded-xl sm:hidden"
|
className="size-9 rounded-xl sm:hidden"
|
||||||
onClick={handleUploadClick}
|
onClick={handleUploadClick}
|
||||||
|
aria-label="Upload documents"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -106,7 +107,7 @@ export function TopBar() {
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
render={
|
render={
|
||||||
<Button variant="ghost" size="icon" className="size-9 rounded-xl" />
|
<Button variant="ghost" size="icon" className="size-9 rounded-xl" aria-label="User menu" />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Avatar size="sm">
|
<Avatar size="sm">
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,22 @@ import { useTheme } from "next-themes";
|
||||||
|
|
||||||
function ThemeToggle() {
|
function ThemeToggle() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
const cycleTheme = () => {
|
||||||
|
if (theme === "light") setTheme("dark");
|
||||||
|
else if (theme === "dark") setTheme("system");
|
||||||
|
else setTheme("light");
|
||||||
|
};
|
||||||
|
|
||||||
|
const themeLabel =
|
||||||
|
theme === "dark" ? "Dark" : theme === "light" ? "Light" : "System";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
onClick={cycleTheme}
|
||||||
className="rounded-full p-2 text-muted-foreground hover:text-foreground transition-colors"
|
className="rounded-full p-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
aria-label="Toggle theme"
|
aria-label={`Theme: ${themeLabel}`}
|
||||||
|
title={`Theme: ${themeLabel}`}
|
||||||
>
|
>
|
||||||
<Sun className="h-4 w-4 hidden dark:block" />
|
<Sun className="h-4 w-4 hidden dark:block" />
|
||||||
<Moon className="h-4 w-4 block dark:hidden" />
|
<Moon className="h-4 w-4 block dark:hidden" />
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type Notification = {
|
type Notification = {
|
||||||
|
|
@ -34,6 +36,7 @@ export function NotificationCenter() {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [notifications, setNotifications] = React.useState<Notification[]>([]);
|
const [notifications, setNotifications] = React.useState<Notification[]>([]);
|
||||||
const [unreadCount, setUnreadCount] = React.useState(0);
|
const [unreadCount, setUnreadCount] = React.useState(0);
|
||||||
|
const [listLoading, setListLoading] = React.useState(false);
|
||||||
|
|
||||||
const fetchUnreadCount = React.useCallback(async () => {
|
const fetchUnreadCount = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -42,7 +45,9 @@ export function NotificationCenter() {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setUnreadCount(data.unreadCount ?? 0);
|
setUnreadCount(data.unreadCount ?? 0);
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
// Polling failure — don't toast on every interval tick
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -52,6 +57,7 @@ export function NotificationCenter() {
|
||||||
}, [fetchUnreadCount]);
|
}, [fetchUnreadCount]);
|
||||||
|
|
||||||
const fetchNotifications = React.useCallback(async () => {
|
const fetchNotifications = React.useCallback(async () => {
|
||||||
|
setListLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/notifications?limit=30");
|
const res = await fetch("/api/notifications?limit=30");
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|
@ -59,7 +65,11 @@ export function NotificationCenter() {
|
||||||
setNotifications(data.notifications ?? []);
|
setNotifications(data.notifications ?? []);
|
||||||
setUnreadCount(data.unreadCount ?? 0);
|
setUnreadCount(data.unreadCount ?? 0);
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
toast.error("Failed to load notifications");
|
||||||
|
} finally {
|
||||||
|
setListLoading(false);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -75,7 +85,9 @@ export function NotificationCenter() {
|
||||||
});
|
});
|
||||||
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||||
setUnreadCount(0);
|
setUnreadCount(0);
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
toast.error("Failed to mark notifications as read");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const markRead = async (id: string) => {
|
const markRead = async (id: string) => {
|
||||||
|
|
@ -87,7 +99,9 @@ export function NotificationCenter() {
|
||||||
});
|
});
|
||||||
setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n));
|
setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n));
|
||||||
setUnreadCount((c) => Math.max(0, c - 1));
|
setUnreadCount((c) => Math.max(0, c - 1));
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
toast.error("Failed to update notification");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -115,7 +129,19 @@ export function NotificationCenter() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-80 overflow-y-auto">
|
<div className="max-h-80 overflow-y-auto">
|
||||||
{notifications.length === 0 ? (
|
{listLoading ? (
|
||||||
|
<div className="space-y-1 p-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex gap-3 py-2">
|
||||||
|
<Skeleton className="size-7 shrink-0 rounded-full" />
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<Skeleton className="h-3 w-3/4 rounded" />
|
||||||
|
<Skeleton className="h-3 w-1/2 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : notifications.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
||||||
<Bell className="size-8 mb-2 opacity-20" />
|
<Bell className="size-8 mb-2 opacity-20" />
|
||||||
<p className="text-sm">No notifications</p>
|
<p className="text-sm">No notifications</p>
|
||||||
|
|
@ -158,18 +184,12 @@ function NotificationItem({
|
||||||
if (n.actionUrl) onClose();
|
if (n.actionUrl) onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = (
|
const inner = (
|
||||||
<div
|
<>
|
||||||
className={cn(
|
|
||||||
"flex gap-3 px-3 py-2.5 border-b border-border/30 transition-colors hover:bg-muted/50 cursor-pointer",
|
|
||||||
!n.read && "bg-primary/[0.03]"
|
|
||||||
)}
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
<div className={cn("flex size-7 shrink-0 items-center justify-center rounded-full mt-0.5", color)}>
|
<div className={cn("flex size-7 shrink-0 items-center justify-center rounded-full mt-0.5", color)}>
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1 text-left">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className={cn("text-xs font-medium truncate", !n.read && "text-foreground")}>{n.title}</span>
|
<span className={cn("text-xs font-medium truncate", !n.read && "text-foreground")}>{n.title}</span>
|
||||||
{!n.read && <span className="size-1.5 shrink-0 rounded-full bg-primary" />}
|
{!n.read && <span className="size-1.5 shrink-0 rounded-full bg-primary" />}
|
||||||
|
|
@ -180,13 +200,27 @@ function NotificationItem({
|
||||||
{formatTimeAgo(n.createdAt)}
|
{formatTimeAgo(n.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const sharedClasses = cn(
|
||||||
|
"flex w-full gap-3 px-3 py-2.5 border-b border-border/30 transition-colors hover:bg-muted/50 cursor-pointer",
|
||||||
|
!n.read && "bg-primary/[0.03]"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (n.actionUrl) {
|
if (n.actionUrl) {
|
||||||
return <Link href={n.actionUrl}>{content}</Link>;
|
return (
|
||||||
|
<Link href={n.actionUrl} className={sharedClasses} onClick={handleClick}>
|
||||||
|
{inner}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return content;
|
|
||||||
|
return (
|
||||||
|
<button type="button" className={sharedClasses} onClick={handleClick}>
|
||||||
|
{inner}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimeAgo(dateStr: string): string {
|
function formatTimeAgo(dateStr: string): string {
|
||||||
|
|
|
||||||
373
src/components/survey/survey-form.tsx
Normal file
373
src/components/survey/survey-form.tsx
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { CheckCircle2, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
type FormField = {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
section: string;
|
||||||
|
required: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
options: unknown;
|
||||||
|
placeholder: string | null;
|
||||||
|
helpText: string | null;
|
||||||
|
isCore: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SurveyFormProps = {
|
||||||
|
formTemplateId: string;
|
||||||
|
organizationId: string;
|
||||||
|
fields: FormField[];
|
||||||
|
branding: { primaryColor?: string; logoUrl?: string } | null;
|
||||||
|
prefill: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SurveyForm({
|
||||||
|
formTemplateId,
|
||||||
|
organizationId,
|
||||||
|
fields,
|
||||||
|
branding,
|
||||||
|
prefill,
|
||||||
|
}: SurveyFormProps) {
|
||||||
|
const [values, setValues] = React.useState<Record<string, string>>(() => {
|
||||||
|
const init: Record<string, string> = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
init[field.key] = prefill[field.key] ?? "";
|
||||||
|
}
|
||||||
|
init.firstName = prefill.firstName ?? "";
|
||||||
|
init.lastName = prefill.lastName ?? "";
|
||||||
|
return init;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [submitting, setSubmitting] = React.useState(false);
|
||||||
|
const [submitted, setSubmitted] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const primaryColor = branding?.primaryColor || undefined;
|
||||||
|
|
||||||
|
const handleChange = (key: string, value: string) => {
|
||||||
|
setValues((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!values.firstName?.trim() || !values.lastName?.trim()) {
|
||||||
|
setError("First name and last name are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const { firstName, lastName, ...rest } = values;
|
||||||
|
const res = await fetch("/api/survey/submit", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
formTemplateId,
|
||||||
|
organizationId,
|
||||||
|
firstName: firstName.trim(),
|
||||||
|
lastName: lastName.trim(),
|
||||||
|
fieldData: rest,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
throw new Error(data?.error || "Submission failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitted(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Something went wrong");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (submitted) {
|
||||||
|
return (
|
||||||
|
<div className="glass-card flex flex-col items-center gap-4 rounded-2xl p-10 text-center">
|
||||||
|
<div
|
||||||
|
className={`flex size-14 items-center justify-center rounded-full ${
|
||||||
|
primaryColor ? "" : "bg-green-100 dark:bg-green-900/30"
|
||||||
|
}`}
|
||||||
|
style={primaryColor ? { backgroundColor: `${primaryColor}20` } : undefined}
|
||||||
|
>
|
||||||
|
<CheckCircle2
|
||||||
|
className={`size-7 ${primaryColor ? "" : "text-green-600 dark:text-green-400"}`}
|
||||||
|
style={primaryColor ? { color: primaryColor } : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">Thank you!</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Your response has been submitted successfully.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonCoreFields = fields.filter(
|
||||||
|
(f) => f.key !== "firstName" && f.key !== "lastName"
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="glass-card space-y-6 rounded-2xl p-6 sm:p-8">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FieldInput
|
||||||
|
label="First Name"
|
||||||
|
value={values.firstName}
|
||||||
|
onChange={(v) => handleChange("firstName", v)}
|
||||||
|
required
|
||||||
|
placeholder="First name"
|
||||||
|
primaryColor={primaryColor}
|
||||||
|
/>
|
||||||
|
<FieldInput
|
||||||
|
label="Last Name"
|
||||||
|
value={values.lastName}
|
||||||
|
onChange={(v) => handleChange("lastName", v)}
|
||||||
|
required
|
||||||
|
placeholder="Last name"
|
||||||
|
primaryColor={primaryColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{nonCoreFields.map((field) => (
|
||||||
|
<div
|
||||||
|
key={field.id}
|
||||||
|
className={
|
||||||
|
field.type === "textarea" || field.type === "multiline"
|
||||||
|
? "sm:col-span-2"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FieldRenderer
|
||||||
|
field={field}
|
||||||
|
value={values[field.key] ?? ""}
|
||||||
|
onChange={(v) => handleChange(field.key, v)}
|
||||||
|
primaryColor={primaryColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-xl px-6 py-3 text-sm font-semibold text-white transition-opacity disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: primaryColor || "oklch(0.20 0.005 90)" }}
|
||||||
|
>
|
||||||
|
{submitting && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
{submitting ? "Submitting..." : "Submit"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldInput({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
required,
|
||||||
|
placeholder,
|
||||||
|
type = "text",
|
||||||
|
primaryColor,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
required?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
type?: string;
|
||||||
|
primaryColor?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-medium text-foreground">
|
||||||
|
{label}
|
||||||
|
{required && <span className="ml-0.5 text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
required={required}
|
||||||
|
placeholder={placeholder || undefined}
|
||||||
|
className="glass-input w-full rounded-lg px-3 py-2 text-sm outline-none transition-shadow focus:ring-2"
|
||||||
|
style={primaryColor ? { "--tw-ring-color": primaryColor } as React.CSSProperties : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldRenderer({
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
primaryColor,
|
||||||
|
}: {
|
||||||
|
field: FormField;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
primaryColor?: string;
|
||||||
|
}) {
|
||||||
|
const options = Array.isArray(field.options) ? field.options as string[] : [];
|
||||||
|
|
||||||
|
switch (field.type) {
|
||||||
|
case "select":
|
||||||
|
case "dropdown":
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-medium text-foreground">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="ml-0.5 text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
{field.helpText && (
|
||||||
|
<p className="text-xs text-muted-foreground">{field.helpText}</p>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
required={field.required}
|
||||||
|
className="glass-input w-full rounded-lg px-3 py-2 text-sm outline-none transition-shadow focus:ring-2"
|
||||||
|
style={primaryColor ? { "--tw-ring-color": primaryColor } as React.CSSProperties : undefined}
|
||||||
|
>
|
||||||
|
<option value="">{field.placeholder || "Select..."}</option>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<option key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "checkbox":
|
||||||
|
case "multi-select":
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-medium text-foreground">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="ml-0.5 text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
{field.helpText && (
|
||||||
|
<p className="text-xs text-muted-foreground">{field.helpText}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{options.map((opt) => {
|
||||||
|
const selected = value
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.includes(opt);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const current = value
|
||||||
|
? value.split(",").map((s) => s.trim())
|
||||||
|
: [];
|
||||||
|
const next = selected
|
||||||
|
? current.filter((v) => v !== opt)
|
||||||
|
: [...current, opt];
|
||||||
|
onChange(next.join(","));
|
||||||
|
}}
|
||||||
|
className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${
|
||||||
|
selected
|
||||||
|
? "border-transparent text-white"
|
||||||
|
: "border-border bg-background text-foreground hover:bg-muted"
|
||||||
|
}`}
|
||||||
|
style={
|
||||||
|
selected
|
||||||
|
? { backgroundColor: primaryColor || "oklch(0.20 0.005 90)" }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "textarea":
|
||||||
|
case "multiline":
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-medium text-foreground">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="ml-0.5 text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
{field.helpText && (
|
||||||
|
<p className="text-xs text-muted-foreground">{field.helpText}</p>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
required={field.required}
|
||||||
|
placeholder={field.placeholder || undefined}
|
||||||
|
rows={3}
|
||||||
|
className="glass-input w-full rounded-lg px-3 py-2 text-sm outline-none transition-shadow focus:ring-2"
|
||||||
|
style={primaryColor ? { "--tw-ring-color": primaryColor } as React.CSSProperties : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "boolean":
|
||||||
|
case "toggle":
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 pt-5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={value === "true"}
|
||||||
|
onClick={() => onChange(value === "true" ? "false" : "true")}
|
||||||
|
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${
|
||||||
|
value === "true" ? "" : "bg-muted"
|
||||||
|
}`}
|
||||||
|
style={
|
||||||
|
value === "true"
|
||||||
|
? { backgroundColor: primaryColor || "oklch(0.20 0.005 90)" }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute left-0.5 top-0.5 size-5 rounded-full bg-white shadow-sm transition-transform ${
|
||||||
|
value === "true" ? "translate-x-5" : "translate-x-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
{field.label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<FieldInput
|
||||||
|
label={field.label}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
required={field.required}
|
||||||
|
placeholder={field.placeholder || undefined}
|
||||||
|
type={field.type === "email" ? "email" : field.type === "phone" || field.type === "tel" ? "tel" : "text"}
|
||||||
|
primaryColor={primaryColor}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prisma } from "./db";
|
import { prisma } from "./db";
|
||||||
import type { LanguageModel } from "ai";
|
import type { LanguageModel } from "ai";
|
||||||
|
import type { FormField as PrismaFormField } from "@/generated/prisma/client";
|
||||||
|
|
||||||
const DEFAULT_GATEWAY_MODEL = "openai/gpt-4o-mini";
|
const DEFAULT_GATEWAY_MODEL = "openai/gpt-4o-mini";
|
||||||
const DEFAULT_OLLAMA_MODEL = "llava:7b";
|
const DEFAULT_OLLAMA_MODEL = "llava:7b";
|
||||||
|
|
@ -107,6 +108,79 @@ SPECIAL ATTENTION for "Next Step" question (question 2): It has exactly 2 checkb
|
||||||
|
|
||||||
Be precise: return null for fields you cannot read.`;
|
Be precise: return null for fields you cannot read.`;
|
||||||
|
|
||||||
|
type FormFieldDef = Pick<
|
||||||
|
PrismaFormField,
|
||||||
|
"key" | "label" | "type" | "options" | "visibleOnCard" | "visibleOnSurvey"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function buildSchemaFromFields(fields: FormFieldDef[]) {
|
||||||
|
const shape: Record<string, z.ZodTypeAny> = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
const opts = Array.isArray(field.options)
|
||||||
|
? (field.options as string[])
|
||||||
|
: [];
|
||||||
|
switch (field.type) {
|
||||||
|
case "text":
|
||||||
|
case "email":
|
||||||
|
case "phone":
|
||||||
|
case "textarea":
|
||||||
|
case "url":
|
||||||
|
shape[field.key] = z.string().nullable().describe(field.label);
|
||||||
|
break;
|
||||||
|
case "select":
|
||||||
|
case "radio":
|
||||||
|
shape[field.key] = z
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.describe(
|
||||||
|
opts.length > 0
|
||||||
|
? `${field.label}. Options: ${opts.join(", ")}`
|
||||||
|
: field.label
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "multiselect":
|
||||||
|
shape[field.key] = z
|
||||||
|
.array(z.string())
|
||||||
|
.describe(
|
||||||
|
opts.length > 0
|
||||||
|
? `ONLY checked items for ${field.label}. Options: ${opts.join(", ")}`
|
||||||
|
: `Checked items for ${field.label}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "checkbox":
|
||||||
|
shape[field.key] = z
|
||||||
|
.boolean()
|
||||||
|
.describe(`Whether ${field.label} checkbox is checked`);
|
||||||
|
break;
|
||||||
|
case "date":
|
||||||
|
shape[field.key] = z
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.describe(`${field.label} as written`);
|
||||||
|
break;
|
||||||
|
case "number":
|
||||||
|
shape[field.key] = z.number().nullable().describe(field.label);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
shape[field.key] = z.string().nullable().describe(field.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shape.confidence = z
|
||||||
|
.number()
|
||||||
|
.min(0)
|
||||||
|
.max(100)
|
||||||
|
.describe("Your confidence in the OCR accuracy, 0-100");
|
||||||
|
return z.object(shape);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPromptFromFields(
|
||||||
|
fields: FormFieldDef[],
|
||||||
|
templateName: string
|
||||||
|
): string {
|
||||||
|
const fieldList = fields.map((f) => `- ${f.label} (${f.type})`).join("\n");
|
||||||
|
return `You are analyzing a scanned ${templateName}. Extract ALL of the following fields from the image:\n\n${fieldList}\n\nCHECKBOX RULES - be very strict:\n- A checkbox is CHECKED only if it has a visible mark inside it: an X, a checkmark, a filled square, or pen/pencil marks inside the box.\n- An EMPTY box means UNCHECKED.\n- When in doubt, treat a checkbox as UNCHECKED.\n\nFor handwritten text, read it as accurately as possible.\nBe precise: return null for fields you cannot read.`;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OcrResult {
|
export interface OcrResult {
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
confidence: number;
|
confidence: number;
|
||||||
|
|
@ -165,7 +239,8 @@ async function extractStructured<T extends Record<string, unknown>>(
|
||||||
|
|
||||||
export async function ocrImage(
|
export async function ocrImage(
|
||||||
imageBase64: string,
|
imageBase64: string,
|
||||||
side: "response" | "survey"
|
side: "response" | "survey",
|
||||||
|
templateFields?: { fields: FormFieldDef[]; templateName: string }
|
||||||
): Promise<OcrResult> {
|
): Promise<OcrResult> {
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
const provider = settings.aiProvider || "gateway";
|
const provider = settings.aiProvider || "gateway";
|
||||||
|
|
@ -174,7 +249,17 @@ export async function ocrImage(
|
||||||
|
|
||||||
let output: Record<string, unknown>;
|
let output: Record<string, unknown>;
|
||||||
|
|
||||||
if (side === "response") {
|
if (templateFields) {
|
||||||
|
const relevantFields = templateFields.fields.filter((f) =>
|
||||||
|
side === "response" ? f.visibleOnCard : f.visibleOnSurvey
|
||||||
|
);
|
||||||
|
const dynamicSchema = buildSchemaFromFields(relevantFields);
|
||||||
|
const dynamicPrompt = buildPromptFromFields(
|
||||||
|
relevantFields,
|
||||||
|
templateFields.templateName
|
||||||
|
);
|
||||||
|
output = await extractStructured(model, dynamicSchema, dynamicPrompt, imageBase64);
|
||||||
|
} else if (side === "response") {
|
||||||
output = await extractStructured(model, responseCardSchema, RESPONSE_SYSTEM_PROMPT, imageBase64);
|
output = await extractStructured(model, responseCardSchema, RESPONSE_SYSTEM_PROMPT, imageBase64);
|
||||||
} else {
|
} else {
|
||||||
output = await extractStructured(model, surveySchema, SURVEY_SYSTEM_PROMPT, imageBase64);
|
output = await extractStructured(model, surveySchema, SURVEY_SYSTEM_PROMPT, imageBase64);
|
||||||
|
|
|
||||||
33
src/lib/api-auth.ts
Normal file
33
src/lib/api-auth.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export class ApiAuthError extends Error {
|
||||||
|
constructor(message = "Unauthorized") {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiAuthError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireApiAuth() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
throw new ApiAuthError();
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireApiAuthWithOrg() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id || !session.user.orgId) {
|
||||||
|
throw new ApiAuthError();
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleApiError(error: unknown) {
|
||||||
|
if (error instanceof ApiAuthError) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
console.error("[API Error]", error);
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
@ -64,8 +64,6 @@ async function handleMessage(client: ImapFlow, uid: number, config: EmailConfig)
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPdf = att.contentType?.toLowerCase() === "application/pdf";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const job = await prisma.processingJob.create({
|
const job = await prisma.processingJob.create({
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -76,8 +74,7 @@ async function handleMessage(client: ImapFlow, uid: number, config: EmailConfig)
|
||||||
});
|
});
|
||||||
|
|
||||||
const sourceKey = `sources/${job.id}/${fileName}`;
|
const sourceKey = `sources/${job.id}/${fileName}`;
|
||||||
const contentType = isPdf ? "application/pdf" : "image/jpeg";
|
await uploadBuffer(sourceKey, buffer, att.contentType || "application/octet-stream");
|
||||||
await uploadBuffer(sourceKey, buffer, contentType);
|
|
||||||
|
|
||||||
await enqueueProcessing(job.id);
|
await enqueueProcessing(job.id);
|
||||||
log(`Queued: ${fileName} (job ${job.id})`);
|
log(`Queued: ${fileName} (job ${job.id})`);
|
||||||
|
|
|
||||||
65
src/lib/form-templates.ts
Normal file
65
src/lib/form-templates.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "./db";
|
||||||
|
|
||||||
|
const DEFAULT_FIELDS = [
|
||||||
|
{ key: "firstName", label: "First Name", type: "text", section: "personal", required: true, removable: false, isCore: true, sortOrder: 0 },
|
||||||
|
{ key: "lastName", label: "Last Name", type: "text", section: "personal", required: true, removable: false, isCore: true, sortOrder: 1 },
|
||||||
|
{ key: "email", label: "Email", type: "email", section: "contact", sortOrder: 2 },
|
||||||
|
{ key: "cellPhone", label: "Cell Phone", type: "phone", section: "contact", sortOrder: 3 },
|
||||||
|
{ key: "homePhone", label: "Home Phone", type: "phone", section: "contact", sortOrder: 4 },
|
||||||
|
{ key: "gender", label: "Gender", type: "select", section: "personal", options: ["Male", "Female"], sortOrder: 5 },
|
||||||
|
{ key: "dateOfBirth", label: "Date of Birth", type: "date", section: "personal", sortOrder: 6 },
|
||||||
|
{ key: "maritalStatus", label: "Marital Status", type: "select", section: "personal", options: ["Married", "Single", "Other"], sortOrder: 7 },
|
||||||
|
{ key: "visitType", label: "Visit Type", type: "select", section: "survey", options: ["First/Second Time Guest", "Update My Information"], sortOrder: 8 },
|
||||||
|
{ key: "address", label: "Address", type: "text", section: "address", sortOrder: 9 },
|
||||||
|
{ key: "aptNumber", label: "Apt #", type: "text", section: "address", sortOrder: 10 },
|
||||||
|
{ key: "city", label: "City", type: "text", section: "address", sortOrder: 11 },
|
||||||
|
{ key: "state", label: "State", type: "text", section: "address", sortOrder: 12 },
|
||||||
|
{ key: "zip", label: "Zip", type: "text", section: "address", sortOrder: 13 },
|
||||||
|
{ key: "prayerRequests", label: "Prayer Requests", type: "textarea", section: "survey", sortOrder: 14 },
|
||||||
|
{ key: "prayerForTeam", label: "For Prayer Team", type: "checkbox", section: "survey", sortOrder: 15 },
|
||||||
|
{ key: "prayerConfidential", label: "Confidential", type: "checkbox", section: "survey", sortOrder: 16 },
|
||||||
|
{ key: "messageTopics", label: "Message Topics", type: "multiselect", section: "survey", options: ["Stress/Anxiety", "Marriage", "Hearing God's Voice", "Dealing With Doubt", "Parenting", "Grief & Loss", "Forgiveness", "Finances", "Purpose/Calling", "Prayer", "Healthy Boundaries", "Understanding The Bible", "Emotional Health", "Sharing My Faith", "Decision Making", "Spiritual Disciplines", "Spiritual Gifts"], sortOrder: 17 },
|
||||||
|
{ key: "nextStep", label: "Next Steps", type: "multiselect", section: "survey", options: ["Baptism", "Next Steps"], sortOrder: 18 },
|
||||||
|
{ key: "attendanceDuration", label: "Attendance Duration", type: "radio", section: "survey", options: ["Less than 6 months", "6 Months - 1 Year", "1-3 Years", "4-6 Years", "7+ Years"], sortOrder: 19 },
|
||||||
|
{ key: "campusPreference", label: "Campus Preference", type: "multiselect", section: "survey", options: ["Beulah", "Pace/Milton", "Gulf Breeze", "Warrington"], sortOrder: 20 },
|
||||||
|
{ key: "howHeard", label: "How Did You Hear About Us?", type: "multiselect", section: "survey", options: ["This is my church home", "Regular Attender", "Drove by", "Social Media", "Google", "Personal Invite"], sortOrder: 21 },
|
||||||
|
{ key: "serviceAttended", label: "Service Attended", type: "select", section: "survey", options: ["A", "B", "C", "D"], sortOrder: 22 },
|
||||||
|
{ key: "followUp", label: "Follow-Up", type: "text", section: "followup", sortOrder: 23 },
|
||||||
|
{ key: "notes", label: "Notes", type: "textarea", section: "followup", sortOrder: 24 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function seedDefaultTemplate(organizationId: string) {
|
||||||
|
const existing = await prisma.formTemplate.findFirst({
|
||||||
|
where: { organizationId, isDefault: true },
|
||||||
|
});
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const template = await prisma.formTemplate.create({
|
||||||
|
data: {
|
||||||
|
organizationId,
|
||||||
|
name: "Connect Card",
|
||||||
|
slug: "connect-card",
|
||||||
|
description: "Default connect card template",
|
||||||
|
isDefault: true,
|
||||||
|
isActive: true,
|
||||||
|
fields: {
|
||||||
|
create: DEFAULT_FIELDS.map((f) => ({
|
||||||
|
key: f.key,
|
||||||
|
label: f.label,
|
||||||
|
type: f.type,
|
||||||
|
section: f.section,
|
||||||
|
required: f.required ?? false,
|
||||||
|
removable: f.removable ?? true,
|
||||||
|
isCore: f.isCore ?? false,
|
||||||
|
sortOrder: f.sortOrder,
|
||||||
|
options: f.options ?? Prisma.JsonNull,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { fields: true },
|
||||||
|
});
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DEFAULT_FIELDS };
|
||||||
16
src/lib/job-recovery.ts
Normal file
16
src/lib/job-recovery.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { prisma } from "./db";
|
||||||
|
|
||||||
|
export async function recoverStuckJobs() {
|
||||||
|
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
|
||||||
|
const stuck = await prisma.processingJob.updateMany({
|
||||||
|
where: {
|
||||||
|
status: "processing",
|
||||||
|
updatedAt: { lt: tenMinutesAgo },
|
||||||
|
},
|
||||||
|
data: { status: "queued" },
|
||||||
|
});
|
||||||
|
if (stuck.count > 0) {
|
||||||
|
console.log(`[job-recovery] Reset ${stuck.count} stuck job(s) to queued`);
|
||||||
|
}
|
||||||
|
return stuck.count;
|
||||||
|
}
|
||||||
|
|
@ -31,10 +31,11 @@ export async function createNotification(input: CreateNotificationInput) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getNotifications({
|
export async function getNotifications({
|
||||||
|
userId,
|
||||||
unreadOnly = false,
|
unreadOnly = false,
|
||||||
limit = 50,
|
limit = 50,
|
||||||
}: { unreadOnly?: boolean; limit?: number } = {}) {
|
}: { userId: string; unreadOnly?: boolean; limit?: number }) {
|
||||||
const where: Record<string, unknown> = { dismissed: false };
|
const where: Record<string, unknown> = { dismissed: false, userId };
|
||||||
if (unreadOnly) where.read = false;
|
if (unreadOnly) where.read = false;
|
||||||
|
|
||||||
return prisma.notification.findMany({
|
return prisma.notification.findMany({
|
||||||
|
|
@ -44,29 +45,29 @@ export async function getNotifications({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUnreadCount() {
|
export async function getUnreadCount(userId: string) {
|
||||||
return prisma.notification.count({
|
return prisma.notification.count({
|
||||||
where: { read: false, dismissed: false },
|
where: { read: false, dismissed: false, userId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markRead(id: string) {
|
export async function markRead(id: string, userId: string) {
|
||||||
return prisma.notification.update({
|
return prisma.notification.update({
|
||||||
where: { id },
|
where: { id, userId },
|
||||||
data: { read: true },
|
data: { read: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markAllRead() {
|
export async function markAllRead(userId: string) {
|
||||||
return prisma.notification.updateMany({
|
return prisma.notification.updateMany({
|
||||||
where: { read: false, dismissed: false },
|
where: { read: false, dismissed: false, userId },
|
||||||
data: { read: true },
|
data: { read: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dismissNotification(id: string) {
|
export async function dismissNotification(id: string, userId: string) {
|
||||||
return prisma.notification.update({
|
return prisma.notification.update({
|
||||||
where: { id },
|
where: { id, userId },
|
||||||
data: { dismissed: true },
|
data: { dismissed: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { uploadBuffer, getBuffer, deleteObject } from "./storage";
|
||||||
import { ocrImage } from "./ai-ocr";
|
import { ocrImage } from "./ai-ocr";
|
||||||
import { pdfToImages, imageToBase64, processUploadedImage } from "./pdf";
|
import { pdfToImages, imageToBase64, processUploadedImage } from "./pdf";
|
||||||
import { fireIntegrationEvent } from "./integrations";
|
import { fireIntegrationEvent } from "./integrations";
|
||||||
|
import { linkCardToPerson } from "./person-linker";
|
||||||
|
|
||||||
const DELAY_BETWEEN_OCR_CALLS_MS = 2_000;
|
const DELAY_BETWEEN_OCR_CALLS_MS = 2_000;
|
||||||
function ocrDelay() {
|
function ocrDelay() {
|
||||||
|
|
@ -19,7 +20,7 @@ export async function processFile(
|
||||||
const cardIds: string[] = [];
|
const cardIds: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.processingJob.update({
|
const job = await prisma.processingJob.update({
|
||||||
where: { id: jobId },
|
where: { id: jobId },
|
||||||
data: { status: "processing" },
|
data: { status: "processing" },
|
||||||
});
|
});
|
||||||
|
|
@ -70,6 +71,7 @@ export async function processFile(
|
||||||
data: {
|
data: {
|
||||||
sourceFile: sourceKey,
|
sourceFile: sourceKey,
|
||||||
ocrStatus: "processing",
|
ocrStatus: "processing",
|
||||||
|
organizationId: job.organizationId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
cardIds.push(card.id);
|
cardIds.push(card.id);
|
||||||
|
|
@ -156,6 +158,12 @@ export async function processFile(
|
||||||
});
|
});
|
||||||
|
|
||||||
fireIntegrationEvent("ocr_complete", card.id).catch(() => {});
|
fireIntegrationEvent("ocr_complete", card.id).catch(() => {});
|
||||||
|
|
||||||
|
if (job.organizationId) {
|
||||||
|
linkCardToPerson(card.id, job.organizationId).catch((err) => {
|
||||||
|
console.error("[person-linker] Failed to link card:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Unknown OCR error";
|
const message = err instanceof Error ? err.message : "Unknown OCR error";
|
||||||
await prisma.responseCard.update({
|
await prisma.responseCard.update({
|
||||||
|
|
@ -268,6 +276,12 @@ export async function reprocessCard(cardId: string): Promise<void> {
|
||||||
rawOcrResponse: JSON.parse(JSON.stringify({ response: responseData, survey: surveyData })),
|
rawOcrResponse: JSON.parse(JSON.stringify({ response: responseData, survey: surveyData })),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (card.organizationId) {
|
||||||
|
linkCardToPerson(card.id, card.organizationId).catch((err) => {
|
||||||
|
console.error("[person-linker] Failed to link card:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Unknown OCR error";
|
const message = err instanceof Error ? err.message : "Unknown OCR error";
|
||||||
await prisma.responseCard.update({
|
await prisma.responseCard.update({
|
||||||
|
|
|
||||||
53
src/lib/person-linker.ts
Normal file
53
src/lib/person-linker.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { prisma } from "./db";
|
||||||
|
|
||||||
|
export async function linkCardToPerson(cardId: string, organizationId: string) {
|
||||||
|
const card = await prisma.responseCard.findUnique({ where: { id: cardId } });
|
||||||
|
if (!card || card.personId) return null;
|
||||||
|
|
||||||
|
const firstName = card.firstName || card.name?.split(" ")[0] || "";
|
||||||
|
const lastName = card.lastName || card.name?.split(" ").slice(1).join(" ") || "";
|
||||||
|
const email = card.email;
|
||||||
|
|
||||||
|
if (!firstName && !lastName && !email) return null;
|
||||||
|
|
||||||
|
// Try email match first
|
||||||
|
if (email) {
|
||||||
|
const match = await prisma.person.findFirst({
|
||||||
|
where: { organizationId, email: email.toLowerCase(), mergedIntoId: null },
|
||||||
|
});
|
||||||
|
if (match) {
|
||||||
|
await prisma.responseCard.update({ where: { id: cardId }, data: { personId: match.id } });
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try name match
|
||||||
|
if (firstName && lastName) {
|
||||||
|
const match = await prisma.person.findFirst({
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
firstName: { equals: firstName, mode: "insensitive" },
|
||||||
|
lastName: { equals: lastName, mode: "insensitive" },
|
||||||
|
mergedIntoId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (match) {
|
||||||
|
await prisma.responseCard.update({ where: { id: cardId }, data: { personId: match.id } });
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new person
|
||||||
|
const person = await prisma.person.create({
|
||||||
|
data: {
|
||||||
|
organizationId,
|
||||||
|
firstName: firstName || "Unknown",
|
||||||
|
lastName: lastName || "",
|
||||||
|
email: email?.toLowerCase() || null,
|
||||||
|
cellPhone: card.cellPhone || null,
|
||||||
|
fieldData: (card.fieldData as object) || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.responseCard.update({ where: { id: cardId }, data: { personId: person.id } });
|
||||||
|
return person;
|
||||||
|
}
|
||||||
|
|
@ -28,7 +28,7 @@ const STORAGE_KEY = "echo-ocr-user-profile";
|
||||||
|
|
||||||
type UserProfileContextValue = {
|
type UserProfileContextValue = {
|
||||||
profile: UserProfile;
|
profile: UserProfile;
|
||||||
updateProfile: (updates: Partial<UserProfile>) => void;
|
updateProfile: (updates: Partial<UserProfile>) => Promise<void>;
|
||||||
initials: string;
|
initials: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
|
|
@ -96,12 +96,26 @@ export function UserProfileProvider({ children }: { children: React.ReactNode })
|
||||||
};
|
};
|
||||||
}, [session, localOverrides]);
|
}, [session, localOverrides]);
|
||||||
|
|
||||||
const updateProfile = React.useCallback((updates: Partial<UserProfile>) => {
|
const updateProfile = React.useCallback(async (updates: Partial<UserProfile>) => {
|
||||||
setLocalOverrides((prev) => {
|
setLocalOverrides((prev) => {
|
||||||
const next = { ...prev, ...updates };
|
const next = { ...prev, ...updates };
|
||||||
saveLocalProfile(next);
|
saveLocalProfile(next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const apiPayload: Record<string, string> = {};
|
||||||
|
if (typeof updates.displayName === "string") apiPayload.displayName = updates.displayName;
|
||||||
|
if (typeof updates.avatarUrl === "string") apiPayload.avatarUrl = updates.avatarUrl;
|
||||||
|
|
||||||
|
if (Object.keys(apiPayload).length > 0) {
|
||||||
|
try {
|
||||||
|
await fetch("/api/auth/me", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(apiPayload),
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const initials = React.useMemo(() => getInitials(profile.displayName), [profile.displayName]);
|
const initials = React.useMemo(() => getInitials(profile.displayName), [profile.displayName]);
|
||||||
|
|
@ -144,7 +158,7 @@ export function useUserProfile() {
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
return {
|
return {
|
||||||
profile: DEFAULT_PROFILE,
|
profile: DEFAULT_PROFILE,
|
||||||
updateProfile: () => {},
|
updateProfile: async () => {},
|
||||||
initials: "",
|
initials: "",
|
||||||
userId: undefined,
|
userId: undefined,
|
||||||
role: "viewer" as UserRole,
|
role: "viewer" as UserRole,
|
||||||
|
|
|
||||||
|
|
@ -51,11 +51,10 @@ function isOnboardingExempt(pathname: string) {
|
||||||
export async function middleware(req: NextRequest) {
|
export async function middleware(req: NextRequest) {
|
||||||
const { pathname } = req.nextUrl;
|
const { pathname } = req.nextUrl;
|
||||||
|
|
||||||
if (
|
const isStaticFile = pathname.startsWith("/_next/") ||
|
||||||
pathname.startsWith("/_next") ||
|
|
||||||
pathname.startsWith("/favicon") ||
|
pathname.startsWith("/favicon") ||
|
||||||
pathname.includes(".")
|
/^\/(.*\.(ico|svg|png|jpg|jpeg|gif|webp|woff2?|ttf|css|js|map))$/.test(pathname);
|
||||||
) {
|
if (isStaticFile) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue