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:
parent
5b8c09de9e
commit
d01e663ecf
17 changed files with 1968 additions and 15 deletions
|
|
@ -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.*
|
||||
457
package-lock.json
generated
457
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
19
src/app/(app)/reports/page.tsx
Normal file
19
src/app/(app)/reports/page.tsx
Normal 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;
|
||||
}
|
||||
146
src/app/(app)/reports/weekly/[date]/page.tsx
Normal file
146
src/app/(app)/reports/weekly/[date]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/app/api/reports/weekly/route.ts
Normal file
33
src/app/api/reports/weekly/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
];
|
||||
|
||||
|
|
|
|||
121
src/components/reports/weekly/at-risk-section.tsx
Normal file
121
src/components/reports/weekly/at-risk-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
src/components/reports/weekly/completed-section.tsx
Normal file
81
src/components/reports/weekly/completed-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
src/components/reports/weekly/deadlines-section.tsx
Normal file
141
src/components/reports/weekly/deadlines-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
src/components/reports/weekly/kpi-strip.tsx
Normal file
130
src/components/reports/weekly/kpi-strip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
src/components/reports/weekly/pipeline-snapshot.tsx
Normal file
118
src/components/reports/weekly/pipeline-snapshot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/components/reports/weekly/report-header.tsx
Normal file
104
src/components/reports/weekly/report-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/components/reports/weekly/upcoming-section.tsx
Normal file
97
src/components/reports/weekly/upcoming-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/hooks/use-weekly-report.ts
Normal file
25
src/hooks/use-weekly-report.ts
Normal 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
|
||||
});
|
||||
}
|
||||
362
src/lib/services/weekly-report-service.ts
Normal file
362
src/lib/services/weekly-report-service.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue