docs: add Plan 6 (subcontractor portal data + project parity)

This commit is contained in:
Satyam Rastogi
2026-05-29 19:54:05 +05:30
parent 9625aafd73
commit 5141d2adea
2 changed files with 173 additions and 1 deletions
+2 -1
View File
@@ -521,7 +521,8 @@ Each plan = one phase, produces working/verifiable software on its own, and is e
### Plan 6 — Subcontractor portal data + project parity ### Plan 6 — Subcontractor portal data + project parity
- **What:** realistic data on `/subcontractor/*`; a subcontractor viewing their task sees the **same project** the owner sees (permitted subset — address, scope, schedule, their slice of budget/payments). - **What:** realistic data on `/subcontractor/*`; a subcontractor viewing their task sees the **same project** the owner sees (permitted subset — address, scope, schedule, their slice of budget/payments).
- **Why:** coherence across logins is the demo's credibility test — the same job must look like the same job to owner and subcontractor. - **Why:** coherence across logins is the demo's credibility test — the same job must look like the same job to owner and subcontractor.
- **How:** reuse the unified project entity (Plan 2) + the upcoming ABAC visibility (Plan 8) to render a scoped project view in the subcontractor pages; seed/enrich subcontractor-facing data in `mockStore.jsx`; build the portal views (component files → parallelizable). Verify: a sub's task links to the real project; the owner and sub views agree. - **How:** reuse the unified project entity (Plan 2) + the upcoming ABAC visibility (Plan 8) to render a scoped project view in the subcontractor pages; seed/enrich subcontractor-facing data in `mockStore.jsx`; build the portal views (component files → parallelizable). Verify: a sub's task links to the real project; the owner and sub views agree. (Concretely: reconcile sub↔project linkage in `mockStore.jsx`; add `subcontractorProjects` + `scopedProjectForSub` selectors — the latter is the single guard that omits owner-private fields; ground the dashboard KPIs in real `subcontractorTasks`; build a scoped `/subcontractor/projects/:id` page; link the task detail to it. Plan 8 later replaces the hand-written field-omission with an ABAC policy.)
- **Plan file:** `docs/superpowers/plans/2026-05-29-plan6-subcontractor-portal-parity.md`.
### Plan 7 — Profiles, skill catalog & AI dispatch context ### Plan 7 — Profiles, skill catalog & AI dispatch context
- **What:** two-layer profiles (self + company) per person; the self-service User Details page (all roles); the owner Employee/Subcontractor Details view; the org skill catalog; ProCanvas achievements surfaced; `DISPATCH_REPS` mapped to canonical people; dispatch `reasons`/`aiInsight`/`skillMatch` authored to cite real profile facts. - **What:** two-layer profiles (self + company) per person; the self-service User Details page (all roles); the owner Employee/Subcontractor Details view; the org skill catalog; ProCanvas achievements surfaced; `DISPATCH_REPS` mapped to canonical people; dispatch `reasons`/`aiInsight`/`skillMatch` authored to cite real profile facts.
@@ -0,0 +1,171 @@
# Plan 6 — Subcontractor Portal Data + Project Parity
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`). Follow the execution protocol (`2026-05-29-execution-protocol.md`) — sequential on shared files, parallel only where lanes are disjoint.
**Goal:** A subcontractor sees the **same project the owner sees**, scoped to a permitted subset (address · type · status · phase · completion · schedule/milestones · *their* tasks · *their* payments), and the subcontractor portal's data is **grounded** (real KPIs + a working "My Projects" list derived from their tasks). Coherence across logins is the demo's credibility test — the same job must look like the same job to owner and subcontractor, minus the owner-private financials.
**Architecture:** First reconcile the sub↔project linkage in `mockStore.jsx` (every subcontractor task's sub is added to that project's `subcontractorIds[]`, so the link is consistent — today only `sub_001` is linked; `sub_002`/`003`/`004` link only via their tasks). Then add pure selectors that derive (a) the projects a sub is on and (b) a **scoped project view** (permitted subset only) — these guarantee no owner-private field can leak. Then ground the dashboard KPIs from `subcontractorTasks` (not the legacy `projects.milestones`), build a scoped subcontractor project list + detail page, and add a project-context link on the task detail page. Plan 8 (ABAC) later formalises the "permitted subset" as a policy; Plan 6 implements it directly via the scoping selector.
**Tech stack:** React 18, Vite 7, mock React-context store, pnpm. No test harness → verify via `npm run build` + grep + dev-run comparing an owner login and a subcontractor login (`maya`) on the same project.
**Spec/master:** `docs/superpowers/2026-05-29-MASTER-PLAN.md` §14 (coherency — "same job across logins"), Appendix G Plan 6. Builds on Plan 2 (unified project entity) and Plan 5 (task state machine).
---
## Current state (verified)
- **`SubContractorDashboard.jsx`**: `subId = user?.id`. The 4 KPI cards (Tasks In Progress / Pending / Jobs Completed / Pending Payouts) are derived from **legacy `projects.milestones` filtered by `assignedTo === subId`** (lines ~212-218) — this is **0 for sub_002** (milestones only carry `sub_001`). The real source `myAssignments = subcontractorTasks.filter(t => t.subcontractorId === subId)` (lines ~221-224) is used by the payment-history table but NOT the KPIs. Clock-in widget (~234-240) is hardcoded.
- **`SubcontractorTaskDetailPage.jsx`**: task-scoped only. `task.projectId` exists on every task but is **never read** — no project name, badge, or link. Does not pull `projects` from the store.
- **Linkage:** `sub_001` appears in 7 projects' `subcontractorIds[]` + `teamMembers[]`. `sub_002/003/004` appear in **none** — their only project link is `subcontractorTasks[].projectId` (e.g. sub_002 → PRJ-2026-004 via sct_001, PRJ-2026-007 via sct_005, PRJ-2026-009 via sct_006).
- **Sub identity:** `MOCK_SUBCONTRACTORS` (~line 6883): `{ id, empId, name, companyName, tradeType, email, phone, status, rating, companies[], joinedAt }`. Login record in `MOCK_USERS` mirrors name/company/trade/phone.
- **No subcontractor project view:** `/subcontractor/projects` (App.jsx ~402) reuses `src/pages/contractor/ProjectList.jsx` which filters `subcontractorIds?.includes(user.id)`**empty for sub_002**. Clicking opens a contractor `ProjectDetailsModal` (address/type/status/budget bar/milestones). No `src/pages/subcontractor/` project page exists.
- **Owner `OwnerProjectDetail.jsx`** tabs: overview, budget, team, changeOrders, rfis, milestones, invoices, estimates, risks, inspections, activity, docs. **Owner-private:** budget/profit/commission, team (other subs + rates), invoices (all), estimates, changeOrders, risks. **Sub-safe:** address, projectType, status, phase, completion, milestones (schedule), inspections touching their scope, their own tasks/payments.
- **Routes** (App.jsx ~396-411): `/subcontractor/dashboard`, `/subcontractor/projects` (→ contractor ProjectList), `/subcontractor/tasks/:taskId`. Clean slot for `/subcontractor/projects/:projectId`.
---
## Lane / ownership map
| Task | Lane (exclusive files) | Phase |
|------|------------------------|-------|
| T1 Reconcile sub↔project linkage | `src/data/mockStore.jsx` | A (seq) |
| T2 Scoped-project selectors | `src/data/selectors.js` | B (after A) |
| T3 Ground dashboard KPIs + My Projects | `src/pages/subcontractor/SubContractorDashboard.jsx` | C (parallel) |
| T4 Scoped project list + detail page + routes | new `src/pages/subcontractor/SubcontractorProjectsPage.jsx`, new `src/pages/subcontractor/SubcontractorProjectDetailPage.jsx`, `src/App.jsx` | C (parallel) |
| T5 Task-detail project context link | `src/pages/subcontractor/SubcontractorTaskDetailPage.jsx` | C (parallel) |
| T6 Verify | (read-only) | C+ |
**Phase A → Phase B → Phase C.** T3/T4/T5 are disjoint files (T4 owns App.jsx + 2 new files; T3 and T5 don't touch App.jsx) → parallel-safe. All of C imports the T2 selectors, so T2 must land first.
---
## Task 1: Reconcile sub↔project linkage
**Files:** `src/data/mockStore.jsx` only.
So that every subcontractor is consistently linked to the projects they work on (today only `sub_001` is).
- [ ] **Step 1:** For each entry in `MOCK_SUBCONTRACTOR_TASKS` that has a `projectId` (not null) and a `subcontractorId`, ensure that project's `subcontractorIds[]` array INCLUDES that `subcontractorId` (add it if missing, no duplicates). E.g. sct_001 (sub_002 → PRJ-2026-004) means PRJ-2026-004's `subcontractorIds` must include `'sub_002'`. Do this for all 10 tasks. (Tasks with `projectId: null` — sct_009 — are skipped.)
- [ ] **Step 2:** OPTIONAL but recommended for team coherence: if a project gains a sub via Step 1 and that sub is not already in the project's `teamMembers[]`, append a `teamMembers` entry `{ id: 'jtm_<projNum>_sub_<id>', userId: <subId>, name: <sub name>, email: <sub email>, orgRole: 'SUBCONTRACTOR', jobRole: 'Subcontractor', status: 'active', commissionEnabled: false, jobCommission: null }` using the canonical sub identity (sub_002 Maya Patel / Skyline Roof Crew, sub_003 Dwayne Boudreaux / Holland Painting Co., sub_004 Jennifer Castillo / Wu Plumbing). Keep it minimal — do NOT alter financial fields.
- [ ] **Step 3 (verify):** `npm run build``✓ built`, 0 duplicate-key warnings. For each task's (subId, projectId) pair, confirm `subcontractorIds` of that project now contains subId (write a throwaway node check, delete it). sub_002 should now resolve to PRJ-2026-004 / 007 / 009.
- [ ] **Step 4:** commit `git commit -am "feat(data): reconcile subcontractor↔project linkage (subcontractorIds from tasks)"`.
## Task 2: Scoped-project selectors
**Files:** `src/data/selectors.js`. **After T1.**
Pure functions that derive the sub's projects and the permitted subset. The scoping selector is the single guard that keeps owner-private fields out of the sub view.
- [ ] **Step 1:** Add:
```js
// Projects a subcontractor is involved in — union of (a) projects listing them in subcontractorIds
// and (b) projects their tasks reference. Returns light summaries for a "My Projects" list.
export function subcontractorProjects(projects = [], tasks = [], subId) {
const fromTasks = new Set(tasks.filter((t) => t.subcontractorId === subId && t.projectId).map((t) => t.projectId));
return projects
.filter((p) => (p.subcontractorIds || []).includes(subId) || fromTasks.has(p.id))
.map((p) => {
const myTasks = tasks.filter((t) => t.subcontractorId === subId && t.projectId === p.id);
return {
id: p.id,
address: p.address,
customerName: p.customerName,
projectType: p.projectType,
status: p.status,
phase: p.phase,
completionPercentage: p.completionPercentage,
taskCount: myTasks.length,
openTaskCount: myTasks.filter((t) => !['Completed', 'Cancelled'].includes(t.status)).length,
};
});
}
// The PERMITTED SUBSET of a single project for a subcontractor. Deliberately omits owner-private
// fields (budget, committedCost, margins, commission, other subs, all invoices, estimates,
// changeOrders, risks). Includes the schedule (milestones) read-only + the sub's own tasks/payments.
export function scopedProjectForSub(project, subId, tasks = []) {
if (!project) return null;
const myTasks = tasks.filter((t) => t.subcontractorId === subId && t.projectId === project.id);
const sumFees = (t) => (t.fees || []).reduce((s, f) => s + (f.amount || 0), 0);
const sumExpenses = (t) => (t.expenses || []).reduce((s, e) => s + (e.amount || 0), 0);
const myEarnings = myTasks.reduce((s, t) => s + sumFees(t) + sumExpenses(t), 0);
const paid = myTasks.filter((t) => t.paymentStatus === 'Paid').reduce((s, t) => s + sumFees(t) + sumExpenses(t), 0);
return {
id: project.id,
address: project.address,
customerName: project.customerName,
projectType: project.projectType,
status: project.status,
phase: project.phase,
lifecycleStage: project.lifecycleStage,
completionPercentage: project.completionPercentage,
startDate: project.startDate,
endDate: project.endDate,
// schedule (read-only) — names/dates/status only; no costs
milestones: (project.milestones || []).map((m) => ({ id: m.id, name: m.name, dueDate: m.dueDate, status: m.status })),
// the sub's own scope on this job
myTasks: myTasks.map((t) => ({ id: t.id, title: t.title, status: t.status, dueDate: t.dueDate, category: t.category })),
myPayments: { earnings: myEarnings, paid, unpaid: myEarnings - paid },
};
}
```
- [ ] **Step 2 (verify):** throwaway node fixture check — `scopedProjectForSub` returns NO `budget`/`committedCost`/`commission`/`teamMembers`/`invoices` keys (assert those are undefined on the result), and `myTasks` only contains the given sub's tasks. `subcontractorProjects` returns sub_002's 3 projects. Delete the script. `node -e "import('./src/data/selectors.js').then(m=>console.log(typeof m.scopedProjectForSub, typeof m.subcontractorProjects))"`.
- [ ] **Step 3:** commit `git commit -am "feat(analytics): add subcontractor project scoping selectors (permitted-subset project view)"` (after `git add` since selectors.js is tracked — it is).
## Task 3: Ground the dashboard KPIs + add My Projects
**Files:** `src/pages/subcontractor/SubContractorDashboard.jsx` only. **Phase C (parallel). Imports T2.**
- [ ] **Step 1:** Replace the legacy milestone-based KPI source. The 4 KPI cards must derive from `myAssignments` (already computed = `subcontractorTasks.filter(t => t.subcontractorId === subId)`):
- **Tasks In Progress** = `myAssignments.filter(t => t.status === 'In Progress').length`
- **Pending Review** = `myAssignments.filter(t => t.status === 'Post-Work Review').length` (or "Awaiting Review")
- **Jobs Completed** = `myAssignments.filter(t => t.status === 'Completed').length`
- **Pending Payouts** = Σ (fees + expenses) over `myAssignments` where `paymentStatus !== 'Paid'``$` formatted with `toLocaleString('en-US')` (avoid the Indian-grouping bug).
Keep the existing card styling; just swap the data. Remove or repurpose the dead `myTasks`/`inProgressTasks`/`pendingTasks`/`completedTasks` milestone derivation (and the `SubDetailModal` drill-downs that used them — re-point those drill-downs at `myAssignments` subsets, or simplify).
- [ ] **Step 2:** Add a **"My Projects"** section: `const myProjects = useMemo(() => subcontractorProjects(projects, subcontractorTasks, subId), [projects, subcontractorTasks, subId]);` (pull `projects` into the `useMockStore()` destructure). Render each as a card/row (projectType — address, status/phase chip, "{openTaskCount} open of {taskCount} tasks") that navigates to `/subcontractor/projects/${p.id}` on click. This is the sub's grounded project list.
- [ ] **Step 3:** (optional) ground the hardcoded clock-in widget to derive from recent task `activities`/`statusHistory`, OR leave it but label it clearly as a sample. Don't overbuild — KPIs + My Projects are the required deliverables.
- [ ] **Step 4 (verify):** `npm run build``✓ built`. Dev-run as `maya` (sub_002): KPIs are non-zero and match her real tasks; "My Projects" shows her 3 projects.
- [ ] **Step 5:** commit `git commit -am "feat(subportal): ground dashboard KPIs in real tasks; add My Projects list"`.
## Task 4: Scoped project list + detail page + routes
**Files:** Create `src/pages/subcontractor/SubcontractorProjectsPage.jsx` and `src/pages/subcontractor/SubcontractorProjectDetailPage.jsx`; edit `src/App.jsx`. **Phase C (parallel). Imports T2.**
- [ ] **Step 1 — list page** `SubcontractorProjectsPage.jsx`: `const { projects, subcontractorTasks } = useMockStore(); const { user } = useAuth();``subcontractorProjects(projects, subcontractorTasks, user?.id)`. Render the project summaries as cards (projectType, address, status/phase chip, open/total task counts), each linking to `/subcontractor/projects/:projectId`. Friendly empty state. Match the subcontractor portal's dark styling (mirror `SubContractorDashboard`).
- [ ] **Step 2 — scoped detail page** `SubcontractorProjectDetailPage.jsx`: `const { projectId } = useParams();` → look up the project in `projects`, then `const scoped = scopedProjectForSub(project, user?.id, subcontractorTasks);`. **Render ONLY `scoped` fields** (never the raw project): header (projectType, address, customerName, status/phase/lifecycleStage badge, completion bar); a **Schedule** card (the `milestones` — name/dueDate/status, read-only); a **My Tasks on this job** card (`myTasks` — each links to `/subcontractor/tasks/:id`); a **My Payments** card (`myPayments.earnings`/`paid`/`unpaid`, `toLocaleString('en-US')`). Do NOT render budget, commission, other team members, invoices, estimates, change orders, or risks. If `scoped` is null (sub not on this project), show "You don't have access to this project" + back link. A back link to `/subcontractor/projects`.
- [ ] **Step 3 — routes** (`App.jsx`): import both pages. Replace the `/subcontractor/projects` route's element with `<SubcontractorProjectsPage />` (instead of the contractor `ProjectList`). Add `/subcontractor/projects/:projectId``<SubcontractorProjectDetailPage />`, gated `allowedRoles={['SUBCONTRACTOR']}`. (Leave `src/pages/contractor/ProjectList.jsx` untouched — contractors keep using it.)
- [ ] **Step 4 (verify):** `npm run build``✓ built`. Dev-run as `maya`: `/subcontractor/projects` lists her 3 projects; opening one shows the scoped view with her tasks + schedule + payments and **no budget/commission/other-sub data**. Compare the address/type/status/milestones against the owner's `/projects/PRJ-2026-004` — they agree.
- [ ] **Step 5:** commit `git commit -am "feat(subportal): scoped subcontractor project list + detail (project parity, permitted subset)"`.
## Task 5: Task-detail project context link
**Files:** `src/pages/subcontractor/SubcontractorTaskDetailPage.jsx` only. **Phase C (parallel).**
- [ ] **Step 1:** Pull `projects` from `useMockStore()`. Resolve the parent project: `const project = projects.find(p => p.id === task.projectId);`.
- [ ] **Step 2:** In `TaskHeaderCard` (or just below the breadcrumb), add a **project context** line/chip: the project's `projectType` + short address, as a clickable link to `/subcontractor/projects/${task.projectId}` (only when `task.projectId` resolves). Add the project to the breadcrumb trail (Dashboard {Project} Task) if trivial. Keep it minimal — a chip + link is enough; do NOT render project financials here.
- [ ] **Step 3 (verify):** `npm run build``✓ built`. Dev-run: opening a task shows its project chip; clicking it opens the scoped project page for that project.
- [ ] **Step 4:** commit `git commit -am "feat(subportal): show parent-project context + link on the task detail page"`.
## Task 6: Verify (read-only)
- [ ] `npm run build` clean, 0 duplicate-key warnings.
- [ ] **Linkage:** every subcontractor task's (subId, projectId) is reflected in that project's `subcontractorIds`; sub_002 resolves to 3 projects.
- [ ] **Parity:** for a shared job (e.g. PRJ-2026-004), the subcontractor's scoped view and the owner's `/projects/PRJ-2026-004` agree on address, projectType, status/phase, and milestone names/dates/status.
- [ ] **No leakage:** the scoped page renders NO budget/committedCost/margin/commission/other-subcontractor/invoice/estimate/changeOrder data. (Grep the new detail page — it should reference only `scoped.*`, never raw `project.budget`/`.commission`/`.teamMembers`/`.invoices`.)
- [ ] **Grounded portal:** dashboard KPIs for `maya` are non-zero and match her real `subcontractorTasks`; "My Projects" + the projects route list her real projects; the task detail page links to the project.
- [ ] Dev-run both logins (an owner and `maya`) on the same job — same job, same facts, sub sees only their slice.
---
## Self-review checklist
- [ ] sub↔project linkage reconciled from tasks (all subs, not just sub_001).
- [ ] `scopedProjectForSub` returns ONLY permitted fields (no owner-private keys); fixture-verified.
- [ ] Dashboard KPIs grounded in `subcontractorTasks`; My Projects list real; en-US currency formatting.
- [ ] Scoped list + detail pages built; `/subcontractor/projects` repointed; `/subcontractor/projects/:id` added; contractor `ProjectList` untouched.
- [ ] Task detail links to the scoped project; no financials leaked there.
- [ ] Build clean; owner & sub views of the same job agree on shared facts.
## Notes for Plan 7 / 8
Plan 7 (profiles, skills, AI dispatch) adds the sub's profile (equipment, skills) that LynkDispatch cites. Plan 8 (ABAC) replaces the hand-written `scopedProjectForSub` field-omission with a **policy** (`can(SUBCONTRACTOR, 'view', project, {scope:'assigned'})` → field-level visibility), and the coarse `allowedRoles` route guards with the policy engine. The scoping selector here is the concrete behaviour Plan 8 will generalise.