Files
LynkedUpPro_CRM/docs/superpowers/plans/2026-05-29-plan6-subcontractor-portal-parity.md
T

172 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.