diff --git a/UPGRADE_PLAN.md b/UPGRADE_PLAN.md index 67580af..7ef96c3 100644 --- a/UPGRADE_PLAN.md +++ b/UPGRADE_PLAN.md @@ -1148,6 +1148,77 @@ model SLATarget { --- +### 9.5 — Weekly Executive Report + +**What:** A manually generated, print-ready weekly report summarizing production activity +for executive stakeholders. Covers deliverables completed, deadlines met/missed, pipeline +status, at-risk projects, and the upcoming week's schedule. Available as an in-browser +page and downloadable PDF. + +**Why:** Executives don't use the tracker daily but need a regular pulse on production +health. A structured, branded report replaces ad-hoc Slack summaries and gives leadership +consistent, reliable visibility into team output and project risk. + +**Report sections:** + +1. **Deliverables Completed** — count + list with project name, deliverable type, stage, + and completion date. The "wins" summary for the week. +2. **Deadlines Met vs. Missed** — ratio with trend vs. previous weeks (e.g., "14/16 met, + 87.5%"). Missed deadlines called out by name with project, days overdue, and current + status. +3. **Pipeline Snapshot** — aggregate status breakdown across all active projects: how many + stages are In Progress, In Review, Blocked, Approved. Stacked bar or donut chart. +4. **Projects At Risk** — projects with overdue deliverables, stages stuck too long, or + approaching deadlines with low completion rates. Auto-flagged based on configurable + thresholds. +5. **Upcoming Week** — deliverables due in the next 7 days, grouped by project. Gives + executives a heads-up on what to expect. +6. **Team Utilization Summary** (optional) — high-level capacity usage without individual + names (e.g., "Team at 82% capacity, 2 artists overallocated"). Added when Phase 6 + workload data is available. + +**Implementation:** + +- Report data service that accepts a date range (defaults to previous Monday–Sunday) and + aggregates: completed stages, missed deadlines, current status distribution, at-risk + projects, upcoming deadlines +- Dedicated report page at `/reports/weekly/[date]` — viewable in-browser with + print-optimized CSS and the Oliver Agency branded layout (forest green + coral palette, + Montserrat headings, `label-upper` section headers) +- PDF export via `@react-pdf/renderer` — same branded layout rendered as a downloadable + PDF from a "Download PDF" button on the report page +- "Generate Weekly Report" action accessible from the dashboard — opens the report page + for the selected week +- Date picker to generate reports for any past week, not just the current one +- All data sourced from existing models — no new database tables required + +**Future enhancements (not in initial scope):** +- Scheduled auto-generation via cron job (every Monday morning) +- Email distribution to a configured recipient list +- Customizable report sections per organization + +**No new data model required** — purely an aggregation and presentation layer over existing +`DeliverableStage`, `Project`, `Revision`, and `StageAssignment` records. + +**Key files:** +- `src/app/(app)/reports/weekly/[date]/page.tsx` — In-browser report page +- `src/components/reports/weekly/report-header.tsx` — Branded header with date range + org info +- `src/components/reports/weekly/completed-section.tsx` — Deliverables completed table +- `src/components/reports/weekly/deadlines-section.tsx` — Met/missed breakdown with trend +- `src/components/reports/weekly/pipeline-snapshot.tsx` — Status distribution chart +- `src/components/reports/weekly/at-risk-section.tsx` — Flagged projects list +- `src/components/reports/weekly/upcoming-section.tsx` — Next week's schedule +- `src/components/reports/weekly/report-pdf.tsx` — React-pdf document definition +- `src/lib/services/weekly-report-service.ts` — Data aggregation queries +- `src/hooks/use-weekly-report.ts` — TanStack Query hook + +**New dependency:** `@react-pdf/renderer` (PDF generation from React components) + +**Dependencies:** None — uses existing data. Enhanced by 6.1 (Capacity data for +utilization section) and 9.1 (Velocity metrics for trend calculations). + +--- + ## Phase 10: Collaboration Enhancements Deepens the communication layer to reduce dependency on email and Slack for @@ -1567,11 +1638,11 @@ npm run dev | 6 | `/api/workload/`, `/api/skills/`, `/api/users/[id]/skills/` | | 7 | `/api/automations/`, `/api/automations/[id]/executions/`, `/api/approval-chains/`, `/api/stages/[id]/approve/`, `/api/templates/`, `/api/templates/[id]/instantiate/` | | 8 | `/api/asset-specs/`, `/api/revisions/[id]/validate/`, `/api/webhooks/ai-review/`, `/api/revisions/[id]/ai-review/`, `/api/search/semantic/` | -| 9 | `/api/portal/`, `/api/portal/[token]/`, `/api/analytics/velocity/`, `/api/analytics/sla/` | +| 9 | `/api/portal/`, `/api/portal/[token]/`, `/api/analytics/velocity/`, `/api/analytics/sla/`, `/api/reports/weekly/` | | 10 | `/api/projects/[id]/activity/`, `/api/external-links/` | | 11 | `/api/views/` | -**Total new API routes: ~26** +**Total new API routes: ~27** --- @@ -1583,11 +1654,11 @@ npm run dev | 6 | Workload/capacity page, Skills management (settings) | | 7 | Automations management (settings), Approval chains (settings), Template library | | 8 | Asset specs (settings), Smart search panel (chat UI) | -| 9 | Client portal (external), SLA configuration (settings) | +| 9 | Client portal (external), SLA configuration (settings), Weekly executive report | | 10 | Activity feed (per project), External review page | | 11 | — (enhancements to existing pages) | -**Total new pages: ~13** +**Total new pages: ~14** --- @@ -1598,6 +1669,7 @@ npm run dev | `sharp` | Server-side image processing (validation, thumbnails, previews) | 8 | | `fabric.js` or native Canvas/SVG | Annotation drawing on canvas (evaluate during 5.2) | 5 | | `bcryptjs` | Password hashing for portal links | 9 | +| `@react-pdf/renderer` | PDF generation for weekly executive reports | 9 | **Philosophy:** Minimize new dependencies. Most features build on the existing stack (React, TanStack Query, shadcn/ui, recharts, Prisma). Canvas/SVG APIs are native browser @@ -1637,6 +1709,8 @@ Phase 8 (Asset Intelligence) ─── requires Phase 5 for full value +-- 8.4 Semantic Search <-- standalone, requires pgvector extension Phase 9 (Reporting) ─── benefits from Phase 6 + 7 data + | + +-- 9.5 Weekly Executive Report <-- standalone, no new models needed Phase 10 (Collaboration) ─── benefits from Phase 5 Phase 11 (QoL) ─── standalone incremental improvements, can be interleaved @@ -1658,14 +1732,14 @@ Phase 12 (Docker) ─── can be done at any time, benefits from 8.4 for Ollam | 6 | 3 | 3 | 2 | ~8 | | 7 | 6 | 6 | 3 | ~10 | | 8 | 4 | 5 | 2 | ~13 | -| 9 | 2 | 4 | 2 | ~8 | +| 9 | 2 | 5 | 3 | ~18 | | 10 | 1 | 2 | 2 | ~6 | | 11 | 1 | 1 | 0 | ~8 | | 12 | 0 | 0 | 0 | 0 (infra only) | -| **Total** | **22** | **~27** | **~14** | **~85** | +| **Total** | **22** | **~28** | **~15** | **~95** | --- -*Document version: 1.1 — Created 2026-03-01, updated 2026-03-06* -*Updates: Added 8.4 (AI semantic search with Ollama + pgvector), Phase 12 (Docker deployment)* +*Document version: 1.2 — Created 2026-03-01, updated 2026-03-13* +*Updates: Added 8.4 (AI semantic search with Ollama + pgvector), Phase 12 (Docker deployment), 9.5 (Weekly executive report)* *To be updated as features are refined and priorities shift.* \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 14afcc8..1e802b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@hookform/resolvers": "^5.2.2", "@prisma/adapter-pg": "^7.4.2", "@prisma/client": "^7.4.2", + "@react-pdf/renderer": "^4.3.2", "@tailwindcss/postcss": "^4.2.1", "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.90.21", @@ -4564,6 +4565,180 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@react-pdf/fns": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.2.tgz", + "integrity": "sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==", + "license": "MIT" + }, + "node_modules/@react-pdf/font": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.4.tgz", + "integrity": "sha512-8YtgGtL511txIEc9AjiilpZ7yjid8uCd8OGUl6jaL3LIHnrToUupSN4IzsMQpVTCMYiDLFnDNQzpZsOYtRS/Pg==", + "license": "MIT", + "dependencies": { + "@react-pdf/pdfkit": "^4.1.0", + "@react-pdf/types": "^2.9.2", + "fontkit": "^2.0.2", + "is-url": "^1.2.4" + } + }, + "node_modules/@react-pdf/image": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.0.4.tgz", + "integrity": "sha512-z0ogVQE0bKqgXQ5smgzIU857rLV7bMgVdrYsu3UfXDDLSzI7QPvzf6MFTFllX6Dx2rcsF13E01dqKPtJEM799g==", + "license": "MIT", + "dependencies": { + "@react-pdf/png-js": "^3.0.0", + "jay-peg": "^1.1.1" + } + }, + "node_modules/@react-pdf/layout": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.4.2.tgz", + "integrity": "sha512-gNu2oh8MiGR+NJZYTJ4c4q0nWCESBI6rKFiodVhE7OeVAjtzZzd6l65wsN7HXdWJqOZD3ttD97iE+tf5SOd/Yg==", + "license": "MIT", + "dependencies": { + "@react-pdf/fns": "3.1.2", + "@react-pdf/image": "^3.0.4", + "@react-pdf/primitives": "^4.1.1", + "@react-pdf/stylesheet": "^6.1.2", + "@react-pdf/textkit": "^6.1.0", + "@react-pdf/types": "^2.9.2", + "emoji-regex-xs": "^1.0.0", + "queue": "^6.0.1", + "yoga-layout": "^3.2.1" + } + }, + "node_modules/@react-pdf/pdfkit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-4.1.0.tgz", + "integrity": "sha512-Wm/IOAv0h/U5Ra94c/PltFJGcpTUd/fwVMVeFD6X9tTTPCttIwg0teRG1Lqq617J8K4W7jpL/B0HTH0mjp3QpQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/png-js": "^3.0.0", + "browserify-zlib": "^0.2.0", + "crypto-js": "^4.2.0", + "fontkit": "^2.0.2", + "jay-peg": "^1.1.1", + "linebreak": "^1.1.0", + "vite-compatible-readable-stream": "^3.6.1" + } + }, + "node_modules/@react-pdf/png-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-pdf/png-js/-/png-js-3.0.0.tgz", + "integrity": "sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==", + "license": "MIT", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, + "node_modules/@react-pdf/primitives": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.1.1.tgz", + "integrity": "sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==", + "license": "MIT" + }, + "node_modules/@react-pdf/reconciler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-2.0.0.tgz", + "integrity": "sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "scheduler": "0.25.0-rc-603e6108-20241029" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-pdf/reconciler/node_modules/scheduler": { + "version": "0.25.0-rc-603e6108-20241029", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz", + "integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==", + "license": "MIT" + }, + "node_modules/@react-pdf/render": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.3.2.tgz", + "integrity": "sha512-el5KYM1sH/PKcO4tRCIm8/AIEmhtraaONbwCrBhFdehoGv6JtgnXiMxHGAvZbI5kEg051GbyP+XIU6f6YbOu6Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/fns": "3.1.2", + "@react-pdf/primitives": "^4.1.1", + "@react-pdf/textkit": "^6.1.0", + "@react-pdf/types": "^2.9.2", + "abs-svg-path": "^0.1.1", + "color-string": "^1.9.1", + "normalize-svg-path": "^1.1.0", + "parse-svg-path": "^0.1.2", + "svg-arc-to-cubic-bezier": "^3.2.0" + } + }, + "node_modules/@react-pdf/renderer": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.3.2.tgz", + "integrity": "sha512-EhPkj35gO9rXIyyx29W3j3axemvVY5RigMmlK4/6Ku0pXB8z9PEE/sz4ZBOShu2uot6V4xiCR3aG+t9IjJJlBQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/fns": "3.1.2", + "@react-pdf/font": "^4.0.4", + "@react-pdf/layout": "^4.4.2", + "@react-pdf/pdfkit": "^4.1.0", + "@react-pdf/primitives": "^4.1.1", + "@react-pdf/reconciler": "^2.0.0", + "@react-pdf/render": "^4.3.2", + "@react-pdf/types": "^2.9.2", + "events": "^3.3.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "queue": "^6.0.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-pdf/stylesheet": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.1.2.tgz", + "integrity": "sha512-E3ftGRYUQGKiN3JOgtGsLDo0hGekA6dmkmi/MYACytmPTKxQRBSO3126MebmCq+t1rgU9uRlREIEawJ+8nzSbw==", + "license": "MIT", + "dependencies": { + "@react-pdf/fns": "3.1.2", + "@react-pdf/types": "^2.9.2", + "color-string": "^1.9.1", + "hsl-to-hex": "^1.0.0", + "media-engine": "^1.0.3", + "postcss-value-parser": "^4.1.0" + } + }, + "node_modules/@react-pdf/textkit": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.1.0.tgz", + "integrity": "sha512-sFlzDC9CDFrJsnL3B/+NHrk9+Advqk7iJZIStiYQDdskbow8GF/AGYrpIk+vWSnh35YxaGbHkqXq53XOxnyrjQ==", + "license": "MIT", + "dependencies": { + "@react-pdf/fns": "3.1.2", + "bidi-js": "^1.0.2", + "hyphen": "^1.6.4", + "unicode-properties": "^1.4.1" + } + }, + "node_modules/@react-pdf/types": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.9.2.tgz", + "integrity": "sha512-dufvpKId9OajLLbgn9q7VLUmyo1Jf+iyGk2ZHmCL8nIDtL8N1Ejh9TH7+pXXrR0tdie1nmnEb5Bz9U7g4hI4/g==", + "license": "MIT", + "dependencies": { + "@react-pdf/font": "^4.0.4", + "@react-pdf/primitives": "^4.1.1", + "@react-pdf/stylesheet": "^6.1.2" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -5740,6 +5915,12 @@ "win32" ] }, + "node_modules/abs-svg-path": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", + "integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -6203,6 +6384,15 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -6277,6 +6467,24 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -6634,6 +6842,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -6685,9 +6902,18 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -6789,6 +7015,12 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/css-box-model": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", @@ -7194,6 +7426,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -7304,6 +7542,12 @@ "dev": true, "license": "MIT" }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "license": "MIT" + }, "node_modules/empathic": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", @@ -8034,6 +8278,15 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/exceljs": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", @@ -8119,7 +8372,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -8249,6 +8501,23 @@ "dev": true, "license": "ISC" }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -8776,6 +9045,21 @@ "node": ">=16.9.0" } }, + "node_modules/hsl-to-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz", + "integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==", + "license": "MIT", + "dependencies": { + "hsl-to-rgb-for-reals": "^1.1.0" + } + }, + "node_modules/hsl-to-rgb-for-reals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz", + "integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==", + "license": "ISC" + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -8793,6 +9077,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/hyphen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.14.1.tgz", + "integrity": "sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==", + "license": "ISC" + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -8981,6 +9271,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -9371,6 +9667,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -9449,6 +9751,15 @@ "node": ">= 0.4" } }, + "node_modules/jay-peg": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz", + "integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==", + "license": "MIT", + "dependencies": { + "restructure": "^3.0.0" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -9471,7 +9782,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -9958,6 +10268,25 @@ "node": ">=10" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/listenercount": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", @@ -10094,7 +10423,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -10449,6 +10777,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/media-engine": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz", + "integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -11402,6 +11736,15 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-svg-path": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz", + "integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==", + "license": "MIT", + "dependencies": { + "svg-arc-to-cubic-bezier": "^3.0.0" + } + }, "node_modules/nuqs": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/nuqs/-/nuqs-2.8.9.tgz", @@ -11477,7 +11820,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11724,6 +12066,12 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse-svg-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", + "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -11954,6 +12302,12 @@ "node": ">=4" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, "node_modules/postgres": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", @@ -12205,7 +12559,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -12217,7 +12570,6 @@ "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/proper-lockfile": { @@ -12289,6 +12641,15 @@ ], "license": "MIT" }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -12850,6 +13211,15 @@ "url": "https://github.com/sponsors/remeda" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -12897,6 +13267,12 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -13347,6 +13723,15 @@ "simple-concat": "^1.0.0" } }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -13661,6 +14046,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-arc-to-cubic-bezier": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", + "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==", + "license": "ISC" + }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -13730,6 +14121,12 @@ "node": ">=6" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -14080,6 +14477,32 @@ "dev": true, "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -14429,6 +14852,20 @@ "d3-timer": "^3.0.1" } }, + "node_modules/vite-compatible-readable-stream": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz", + "integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -14624,6 +15061,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + }, "node_modules/zeptomatch": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", diff --git a/package.json b/package.json index 9432dd8..fe39dcd 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@hookform/resolvers": "^5.2.2", "@prisma/adapter-pg": "^7.4.2", "@prisma/client": "^7.4.2", + "@react-pdf/renderer": "^4.3.2", "@tailwindcss/postcss": "^4.2.1", "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.90.21", diff --git a/src/app/(app)/reports/page.tsx b/src/app/(app)/reports/page.tsx new file mode 100644 index 0000000..c957c31 --- /dev/null +++ b/src/app/(app)/reports/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { format } from "date-fns"; + +/** + * /reports redirects to the current week's weekly report. + */ +export default function ReportsPage() { + const router = useRouter(); + + useEffect(() => { + const today = format(new Date(), "yyyy-MM-dd"); + router.replace(`/reports/weekly/${today}`); + }, [router]); + + return null; +} diff --git a/src/app/(app)/reports/weekly/[date]/page.tsx b/src/app/(app)/reports/weekly/[date]/page.tsx new file mode 100644 index 0000000..3df643c --- /dev/null +++ b/src/app/(app)/reports/weekly/[date]/page.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useParams, useRouter } from "next/navigation"; +import { format, parseISO, subWeeks, addWeeks, isValid } from "date-fns"; +import { useWeeklyReport } from "@/hooks/use-weekly-report"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ReportHeader } from "@/components/reports/weekly/report-header"; +import { KpiStrip } from "@/components/reports/weekly/kpi-strip"; +import { CompletedSection } from "@/components/reports/weekly/completed-section"; +import { DeadlinesSection } from "@/components/reports/weekly/deadlines-section"; +import { PipelineSnapshot } from "@/components/reports/weekly/pipeline-snapshot"; +import { AtRiskSection } from "@/components/reports/weekly/at-risk-section"; +import { UpcomingSection } from "@/components/reports/weekly/upcoming-section"; + +export default function WeeklyReportPage() { + const params = useParams<{ date: string }>(); + const router = useRouter(); + const dateStr = params.date; + + // Validate date param + const parsed = parseISO(dateStr); + const isValidDate = isValid(parsed); + + const { data, isLoading, error } = useWeeklyReport( + isValidDate ? dateStr : "" + ); + + function navigateWeek(direction: "prev" | "next") { + const current = parseISO(dateStr); + const target = + direction === "prev" ? subWeeks(current, 1) : addWeeks(current, 1); + router.push(`/reports/weekly/${format(target, "yyyy-MM-dd")}`); + } + + function handleDownloadPdf() { + // Stub — will be wired to @react-pdf/renderer + window.print(); + } + + if (!isValidDate) { + return ( +
+

+ Invalid date format. Use YYYY-MM-DD. +

+
+ ); + } + + if (isLoading) { + return ; + } + + if (error || !data) { + return ( +
+

+ Failed to load report. {error?.message} +

+
+ ); + } + + return ( +
+ navigateWeek("prev")} + onNextWeek={() => navigateWeek("next")} + onDownloadPdf={handleDownloadPdf} + /> + + + +
+ + +
+ +
+ + +
+ + + + {/* Footer rule */} +
+
+

