feat: add weekly report API and components

- Implemented GET endpoint for weekly report data retrieval.
- Created components for displaying various sections of the weekly report:
  - At-Risk Projects
  - Completed Deliverables
  - Deadline Compliance
  - KPI Strip
  - Pipeline Snapshot
  - Upcoming Deliverables
  - Report Header
- Added a custom hook for fetching weekly report data using React Query.
- Developed service functions to generate weekly report data from the database.
- Enhanced UI with responsive design and improved accessibility features.
This commit is contained in:
Leivur Djurhuus 2026-03-13 16:39:23 -05:00
parent 5b8c09de9e
commit d01e663ecf
17 changed files with 1968 additions and 15 deletions

View file

@ -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 MondaySunday) 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.*

457
package-lock.json generated
View file

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

View file

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

View file

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

View file

@ -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 (
<div className="flex items-center justify-center py-20">
<p className="text-sm text-[var(--muted-foreground)]">
Invalid date format. Use YYYY-MM-DD.
</p>
</div>
);
}
if (isLoading) {
return <ReportSkeleton />;
}
if (error || !data) {
return (
<div className="flex items-center justify-center py-20">
<p className="text-sm text-[var(--accent)]">
Failed to load report. {error?.message}
</p>
</div>
);
}
return (
<div className="weekly-report mx-auto max-w-[960px] space-y-8 pb-12 print:max-w-none print:space-y-6 print:pb-0">
<ReportHeader
weekLabel={data.period.weekLabel}
periodStart={data.period.start}
periodEnd={data.period.end}
onPrevWeek={() => navigateWeek("prev")}
onNextWeek={() => navigateWeek("next")}
onDownloadPdf={handleDownloadPdf}
/>
<KpiStrip data={data} />
<div className="grid gap-8 lg:grid-cols-2 print:grid-cols-2 print:gap-6">
<CompletedSection items={data.completedDeliverables} />
<DeadlinesSection deadlines={data.deadlines} />
</div>
<div className="grid gap-8 lg:grid-cols-2 print:grid-cols-2 print:gap-6">
<PipelineSnapshot
snapshot={data.pipelineSnapshot}
totalStages={data.totalActiveStages}
/>
<AtRiskSection projects={data.atRiskProjects} />
</div>
<UpcomingSection items={data.upcomingDeliverables} />
{/* Footer rule */}
<footer className="border-t pt-4 print:mt-8">
<div className="flex items-center justify-between">
<p className="label-upper text-[var(--primary)]">
HP Production Tracker
</p>
<p className="text-[10px] text-[var(--muted-foreground)]">
Confidential Oliver Agency
</p>
</div>
</footer>
</div>
);
}
function ReportSkeleton() {
return (
<div className="mx-auto max-w-[960px] space-y-8">
{/* Header skeleton */}
<div>
<Skeleton className="h-1.5 w-full rounded-full" />
<div className="mt-6 flex items-start justify-between">
<div>
<Skeleton className="h-3 w-32" />
<Skeleton className="mt-3 h-9 w-64" />
<Skeleton className="mt-2 h-4 w-48" />
</div>
<div className="flex gap-2">
<Skeleton className="h-8 w-28" />
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-16" />
</div>
</div>
</div>
{/* KPI strip skeleton */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
{/* Content skeletons */}
<div className="grid gap-8 lg:grid-cols-2">
<Skeleton className="h-64 rounded-xl" />
<Skeleton className="h-64 rounded-xl" />
</div>
<div className="grid gap-8 lg:grid-cols-2">
<Skeleton className="h-56 rounded-xl" />
<Skeleton className="h-56 rounded-xl" />
</div>
<Skeleton className="h-48 rounded-xl" />
</div>
);
}

View file

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

View file

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

View file

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

View file

@ -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 (
<section className="print:break-inside-avoid">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-[var(--accent)]" />
<h2 className="label-upper text-[var(--foreground)]">
At-Risk Projects
</h2>
{projects.length > 0 && (
<span className="ml-auto rounded-full bg-[var(--accent)]/10 px-2 py-0.5 text-[10px] font-bold text-[var(--accent)]">
{projects.length}
</span>
)}
</div>
<div className="mt-3">
{projects.length === 0 ? (
<div className="flex items-center gap-3 rounded-xl border bg-[var(--card)] px-5 py-6">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[var(--status-approved)]/10">
<ShieldCheck className="h-5 w-5 text-[var(--status-approved)]" />
</div>
<div>
<p className="text-sm font-medium text-[var(--status-approved)]">
All projects on track
</p>
<p className="text-xs text-[var(--muted-foreground)]">
No projects flagged for overdue deliverables or low completion
</p>
</div>
</div>
) : (
<div className="space-y-2">
{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 (
<div
key={project.id}
className={`rounded-xl border border-l-[3px] ${severityBorder} bg-[var(--card)] px-4 py-3`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold truncate">
{project.name}
</p>
{project.projectCode && (
<span className="label-upper text-[var(--primary)] opacity-60">
{project.projectCode}
</span>
)}
</div>
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
{project.totalDeliverables} deliverable{project.totalDeliverables !== 1 ? "s" : ""}
{project.nearestDeadline && (
<>
{" · Next deadline "}
{format(new Date(project.nearestDeadline), "MMM d")}
</>
)}
</p>
</div>
<div className="flex shrink-0 gap-4 text-right">
{/* Overdue count */}
<div>
<p className="font-heading text-lg font-bold text-[var(--accent)]">
{project.overdueCount}
</p>
<p className="label-upper">Overdue</p>
</div>
{/* Completion bar */}
<div className="w-16">
<p className="font-heading text-lg font-bold">
{project.completionRate}%
</p>
<div className="mt-1 h-1.5 w-full rounded-full bg-[var(--muted)]">
<div
className="h-full rounded-full bg-[var(--primary)] transition-all"
style={{ width: `${project.completionRate}%` }}
/>
</div>
</div>
</div>
</div>
</div>
);
})}
{projects.length > 6 && (
<p className="py-2 text-center text-[10px] font-medium text-[var(--muted-foreground)]">
+ {projects.length - 6} more at-risk project{projects.length - 6 !== 1 ? "s" : ""}
</p>
)}
</div>
)}
</div>
</section>
);
}

