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

18 KiB
Raw Blame History

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:
// 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)".

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.