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:
Randall Stillwell 2026-04-16 23:29:26 -05:00
parent 1bceeb0418
commit f53b08f99f
91 changed files with 7301 additions and 450 deletions

601
package-lock.json generated
View file

@ -20,6 +20,7 @@
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2",
"@tanstack/react-table": "^8.21.3",
"@types/qrcode": "^1.5.6",
"@upstash/qstash": "^2.10.1",
"ai": "^6.0.116",
"basic-ftp": "^5.3.0",
@ -39,8 +40,10 @@
"pdfjs-dist": "^5.6.205",
"pg": "^8.20.0",
"prisma": "^7.4.2",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"recharts": "^3.8.1",
"rrule": "^2.8.1",
"shadcn": "^4.0.2",
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@ -4745,6 +4784,12 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -5151,6 +5196,69 @@
"dev": true,
"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": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -5200,7 +5308,6 @@
"version": "25.4.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz",
"integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
@ -5218,6 +5325,15 @@
"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": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@ -5243,6 +5359,12 @@
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
"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": {
"version": "4.0.2",
"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_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": {
"version": "1.0.30001777",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
@ -6944,6 +7075,127 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -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": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@ -7210,6 +7477,12 @@
"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": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@ -7595,6 +7868,16 @@
"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": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -8075,6 +8358,12 @@
"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": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@ -9093,6 +9382,16 @@
"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": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -9140,6 +9439,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@ -11287,6 +11595,15 @@
"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": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
@ -11373,7 +11690,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -11626,6 +11942,15 @@
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@ -11933,6 +12258,168 @@
],
"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": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
@ -12033,9 +12520,31 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.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": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@ -12130,6 +12639,51 @@
"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": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -12207,6 +12761,12 @@
"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": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
@ -12511,6 +13071,12 @@
"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": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@ -13661,7 +14227,6 @@
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/unicorn-magic": {
@ -13868,6 +14433,28 @@
"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": {
"version": "3.3.3",
"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"
}
},
"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": {
"version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",

View file

@ -24,6 +24,7 @@
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2",
"@tanstack/react-table": "^8.21.3",
"@types/qrcode": "^1.5.6",
"@upstash/qstash": "^2.10.1",
"ai": "^6.0.116",
"basic-ftp": "^5.3.0",
@ -43,8 +44,10 @@
"pdfjs-dist": "^5.6.205",
"pg": "^8.20.0",
"prisma": "^7.4.2",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"recharts": "^3.8.1",
"rrule": "^2.8.1",
"shadcn": "^4.0.2",
"sharp": "^0.34.5",

View file

@ -84,13 +84,15 @@ model Organization {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
locations Location[]
members OrgMember[]
invitations Invitation[]
apiKeys ApiKey[]
cards ResponseCard[]
jobs ProcessingJob[]
integrations Integration[]
locations Location[]
members OrgMember[]
invitations Invitation[]
apiKeys ApiKey[]
cards ResponseCard[]
jobs ProcessingJob[]
integrations Integration[]
formTemplates FormTemplate[]
persons Person[]
}
model OrgMember {
@ -137,6 +139,81 @@ model CollectionDay {
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 ────────────────────────────────────────────
model Integration {
@ -174,6 +251,18 @@ model Invitation {
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 ───────────────────────────────────────────
model SystemConfig {
@ -191,6 +280,8 @@ model ResponseCard {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
firstName String?
lastName String?
name String?
gender String?
dateOfBirth String?
@ -250,6 +341,11 @@ model ResponseCard {
mondayItemId String?
fieldData Json?
formTemplateId String?
personId String?
submissionSource String?
organizationId String?
locationId String?
collectionDayId String?
@ -258,10 +354,13 @@ model ResponseCard {
organization Organization? @relation(fields: [organizationId], references: [id])
location Location? @relation(fields: [locationId], 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([reviewStatus])
@@index([name])
@@index([firstName, lastName])
@@index([createdAt])
@@index([mondayItemId])
@@index([assignedToId])
@ -269,6 +368,8 @@ model ResponseCard {
@@index([organizationId])
@@index([locationId])
@@index([collectionDayId])
@@index([formTemplateId])
@@index([personId])
}
model ProcessingJob {

View 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&apos;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&apos;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>
);
}

View file

@ -60,7 +60,7 @@ function LoginForm() {
</div>
<h1 className="text-2xl font-bold tracking-tight">Welcome back</h1>
<p className="mt-1 text-sm text-muted-foreground">
Sign in to your Echo OCR account
Sign in to your Echo account
</p>
</div>
@ -89,7 +89,12 @@ function LoginForm() {
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link href="/forgot-password" className="text-xs text-primary hover:underline">
Forgot password?
</Link>
</div>
<div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input

View file

@ -202,7 +202,7 @@ export default function OnboardingPage() {
Set up your workspace
</h1>
<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>
</div>
@ -587,7 +587,7 @@ export default function OnboardingPage() {
<div>
<h2 className="text-xl font-semibold">Upload Source</h2>
<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>
</div>
@ -746,7 +746,7 @@ export default function OnboardingPage() {
<div>
<h2 className="text-xl font-semibold">AI Provider</h2>
<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>
</div>

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

View file

@ -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">
<ScanLine className="size-7 text-white" />
</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">
Configure your instance in a few steps
</p>

View file

@ -90,7 +90,7 @@ function SignupForm() {
<p className="mt-1 text-sm text-muted-foreground">
{token
? "Complete your account setup"
: "Get started with Echo OCR"}
: "Get started with Echo"}
</p>
</div>

View file

@ -47,6 +47,27 @@ import {
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
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 = [
"Stress/Anxiety", "Marriage", "Hearing God's Voice", "Dealing With Doubt",
@ -64,8 +85,16 @@ const HOW_HEARD_OPTIONS = [
"Social Media", "Google", "Personal Invite",
];
type TemplateData = {
id: string;
name: string;
fields: FormFieldDef[];
};
type CardData = {
id: string;
firstName: string | null;
lastName: string | null;
name: string | null;
gender: string | null;
dateOfBirth: string | null;
@ -114,6 +143,9 @@ type CardData = {
mondayItemId: string | null;
frontImageUrl: string | null;
backImageUrl: string | null;
fieldData: Record<string, unknown> | null;
formTemplateId: string | null;
formTemplate?: TemplateData | null;
};
type ActivityEntry = {
@ -137,6 +169,7 @@ export default function CardDetailPage() {
const isViewer = role === "viewer";
const [card, setCard] = React.useState<CardData | null>(null);
const [loadError, setLoadError] = React.useState(false);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [reprocessing, setReprocessing] = React.useState(false);
@ -149,6 +182,8 @@ export default function CardDetailPage() {
const [pushingToMonday, setPushingToMonday] = React.useState(false);
const [users, setUsers] = React.useState<AssignableUser[]>([]);
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 canEdit = isAdmin || (isReviewer && isAssignedToMe);
@ -156,13 +191,27 @@ export default function CardDetailPage() {
const fetchCard = React.useCallback(async () => {
setLoading(true);
setLoadError(false);
try {
const res = await fetch(`/api/cards/${id}`);
if (!res.ok) throw new Error();
const data = await res.json();
setCard(data);
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 {
setLoadError(true);
toast.error("Failed to load card");
} finally {
setLoading(false);
@ -253,11 +302,62 @@ export default function CardDetailPage() {
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 () => {
if (Object.keys(edits).length === 0) return;
const hasFieldDataChanges = Object.keys(fieldDataEdits).length > 0;
if (Object.keys(edits).length === 0 && !hasFieldDataChanges) return;
setSaving(true);
try {
const payload: Record<string, unknown> = { ...edits };
if (hasFieldDataChanges) {
const currentFieldData = (card?.fieldData as Record<string, unknown>) || {};
payload.fieldData = { ...currentFieldData, ...fieldDataEdits };
}
if (card?.reviewStatus === "assigned") {
payload.reviewStatus = "in_review";
}
@ -286,6 +386,10 @@ export default function CardDetailPage() {
...edits,
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}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
@ -399,10 +503,28 @@ export default function CardDetailPage() {
if (!card) {
return (
<div className="space-y-6">
<Header title="Card Not Found" icon={ScanLine} />
<Button variant="outline" className="rounded-xl" onClick={() => router.push("/")}>
<ArrowLeft className="mr-2 size-4" /> Back to Dashboard
</Button>
<Header title={loadError ? "Failed to Load Card" : "Card Not Found"} icon={loadError ? AlertCircle : ScanLine} />
<div className="glass-card flex flex-col items-center justify-center rounded-2xl p-12 text-center">
<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 variant="outline" className="rounded-xl" onClick={() => router.push("/cards")}>
<ArrowLeft className="mr-2 size-4" /> Go back to cards
</Button>
</div>
</div>
</div>
);
}
@ -410,7 +532,7 @@ export default function CardDetailPage() {
const confidence = card.ocrConfidence;
const ocrStatus = card.ocrStatus;
const reviewStatus = card.reviewStatus;
const hasEdits = Object.keys(edits).length > 0;
const hasEdits = Object.keys(edits).length > 0 || Object.keys(fieldDataEdits).length > 0;
return (
<div className="space-y-6">
@ -552,92 +674,130 @@ export default function CardDetailPage() {
</CardContent>
</Card>
<Card variant="glass">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<User className="size-4 text-primary" />
Personal Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
<Field label="Name" value={getValue("name")} onChange={(v) => setField("name", v)} readOnly={!canEdit} />
<Field label="Email" value={getValue("email")} onChange={(v) => setField("email", v)} readOnly={!canEdit} />
<Field label="Cell Phone" value={getValue("cellPhone")} onChange={(v) => setField("cellPhone", v)} readOnly={!canEdit} />
<Field label="Home Phone" value={getValue("homePhone")} onChange={(v) => setField("homePhone", v)} readOnly={!canEdit} />
<SelectField label="Gender" value={getValue("gender")} options={["Male", "Female"]} onChange={(v) => setField("gender", v)} readOnly={!canEdit} />
<Field label="Date of Birth" value={getValue("dateOfBirth")} onChange={(v) => setField("dateOfBirth", v)} readOnly={!canEdit} />
<SelectField label="Marital Status" value={getValue("maritalStatus")} options={["Married", "Single", "Other"]} onChange={(v) => setField("maritalStatus", v)} readOnly={!canEdit} />
<SelectField label="Visit Type" value={getValue("visitType")} options={["First/Second Time Guest", "Update My Information"]} onChange={(v) => setField("visitType", v)} readOnly={!canEdit} />
</div>
<Separator className="opacity-50" />
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
<Field label="Address" value={getValue("address")} onChange={(v) => setField("address", v)} readOnly={!canEdit} />
<Field label="Apt #" value={getValue("aptNumber")} onChange={(v) => setField("aptNumber", v)} readOnly={!canEdit} />
<Field label="City" value={getValue("city")} onChange={(v) => setField("city", v)} readOnly={!canEdit} />
<Field label="State" value={getValue("state")} onChange={(v) => setField("state", v)} readOnly={!canEdit} />
<Field label="Zip" value={getValue("zip")} onChange={(v) => setField("zip", v)} readOnly={!canEdit} />
</div>
<Separator className="opacity-50" />
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Prayer Requests</Label>
<Textarea
value={getValue("prayerRequests") || ""}
onChange={(e) => setField("prayerRequests", e.target.value)}
rows={3}
readOnly={!canEdit}
className={!canEdit ? "opacity-70 cursor-default" : ""}
/>
</div>
<div className="flex flex-wrap gap-6">
<BooleanField label="For Prayer Team" value={getBoolValue("prayerForTeam")} onChange={(v) => setField("prayerForTeam", v)} readOnly={!canEdit} />
<BooleanField label="Confidential" value={getBoolValue("prayerConfidential")} onChange={(v) => setField("prayerConfidential", v)} readOnly={!canEdit} />
</div>
</CardContent>
</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">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<User className="size-4 text-primary" />
Personal Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
<Field label="Name" value={getValue("name")} onChange={(v) => setField("name", v)} readOnly={!canEdit} />
<Field label="Email" value={getValue("email")} onChange={(v) => setField("email", v)} readOnly={!canEdit} />
<Field label="Cell Phone" value={getValue("cellPhone")} onChange={(v) => setField("cellPhone", v)} readOnly={!canEdit} />
<Field label="Home Phone" value={getValue("homePhone")} onChange={(v) => setField("homePhone", v)} readOnly={!canEdit} />
<SelectField label="Gender" value={getValue("gender")} options={["Male", "Female"]} onChange={(v) => setField("gender", v)} readOnly={!canEdit} />
<Field label="Date of Birth" value={getValue("dateOfBirth")} onChange={(v) => setField("dateOfBirth", v)} readOnly={!canEdit} />
<SelectField label="Marital Status" value={getValue("maritalStatus")} options={["Married", "Single", "Other"]} onChange={(v) => setField("maritalStatus", v)} readOnly={!canEdit} />
<SelectField label="Visit Type" value={getValue("visitType")} options={["First/Second Time Guest", "Update My Information"]} onChange={(v) => setField("visitType", v)} readOnly={!canEdit} />
</div>
<Separator className="opacity-50" />
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
<Field label="Address" value={getValue("address")} onChange={(v) => setField("address", v)} readOnly={!canEdit} />
<Field label="Apt #" value={getValue("aptNumber")} onChange={(v) => setField("aptNumber", v)} readOnly={!canEdit} />
<Field label="City" value={getValue("city")} onChange={(v) => setField("city", v)} readOnly={!canEdit} />
<Field label="State" value={getValue("state")} onChange={(v) => setField("state", v)} readOnly={!canEdit} />
<Field label="Zip" value={getValue("zip")} onChange={(v) => setField("zip", v)} readOnly={!canEdit} />
</div>
<Separator className="opacity-50" />
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Prayer Requests</Label>
<Textarea
value={getValue("prayerRequests") || ""}
onChange={(e) => setField("prayerRequests", e.target.value)}
rows={3}
readOnly={!canEdit}
className={!canEdit ? "opacity-70 cursor-default" : ""}
/>
</div>
<div className="flex flex-wrap gap-6">
<BooleanField label="For Prayer Team" value={getBoolValue("prayerForTeam")} onChange={(v) => setField("prayerForTeam", v)} readOnly={!canEdit} />
<BooleanField label="Confidential" value={getBoolValue("prayerConfidential")} onChange={(v) => setField("prayerConfidential", v)} readOnly={!canEdit} />
</div>
</CardContent>
</Card>
<Card variant="glass" className="xl:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ClipboardList className="size-4 text-primary" />
Survey Responses
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<MultiSelectField
label="Message Topics"
value={getArrayValue("messageTopics")}
options={MESSAGE_TOPIC_OPTIONS}
onChange={(v) => setField("messageTopics", v)}
readOnly={!canEdit}
/>
<MultiSelectField
label="Next Steps"
value={getArrayValue("nextStep")}
options={NEXT_STEP_OPTIONS}
onChange={(v) => setField("nextStep", v)}
readOnly={!canEdit}
/>
<SelectField label="Attendance Duration" value={getValue("attendanceDuration")} options={["Less than 6 months", "6 Months - 1 Year", "1-3 Years", "4-6 Years", "7+ Years"]} onChange={(v) => setField("attendanceDuration", v)} readOnly={!canEdit} />
<MultiSelectField
label="Campus Preference"
value={getArrayValue("campusPreference")}
options={CAMPUS_OPTIONS}
onChange={(v) => setField("campusPreference", v)}
readOnly={!canEdit}
/>
<MultiSelectField
label="How Heard"
value={getArrayValue("howHeard")}
options={HOW_HEARD_OPTIONS}
onChange={(v) => setField("howHeard", v)}
readOnly={!canEdit}
/>
<SelectField label="Service Attended" value={getValue("serviceAttended")} options={["A", "B", "C", "D"]} onChange={(v) => setField("serviceAttended", v)} readOnly={!canEdit} />
</div>
</CardContent>
</Card>
<Card variant="glass" className="xl:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ClipboardList className="size-4 text-primary" />
Survey Responses
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<MultiSelectField
label="Message Topics"
value={getArrayValue("messageTopics")}
options={MESSAGE_TOPIC_OPTIONS}
onChange={(v) => setField("messageTopics", v)}
readOnly={!canEdit}
/>
<MultiSelectField
label="Next Steps"
value={getArrayValue("nextStep")}
options={NEXT_STEP_OPTIONS}
onChange={(v) => setField("nextStep", v)}
readOnly={!canEdit}
/>
<SelectField label="Attendance Duration" value={getValue("attendanceDuration")} options={["Less than 6 months", "6 Months - 1 Year", "1-3 Years", "4-6 Years", "7+ Years"]} onChange={(v) => setField("attendanceDuration", v)} readOnly={!canEdit} />
<MultiSelectField
label="Campus Preference"
value={getArrayValue("campusPreference")}
options={CAMPUS_OPTIONS}
onChange={(v) => setField("campusPreference", v)}
readOnly={!canEdit}
/>
<MultiSelectField
label="How Heard"
value={getArrayValue("howHeard")}
options={HOW_HEARD_OPTIONS}
onChange={(v) => setField("howHeard", v)}
readOnly={!canEdit}
/>
<SelectField label="Service Attended" value={getValue("serviceAttended")} options={["A", "B", "C", "D"]} onChange={(v) => setField("serviceAttended", v)} readOnly={!canEdit} />
</div>
</CardContent>
</Card>
</>
)}
<Card variant="glass" className="xl:col-span-2">
<CardHeader>

View file

@ -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 { 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() {
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 (
<div className="space-y-6">
<Header
title="Not Found"
description="This collection day doesn't exist or you don't have access."
icon={CalendarDays}
/>
<Button render={<Link href="/events" />} variant="outline" size="sm" className="rounded-xl">
<ArrowLeft className="size-4" />
Back to Collection Days
</Button>
</div>
);
}
return (
<div className="space-y-6">
<Header
title="Event Detail"
description="View cards assigned to this event"
title={day.name}
description={day.description || "Collection day details"}
icon={CalendarDays}
/>
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
Event detail view coming soon.
>
<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>
);
}
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>
);
}

View file

@ -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 { 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() {
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 (
<div className="space-y-6">
<Header
title="Collection Days"
description="Manage recurring events and service times"
icon={CalendarDays}
/>
<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" />
<h2 className="text-lg font-semibold">No collection days yet</h2>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
Collection days will be available once organizations and locations are
configured. Check back after setup is complete.
</p>
</div>
>
<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">
<CalendarDays className="mb-4 size-12 text-muted-foreground/40" />
<h2 className="text-lg font-semibold">No collection days configured</h2>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
Set up your locations and service schedule to start organizing
response cards by collection day.
</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 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>
);
}

View file

@ -15,6 +15,12 @@ import {
UserPlus,
X,
} from "lucide-react";
import {
ResponsiveContainer,
AreaChart,
Area,
} from "recharts";
import { toast } from "sonner";
import { Header } from "@/components/layout/header";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
@ -25,6 +31,14 @@ type Stats = {
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";
function loadDismissedHints(): string[] {
@ -49,6 +63,7 @@ function dismissHint(id: string) {
export default function DashboardHomePage() {
const [stats, setStats] = useState<Stats | null>(null);
const [trends, setTrends] = useState<TrendsData | null>(null);
const [loading, setLoading] = useState(true);
const [dismissedHints, setDismissedHints] = useState<string[]>([]);
@ -62,10 +77,17 @@ export default function DashboardHomePage() {
}, []);
useEffect(() => {
fetch("/api/stats")
.then((r) => r.json())
.then(setStats)
.catch(() => {})
Promise.all([
fetch("/api/stats").then((r) => r.json()),
fetch("/api/stats/trends?period=7&groupBy=day")
.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));
}, []);
@ -74,30 +96,44 @@ export default function DashboardHomePage() {
const pending = (stats?.byOcrStatus?.pending ?? 0) + (stats?.byOcrStatus?.processing ?? 0);
const unreviewed = stats?.byReviewStatus?.unreviewed ?? 0;
const sparkData = trends?.series ?? [];
const summaryCards = [
{
label: "Total Cards",
value: stats?.total ?? 0,
icon: CreditCard,
href: "/cards",
sparkKey: "total" as const,
},
{
label: "OCR Complete",
value: completed,
icon: CheckCircle2,
href: "/cards?ocrStatus=complete",
sparkKey: "scanned" as const,
},
{
label: "Needs Review",
value: unreviewed,
icon: Clock,
href: "/cards?reviewStatus=unreviewed",
sparkKey: null,
},
{
label: "Errors",
value: errors,
icon: AlertCircle,
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}
/>
<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) =>
loading ? (
<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">
{card.value.toLocaleString()}
</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>
)
)}

View 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&apos;re looking for doesn&apos;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>
);
}

View file

@ -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 { 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() {
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 (
<div className="space-y-6">
<Header
title="People"
description="Contact directory from scanned response cards"
icon={Users}
/>
<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" />
<h2 className="text-lg font-semibold">People directory</h2>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
A deduplicated contact list aggregated from your response cards will
appear here once the people module is built out.
</p>
</div>
>
<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">
<Users className="mb-4 size-12 text-muted-foreground/40" />
<h2 className="text-lg font-semibold">No people yet</h2>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
People are automatically created when response cards are processed.
</p>
</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 &ldquo;{debouncedSearch}&rdquo;
</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>
);
}

View file

@ -59,17 +59,26 @@ export default function ProfilePage() {
setDirty(true);
};
const handleSave = () => {
updateProfile({
jobTitle: form.jobTitle,
company: form.company,
bio: form.bio,
displayName: form.displayName,
email: form.email,
avatarUrl: form.avatarUrl,
});
setDirty(false);
toast.success("Profile updated");
const [saving, setSaving] = React.useState(false);
const handleSave = async () => {
setSaving(true);
try {
await updateProfile({
jobTitle: form.jobTitle,
company: form.company,
bio: form.bio,
displayName: form.displayName,
email: form.email,
avatarUrl: form.avatarUrl,
});
setDirty(false);
toast.success("Profile updated");
} catch {
toast.error("Failed to save profile");
} finally {
setSaving(false);
}
};
const handleAvatarUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -108,9 +117,9 @@ export default function ProfilePage() {
return (
<div className="space-y-6">
<Header title="Profile" description="Manage your personal information" icon={UserCircle}>
<Button className="rounded-xl" onClick={handleSave} disabled={!dirty}>
<Save className="mr-2 size-4" />
Save Changes
<Button className="rounded-xl" onClick={handleSave} disabled={!dirty || saving}>
{saving ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Save className="mr-2 size-4" />}
{saving ? "Saving…" : "Save Changes"}
</Button>
</Header>

View file

@ -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 { 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() {
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 (
<div className="space-y-6">
<div className="space-y-8">
<Header
title="Reports"
description="Analytics, trends, and export center"
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" />
<h2 className="text-lg font-semibold">Reports &amp; Analytics</h2>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
Detailed reports, trends over time, and data exports will be available
here in a future update.
</p>
{/* Period selector + export */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex gap-1 rounded-lg bg-muted/50 p-1">
{PERIOD_OPTIONS.map((opt) => (
<button
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>
);
}
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>
);
}

View 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&apos;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>
);
}

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

View file

@ -62,6 +62,7 @@ export default function GeneralSettingsPage() {
imagesEligible: number;
} | null>(null);
const [cleaning, setCleaning] = React.useState(false);
const [fetchError, setFetchError] = React.useState(false);
const [notifPrefs, setNotifPrefs] = React.useState<NotificationPrefs>(
DEFAULT_NOTIFICATION_PREFS
);
@ -78,14 +79,20 @@ export default function GeneralSettingsPage() {
setImageRetention(data.imageRetentionDays || 180);
setLoading(false);
})
.catch(() => setLoading(false));
.catch(() => {
setFetchError(true);
toast.error("Failed to load settings");
setLoading(false);
});
fetch("/api/cleanup")
.then((r) => r.json())
.then((data) => {
if (data.sourcesEligible !== undefined) setCleanupStatus(data);
})
.catch(() => {});
.catch(() => {
toast.error("Failed to load cleanup status");
});
}, []);
const updateNotifPref = (key: keyof NotificationPrefs, value: boolean) => {
@ -265,8 +272,11 @@ export default function GeneralSettingsPage() {
</CardContent>
</Card>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
<div className="flex items-center justify-end gap-3">
{fetchError && (
<p className="text-sm text-destructive">Settings failed to load saving is disabled</p>
)}
<Button onClick={handleSave} disabled={saving || fetchError}>
{saving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (

View file

@ -10,6 +10,7 @@ import {
Users,
MapPin,
Building2,
FileText,
} from "lucide-react";
import { Header } from "@/components/layout/header";
import { cn } from "@/lib/utils";
@ -22,6 +23,7 @@ const SECTIONS = [
{ href: "/settings/users", label: "Team Members", icon: Users },
{ href: "/settings/locations", label: "Locations", icon: MapPin },
{ href: "/settings/organization", label: "Organization", icon: Building2 },
{ href: "/settings/forms", label: "Form Templates", icon: FileText },
];
export default function SettingsLayout({

View file

@ -3,6 +3,8 @@ import { generateText } from "ai";
import { gateway } from "@ai-sdk/gateway";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
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 {
if (provider === "ollama") {
@ -16,10 +18,13 @@ function getTestModel(provider: string, modelId: string, ollamaUrl: string): Lan
export async function POST(request: NextRequest) {
try {
await requireApiAuth();
const body = await request.json().catch(() => ({}));
const provider = body.provider || "gateway";
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);
@ -30,9 +35,7 @@ export async function POST(request: NextRequest) {
});
return NextResponse.json({ ok: true, response: text.trim() });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
console.error("[ai-test]", message);
return NextResponse.json({ error: message }, { status: 500 });
} catch (error) {
return handleApiError(error);
}
}

View 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&#847;&zwnj;&nbsp;</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 });
}
}

View file

@ -1,4 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { getOrCreateUser, type AppUser } from "@/lib/auth";
export type AuthentikUser = {
@ -74,3 +76,22 @@ export async function GET(req: NextRequest) {
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 });
}

View file

@ -53,6 +53,12 @@ export async function POST(req: NextRequest) {
{ status: 400 }
);
}
if (invitation.email.toLowerCase() !== email.toLowerCase()) {
return NextResponse.json(
{ error: "Email does not match invitation" },
{ status: 400 }
);
}
inviteRole = invitation.role;
inviteOrgId = invitation.organizationId;

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

View file

@ -1,19 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getCardActivity } from "@/lib/activity-log";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await requireApiAuthWithOrg();
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);
return NextResponse.json(activity);
} catch (error) {
console.error("[cards/[id]/activity GET]", error);
return NextResponse.json(
{ error: "Failed to fetch activity" },
{ status: 500 }
);
return handleApiError(error);
}
}

View file

@ -1,18 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { fireIntegrationEvent } from "@/lib/integrations";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await requireApiAuthWithOrg();
const { id } = await params;
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 });
}
@ -27,10 +29,6 @@ export async function POST(
return NextResponse.json(updated);
} catch (error) {
console.error("[cards/[id]/export POST]", error);
return NextResponse.json(
{ error: "Failed to mark card as exported" },
{ status: 500 }
);
return handleApiError(error);
}
}

View file

@ -1,16 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { reprocessCard } from "@/lib/ocr";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await requireApiAuthWithOrg();
const { id } = await params;
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 });
}
@ -27,10 +29,6 @@ export async function POST(
return NextResponse.json({ success: true });
} catch (error) {
console.error("[cards/[id]/reprocess POST]", error);
return NextResponse.json(
{ error: "Failed to start reprocessing" },
{ status: 500 }
);
return handleApiError(error);
}
}

View file

@ -3,19 +3,26 @@ import { prisma } from "@/lib/db";
import { deleteObject } from "@/lib/storage";
import { fireIntegrationEvent } from "@/lib/integrations";
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(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await requireApiAuthWithOrg();
const { id } = await params;
const card = await prisma.responseCard.findUnique({
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 });
}
@ -32,11 +39,7 @@ export async function GET(
backImageUrl,
});
} catch (error) {
console.error("[cards/[id] GET]", error);
return NextResponse.json(
{ error: "Failed to fetch card" },
{ status: 500 }
);
return handleApiError(error);
}
}
@ -45,24 +48,23 @@ export async function PUT(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const user = await getOrCreateUser(request.headers);
const session = await requireApiAuthWithOrg();
const user = session.user;
const { id } = await params;
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 });
}
if (user) {
if (user.role === "viewer") {
return NextResponse.json({ error: "Viewers cannot edit cards" }, { status: 403 });
}
if (user.role === "reviewer" && card.assignedToId !== user.id) {
return NextResponse.json({ error: "You can only edit cards assigned to you" }, { status: 403 });
}
if (user.role === "viewer") {
return NextResponse.json({ error: "Viewers cannot edit cards" }, { status: 403 });
}
if (user.role === "reviewer" && card.assignedToId !== user.id) {
return NextResponse.json({ error: "You can only edit cards assigned to you" }, { status: 403 });
}
const body = await request.json().catch(() => ({}));
@ -70,6 +72,8 @@ export async function PUT(
const stringFields = [
"name",
"firstName",
"lastName",
"gender",
"dateOfBirth",
"maritalStatus",
@ -118,6 +122,7 @@ export async function PUT(
if (body.campusPreference != null) data.campusPreference = body.campusPreference;
if (body.howHeard != null) data.howHeard = body.howHeard;
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) {
if (body[assignField] !== undefined) {
@ -129,7 +134,7 @@ export async function PUT(
data.reviewStatus = "in_review";
}
if (body.reviewStatus === "reviewed" && user) {
if (body.reviewStatus === "reviewed") {
data.reviewedById = user.id;
data.reviewedAt = new Date();
}
@ -150,7 +155,7 @@ export async function PUT(
"user",
`${changes.length} field(s) updated manually`,
changes,
user?.id
user.id
).catch(() => {});
}
@ -169,11 +174,7 @@ export async function PUT(
if (error instanceof RoleError) {
return NextResponse.json({ error: error.message }, { status: 403 });
}
console.error("[cards/[id] PUT]", error);
return NextResponse.json(
{ error: "Failed to update card" },
{ status: 500 }
);
return handleApiError(error);
}
}
@ -182,8 +183,8 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const user = await getOrCreateUser(request.headers);
if (user && user.role !== "admin" && user.role !== "owner") {
const session = await requireApiAuthWithOrg();
if (session.user.role !== "admin" && session.user.role !== "owner") {
return NextResponse.json({ error: "Only admins can delete cards" }, { status: 403 });
}
@ -192,7 +193,7 @@ export async function DELETE(
where: { id },
});
if (!card) {
if (!card || card.organizationId !== session.user.orgId) {
return NextResponse.json({ error: "Card not found" }, { status: 404 });
}
@ -209,10 +210,6 @@ export async function DELETE(
return NextResponse.json({ success: true });
} catch (error) {
console.error("[cards/[id] DELETE]", error);
return NextResponse.json(
{ error: "Failed to delete card" },
{ status: 500 }
);
return handleApiError(error);
}
}

View file

@ -1,13 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOrCreateUser, requireRole, RoleError } from "@/lib/auth";
import { createNotification } from "@/lib/notifications";
import { logActivity } from "@/lib/activity-log";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function POST(req: NextRequest) {
try {
const user = await getOrCreateUser(req.headers);
requireRole(user, "admin");
const session = await requireApiAuthWithOrg();
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 { cardIds, assignToUserId } = body as {
@ -30,10 +34,10 @@ export async function POST(req: NextRequest) {
}
await prisma.responseCard.updateMany({
where: { id: { in: cardIds } },
where: { id: { in: cardIds }, organizationId: session.user.orgId },
data: {
assignedToId: assignToUserId,
assignedById: user!.id,
assignedById: user.id,
assignedAt: new Date(),
reviewStatus: "assigned",
},
@ -44,16 +48,16 @@ export async function POST(req: NextRequest) {
cardId,
"assignment",
"user",
`Assigned to ${assignee.displayName} by ${user!.displayName}`,
`Assigned to ${assignee.displayName} by ${user.displayName}`,
[{ field: "assignedToId", from: null, to: assignToUserId }],
user!.id
user.id
).catch(() => {});
}
await createNotification({
type: "card_assigned",
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",
userId: assignToUserId,
});
@ -64,10 +68,6 @@ export async function POST(req: NextRequest) {
assignee: assignee.displayName,
});
} catch (error) {
if (error instanceof RoleError) {
return NextResponse.json({ error: error.message }, { status: 403 });
}
console.error("[cards/assign POST]", error);
return NextResponse.json({ error: "Assignment failed" }, { status: 500 });
return handleApiError(error);
}
}

View file

@ -3,20 +3,27 @@ import { prisma } from "@/lib/db";
import { getBuffer, uploadBuffer } from "@/lib/storage";
import { pdfToImages, imageToBase64 } from "@/lib/pdf";
import { ocrImage } from "@/lib/ai-ocr";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
const DELAY_BETWEEN_OCR_MS = 3_000;
export async function GET() {
const missing = await prisma.responseCard.count({
where: { frontImagePath: null, backImagePath: { not: null } },
});
return NextResponse.json({ missingCount: missing });
try {
const session = await requireApiAuthWithOrg();
const missing = await prisma.responseCard.count({
where: { frontImagePath: null, backImagePath: { not: null }, organizationId: session.user.orgId },
});
return NextResponse.json({ missingCount: missing });
} catch (error) {
return handleApiError(error);
}
}
export async function POST() {
try {
const session = await requireApiAuthWithOrg();
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 },
});
@ -134,8 +141,7 @@ export async function POST() {
message: `Recovery started for ${cards.length} card(s) from ${sourceFiles.length} PDF(s)`,
});
} catch (error) {
console.error("[recover-survey POST]", error);
return NextResponse.json({ error: "Recovery failed" }, { status: 500 });
return handleApiError(error);
}
}

View file

@ -1,11 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { reprocessCard } from "@/lib/ocr";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
const DELAY_BETWEEN_CARDS_MS = 3_000;
export async function POST(request: NextRequest) {
try {
const session = await requireApiAuthWithOrg();
const body = await request.json().catch(() => ({}));
const ids = body.ids as string[] | undefined;
@ -14,7 +16,7 @@ export async function POST(request: NextRequest) {
}
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 },
});
@ -30,7 +32,7 @@ export async function POST(request: NextRequest) {
}
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 },
});
@ -56,7 +58,6 @@ export async function POST(request: NextRequest) {
skipped: ids.length - eligible.length,
});
} catch (error) {
console.error("[reprocess-batch POST]", error);
return NextResponse.json({ error: "Failed to start batch reprocessing" }, { status: 500 });
return handleApiError(error);
}
}

View file

@ -1,9 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOrCreateUser } from "@/lib/auth";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function GET(request: NextRequest) {
try {
const session = await requireApiAuthWithOrg();
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)));
@ -17,8 +19,6 @@ export async function GET(request: NextRequest) {
const sortBy = searchParams.get("sortBy") ?? "createdAt";
const sortOrder = searchParams.get("sortOrder") ?? "desc";
const user = await getOrCreateUser(request.headers);
const validSortFields = [
"createdAt",
"updatedAt",
@ -33,7 +33,9 @@ export async function GET(request: NextRequest) {
const orderByField = validSortFields.includes(sortBy) ? sortBy : "createdAt";
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 (reviewStatus) where.reviewStatus = reviewStatus;
@ -41,8 +43,8 @@ export async function GET(request: NextRequest) {
if (visitType) where.visitType = visitType;
if (serviceAttended) where.serviceAttended = serviceAttended;
if (assignedToId === "me" && user) {
where.assignedToId = user.id;
if (assignedToId === "me") {
where.assignedToId = session.user.id;
} else if (assignedToId === "unassigned") {
where.assignedToId = null;
} else if (assignedToId) {
@ -74,18 +76,18 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ cards, total, page, limit });
} catch (error) {
console.error("[cards GET]", error);
return NextResponse.json(
{ error: "Failed to fetch cards" },
{ status: 500 }
);
return handleApiError(error);
}
}
export async function POST(request: NextRequest) {
try {
const session = await requireApiAuthWithOrg();
const body = await request.json().catch(() => ({}));
const data: Record<string, unknown> = {};
const data: Record<string, unknown> = {
organizationId: session.user.orgId,
};
const stringFields = [
"name",
@ -134,10 +136,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json(card, { status: 201 });
} catch (error) {
console.error("[cards POST]", error);
return NextResponse.json(
{ error: "Failed to create card" },
{ status: 500 }
);
return handleApiError(error);
}
}

View file

@ -1,9 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { deleteObject, listObjects } from "@/lib/storage";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function POST(request: NextRequest) {
try {
const session = await requireApiAuthWithOrg();
const orgId = session.user.orgId;
const body = await request.json().catch(() => ({}));
const dryRun = body.dryRun === true;
@ -31,7 +34,7 @@ export async function POST(request: NextRequest) {
// 1. Purge source PDFs older than sourceRetentionDays
const sourceKeys = await listObjects("sources/");
const jobsWithSources = await prisma.processingJob.findMany({
where: { createdAt: { lt: sourcesCutoff }, status: "complete" },
where: { createdAt: { lt: sourcesCutoff }, status: "complete", organizationId: orgId },
select: { id: true },
});
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
const oldCards = await prisma.responseCard.findMany({
where: { createdAt: { lt: imagesCutoff } },
where: { createdAt: { lt: imagesCutoff }, organizationId: orgId },
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
if (!dryRun) {
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;
} else {
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);
} catch (error) {
console.error("[cleanup POST]", error);
return NextResponse.json(
{ error: "Cleanup failed" },
{ status: 500 }
);
return handleApiError(error);
}
}
export async function GET() {
try {
const session = await requireApiAuthWithOrg();
const orgId = session.user.orgId;
let settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
});
@ -106,11 +107,12 @@ export async function GET() {
const imagesCutoff = new Date(now.getTime() - settings.imageRetentionDays * 86400000);
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({
where: {
createdAt: { lt: imagesCutoff },
organizationId: orgId,
OR: [
{ frontImagePath: { not: null } },
{ backImagePath: { not: null } },
@ -125,10 +127,6 @@ export async function GET() {
imagesEligible,
});
} catch (error) {
console.error("[cleanup GET]", error);
return NextResponse.json(
{ error: "Failed to check cleanup status" },
{ status: 500 }
);
return handleApiError(error);
}
}

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

View file

@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
import { scanInbox } from "@/lib/email-watcher";
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");
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

View file

@ -1,9 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { scanInbox } from "@/lib/email-watcher";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST(request: NextRequest) {
try {
await requireApiAuth();
const body = await request.json().catch(() => ({}));
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" } });
return NextResponse.json({ emailWatching: updated?.emailWatching ?? false });
} catch (error) {
console.error("[email-watch POST]", error);
const message =
error instanceof Error ? error.message : "Failed to update email watch";
return NextResponse.json({ error: message }, { status: 500 });
return handleApiError(error);
}
}

View file

@ -1,8 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { testEmailConnection } from "@/lib/email-watcher";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST(request: NextRequest) {
try {
await requireApiAuth();
const body = await request.json().catch(() => ({}));
const host = String(body.host || "");
@ -33,9 +35,6 @@ export async function POST(request: NextRequest) {
);
}
} catch (error) {
console.error("[email-watch/test POST]", error);
const message =
error instanceof Error ? error.message : "Connection test failed";
return NextResponse.json({ ok: false, error: message }, { status: 500 });
return handleApiError(error);
}
}

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

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

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

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

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

View file

@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
import { pollFtp } from "@/lib/ftp-watcher";
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");
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

View file

@ -1,9 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { pollFtp } from "@/lib/ftp-watcher";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST(request: NextRequest) {
try {
await requireApiAuth();
const body = await request.json().catch(() => ({}));
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" } });
return NextResponse.json({ ftpEnabled: updated?.ftpEnabled ?? false });
} catch (error) {
console.error("[ftp-watch POST]", error);
const message =
error instanceof Error ? error.message : "Failed to update FTP watch";
return NextResponse.json({ error: message }, { status: 500 });
return handleApiError(error);
}
}

View file

@ -1,8 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { testFtpConnection } from "@/lib/ftp-watcher";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST(request: NextRequest) {
try {
await requireApiAuth();
const body = await request.json();
const result = await testFtpConnection({
host: body.host,
@ -14,10 +16,6 @@ export async function POST(request: NextRequest) {
});
return NextResponse.json(result);
} catch (error) {
console.error("[ftp-watch/test POST]", error);
return NextResponse.json(
{ ok: false, error: "Test failed" },
{ status: 500 }
);
return handleApiError(error);
}
}

View file

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { getBuffer } from "@/lib/storage";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
const EXT_TO_CONTENT_TYPE: Record<string, string> = {
jpg: "image/jpeg",
@ -15,6 +16,7 @@ export async function GET(
{ params }: { params: Promise<{ path: string[] }> }
) {
try {
await requireApiAuth();
const { path: pathSegments } = await params;
const key = pathSegments.join("/");
@ -34,10 +36,6 @@ export async function GET(
},
});
} catch (error) {
console.error("[images GET]", error);
return NextResponse.json(
{ error: "Failed to get image" },
{ status: 500 }
);
return handleApiError(error);
}
}

View file

@ -1,12 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { fetchBoardColumns } from "@/lib/monday";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
import { prisma } from "@/lib/db";
export async function POST(request: NextRequest) {
try {
await requireApiAuth();
const body = await request.json().catch(() => ({}));
const token = String(body.token || "");
const boardId = String(body.boardId || "");
const settings = await prisma.appSettings.findUnique({ where: { id: "singleton" } });
const token = settings?.mondayApiToken || "";
if (!token || !boardId) {
return NextResponse.json(
{ error: "API token and board ID are required" },
@ -17,8 +22,6 @@ export async function POST(request: NextRequest) {
const columns = await fetchBoardColumns(token, boardId);
return NextResponse.json({ columns });
} catch (error) {
console.error("[monday/columns POST]", error);
const message = error instanceof Error ? error.message : "Failed to fetch columns";
return NextResponse.json({ error: message }, { status: 500 });
return handleApiError(error);
}
}

View file

@ -1,11 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { pushCardToMonday } from "@/lib/integrations";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
await requireApiAuth();
const { id } = await params;
const result = await pushCardToMonday(id);
return NextResponse.json({
@ -14,8 +16,6 @@ export async function POST(
mondayItemId: result.mondayItemId,
});
} catch (error) {
console.error("[monday/push POST]", error);
const message = error instanceof Error ? error.message : "Push failed";
return NextResponse.json({ error: message }, { status: 500 });
return handleApiError(error);
}
}

View file

@ -1,9 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { createWebhookSubscription, deleteWebhookSubscription } from "@/lib/monday";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST(request: NextRequest) {
try {
await requireApiAuth();
const body = await request.json().catch(() => ({}));
const action = body.action as string;
@ -61,8 +63,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
} catch (error) {
console.error("[monday/subscribe POST]", error);
const message = error instanceof Error ? error.message : "Failed";
return NextResponse.json({ error: message }, { status: 500 });
return handleApiError(error);
}
}

View file

@ -2,9 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { pushCardToMonday } from "@/lib/integrations";
import { createNotification } from "@/lib/notifications";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST(req: NextRequest) {
try {
await requireApiAuth();
const settings = await prisma.appSettings.findUnique({
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) });
} catch (error) {
console.error("[monday/sync-all POST]", error);
const message = error instanceof Error ? error.message : "Sync failed";
return NextResponse.json({ error: message }, { status: 500 });
return handleApiError(error);
}
}

View file

@ -1,9 +1,11 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { sendWebhook } from "@/lib/webhook";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST() {
try {
await requireApiAuth();
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
});
@ -40,8 +42,6 @@ export async function POST() {
);
}
} catch (error) {
console.error("[webhook/test POST]", error);
const message = error instanceof Error ? error.message : "Test failed";
return NextResponse.json({ ok: false, error: message }, { status: 500 });
return handleApiError(error);
}
}

View file

@ -1,14 +1,17 @@
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 { 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 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([
prisma.processingJob.findMany({
@ -22,10 +25,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ jobs, total, page, limit });
} catch (error) {
console.error("[jobs GET]", error);
return NextResponse.json(
{ error: "Failed to fetch jobs" },
{ status: 500 }
);
return handleApiError(error);
}
}

View file

@ -6,50 +6,47 @@ import {
markAllRead,
dismissNotification,
} from "@/lib/notifications";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function GET(request: NextRequest) {
try {
const session = await requireApiAuth();
const userId = session.user.id!;
const { searchParams } = new URL(request.url);
const unreadOnly = searchParams.get("unreadOnly") === "true";
const limit = Math.min(100, parseInt(searchParams.get("limit") || "50"));
const [notifications, unreadCount] = await Promise.all([
getNotifications({ unreadOnly, limit }),
getUnreadCount(),
getNotifications({ userId, unreadOnly, limit }),
getUnreadCount(userId),
]);
return NextResponse.json({ notifications, unreadCount });
} catch (error) {
console.error("[notifications GET]", error);
return NextResponse.json(
{ error: "Failed to fetch notifications" },
{ status: 500 }
);
return handleApiError(error);
}
}
export async function PUT(request: NextRequest) {
try {
const session = await requireApiAuth();
const userId = session.user.id!;
const body = await request.json().catch(() => ({}));
const action = body.action as string;
if (action === "mark_read" && body.id) {
await markRead(body.id);
await markRead(body.id, userId);
} else if (action === "mark_all_read") {
await markAllRead();
await markAllRead(userId);
} else if (action === "dismiss" && body.id) {
await dismissNotification(body.id);
await dismissNotification(body.id, userId);
} else {
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
}
const unreadCount = await getUnreadCount();
const unreadCount = await getUnreadCount(userId);
return NextResponse.json({ ok: true, unreadCount });
} catch (error) {
console.error("[notifications PUT]", error);
return NextResponse.json(
{ error: "Failed to update notification" },
{ status: 500 }
);
return handleApiError(error);
}
}

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

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

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

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

View file

@ -1,8 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function GET() {
try {
await requireApiAuth();
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
});
@ -14,18 +16,20 @@ export async function GET() {
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) {
console.error("[settings GET]", error);
return NextResponse.json(
{ error: "Failed to fetch settings" },
{ status: 500 }
);
return handleApiError(error);
}
}
export async function PUT(request: NextRequest) {
try {
await requireApiAuth();
const body = await request.json().catch(() => ({}));
const data: Record<string, unknown> = {};
@ -81,10 +85,6 @@ export async function PUT(request: NextRequest) {
return NextResponse.json(settings);
} catch (error) {
console.error("[settings PUT]", error);
return NextResponse.json(
{ error: "Failed to update settings" },
{ status: 500 }
);
return handleApiError(error);
}
}

View file

@ -1,32 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
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) {
recoverStuckJobs().catch(() => {});
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 effectiveUserId = userId === "me" && user ? user.id : userId;
const effectiveUserId = userId === "me" ? session.user.id : userId;
const [total, byOcrStatus, byReviewStatus, myCards] = await Promise.all([
prisma.responseCard.count(),
prisma.responseCard.count({ where: { organizationId: orgId } }),
prisma.responseCard.groupBy({
by: ["ocrStatus"],
where: { organizationId: orgId },
_count: { id: true },
}),
prisma.responseCard.groupBy({
by: ["reviewStatus"],
where: { organizationId: orgId },
_count: { id: true },
}),
effectiveUserId
? prisma.responseCard.count({
where: { assignedToId: effectiveUserId },
where: { assignedToId: effectiveUserId, organizationId: orgId },
})
: user
? prisma.responseCard.count({
where: { assignedToId: user.id },
})
: Promise.resolve(0),
: prisma.responseCard.count({
where: { assignedToId: session.user.id, organizationId: orgId },
}),
]);
const ocrStatusCounts = Object.fromEntries(
@ -43,10 +47,6 @@ export async function GET(request: NextRequest) {
myCards,
});
} catch (error) {
console.error("[stats GET]", error);
return NextResponse.json(
{ error: "Failed to fetch stats" },
{ status: 500 }
);
return handleApiError(error);
}
}

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

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

View file

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { uploadBuffer } from "@/lib/storage";
import { enqueueProcessing } from "@/lib/processing-queue";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50 MB
@ -15,6 +16,7 @@ const ALLOWED_TYPES = [
export async function POST(request: NextRequest) {
try {
const session = await requireApiAuthWithOrg();
const formData = await request.formData();
const files = [
...(formData.getAll("files") as File[]),
@ -56,6 +58,7 @@ export async function POST(request: NextRequest) {
fileName,
filePath: `sources/${fileName}`,
status: "queued",
organizationId: session.user.orgId,
},
});
@ -68,10 +71,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ jobIds });
} catch (error) {
console.error("[upload POST]", error);
return NextResponse.json(
{ error: "Failed to upload files" },
{ status: 500 }
);
return handleApiError(error);
}
}

View file

@ -1,34 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOrCreateUser } from "@/lib/auth";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function GET(req: NextRequest) {
try {
const user = await getOrCreateUser(req.headers);
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const session = await requireApiAuthWithOrg();
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,
select: {
id: true,
username: true,
displayName: true,
email: true,
avatarUrl: true,
role: true,
include: {
user: {
select: {
id: true,
username: true,
displayName: true,
email: true,
avatarUrl: true,
role: true,
},
},
},
orderBy: { displayName: "asc" },
orderBy: { user: { displayName: "asc" } },
});
const users = members.map((m) => m.user);
return NextResponse.json({ users });
} catch (error) {
console.error("[users GET]", error);
return NextResponse.json({ error: "Failed to fetch users" }, { status: 500 });
return handleApiError(error);
}
}

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

View file

@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { toast } from "sonner";
import { StatCards, type StatFilter } from "./stat-cards";
@ -27,9 +27,18 @@ const ATTENDANCE_OPTIONS = [
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() {
const router = useRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
const { role } = useUserProfile();
const isAdmin = role === "admin" || role === "owner";
@ -45,8 +54,9 @@ export function DashboardContent() {
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const [uploadModalOpen, setUploadModalOpen] = React.useState(false);
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 [initialFiles, setInitialFiles] = React.useState<File[]>([]);
const fetchCards = React.useCallback(async () => {
setLoading(true);
@ -83,6 +93,10 @@ export function DashboardContent() {
fetchCards();
}, [fetchCards]);
React.useEffect(() => {
setActiveStatFilter(deriveStatFilter(searchParams));
}, [searchParams]);
const updateUrl = React.useCallback(
(updates: Record<string, string | number | undefined>) => {
const params = new URLSearchParams(searchParams.toString());
@ -93,9 +107,9 @@ export function DashboardContent() {
params.set(key, String(value));
}
}
router.push(`/?${params.toString()}`);
router.push(`${pathname}?${params.toString()}`);
},
[router, searchParams]
[router, searchParams, pathname]
);
const handleStatFilterChange = React.useCallback(
@ -118,9 +132,9 @@ export function DashboardContent() {
}
params.set("page", "1");
router.push(`/?${params.toString()}`);
router.push(`${pathname}?${params.toString()}`);
},
[router, searchParams]
[router, searchParams, pathname]
);
const handleBulkAction = async (
@ -398,6 +412,7 @@ export function DashboardContent() {
e.preventDefault();
setGlobalDragOver(false);
if (e.dataTransfer?.files.length) {
setInitialFiles(Array.from(e.dataTransfer.files));
setUploadModalOpen(true);
}
};
@ -417,6 +432,12 @@ export function DashboardContent() {
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 (
<div className="space-y-6">
{/* Global drag overlay */}
@ -476,6 +497,9 @@ export function DashboardContent() {
onSortChange={(sb, so) => updateUrl({ sortBy: sb, sortOrder: so })}
uploadingRows={uploadingRows.filter((f) => f.status === "uploading")}
onSelectionChange={setSelectedIds}
hasActiveFilters={hasActiveFilters}
onClearFilters={clearFilters}
onUploadClick={isAdmin ? openUpload : undefined}
/>
</div>
@ -495,7 +519,11 @@ export function DashboardContent() {
{/* Upload modal */}
<UploadModal
open={uploadModalOpen}
onOpenChange={setUploadModalOpen}
onOpenChange={(open) => {
setUploadModalOpen(open);
if (!open) setInitialFiles([]);
}}
initialFiles={initialFiles}
onUploadStart={handleUploadStart}
onUploadProgress={handleUploadProgress}
onUploadComplete={handleUploadComplete}

View file

@ -18,6 +18,9 @@ import {
ChevronRight,
Loader2,
Copy,
Columns3,
Upload,
SearchX,
} from "lucide-react";
import { toast } from "sonner";
@ -32,6 +35,8 @@ import {
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
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 type { ResponseCard } from "./columns";
import type { UploadingFile } from "./upload-modal";
@ -133,6 +138,9 @@ type DataTableProps<TData> = {
onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void;
uploadingRows?: UploadingFile[];
onSelectionChange?: (selectedIds: string[]) => void;
hasActiveFilters?: boolean;
onClearFilters?: () => void;
onUploadClick?: () => void;
};
const PAGE_SIZES = [10, 20, 50, 100];
@ -158,6 +166,9 @@ export function DataTable<TData extends ResponseCard>({
onSortChange,
uploadingRows = [],
onSelectionChange,
hasActiveFilters,
onClearFilters,
onUploadClick,
}: DataTableProps<TData>) {
const router = useRouter();
@ -333,6 +344,54 @@ export function DataTable<TData extends ResponseCard>({
return (
<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 */}
<div className="glass-card overflow-hidden rounded-2xl hidden md:block">
<div className="overflow-x-auto">
@ -455,9 +514,31 @@ export function DataTable<TData extends ResponseCard>({
<TableRow>
<TableCell
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>
</TableRow>
)
@ -557,8 +638,30 @@ export function DataTable<TData extends ResponseCard>({
))
) : (
uploadingRows.length === 0 && (
<div className="py-12 text-center text-sm text-muted-foreground">
No results.
<div className="flex flex-col items-center py-12 text-center">
{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>
)
)}

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

View file

@ -8,7 +8,9 @@ import {
Clock,
UserCheck,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
type Stats = {
total: number;
@ -36,11 +38,14 @@ interface StatCardsProps {
export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
const [stats, setStats] = React.useState<Stats | null>(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
fetch("/api/stats")
.then((r) => r.json())
.then(setStats)
.catch(() => {});
.catch(() => { toast.error("Failed to load card stats"); })
.finally(() => setLoading(false));
}, []);
const cards: StatCardData[] = React.useMemo(() => {
@ -105,8 +110,18 @@ export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
}
};
if (loading) {
return (
<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-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">
{cards.map((card) => {
const Icon = card.icon;
const isActive = activeFilter === card.filterKey;
@ -131,7 +146,7 @@ export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
</div>
<div>
<p className="text-lg font-bold tracking-tight sm:text-2xl">
{stats ? card.value.toLocaleString() : "—"}
{card.value.toLocaleString()}
</p>
<p className="text-[11px] text-muted-foreground sm:text-xs">
{card.label}

View file

@ -37,6 +37,7 @@ export type UploadingFile = {
interface UploadModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
initialFiles?: File[];
onUploadStart?: (files: UploadingFile[]) => void;
onUploadProgress?: (fileId: string, progress: number) => void;
onUploadComplete?: (fileId: string) => void;
@ -46,6 +47,7 @@ interface UploadModalProps {
export function UploadModal({
open,
onOpenChange,
initialFiles,
onUploadStart,
onUploadProgress,
onUploadComplete,
@ -56,6 +58,17 @@ export function UploadModal({
const [dragOver, setDragOver] = React.useState(false);
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) => {
e.preventDefault();
e.stopPropagation();
@ -95,6 +108,9 @@ export function UploadModal({
const filesToUpload = [...stagedFiles];
setStagedFiles([]);
let successCount = 0;
let failCount = 0;
for (let i = 0; i < filesToUpload.length; i++) {
const file = filesToUpload[i];
const uploadEntry = newUploading[i];
@ -127,6 +143,7 @@ export function UploadModal({
)
);
onUploadComplete?.(uploadEntry.id);
successCount++;
resolve();
} else {
const errMsg = "Upload failed";
@ -138,6 +155,7 @@ export function UploadModal({
)
);
onUploadError?.(uploadEntry.id, errMsg);
failCount++;
reject(new Error(errMsg));
}
});
@ -152,6 +170,7 @@ export function UploadModal({
)
);
onUploadError?.(uploadEntry.id, errMsg);
failCount++;
reject(new Error(errMsg));
});
@ -163,8 +182,13 @@ export function UploadModal({
}
}
const completeCount = filesToUpload.length;
toast.success(`Uploaded ${completeCount} file(s). Processing started.`);
if (failCount === 0) {
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 = () => {

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

View 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 &amp; 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>
);
}

View file

@ -4,16 +4,25 @@ import { cn } from "@/lib/utils";
import { TopBar } from "@/components/layout/top-bar";
import { Sidebar, SidebarProvider, useSidebar } from "@/components/layout/sidebar";
import { EmailVerificationBanner } from "@/components/layout/email-verification-banner";
import { CommandPalette } from "@/components/command-palette";
function ShellContent({ children }: { children: React.ReactNode }) {
const { collapsed } = useSidebar();
return (
<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" />
<TopBar />
<Sidebar />
<CommandPalette />
<main
id="main-content"
className={cn(
"relative z-0 pt-16 transition-[margin-left] duration-200 ease-in-out",
collapsed ? "ml-16" : "ml-60"

View file

@ -2,7 +2,8 @@
import * as React from "react";
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 { useUserProfile } from "@/lib/user-profile";
import {
@ -31,15 +32,21 @@ export function OrgSwitcher({ collapsed }: { collapsed: boolean }) {
const router = useRouter();
const [orgs, setOrgs] = React.useState<OrgListItem[]>([]);
const [loading, setLoading] = React.useState(false);
const [orgsLoading, setOrgsLoading] = React.useState(false);
const fetchOrgs = React.useCallback(async () => {
setOrgsLoading(true);
try {
const res = await fetch("/api/org/list");
if (res.ok) {
const data = await res.json();
setOrgs(data.orgs ?? []);
}
} catch {}
} catch {
toast.error("Failed to load workspaces");
} finally {
setOrgsLoading(false);
}
}, []);
const initial = orgName
@ -94,7 +101,11 @@ export function OrgSwitcher({ collapsed }: { collapsed: boolean }) {
sideOffset={collapsed ? 8 : 4}
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
key={org.id}
className="flex items-center gap-2"

View file

@ -106,6 +106,7 @@ function NavItem({
const content = (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
active
@ -139,6 +140,7 @@ export function Sidebar() {
return (
<aside
aria-label="Main navigation"
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",
collapsed ? "w-16" : "w-60"
@ -182,6 +184,7 @@ export function Sidebar() {
))}
<button
onClick={toggle}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
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",
collapsed && "justify-center px-0"

View file

@ -58,7 +58,7 @@ export function TopBar() {
<div className="flex size-9 items-center justify-center rounded-xl gradient-banner shadow-sm">
<ScanLine className="size-[18px] text-white" />
</div>
<span className="text-base font-bold tracking-tight">Echo OCR</span>
<span className="text-base font-bold tracking-tight">Echo</span>
</Link>
{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">
@ -93,6 +93,7 @@ export function TopBar() {
size="icon"
className="size-9 rounded-xl sm:hidden"
onClick={handleUploadClick}
aria-label="Upload documents"
/>
}
>
@ -106,7 +107,7 @@ export function TopBar() {
<DropdownMenu>
<DropdownMenuTrigger
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">

View file

@ -7,11 +7,22 @@ import { useTheme } from "next-themes";
function ThemeToggle() {
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 (
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
onClick={cycleTheme}
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" />
<Moon className="h-4 w-4 block dark:hidden" />

View file

@ -16,7 +16,9 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
type Notification = {
@ -34,6 +36,7 @@ export function NotificationCenter() {
const [open, setOpen] = React.useState(false);
const [notifications, setNotifications] = React.useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = React.useState(0);
const [listLoading, setListLoading] = React.useState(false);
const fetchUnreadCount = React.useCallback(async () => {
try {
@ -42,7 +45,9 @@ export function NotificationCenter() {
const data = await res.json();
setUnreadCount(data.unreadCount ?? 0);
}
} catch { /* ignore */ }
} catch {
// Polling failure — don't toast on every interval tick
}
}, []);
React.useEffect(() => {
@ -52,6 +57,7 @@ export function NotificationCenter() {
}, [fetchUnreadCount]);
const fetchNotifications = React.useCallback(async () => {
setListLoading(true);
try {
const res = await fetch("/api/notifications?limit=30");
if (res.ok) {
@ -59,7 +65,11 @@ export function NotificationCenter() {
setNotifications(data.notifications ?? []);
setUnreadCount(data.unreadCount ?? 0);
}
} catch { /* ignore */ }
} catch {
toast.error("Failed to load notifications");
} finally {
setListLoading(false);
}
}, []);
React.useEffect(() => {
@ -75,7 +85,9 @@ export function NotificationCenter() {
});
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
setUnreadCount(0);
} catch { /* ignore */ }
} catch {
toast.error("Failed to mark notifications as read");
}
};
const markRead = async (id: string) => {
@ -87,7 +99,9 @@ export function NotificationCenter() {
});
setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n));
setUnreadCount((c) => Math.max(0, c - 1));
} catch { /* ignore */ }
} catch {
toast.error("Failed to update notification");
}
};
return (
@ -115,7 +129,19 @@ export function NotificationCenter() {
)}
</div>
<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">
<Bell className="size-8 mb-2 opacity-20" />
<p className="text-sm">No notifications</p>
@ -158,18 +184,12 @@ function NotificationItem({
if (n.actionUrl) onClose();
};
const content = (
<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}
>
const inner = (
<>
<div className={cn("flex size-7 shrink-0 items-center justify-center rounded-full mt-0.5", color)}>
{icon}
</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">
<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" />}
@ -180,13 +200,27 @@ function NotificationItem({
{formatTimeAgo(n.createdAt)}
</span>
</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) {
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 {

View 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}
/>
);
}
}

View file

@ -4,6 +4,7 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { z } from "zod";
import { prisma } from "./db";
import type { LanguageModel } from "ai";
import type { FormField as PrismaFormField } from "@/generated/prisma/client";
const DEFAULT_GATEWAY_MODEL = "openai/gpt-4o-mini";
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.`;
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 {
data: Record<string, unknown>;
confidence: number;
@ -165,7 +239,8 @@ async function extractStructured<T extends Record<string, unknown>>(
export async function ocrImage(
imageBase64: string,
side: "response" | "survey"
side: "response" | "survey",
templateFields?: { fields: FormFieldDef[]; templateName: string }
): Promise<OcrResult> {
const settings = await getSettings();
const provider = settings.aiProvider || "gateway";
@ -174,7 +249,17 @@ export async function ocrImage(
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);
} else {
output = await extractStructured(model, surveySchema, SURVEY_SYSTEM_PROMPT, imageBase64);

33
src/lib/api-auth.ts Normal file
View 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 });
}

View file

@ -64,8 +64,6 @@ async function handleMessage(client: ImapFlow, uid: number, config: EmailConfig)
continue;
}
const isPdf = att.contentType?.toLowerCase() === "application/pdf";
try {
const job = await prisma.processingJob.create({
data: {
@ -76,8 +74,7 @@ async function handleMessage(client: ImapFlow, uid: number, config: EmailConfig)
});
const sourceKey = `sources/${job.id}/${fileName}`;
const contentType = isPdf ? "application/pdf" : "image/jpeg";
await uploadBuffer(sourceKey, buffer, contentType);
await uploadBuffer(sourceKey, buffer, att.contentType || "application/octet-stream");
await enqueueProcessing(job.id);
log(`Queued: ${fileName} (job ${job.id})`);

65
src/lib/form-templates.ts Normal file
View 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
View 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;
}

View file

@ -31,10 +31,11 @@ export async function createNotification(input: CreateNotificationInput) {
}
export async function getNotifications({
userId,
unreadOnly = false,
limit = 50,
}: { unreadOnly?: boolean; limit?: number } = {}) {
const where: Record<string, unknown> = { dismissed: false };
}: { userId: string; unreadOnly?: boolean; limit?: number }) {
const where: Record<string, unknown> = { dismissed: false, userId };
if (unreadOnly) where.read = false;
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({
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({
where: { id },
where: { id, userId },
data: { read: true },
});
}
export async function markAllRead() {
export async function markAllRead(userId: string) {
return prisma.notification.updateMany({
where: { read: false, dismissed: false },
where: { read: false, dismissed: false, userId },
data: { read: true },
});
}
export async function dismissNotification(id: string) {
export async function dismissNotification(id: string, userId: string) {
return prisma.notification.update({
where: { id },
where: { id, userId },
data: { dismissed: true },
});
}

View file

@ -4,6 +4,7 @@ import { uploadBuffer, getBuffer, deleteObject } from "./storage";
import { ocrImage } from "./ai-ocr";
import { pdfToImages, imageToBase64, processUploadedImage } from "./pdf";
import { fireIntegrationEvent } from "./integrations";
import { linkCardToPerson } from "./person-linker";
const DELAY_BETWEEN_OCR_CALLS_MS = 2_000;
function ocrDelay() {
@ -19,7 +20,7 @@ export async function processFile(
const cardIds: string[] = [];
try {
await prisma.processingJob.update({
const job = await prisma.processingJob.update({
where: { id: jobId },
data: { status: "processing" },
});
@ -70,6 +71,7 @@ export async function processFile(
data: {
sourceFile: sourceKey,
ocrStatus: "processing",
organizationId: job.organizationId,
},
});
cardIds.push(card.id);
@ -156,6 +158,12 @@ export async function processFile(
});
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) {
const message = err instanceof Error ? err.message : "Unknown OCR error";
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 })),
},
});
if (card.organizationId) {
linkCardToPerson(card.id, card.organizationId).catch((err) => {
console.error("[person-linker] Failed to link card:", err);
});
}
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown OCR error";
await prisma.responseCard.update({

53
src/lib/person-linker.ts Normal file
View 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;
}

View file

@ -28,7 +28,7 @@ const STORAGE_KEY = "echo-ocr-user-profile";
type UserProfileContextValue = {
profile: UserProfile;
updateProfile: (updates: Partial<UserProfile>) => void;
updateProfile: (updates: Partial<UserProfile>) => Promise<void>;
initials: string;
userId?: string;
role: UserRole;
@ -96,12 +96,26 @@ export function UserProfileProvider({ children }: { children: React.ReactNode })
};
}, [session, localOverrides]);
const updateProfile = React.useCallback((updates: Partial<UserProfile>) => {
const updateProfile = React.useCallback(async (updates: Partial<UserProfile>) => {
setLocalOverrides((prev) => {
const next = { ...prev, ...updates };
saveLocalProfile(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]);
@ -144,7 +158,7 @@ export function useUserProfile() {
if (!ctx) {
return {
profile: DEFAULT_PROFILE,
updateProfile: () => {},
updateProfile: async () => {},
initials: "",
userId: undefined,
role: "viewer" as UserRole,

View file

@ -51,11 +51,10 @@ function isOnboardingExempt(pathname: string) {
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
if (
pathname.startsWith("/_next") ||
const isStaticFile = pathname.startsWith("/_next/") ||
pathname.startsWith("/favicon") ||
pathname.includes(".")
) {
/^\/(.*\.(ico|svg|png|jpg|jpeg|gif|webp|woff2?|ttf|css|js|map))$/.test(pathname);
if (isStaticFile) {
return NextResponse.next();
}