View file

@ -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<string, WeeklyReportData["completedDeliverables"]>
>((acc, item) => {
const key = item.projectName;
if (!acc[key]) acc[key] = [];
acc[key].push(item);
return acc;
}, {});
const projectNames = Object.keys(byProject).sort();
return (
<section className="print:break-inside-avoid">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-[var(--status-approved)]" />
<h2 className="label-upper text-[var(--foreground)]">
Completed This Week
</h2>
<span className="ml-auto font-heading text-sm font-bold text-[var(--status-approved)]">
{items.length}
</span>
</div>
<div className="mt-3 rounded-xl border bg-[var(--card)] overflow-hidden">
{items.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-[var(--muted-foreground)]">
No stages completed this week
</p>
) : (
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b bg-[var(--muted)]/50">
<th className="label-upper px-4 py-2.5">Deliverable</th>
<th className="label-upper px-4 py-2.5">Project</th>
<th className="label-upper px-4 py-2.5">Stage</th>
<th className="label-upper px-4 py-2.5 text-right">
Completed
</th>
</tr>
</thead>
<tbody>
{projectNames.map((projectName) =>
byProject[projectName].map((item, i) => (
<tr
key={`${item.id}-${item.stageName}`}
className="border-b last:border-0 hover:bg-[var(--muted)]/30 transition-colors"
>
<td className="px-4 py-2.5 font-medium">{item.name}</td>
<td className="px-4 py-2.5 text-[var(--muted-foreground)]">
{i === 0 ? projectName : ""}
</td>
<td className="px-4 py-2.5">
<span className="inline-flex items-center gap-1.5 rounded-md bg-[var(--status-approved)]/8 px-2 py-0.5 text-xs font-medium text-[var(--status-approved)]">
{item.stageName}
</span>
</td>
<td className="px-4 py-2.5 text-right text-xs text-[var(--muted-foreground)]">
{format(new Date(item.completedDate), "EEE, MMM d")}
</td>
</tr>
))
)}
</tbody>
</table>
)}
</div>
</section>
);
}

