18 KiB
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 legacyprojects.milestonesfiltered byassignedTo === subId(lines ~212-218) — this is 0 for sub_002 (milestones only carrysub_001). The real sourcemyAssignments = 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.projectIdexists on every task but is never read — no project name, badge, or link. Does not pullprojectsfrom the store.- Linkage:
sub_001appears in 7 projects'subcontractorIds[]+teamMembers[].sub_002/003/004appear in none — their only project link issubcontractorTasks[].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 inMOCK_USERSmirrors name/company/trade/phone. - No subcontractor project view:
/subcontractor/projects(App.jsx ~402) reusessrc/pages/contractor/ProjectList.jsxwhich filterssubcontractorIds?.includes(user.id)→ empty for sub_002. Clicking opens a contractorProjectDetailsModal(address/type/status/budget bar/milestones). Nosrc/pages/subcontractor/project page exists. - Owner
OwnerProjectDetail.jsxtabs: 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_TASKSthat has aprojectId(not null) and asubcontractorId, ensure that project'ssubcontractorIds[]array INCLUDES thatsubcontractorId(add it if missing, no duplicates). E.g. sct_001 (sub_002 → PRJ-2026-004) means PRJ-2026-004'ssubcontractorIdsmust include'sub_002'. Do this for all 10 tasks. (Tasks withprojectId: 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 ateamMembersentry{ 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, confirmsubcontractorIdsof 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:
// 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 —
scopedProjectForSubreturns NObudget/committedCost/commission/teamMembers/invoiceskeys (assert those are undefined on the result), andmyTasksonly contains the given sub's tasks.subcontractorProjectsreturns 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)"(aftergit addsince 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
myAssignmentswherepaymentStatus !== 'Paid'—$formatted withtoLocaleString('en-US')(avoid the Indian-grouping bug). Keep the existing card styling; just swap the data. Remove or repurpose the deadmyTasks/inProgressTasks/pendingTasks/completedTasksmilestone derivation (and theSubDetailModaldrill-downs that used them — re-point those drill-downs atmyAssignmentssubsets, or simplify).
- Tasks In Progress =
- Step 2: Add a "My Projects" section:
const myProjects = useMemo(() => subcontractorProjects(projects, subcontractorTasks, subId), [projects, subcontractorTasks, subId]);(pullprojectsinto theuseMockStore()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 asmaya(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 (mirrorSubContractorDashboard). - Step 2 — scoped detail page
SubcontractorProjectDetailPage.jsx:const { projectId } = useParams();→ look up the project inprojects, thenconst scoped = scopedProjectForSub(project, user?.id, subcontractorTasks);. Render ONLYscopedfields (never the raw project): header (projectType, address, customerName, status/phase/lifecycleStage badge, completion bar); a Schedule card (themilestones— 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. Ifscopedis 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/projectsroute's element with<SubcontractorProjectsPage />(instead of the contractorProjectList). Add/subcontractor/projects/:projectId→<SubcontractorProjectDetailPage />, gatedallowedRoles={['SUBCONTRACTOR']}. (Leavesrc/pages/contractor/ProjectList.jsxuntouched — contractors keep using it.) - Step 4 (verify):
npm run build→✓ built. Dev-run asmaya:/subcontractor/projectslists 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
projectsfromuseMockStore(). 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'sprojectType+ short address, as a clickable link to/subcontractor/projects/${task.projectId}(only whentask.projectIdresolves). 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 buildclean, 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-004agree 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 rawproject.budget/.commission/.teamMembers/.invoices.) - Grounded portal: dashboard KPIs for
mayaare non-zero and match her realsubcontractorTasks; "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).
scopedProjectForSubreturns 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/projectsrepointed;/subcontractor/projects/:idadded; contractorProjectListuntouched. - 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.