From 17d14aae090ff0a367cd98c615b642cb413f2f34 Mon Sep 17 00:00:00 2001 From: Patrick Plate Date: Thu, 11 Jun 2026 09:02:14 +0200 Subject: [PATCH] docs(cannamanage): update wiki pages and sprint plans + brand pipeline doc --- docs/wiki/pages/CannaManage-02-UserStories.md | 37 + .../wiki/pages/CannaManage-03-Architecture.md | 140 +- docs/wiki/pages/CannaManage-04-Flowcharts.md | 4 +- docs/wiki/pages/CannaManage-06-Wireframes.md | 220 ++- docs/wiki/pages/CannaManage-09-Deployment.md | 105 +- plans/cannabis-club-saas/SPRINT-1-PLAN.md | 547 +++++++ plans/cannabis-club-saas/docs/CHANGELOG.md | 27 +- plans/cannamanage-brand-pipeline.md | 1263 +++++++++++++++++ 8 files changed, 2181 insertions(+), 162 deletions(-) create mode 100644 plans/cannabis-club-saas/SPRINT-1-PLAN.md create mode 100644 plans/cannamanage-brand-pipeline.md diff --git a/docs/wiki/pages/CannaManage-02-UserStories.md b/docs/wiki/pages/CannaManage-02-UserStories.md index 103e553..830606a 100644 --- a/docs/wiki/pages/CannaManage-02-UserStories.md +++ b/docs/wiki/pages/CannaManage-02-UserStories.md @@ -464,4 +464,41 @@ --- +## Could Have — v2 (Additions) + +### US-026: Staff Member Management + +**As a** Club Admin, **I want to** create staff accounts with configurable permissions, **so that** my team members can do their work without having access to data they don't need (DSGVO principle of least privilege). + +**Priority:** Must Have (upgraded from Could Have — see note) +**Acceptance Criteria:** +- [ ] AC1: Admin can create staff accounts with email + temporary password +- [ ] AC2: Admin assigns permissions per staff account from a defined permission set (`RECORD_DISTRIBUTION`, `VIEW_MEMBER_LIST`, `VIEW_MEMBER_QUOTA`, `ADD_MEMBER`, `VIEW_STOCK`, `RECORD_STOCK_IN`, `VIEW_COMPLIANCE_REPORT`, `MANAGE_GROW_CALENDAR`) +- [ ] AC3: Pre-created role templates available: **Ausgabe** (distribution desk), **Lager** (stock/cultivation), **Vorstand** (board member) +- [ ] AC4: Staff accounts cannot access billing, club settings, or staff management +- [ ] AC5: All distributions recorded by staff include `recorded_by = staffUserId` in audit trail +- [ ] AC6: Admin can deactivate a staff account; historical data is retained for audit purposes +- [ ] AC7: Staff member sees only the navigation sections permitted by their granted permissions + +> **Note:** Promoted to core / Must Have. Staff management is not a v2 feature — clubs have multiple people involved from day one. DSGVO requires that each person only accesses data relevant to their function. Designing this post-MVP would require schema, API, and permission model rework. + +--- + +### US-027: Grow Calendar + +**As a** Club Admin or authorised staff member, **I want to** maintain a cultivation calendar for each grow cycle, **so that** the club has a central record of what was planted, when to expect harvest, and the grow diary with notes and photos. + +**Priority:** Could Have (v2) +**Acceptance Criteria:** +- [ ] AC1: Admin/staff can create a grow entry with: strain name, planted date, expected harvest date, grow medium, notes +- [ ] AC2: Grow entries are linked to a batch — when the harvest is registered as a batch, the grow entry is marked as completed +- [ ] AC3: A grow diary allows adding timestamped notes and optional photos per grow entry +- [ ] AC4: Grow calendar view shows a visual timeline of active grow cycles (Gantt-style or calendar grid) +- [ ] AC5: Admin can set who has access to the grow calendar via staff permission `MANAGE_GROW_CALENDAR` +- [ ] AC6: Photos are stored per-tenant and never exposed to members or other tenants + +**Notes:** The grow calendar bridges cultivation management and compliance — it provides provenance traceability from seed/clone to distributed batch. This directly supports §26 CanG batch traceability requirements for the origin of cultivated product. Photo attachments are a nice-to-have within this story; the core diary functionality is the v2 deliverable. + +--- + *Source: [STRATEGY.md](../STRATEGY.md) | Related: [01-PROJECT-CHARTER.md](./01-PROJECT-CHARTER.md)* diff --git a/docs/wiki/pages/CannaManage-03-Architecture.md b/docs/wiki/pages/CannaManage-03-Architecture.md index 2cabfda..9293c04 100644 --- a/docs/wiki/pages/CannaManage-03-Architecture.md +++ b/docs/wiki/pages/CannaManage-03-Architecture.md @@ -2,7 +2,7 @@ **Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs (Anbauvereinigungen) **Phase:** 2 of 5 — Architecture & Data Model -**Stack:** Spring Boot 3.x (Java 21) · JPA/Hibernate · PostgreSQL · PrimeFaces JSF MVP → Next.js v2 +**Stack:** Spring Boot 3.x (Java 21) · JPA/Hibernate · PostgreSQL · React/Vite (MVP) → Next.js v2 **Last updated:** 2026-04-06 --- @@ -14,12 +14,12 @@ graph TD AdminBrowser["🖥️ Browser — Admin Portal"] MemberBrowser["🖥️ Browser — Member Portal"] - JSF["PrimeFaces / JSF Frontend\n(Spring MVC embedded)"] + Frontend["React/Vite Frontend\n(SPA — served by Nginx)"] - AdminBrowser -->|HTTP/S| JSF - MemberBrowser -->|HTTP/S| JSF + AdminBrowser -->|HTTPS| Frontend + MemberBrowser -->|HTTPS| Frontend - JSF -->|REST calls| Backend + Frontend -->|REST/JSON| Backend subgraph Backend ["☕ Spring Boot 3.x Application (Java 21)"] REST["REST API Layer\n/api/v1/"] @@ -45,7 +45,7 @@ graph TD Nginx["🔒 Nginx\n(reverse proxy + TLS)"] end - JSF --> Nginx + Frontend --> Nginx Nginx --> Backend ``` @@ -53,8 +53,8 @@ graph TD | Component | Technology | Role | |---|---|---| -| Admin Portal | PrimeFaces JSF (→ Next.js v2) | Club management UI | -| Member Portal | PrimeFaces JSF (→ Next.js v2) | Member quota & history UI | +| Admin Portal | React/Vite SPA (→ Next.js v2) | Club management UI | +| Member Portal | React/Vite SPA (→ Next.js v2) | Member quota & history UI | | REST API | Spring Boot 3.x / Spring MVC | All business logic endpoints | | Auth | Spring Security 6 + JJWT | Stateless JWT authentication | | ORM | JPA / Hibernate 6 | Entity persistence, tenant filtering | @@ -69,15 +69,47 @@ graph TD ## 2. Multi-Tenancy Strategy -### Approach: Shared Schema with Row-Level Filtering +### Decision: Schema-Per-Tenant -Every JPA entity carries a `tenant_id` column (UUID, `NOT NULL`). A single PostgreSQL database hosts all clubs — row-level filtering enforces data isolation at the application layer. +Each club gets its own PostgreSQL schema (e.g. `tenant_abc123`). A platform-level `public` schema holds only the `tenants` registry. Flyway runs per-schema migrations on onboarding. -**Why shared schema (not separate schema/DB per tenant)?** -- Lower operational overhead for an MVP with < 500 clubs -- Single Flyway migration path across all tenants -- Simpler connection pooling (one pool, not N) -- Acceptable security risk when `tenant_id` filter is enforced at the service layer +**Why schema-per-tenant, not shared schema?** + +A shared-schema approach (single table with `tenant_id` on every row) is operationally convenient in the short term but creates serious problems at scale: + +| Concern | Shared Schema | Schema-Per-Tenant | +|---|---|---| +| Data isolation | Application-layer only — one missing filter = data leak | Enforced at DB level — schemas are hard boundaries | +| DSGVO compliance | Harder to prove isolation; one backup contains all clubs' data | Per-tenant pg_dump; each club's data is cleanly separable | +| Deletion / right to erasure | Must `DELETE WHERE tenant_id = ?` across every table | `DROP SCHEMA tenant_abc123 CASCADE` — clean and auditable | +| Migrations | One migration path for all | Per-schema migration via Flyway `schemas` config; adds ~100ms per onboard | +| Query performance | Cross-tenant index bloat on large shared tables | Smaller per-tenant tables; no cross-tenant contention | +| Future per-club DB isolation | Requires full re-architecture | Trivial: move schema to dedicated DB server | +| Operational overhead | Lower — one connection pool | Slightly higher — one pool per tenant (managed by HikariCP with pool-per-schema) | + +**Conclusion:** The shared-schema "MVP convenience" argument only holds for throwaway prototypes. For a compliance SaaS handling personal health-adjacent data (cannabis consumption records), schema-per-tenant is the correct design from Day 1. The migration complexity is manageable; the data isolation benefit is permanent. + +### Tenant Provisioning + +When a new club onboards: + +``` +POST /api/v1/admin/bootstrap + → TenantProvisioningService.provisionTenant(tenantId) + → CREATE SCHEMA tenant_{tenantId} + → Flyway.migrate(schema=tenant_{tenantId}) // applies all V*.sql + → INSERT INTO public.tenants (id, schema_name, onboarded_at, status) +``` + +### Tenant Resolution + +``` +HTTP Request + └─ Spring Security Filter: extract JWT → resolve tenant_id + └─ TenantContext.setCurrentTenant(tenantId) // ThreadLocal + └─ DataSource routes to schema: SET search_path = tenant_{tenantId} + └─ All queries execute in tenant's private schema +``` ### Tenant Resolution @@ -88,51 +120,38 @@ HTTP Request └─ JPA @Where filter applied on every entity query ``` -### Code Pattern — Tenant-Aware Base Entity +### Code Pattern — Schema Routing DataSource ```java -// AbstractTenantEntity.java (pseudocode) -@MappedSuperclass -@FilterDef( - name = "tenantFilter", - parameters = @ParamDef(name = "tenantId", type = UUID.class) -) -@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId") -public abstract class AbstractTenantEntity { +// TenantRoutingDataSource.java (pseudocode) +public class TenantRoutingDataSource extends AbstractRoutingDataSource { - @Column(name = "tenant_id", nullable = false, updatable = false) - private UUID tenantId; - - @PrePersist - void injectTenant() { - this.tenantId = TenantContext.getCurrentTenant(); + @Override + protected Object determineCurrentLookupKey() { + return TenantContext.getCurrentTenant(); // returns tenant schema name } } ``` ```java -// TenantFilterInterceptor.java (pseudocode) +// TenantInterceptor.java (pseudocode) @Component -public class TenantFilterInterceptor implements HandlerInterceptor { - - @Autowired EntityManager em; +public class TenantInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest req, ...) { - UUID tenantId = TenantContext.getCurrentTenant(); - Session session = em.unwrap(Session.class); - session.enableFilter("tenantFilter") - .setParameter("tenantId", tenantId); + String tenantId = JwtUtils.extractTenantId(req); + TenantContext.setCurrentTenant("tenant_" + tenantId); return true; } } ``` **Invariants enforced:** -- `tenant_id` is set at `@PrePersist` — never accepted from user input -- `tenant_id` is `updatable = false` — cannot be changed after creation -- Hibernate filter is enabled on every request thread before any query executes -- All repository methods inherit the filter; raw JPQL queries must include `AND e.tenantId = :tenantId` +- Every incoming request resolves its schema before any query runs +- No entity has a `tenant_id` column — schema isolation replaces row-level filtering +- Raw JDBC queries must be avoided; all access goes through JPA repositories with schema routing +- The `public` schema contains only the tenants registry and platform-level config --- @@ -148,10 +167,36 @@ public class TenantFilterInterceptor implements HandlerInterceptor { | Role | Description | Access | |---|---|---| -| `ROLE_CLUB_ADMIN` | Club administrator | Full club management, all members, reports, distributions | -| `ROLE_MEMBER` | Club member | Own quota, own distribution history | +| `ROLE_CLUB_ADMIN` | Club administrator | Full club management, all members, reports, distributions, staff management | +| `ROLE_STAFF` | Club staff member | Configurable subset of admin permissions — defined per staff account by the admin | +| `ROLE_MEMBER` | Club member | Own quota, own distribution history (read-only) | | `ROLE_PREVENTION_OFFICER` | Designated prevention officer | Member under-21 reports, prevention data | +> **Staff is a core feature, not an add-on.** Real clubs have multiple staff members (front desk, cultivation responsible, prevention officer designate) with different operational responsibilities. DSGVO requires that each staff member can only access data they need for their specific role. The `ROLE_STAFF` with configurable permission grants from the admin is designed from Phase 0 — retrofitting it later would require schema and API changes. + +### Staff Permission Model + +Admins configure staff permissions at account creation. Permissions are stored as a `JSONB` column `granted_permissions` on the `staff_accounts` table within the tenant schema. + +```java +// Configurable staff permissions (granted by admin per staff account) +public enum StaffPermission { + RECORD_DISTRIBUTION, // can record distributions + VIEW_MEMBER_LIST, // can view member roster + VIEW_MEMBER_QUOTA, // can view individual member quota + ADD_MEMBER, // can register new members + VIEW_STOCK, // can view batch/strain inventory + RECORD_STOCK_IN, // can add new batches + VIEW_COMPLIANCE_REPORT, // can generate/download reports + MANAGE_GROW_CALENDAR // can manage cultivation calendar entries +} +``` + +Pre-created role templates (configurable by admin): +- **Ausgabe** (Distribution desk): `RECORD_DISTRIBUTION`, `VIEW_MEMBER_LIST`, `VIEW_MEMBER_QUOTA` +- **Lager** (Stock/cultivation): `VIEW_STOCK`, `RECORD_STOCK_IN`, `MANAGE_GROW_CALENDAR` +- **Vorstand** (Board member): all permissions except staff management + ### Service-Layer Authorization Example ```java @@ -494,11 +539,12 @@ Flyway migrations run automatically on application startup (`spring.flyway.enabl | Decision | Choice | Rationale | |---|---|---| -| Multi-tenancy | Shared schema + `tenant_id` | MVP simplicity; upgrade to schema-per-tenant possible later | -| Frontend MVP | PrimeFaces JSF | Patrick's existing expertise; fastest path to working UI | -| Frontend v2 | Next.js / React | Modern UX; deferred to avoid scope creep in MVP | +| Multi-tenancy | Schema-per-tenant | Hard data isolation, DSGVO-clean deletion, no cross-tenant query risk | +| Frontend MVP | React/Vite SPA | Modern stack; no JSF/PrimeFaces lock-in; easier to hire for; mobile-friendly from day 1 | +| Frontend v2 | Next.js | SSR/ISR for SEO on marketing pages; same React codebase | | Auth | JWT (stateless) | No sticky sessions needed; horizontal scale ready | | PDF generation | iText 7 | Mature Java library; handles complex compliance report layouts | | Compliance enforcement | Service layer + DB constraint | Belt-and-suspenders: service validates, DB `UNIQUE` prevents duplicates | | Distribution immutability | `immutable = true`, no DELETE API | Audit trail integrity for regulatory compliance | | Hosting | Hetzner (Germany) | DSGVO compliance; low cost; German DC | +| Staff roles | Core feature from Phase 0 | DSGVO requires least-privilege access; retrofitting post-MVP too costly | diff --git a/docs/wiki/pages/CannaManage-04-Flowcharts.md b/docs/wiki/pages/CannaManage-04-Flowcharts.md index 1e7dfa8..7dc1161 100644 --- a/docs/wiki/pages/CannaManage-04-Flowcharts.md +++ b/docs/wiki/pages/CannaManage-04-Flowcharts.md @@ -143,7 +143,7 @@ flowchart TD QUERY_DIST --> HAS_DATA{Any distributions\nin this period?} - HAS_DATA -->|No data| EMPTY_REPORT[Generate empty report\nwith zero totals\n(still valid compliance submission)] + HAS_DATA -->|No data| EMPTY_REPORT["Generate empty report\nwith zero totals\n(still valid compliance submission)"] HAS_DATA -->|Yes| AGG_MEMBER["Aggregate by member:\n• total_distributed_grams\n• number_of_visits\n• quota_usage_percent\n• is_under_21 flag"] EMPTY_REPORT --> AGG_STRAIN @@ -174,7 +174,7 @@ flowchart TD SUBMIT --> FIND_USER["🔍 Spring Security:\nSELECT FROM users\nWHERE email = ?\nAND active = true"] FIND_USER --> USER_FOUND{User found?} - USER_FOUND -->|No| ERR_NOTFOUND[❌ Invalid credentials\n(generic — do not reveal\nwhether email exists)] + USER_FOUND -->|No| ERR_NOTFOUND["❌ Invalid credentials\n(generic — do not reveal\nwhether email exists)"] USER_FOUND -->|Yes| VERIFY_PW{BCrypt.verify\n(password, hash)\nmatches?} VERIFY_PW -->|No| ERR_PW[❌ Invalid credentials] diff --git a/docs/wiki/pages/CannaManage-06-Wireframes.md b/docs/wiki/pages/CannaManage-06-Wireframes.md index 53c804c..f7c6b2b 100644 --- a/docs/wiki/pages/CannaManage-06-Wireframes.md +++ b/docs/wiki/pages/CannaManage-06-Wireframes.md @@ -2,7 +2,7 @@ **Phase 4a | Document 6 of 7** **Date:** 2026-04-06 -**Stack:** Spring Boot 3.x · PrimeFaces JSF · PostgreSQL +**Stack:** Spring Boot 3.x · React/Vite SPA · PostgreSQL --- @@ -45,24 +45,26 @@ ### 1.3 Component Library -All UI components come from **PrimeFaces 13.x** (JSF-based). No external React/Angular dependencies in MVP. +The frontend is a **React/Vite SPA** with no PrimeFaces or JSF dependency. Component primitives come from [shadcn/ui](https://ui.shadcn.com/) (Radix UI + Tailwind CSS). This gives full control over styling, accessibility, and mobile responsiveness without JSF's lifecycle overhead. -| Component | Usage | -|---|---| -| `p:panel` | Section containers, card wrappers | -| `p:dataTable` with `p:column` | Tabular data: distributions, members, batches | -| `p:paginator` | Pagination on all tables | -| `p:inputText` | Single-line text fields | -| `p:inputNumber` | Weight inputs (gram precision) | -| `p:selectOneMenu` | Dropdown selects (member, strain, batch) | -| `p:calendar` | Date range pickers for reports | -| `p:progressBar` | Quota consumption display | -| `p:commandButton` | Primary and secondary actions | -| `p:confirmDialog` | Dangerous actions (recall, delete) | -| `p:messages` / `p:message` | Inline validation errors | -| `p:badge` | Status indicators (AVAILABLE, LOW, RECALLED) | -| `p:sidebar` | Mobile nav drawer (member portal) | -| `p:dialog` | Modal overlays | +> **Why not PrimeFaces?** JSF/PrimeFaces is a server-side component model ill-suited to the modern REST API backend we're building. It tightly couples UI lifecycle to the backend, makes mobile responsiveness painful, and creates a hiring bottleneck. React is the right tool here. PrimeFaces is a fine choice for internal enterprise apps — not for a commercial SaaS. + +| Component | Library | Usage | +|---|---|---| +| `Card` / `Panel` | shadcn/ui | Section containers | +| `DataTable` | TanStack Table v8 | Distributions, members, batches — virtualized | +| `Pagination` | shadcn/ui Pagination | All tables | +| `Input` | shadcn/ui Input | Single-line text fields | +| `NumberInput` | react-number-format | Weight inputs (gram precision, min/max) | +| `Select` | shadcn/ui Select | Dropdown selects (member, strain, batch) | +| `DatePicker` | shadcn/ui Calendar | Date range pickers for reports | +| `Progress` | shadcn/ui Progress | Quota consumption bar | +| `Button` | shadcn/ui Button | Primary and secondary actions | +| `AlertDialog` | shadcn/ui AlertDialog | Dangerous actions (recall) | +| `Toast` | sonner | Success/error notifications | +| `Badge` | shadcn/ui Badge | Status indicators (AVAILABLE, LOW, RECALLED) | +| `Sheet` | shadcn/ui Sheet | Mobile nav drawer | +| `Dialog` | shadcn/ui Dialog | Modal overlays | ### 1.4 Layout Grid @@ -118,13 +120,13 @@ All UI components come from **PrimeFaces 13.x** (JSF-based). No external React/A #### Components & Behavior -| Component | PrimeFaces | Behavior | +| Component | Library | Behavior | |---|---|---| -| KPI Cards | `p:panel` with custom CSS | Auto-refreshed via `@poll` every 60s | -| Recent Distributions table | `p:dataTable` (5 rows, no paginator) | Row click → navigate to distribution detail | -| Member column link | `p:commandLink` | Navigate to `/admin/members/{id}` | -| `+ New Entry` button | `p:commandButton` style="primary" | Navigate to `/admin/distributions/new` | -| Trend indicators | Custom CSS `` | Green ▲ / Red ▼ with delta value | +| KPI Cards | shadcn/ui Card | Auto-refreshed via `useQuery` (react-query, 60s stale) | +| Recent Distributions table | TanStack Table (5 rows) | Row click → navigate to distribution detail | +| Member column link | React Router `` | Navigate to `/admin/members/{id}` | +| `+ New Entry` button | shadcn/ui Button variant="default" | Navigate to `/admin/distributions/new` | +| Trend indicators | Tailwind `text-green-600` / `text-red-600` | ▲/▼ with delta value | --- @@ -178,14 +180,14 @@ The quota progress bar updates live as the weight field changes (via `f:ajax eve #### Components & Behavior -| Component | PrimeFaces | Behavior | +| Component | Library | Behavior | |---|---|---| -| Member search | `p:selectOneMenu` with `p:ajax` filter | Filters on type, shows name + member no. | -| Strain/Batch dropdown | `p:selectOneMenu` | Populated after member selection; shows only `AVAILABLE` batches | -| Weight input | `p:inputNumber` min=`0.1` max=`25.0` step=`0.1` | Triggers quota recalculation on blur | -| Quota bar | `p:progressBar` with dynamic `value` | Color class applied via `styleClass` computed in backing bean | -| Submit | `p:commandButton` | Disabled via `disabled="#{bean.quotaExceeded}"` | -| Cancel | `p:link` | Returns to distribution log without saving | +| Member search | shadcn/ui Combobox | `useQuery` debounced search; shows name + member no. | +| Strain/Batch dropdown | shadcn/ui Select | Populated after member selection; filters `AVAILABLE` batches | +| Weight input | react-number-format | min=0.1 max=25.0 step=0.1; triggers quota recalculation via `onChange` | +| Quota bar | shadcn/ui Progress | Color class via `cn()` utility computed in component state | +| Submit | shadcn/ui Button | `disabled={quotaExceeded}` from react state | +| Cancel | React Router `` | Returns to distribution log without saving | --- @@ -229,15 +231,15 @@ The quota progress bar updates live as the weight field changes (via `f:ajax eve #### Components & Behavior -| Component | PrimeFaces | Behavior | +| Component | Library | Behavior | |---|---|---| -| Strain filter | `p:inputText` with `filterBy` | Filters table client-side on keyup | -| Status filter | `p:selectOneMenu` | Filters table rows by status value | -| Batch table | `p:dataTable` lazy=`true` | Server-side pagination, 10 rows/page | -| Status badge | Custom CSS `` | Icon + text label (not color alone) | -| Recall button | `p:commandButton` styleClass=`p-button-danger` | Opens `p:confirmDialog` before executing | -| Confirm dialog | `p:confirmDialog` | "Recall batch B-12 (OG Kush, 850g)? This cannot be undone." | -| Add Batch | `p:commandButton` | Opens `p:dialog` with batch entry form | +| Strain filter | shadcn/ui Input | Filters TanStack table client-side via `columnFilters` state | +| Status filter | shadcn/ui Select | Filters table rows by status value | +| Batch table | TanStack Table | Server-side pagination via `manualPagination`, 10 rows/page | +| Status badge | shadcn/ui Badge variant mapped | Icon + text label (not color alone) | +| Recall button | shadcn/ui Button variant="destructive" | Opens shadcn/ui AlertDialog before executing | +| Confirm dialog | shadcn/ui AlertDialog | "Recall batch B-12 (OG Kush, 850g)? This cannot be undone." | +| Add Batch | shadcn/ui Button | Opens shadcn/ui Dialog with batch entry form | --- @@ -287,15 +289,15 @@ The quota progress bar updates live as the weight field changes (via `f:ajax eve #### Components & Behavior -| Component | PrimeFaces | Behavior | +| Component | Library | Behavior | |---|---|---| -| Month selector | `p:selectOneMenu` | Months Jan–Dec | -| Year selector | `p:selectOneMenu` | Current year ± 2 | -| Generate button | `p:commandButton` | Calls report service; shows spinner; renders PDF thumbnail | -| PDF preview | `