View file

@ -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 (
<section className="print:break-inside-avoid">
<div className="flex items-center gap-2">
<Target className="h-4 w-4 text-[var(--primary)]" />
<h2 className="label-upper text-[var(--foreground)]">
Deadline Compliance
</h2>
</div>
<div className="mt-3 grid gap-3 lg:grid-cols-[200px_1fr] print:grid-cols-[180px_1fr]">
{/* Left: compliance score */}
<div className="flex flex-col items-center justify-center rounded-xl border bg-[var(--card)] p-5">
{/* SVG ring */}
<div className="relative h-24 w-24">
<svg viewBox="0 0 100 100" className="h-full w-full -rotate-90">
<circle
cx="50"
cy="50"
r="42"
fill="none"
stroke="var(--border)"
strokeWidth="8"
/>
<circle
cx="50"
cy="50"
r="42"
fill="none"
stroke={ringColor}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${metRate * 2.64} ${264 - metRate * 2.64}`}
className="transition-all duration-700"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="font-heading text-xl font-black">{metRate}%</span>
</div>
</div>
<div className="mt-3 flex gap-4 text-center">
<div>
<p className="font-heading text-lg font-bold text-[var(--status-approved)]">
{met.length}
</p>
<p className="label-upper">Met</p>
</div>
<div className="w-px bg-[var(--border)]" />
<div>
<p className="font-heading text-lg font-bold text-[var(--accent)]">
{missed.length}
</p>
<p className="label-upper">Missed</p>
</div>
</div>
</div>
{/* Right: missed deadlines detail */}
<div className="rounded-xl border bg-[var(--card)] overflow-hidden">
{missed.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[var(--status-approved)]/10">
<Target className="h-5 w-5 text-[var(--status-approved)]" />
</div>
<p className="mt-2 text-sm font-medium text-[var(--status-approved)]">
All deadlines met
</p>
<p className="mt-0.5 text-xs text-[var(--muted-foreground)]">
{total > 0 ? `${met.length} of ${total} on time` : "No deadlines this week"}
</p>
</div>
) : (
<>
<div className="border-b bg-[var(--accent)]/5 px-4 py-2">
<p className="flex items-center gap-1.5 text-xs font-semibold text-[var(--accent)]">
<AlertTriangle className="h-3 w-3" />
{missed.length} Missed Deadline{missed.length !== 1 ? "s" : ""}
</p>
</div>
<div className="divide-y">
{missed.slice(0, 8).map((item) => (
<div
key={item.id}
className="flex items-center gap-3 px-4 py-2.5"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{item.name}</p>
<p className="text-[10px] text-[var(--muted-foreground)] truncate">
{item.projectName}
</p>
</div>
<StageStatusBadge status={item.status} />
<div className="shrink-0 text-right">
<p className="text-xs font-semibold text-[var(--accent)]">
+{item.daysOverdue}d
</p>
<p className="text-[10px] text-[var(--muted-foreground)]">
Due {format(new Date(item.dueDate), "MMM d")}
</p>
</div>
</div>
))}
{missed.length > 8 && (
<div className="px-4 py-2 text-center">
<p className="text-[10px] font-medium text-[var(--muted-foreground)]">
+ {missed.length - 8} more overdue deliverable{missed.length - 8 !== 1 ? "s" : ""}
</p>
</div>
)}
</div>
</>
)}
</div>
</div>
</section>
);
}

View file

@ -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: (
<span className={`flex items-center gap-1 text-[10px] font-medium ${trendColor}`}>
<TrendIcon className="h-3 w-3" />
{trend.trend === "flat"
? "Same as last week"
: `${trend.trendPercentage}% ${trend.trend} from last week`}
</span>
),
},
{
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: (
<span className="text-[10px] text-[var(--muted-foreground)]">
{data.deadlines.met.length} met, {data.deadlines.missed.length} missed
</span>
),
},
{
label: "Active Pipeline",
value: data.totalActiveStages,
icon: Layers,
color: "text-[var(--status-in-progress)]",
bg: "bg-[var(--status-in-progress)]",
detail: (
<span className="text-[10px] text-[var(--muted-foreground)]">
Stages across active projects
</span>
),
},
{
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: (
<span className="text-[10px] text-[var(--muted-foreground)]">
{data.atRiskProjects.length > 0
? "Require attention"
: "All projects on track"}
</span>
),
},
];
return (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4 print:grid-cols-4 print:gap-4">
{kpis.map((kpi) => {
const Icon = kpi.icon;
return (
<div
key={kpi.label}
className="relative overflow-hidden rounded-xl border bg-[var(--card)] p-4 print:break-inside-avoid"
>
{/* Accent top edge */}
<div className={`absolute inset-x-0 top-0 h-0.5 ${kpi.bg} opacity-60`} />
<div className="flex items-start gap-3">
<div className={`mt-0.5 ${kpi.color} opacity-70`}>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="label-upper">{kpi.label}</p>
<p className="mt-1 font-heading text-2xl font-black tracking-tight">
{kpi.value}
</p>
<div className="mt-1">{kpi.detail}</div>
</div>
</div>
</div>
);
})}
</div>
);
}

View file

@ -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<string, { label: string; color: string }> = {
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 (
<section className="print:break-inside-avoid">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-[var(--status-in-progress)]" />
<h2 className="label-upper text-[var(--foreground)]">
Pipeline Snapshot
</h2>
</div>
<div className="mt-3 rounded-xl border bg-[var(--card)] p-5">
<div className="grid gap-4 lg:grid-cols-[1fr_200px] items-center">
{/* Donut chart */}
<div className="flex justify-center">
{chartData.length === 0 ? (
<p className="py-8 text-sm text-[var(--muted-foreground)]">
No active stages
</p>
) : (
<div className="relative h-[200px] w-[200px] print:h-[160px] print:w-[160px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius="55%"
outerRadius="85%"
paddingAngle={2}
strokeWidth={0}
>
{chartData.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Pie>
<RTooltip
contentStyle={{
fontSize: "12px",
borderRadius: "10px",
border: "1px solid var(--border)",
background: "var(--card)",
color: "var(--foreground)",
}}
formatter={(value, name) => [
`${value} (${Math.round(((value as number) / totalStages) * 100)}%)`,
name,
]}
/>
</PieChart>
</ResponsiveContainer>
{/* Center label */}
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span className="font-heading text-2xl font-black">
{totalStages}
</span>
<span className="label-upper">Total</span>
</div>
</div>
)}
</div>
{/* Legend — custom, tighter than recharts default */}
<div className="space-y-2">
{chartData.map((item) => (
<div key={item.name} className="flex items-center gap-2">
<div
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: item.color }}
/>
<span className="flex-1 text-xs text-[var(--muted-foreground)]">
{item.name}
</span>
<span className="text-xs font-semibold tabular-nums">
{item.value}
</span>
</div>
))}
</div>
</div>
</div>
</section>
);
}

View file

@ -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 (
<header className="report-header">
{/* Top rule — thick accent bar */}
<div className="h-1.5 bg-[var(--primary)] rounded-full print:rounded-none" />
<div className="mt-6 flex items-start justify-between gap-4">
{/* Left: branding + title */}
<div className="min-w-0">
<div className="flex items-center gap-3">
<span className="label-upper text-[var(--primary)]">
HP Production
</span>
<span className="label-upper opacity-40">/</span>
<span className="label-upper">Oliver Agency</span>
</div>
<h1 className="mt-3 font-heading text-3xl font-black tracking-tight text-[var(--foreground)] sm:text-4xl">
Weekly Report
</h1>
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
{weekLabel}
</p>
</div>
{/* Right: actions (hidden in print) */}
<div className="flex shrink-0 items-center gap-2" data-print-hide>
<div className="flex items-center rounded-lg border bg-[var(--card)]">
<Button
variant="ghost"
size="icon-sm"
onClick={onPrevWeek}
aria-label="Previous week"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="px-2 text-xs font-medium text-[var(--muted-foreground)]">
{periodStart && format(new Date(periodStart), "MMM d")}
{" "}
{periodEnd && format(new Date(periodEnd), "MMM d")}
</span>
<Button
variant="ghost"
size="icon-sm"
onClick={onNextWeek}
aria-label="Next week"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={() => window.print()}
className="gap-1.5"
>
<Printer className="h-3.5 w-3.5" />
Print
</Button>
<Button
size="sm"
onClick={onDownloadPdf}
className="gap-1.5"
>
<Download className="h-3.5 w-3.5" />
PDF
</Button>
</div>
</div>
{/* Generated timestamp */}
<p className="mt-4 text-[10px] text-[var(--muted-foreground)] print:mt-2">
Generated {generatedAt}
</p>
</header>
);
}

View file

@ -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<string, string> = {
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<string, WeeklyReportData["upcomingDeliverables"]>
>((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 (
<section className="print:break-inside-avoid">
<div className="flex items-center gap-2">
<CalendarDays className="h-4 w-4 text-[var(--primary)]" />
<h2 className="label-upper text-[var(--foreground)]">
Upcoming Next Week
</h2>
<span className="ml-auto font-heading text-sm font-bold">
{items.length}
</span>
</div>
<div className="mt-3 rounded-xl border bg-[var(--card)] overflow-hidden">
{items.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-[var(--muted-foreground)]">
No deliverables due next week
</p>
) : (
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b bg-[var(--muted)]/50">
<th className="label-upper px-4 py-2.5">Due</th>
<th className="label-upper px-4 py-2.5">Deliverable</th>
<th className="label-upper px-4 py-2.5">Project</th>
<th className="label-upper px-4 py-2.5">Status</th>
<th className="label-upper px-4 py-2.5 text-right">Priority</th>
</tr>
</thead>
<tbody>
{sortedDates.map((dateKey) =>
byDate[dateKey].map((item, i) => (
<tr
key={item.id}
className="border-b last:border-0 hover:bg-[var(--muted)]/30 transition-colors"
>
<td className="px-4 py-2.5 text-xs font-medium whitespace-nowrap">
{i === 0
? format(new Date(item.dueDate), "EEE, MMM d")
: ""}
</td>
<td className="px-4 py-2.5 font-medium">{item.name}</td>
<td className="px-4 py-2.5 text-[var(--muted-foreground)]">
{item.projectName}
</td>
<td className="px-4 py-2.5">
<StageStatusBadge status={item.status} />
</td>
<td className="px-4 py-2.5 text-right">
<Badge
variant="secondary"
className={PRIORITY_STYLES[item.priority]}
>
{item.priority}
</Badge>
</td>
</tr>
))
)}
</tbody>
</table>
)}
</div>
</section>
);
}

View file

@ -0,0 +1,25 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { WeeklyReportData } from "@/lib/services/weekly-report-service";
async function fetchJson<T>(url: string): Promise<T> {
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<WeeklyReportData>({
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
});
}

View file

@ -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<WeeklyReportData> {
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,
},
};
}