+ HP Production Tracker +

+

+ Confidential — Oliver Agency +

+
+
+
+ ); +} + +function ReportSkeleton() { + return ( +
+ {/* Header skeleton */} +
+ +
+
+ + + +
+
+ + + +
+
+
+ + {/* KPI strip skeleton */} +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ + {/* Content skeletons */} +
+ + +
+
+ + +
+ +
+ ); +} diff --git a/src/app/api/reports/weekly/route.ts b/src/app/api/reports/weekly/route.ts new file mode 100644 index 0000000..565471f --- /dev/null +++ b/src/app/api/reports/weekly/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAuthSession, badRequest, serverError } from "@/lib/api-utils"; +import { getWeeklyReport } from "@/lib/services/weekly-report-service"; +import { parseISO, isValid } from "date-fns"; + +export async function GET(request: NextRequest) { + const { session, error } = await getAuthSession(); + if (error) return error; + + try { + const { searchParams } = request.nextUrl; + const dateParam = searchParams.get("date"); + + let weekOf: Date; + if (dateParam) { + weekOf = parseISO(dateParam); + if (!isValid(weekOf)) { + return badRequest("Invalid date format. Use YYYY-MM-DD."); + } + } else { + weekOf = new Date(); + } + + const report = await getWeeklyReport( + session!.user.organizationId!, + weekOf + ); + + return NextResponse.json(report); + } catch (e) { + return serverError(e); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 62fba4c..8ef3ac6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -232,3 +232,59 @@ background: var(--muted); } } + +/* Print styles — weekly report */ +@media print { + /* Hide app chrome */ + [role="navigation"], + [data-print-hide], + [data-slot="sidebar"], + aside { + display: none !important; + } + + body { + background: white !important; + color: #0c0c0c !important; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + + main { + padding: 0 !important; + overflow: visible !important; + } + + .weekly-report { + max-width: none !important; + } + + /* Avoid page breaks inside sections */ + section, + [data-slot="card"] { + break-inside: avoid; + page-break-inside: avoid; + } + + /* Force light-mode status colors for print */ + :root { + --status-blocked: #cc2200; + --status-not-started: #8c8c8c; + --status-in-progress: #1a56db; + --status-in-review: #b45309; + --status-approved: #08402c; + --status-delivered: #0d9488; + --primary: #08402c; + --accent: #ee5540; + --foreground: #0c0c0c; + --muted-foreground: #6b6b6b; + --border: #e2e0dc; + --muted: #f0efed; + --card: #ffffff; + } + + @page { + margin: 1.5cm; + size: A4 portrait; + } +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 44b0127..04376ea 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -14,6 +14,7 @@ import { PanelLeft, Menu, CalendarDays, + FileBarChart, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; @@ -29,6 +30,7 @@ const navItems = [ { href: "/workload", label: "Workload", icon: Users }, { href: "/timeline", label: "Timeline", icon: GanttChart }, { href: "/calendar", label: "Calendar", icon: CalendarDays }, + { href: "/reports", label: "Reports", icon: FileBarChart }, { href: "/notifications", label: "Notifications", icon: Bell }, ]; diff --git a/src/components/reports/weekly/at-risk-section.tsx b/src/components/reports/weekly/at-risk-section.tsx new file mode 100644 index 0000000..fe1e6a0 --- /dev/null +++ b/src/components/reports/weekly/at-risk-section.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { format } from "date-fns"; +import { AlertTriangle, ShieldCheck } from "lucide-react"; +import type { WeeklyReportData } from "@/lib/services/weekly-report-service"; + +interface AtRiskSectionProps { + projects: WeeklyReportData["atRiskProjects"]; +} + +export function AtRiskSection({ projects }: AtRiskSectionProps) { + return ( +
+
+ +

+ At-Risk Projects +

+ {projects.length > 0 && ( + + {projects.length} + + )} +
+ +
+ {projects.length === 0 ? ( +
+
+ +
+
+

+ All projects on track +

+

+ No projects flagged for overdue deliverables or low completion +

+
+
+ ) : ( +
+ {projects.slice(0, 6).map((project) => { + const severity = + project.overdueCount >= 3 + ? "high" + : project.overdueCount >= 1 + ? "medium" + : "low"; + const severityBorder = + severity === "high" + ? "border-l-[var(--accent)]" + : severity === "medium" + ? "border-l-[var(--status-in-review)]" + : "border-l-[var(--status-not-started)]"; + + return ( +
+
+
+
+

+ {project.name} +

+ {project.projectCode && ( + + {project.projectCode} + + )} +
+

+ {project.totalDeliverables} deliverable{project.totalDeliverables !== 1 ? "s" : ""} + {project.nearestDeadline && ( + <> + {" · Next deadline "} + {format(new Date(project.nearestDeadline), "MMM d")} + + )} +

+
+ +
+ {/* Overdue count */} +
+

+ {project.overdueCount} +

+

Overdue

+
+ + {/* Completion bar */} +
+

+ {project.completionRate}% +

+
+
+
+
+
+
+
+ ); + })} + {projects.length > 6 && ( +

+ + {projects.length - 6} more at-risk project{projects.length - 6 !== 1 ? "s" : ""} +

+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/reports/weekly/completed-section.tsx b/src/components/reports/weekly/completed-section.tsx new file mode 100644 index 0000000..1901a3d --- /dev/null +++ b/src/components/reports/weekly/completed-section.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { format } from "date-fns"; +import { CheckCircle2 } from "lucide-react"; +import type { WeeklyReportData } from "@/lib/services/weekly-report-service"; + +interface CompletedSectionProps { + items: WeeklyReportData["completedDeliverables"]; +} + +export function CompletedSection({ items }: CompletedSectionProps) { + // Group by project for a cleaner read + const byProject = items.reduce< + Record + >((acc, item) => { + const key = item.projectName; + if (!acc[key]) acc[key] = []; + acc[key].push(item); + return acc; + }, {}); + + const projectNames = Object.keys(byProject).sort(); + + return ( +
+
+ +

+ Completed This Week +

+ + {items.length} + +
+ +
+ {items.length === 0 ? ( +

+ No stages completed this week +

+ ) : ( + + + + + + + + + + + {projectNames.map((projectName) => + byProject[projectName].map((item, i) => ( + + + + + + + )) + )} + +
DeliverableProjectStage + Completed +
{item.name} + {i === 0 ? projectName : ""} + + + {item.stageName} + + + {format(new Date(item.completedDate), "EEE, MMM d")} +
+ )} +
+
+ ); +} diff --git a/src/components/reports/weekly/deadlines-section.tsx b/src/components/reports/weekly/deadlines-section.tsx new file mode 100644 index 0000000..cdba29c --- /dev/null +++ b/src/components/reports/weekly/deadlines-section.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { format } from "date-fns"; +import { Target, AlertTriangle } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { StageStatusBadge } from "@/components/stages/stage-status-badge"; +import type { WeeklyReportData } from "@/lib/services/weekly-report-service"; + +interface DeadlinesSectionProps { + deadlines: WeeklyReportData["deadlines"]; +} + +export function DeadlinesSection({ deadlines }: DeadlinesSectionProps) { + const { met, missed, metRate } = deadlines; + const total = met.length + missed.length; + + // Color for the compliance ring + const ringColor = + metRate >= 80 + ? "var(--status-approved)" + : metRate >= 60 + ? "var(--status-in-review)" + : "var(--accent)"; + + return ( +
+
+ +

+ Deadline Compliance +

+
+ +
+ {/* Left: compliance score */} +
+ {/* SVG ring */} +
+ + + + +
+ {metRate}% +
+
+ +
+
+

+ {met.length} +

+

Met

+
+
+
+

+ {missed.length} +

+

Missed

+
+
+
+ + {/* Right: missed deadlines detail */} +
+ {missed.length === 0 ? ( +
+
+ +
+

+ All deadlines met +

+

+ {total > 0 ? `${met.length} of ${total} on time` : "No deadlines this week"} +

+
+ ) : ( + <> +
+

+ + {missed.length} Missed Deadline{missed.length !== 1 ? "s" : ""} +

+
+
+ {missed.slice(0, 8).map((item) => ( +
+
+

{item.name}

+

+ {item.projectName} +

+
+ +
+

+ +{item.daysOverdue}d +

+

+ Due {format(new Date(item.dueDate), "MMM d")} +

+
+
+ ))} + {missed.length > 8 && ( +
+

+ + {missed.length - 8} more overdue deliverable{missed.length - 8 !== 1 ? "s" : ""} +

+
+ )} +
+ + )} +
+
+
+ ); +} diff --git a/src/components/reports/weekly/kpi-strip.tsx b/src/components/reports/weekly/kpi-strip.tsx new file mode 100644 index 0000000..2ef8719 --- /dev/null +++ b/src/components/reports/weekly/kpi-strip.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { + CheckCircle2, + Target, + Layers, + AlertTriangle, + TrendingUp, + TrendingDown, + Minus, +} from "lucide-react"; +import type { WeeklyReportData } from "@/lib/services/weekly-report-service"; + +interface KpiStripProps { + data: WeeklyReportData; +} + +export function KpiStrip({ data }: KpiStripProps) { + const { previousWeekComparison: trend } = data; + const TrendIcon = + trend.trend === "up" + ? TrendingUp + : trend.trend === "down" + ? TrendingDown + : Minus; + const trendColor = + trend.trend === "up" + ? "text-[var(--status-approved)]" + : trend.trend === "down" + ? "text-[var(--accent)]" + : "text-[var(--muted-foreground)]"; + + const kpis = [ + { + label: "Stages Completed", + value: data.completedDeliverables.length, + icon: CheckCircle2, + color: "text-[var(--status-approved)]", + bg: "bg-[var(--status-approved)]", + detail: ( + + + {trend.trend === "flat" + ? "Same as last week" + : `${trend.trendPercentage}% ${trend.trend} from last week`} + + ), + }, + { + label: "Deadline Compliance", + value: `${data.deadlines.metRate}%`, + icon: Target, + color: data.deadlines.metRate >= 80 + ? "text-[var(--status-approved)]" + : data.deadlines.metRate >= 60 + ? "text-[var(--status-in-review)]" + : "text-[var(--accent)]", + bg: data.deadlines.metRate >= 80 + ? "bg-[var(--status-approved)]" + : data.deadlines.metRate >= 60 + ? "bg-[var(--status-in-review)]" + : "bg-[var(--accent)]", + detail: ( + + {data.deadlines.met.length} met, {data.deadlines.missed.length} missed + + ), + }, + { + label: "Active Pipeline", + value: data.totalActiveStages, + icon: Layers, + color: "text-[var(--status-in-progress)]", + bg: "bg-[var(--status-in-progress)]", + detail: ( + + Stages across active projects + + ), + }, + { + label: "At-Risk Projects", + value: data.atRiskProjects.length, + icon: AlertTriangle, + color: data.atRiskProjects.length > 0 + ? "text-[var(--accent)]" + : "text-[var(--muted-foreground)]", + bg: data.atRiskProjects.length > 0 + ? "bg-[var(--accent)]" + : "bg-[var(--muted-foreground)]", + detail: ( + + {data.atRiskProjects.length > 0 + ? "Require attention" + : "All projects on track"} + + ), + }, + ]; + + return ( +
+ {kpis.map((kpi) => { + const Icon = kpi.icon; + return ( +
+ {/* Accent top edge */} +
+ +
+
+ +
+
+

{kpi.label}

+

+ {kpi.value} +

+
{kpi.detail}
+
+
+
+ ); + })} +
+ ); +} diff --git a/src/components/reports/weekly/pipeline-snapshot.tsx b/src/components/reports/weekly/pipeline-snapshot.tsx new file mode 100644 index 0000000..dd9357f --- /dev/null +++ b/src/components/reports/weekly/pipeline-snapshot.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { Layers } from "lucide-react"; +import { + PieChart, + Pie, + Cell, + ResponsiveContainer, + Tooltip as RTooltip, +} from "recharts"; +import type { WeeklyReportData } from "@/lib/services/weekly-report-service"; + +const STATUS_DISPLAY: Record = { + BLOCKED: { label: "Blocked", color: "var(--status-blocked)" }, + NOT_STARTED: { label: "Not Started", color: "var(--status-not-started)" }, + IN_PROGRESS: { label: "In Progress", color: "var(--status-in-progress)" }, + IN_REVIEW: { label: "In Review", color: "var(--status-in-review)" }, + CHANGES_REQUESTED: { label: "Changes Req.", color: "var(--status-in-review)" }, + APPROVED: { label: "Approved", color: "var(--status-approved)" }, + DELIVERED: { label: "Delivered", color: "var(--status-delivered)" }, +}; + +interface PipelineSnapshotProps { + snapshot: WeeklyReportData["pipelineSnapshot"]; + totalStages: number; +} + +export function PipelineSnapshot({ snapshot, totalStages }: PipelineSnapshotProps) { + const chartData = snapshot.map((s) => ({ + name: STATUS_DISPLAY[s.status]?.label ?? s.status, + value: s.count, + color: STATUS_DISPLAY[s.status]?.color ?? "var(--muted-foreground)", + })); + + return ( +
+
+ +

+ Pipeline Snapshot +

+
+ +
+
+ {/* Donut chart */} +
+ {chartData.length === 0 ? ( +

+ No active stages +

+ ) : ( +
+ + + + {chartData.map((entry, i) => ( + + ))} + + [ + `${value} (${Math.round(((value as number) / totalStages) * 100)}%)`, + name, + ]} + /> + + + {/* Center label */} +
+ + {totalStages} + + Total +
+
+ )} +
+ + {/* Legend — custom, tighter than recharts default */} +
+ {chartData.map((item) => ( +
+
+ + {item.name} + + + {item.value} + +
+ ))} +
+
+
+
+ ); +} diff --git a/src/components/reports/weekly/report-header.tsx b/src/components/reports/weekly/report-header.tsx new file mode 100644 index 0000000..a6d9505 --- /dev/null +++ b/src/components/reports/weekly/report-header.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { format } from "date-fns"; +import { ChevronLeft, ChevronRight, Download, Printer } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface ReportHeaderProps { + weekLabel: string; + periodStart: string; + periodEnd: string; + onPrevWeek: () => void; + onNextWeek: () => void; + onDownloadPdf: () => void; +} + +export function ReportHeader({ + weekLabel, + periodStart, + periodEnd, + onPrevWeek, + onNextWeek, + onDownloadPdf, +}: ReportHeaderProps) { + const generatedAt = format(new Date(), "MMM d, yyyy 'at' h:mm a"); + + return ( +
+ {/* Top rule — thick accent bar */} +
+ +
+ {/* Left: branding + title */} +
+
+ + HP Production + + / + Oliver Agency +
+ +

+ Weekly Report +

+ +

+ {weekLabel} +

+
+ + {/* Right: actions (hidden in print) */} +
+
+ + + {periodStart && format(new Date(periodStart), "MMM d")} + {" – "} + {periodEnd && format(new Date(periodEnd), "MMM d")} + + +
+ + + + +
+
+ + {/* Generated timestamp */} +

+ Generated {generatedAt} +

+
+ ); +} diff --git a/src/components/reports/weekly/upcoming-section.tsx b/src/components/reports/weekly/upcoming-section.tsx new file mode 100644 index 0000000..c8c175e --- /dev/null +++ b/src/components/reports/weekly/upcoming-section.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { format } from "date-fns"; +import { CalendarDays } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { StageStatusBadge } from "@/components/stages/stage-status-badge"; +import type { WeeklyReportData } from "@/lib/services/weekly-report-service"; + +const PRIORITY_STYLES: Record = { + LOW: "bg-[var(--status-not-started)]/10 text-[var(--status-not-started)]", + MEDIUM: "bg-[var(--status-in-progress)]/10 text-[var(--status-in-progress)]", + HIGH: "bg-[var(--status-in-review)]/10 text-[var(--status-in-review)]", + URGENT: "bg-[var(--status-blocked)]/10 text-[var(--status-blocked)]", +}; + +interface UpcomingSectionProps { + items: WeeklyReportData["upcomingDeliverables"]; +} + +export function UpcomingSection({ items }: UpcomingSectionProps) { + // Group by due date for visual clustering + const byDate = items.reduce< + Record + >((acc, item) => { + const key = format(new Date(item.dueDate), "yyyy-MM-dd"); + if (!acc[key]) acc[key] = []; + acc[key].push(item); + return acc; + }, {}); + + const sortedDates = Object.keys(byDate).sort(); + + return ( +
+
+ +

+ Upcoming Next Week +

+ + {items.length} + +
+ +
+ {items.length === 0 ? ( +

+ No deliverables due next week +

+ ) : ( + + + + + + + + + + + + {sortedDates.map((dateKey) => + byDate[dateKey].map((item, i) => ( + + + + + + + + )) + )} + +
DueDeliverableProjectStatusPriority
+ {i === 0 + ? format(new Date(item.dueDate), "EEE, MMM d") + : ""} + {item.name} + {item.projectName} + + + + + {item.priority} + +
+ )} +
+
+ ); +} diff --git a/src/hooks/use-weekly-report.ts b/src/hooks/use-weekly-report.ts new file mode 100644 index 0000000..fbe3264 --- /dev/null +++ b/src/hooks/use-weekly-report.ts @@ -0,0 +1,25 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import type { WeeklyReportData } from "@/lib/services/weekly-report-service"; + +async function fetchJson(url: string): Promise { + const res = await fetch(url); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Request failed: ${res.status}`); + } + return res.json(); +} + +/** + * Fetch weekly report data for a given date (YYYY-MM-DD). + */ +export function useWeeklyReport(date: string) { + return useQuery({ + queryKey: ["weekly-report", date], + queryFn: () => fetchJson(`/api/reports/weekly?date=${date}`), + enabled: !!date, + staleTime: 5 * 60 * 1000, // 5 minutes — report data doesn't change fast + }); +} diff --git a/src/lib/services/weekly-report-service.ts b/src/lib/services/weekly-report-service.ts new file mode 100644 index 0000000..e08d6fc --- /dev/null +++ b/src/lib/services/weekly-report-service.ts @@ -0,0 +1,362 @@ +import { prisma } from "@/lib/prisma"; +import { + startOfWeek, + endOfWeek, + subWeeks, + differenceInCalendarDays, +} from "date-fns"; + +export interface WeeklyReportData { + period: { + start: string; + end: string; + weekLabel: string; + }; + completedDeliverables: { + id: string; + name: string; + projectName: string; + projectId: string; + stageName: string; + completedDate: string; + }[]; + deadlines: { + met: { + id: string; + name: string; + projectName: string; + projectId: string; + dueDate: string; + completedDate: string; + }[]; + missed: { + id: string; + name: string; + projectName: string; + projectId: string; + dueDate: string; + daysOverdue: number; + status: string; + }[]; + metRate: number; + }; + pipelineSnapshot: { + status: string; + count: number; + percentage: number; + }[]; + totalActiveStages: number; + atRiskProjects: { + id: string; + name: string; + projectCode: string | null; + overdueCount: number; + totalDeliverables: number; + completionRate: number; + nearestDeadline: string | null; + }[]; + upcomingDeliverables: { + id: string; + name: string; + projectName: string; + projectId: string; + dueDate: string; + status: string; + priority: string; + }[]; + previousWeekComparison: { + completedThisWeek: number; + completedLastWeek: number; + trend: "up" | "down" | "flat"; + trendPercentage: number; + }; +} + +/** + * Generate a weekly executive report for an organization. + * `weekOf` should be any date within the desired week. + */ +export async function getWeeklyReport( + organizationId: string, + weekOf: Date +): Promise { + const weekStart = startOfWeek(weekOf, { weekStartsOn: 1 }); // Monday + const weekEnd = endOfWeek(weekOf, { weekStartsOn: 1 }); // Sunday + const prevWeekStart = subWeeks(weekStart, 1); + const prevWeekEnd = subWeeks(weekEnd, 1); + const nextWeekEnd = endOfWeek(new Date(weekEnd.getTime() + 86400000), { + weekStartsOn: 1, + }); + const now = new Date(); + + const [ + completedStages, + prevWeekCompleted, + allDeliverables, + activeStagesByStatus, + activeProjects, + upcomingDue, + ] = await Promise.all([ + // 1. Stages completed this week (APPROVED or DELIVERED with completedDate in range) + prisma.deliverableStage.findMany({ + where: { + deliverable: { project: { organizationId } }, + status: { in: ["APPROVED", "DELIVERED"] }, + completedDate: { gte: weekStart, lte: weekEnd }, + }, + select: { + id: true, + completedDate: true, + template: { select: { name: true } }, + deliverable: { + select: { + id: true, + name: true, + project: { select: { id: true, name: true } }, + }, + }, + }, + orderBy: { completedDate: "desc" }, + }), + + // 2. Previous week completions for trend comparison + prisma.deliverableStage.count({ + where: { + deliverable: { project: { organizationId } }, + status: { in: ["APPROVED", "DELIVERED"] }, + completedDate: { gte: prevWeekStart, lte: prevWeekEnd }, + }, + }), + + // 3. All deliverables with due dates in or before this week for deadline tracking + prisma.deliverable.findMany({ + where: { + project: { organizationId, status: "ACTIVE" }, + dueDate: { lte: weekEnd }, + }, + select: { + id: true, + name: true, + status: true, + priority: true, + dueDate: true, + actualDeliveryDate: true, + project: { select: { id: true, name: true } }, + }, + }), + + // 4. Pipeline snapshot — all active stage statuses + prisma.deliverableStage.groupBy({ + by: ["status"], + where: { + deliverable: { + project: { organizationId, status: "ACTIVE" }, + }, + status: { notIn: ["SKIPPED"] }, + }, + _count: true, + }), + + // 5. Active projects with their deliverable counts + prisma.project.findMany({ + where: { organizationId, status: "ACTIVE" }, + select: { + id: true, + name: true, + projectCode: true, + deliverables: { + select: { + id: true, + name: true, + status: true, + dueDate: true, + stages: { + select: { + status: true, + }, + }, + }, + }, + }, + }), + + // 6. Upcoming deliverables (due next week) + prisma.deliverable.findMany({ + where: { + project: { organizationId, status: "ACTIVE" }, + dueDate: { gt: weekEnd, lte: nextWeekEnd }, + status: { notIn: ["APPROVED"] }, + }, + select: { + id: true, + name: true, + status: true, + priority: true, + dueDate: true, + project: { select: { id: true, name: true } }, + }, + orderBy: { dueDate: "asc" }, + }), + ]); + + // --- Transform completed deliverables --- + const completedDeliverables = completedStages.map((s) => ({ + id: s.deliverable.id, + name: s.deliverable.name, + projectName: s.deliverable.project.name, + projectId: s.deliverable.project.id, + stageName: s.template.name, + completedDate: s.completedDate!.toISOString(), + })); + + // --- Deadline analysis --- + const deadlinesMet: WeeklyReportData["deadlines"]["met"] = []; + const deadlinesMissed: WeeklyReportData["deadlines"]["missed"] = []; + + for (const d of allDeliverables) { + if (!d.dueDate) continue; + const dueDate = new Date(d.dueDate); + + // Only consider deliverables with due dates within this week for "met" tracking + const dueThisWeek = dueDate >= weekStart && dueDate <= weekEnd; + + if (d.status === "APPROVED") { + const completedOnTime = + d.actualDeliveryDate + ? new Date(d.actualDeliveryDate) <= dueDate + : true; + if (dueThisWeek && completedOnTime) { + deadlinesMet.push({ + id: d.id, + name: d.name, + projectName: d.project.name, + projectId: d.project.id, + dueDate: dueDate.toISOString(), + completedDate: (d.actualDeliveryDate ?? dueDate).toISOString(), + }); + } + } else if (dueDate < now && d.status !== "ON_HOLD") { + deadlinesMissed.push({ + id: d.id, + name: d.name, + projectName: d.project.name, + projectId: d.project.id, + dueDate: dueDate.toISOString(), + daysOverdue: differenceInCalendarDays(now, dueDate), + status: d.status, + }); + } + } + + const totalDeadlines = deadlinesMet.length + deadlinesMissed.length; + const metRate = + totalDeadlines > 0 + ? Math.round((deadlinesMet.length / totalDeadlines) * 100) + : 100; + + // --- Pipeline snapshot --- + const totalActiveStages = activeStagesByStatus.reduce( + (sum, s) => sum + s._count, + 0 + ); + const pipelineSnapshot = activeStagesByStatus + .map((s) => ({ + status: s.status, + count: s._count, + percentage: + totalActiveStages > 0 + ? Math.round((s._count / totalActiveStages) * 100) + : 0, + })) + .sort((a, b) => b.count - a.count); + + // --- At-risk projects --- + const atRiskProjects = activeProjects + .map((p) => { + const totalDel = p.deliverables.length; + const overdueDels = p.deliverables.filter( + (d) => + d.dueDate && + new Date(d.dueDate) < now && + d.status !== "APPROVED" && + d.status !== "ON_HOLD" + ); + + const allStages = p.deliverables.flatMap((d) => d.stages); + const completedStagesCount = allStages.filter( + (s) => + s.status === "APPROVED" || + s.status === "DELIVERED" || + s.status === "SKIPPED" + ).length; + const completionRate = + allStages.length > 0 + ? Math.round((completedStagesCount / allStages.length) * 100) + : 0; + + const upcomingDeadlines = p.deliverables + .filter( + (d) => d.dueDate && new Date(d.dueDate) >= now && d.status !== "APPROVED" + ) + .sort( + (a, b) => + new Date(a.dueDate!).getTime() - new Date(b.dueDate!).getTime() + ); + + return { + id: p.id, + name: p.name, + projectCode: p.projectCode, + overdueCount: overdueDels.length, + totalDeliverables: totalDel, + completionRate, + nearestDeadline: upcomingDeadlines[0]?.dueDate?.toISOString() ?? null, + }; + }) + .filter((p) => p.overdueCount > 0 || p.completionRate < 30) + .sort((a, b) => b.overdueCount - a.overdueCount); + + // --- Trend comparison --- + const completedThisWeek = completedStages.length; + const completedLastWeek = prevWeekCompleted; + const trendDiff = completedThisWeek - completedLastWeek; + const trendPercentage = + completedLastWeek > 0 + ? Math.round(Math.abs(trendDiff / completedLastWeek) * 100) + : completedThisWeek > 0 + ? 100 + : 0; + + return { + period: { + start: weekStart.toISOString(), + end: weekEnd.toISOString(), + weekLabel: `Week of ${weekStart.toLocaleDateString("en-US", { month: "short", day: "numeric" })} – ${weekEnd.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`, + }, + completedDeliverables, + deadlines: { + met: deadlinesMet, + missed: deadlinesMissed.sort((a, b) => b.daysOverdue - a.daysOverdue), + metRate, + }, + pipelineSnapshot, + totalActiveStages, + atRiskProjects, + upcomingDeliverables: upcomingDue.map((d) => ({ + id: d.id, + name: d.name, + projectName: d.project.name, + projectId: d.project.id, + dueDate: d.dueDate!.toISOString(), + status: d.status, + priority: d.priority, + })), + previousWeekComparison: { + completedThisWeek, + completedLastWeek, + trend: + trendDiff > 0 ? "up" : trendDiff < 0 ? "down" : "flat", + trendPercentage, + }, + }; +}