From 3c40b58ff82c31e4cc03b6617b698bb808c1ce0b Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Mon, 15 Jun 2026 15:39:45 -0700 Subject: [PATCH] Add multi-project support: projects entity, picker home page, project context Projects become the top-level container; SOPs and Work Packages belong to one. Backend: - New projects table + CRUD (/api/projects). - sops.project_id (FK, cascade) and work_packages.project_id added; list/latest/metrics endpoints accept a project_id filter. Front end (now under html/): - project-data.js: shared API-first ProjectData adapter with localStorage fallback + active-project helpers. - Home page: removed "About This Suite"; added a Project picker (create / use sample / select). Tool cards stay hidden until a project is active and carry &project=; hero shows the active project. - Suite reads ?project, resolves it, shows it in the header, and prefills the SOP project fields; passes &project into the WP-creator iframe. - WP creator stamps projectId onto saved packages. SOP/WP localStorage is not yet namespaced per project (next step). Co-Authored-By: Claude Opus 4.8 (1M context) --- ONBOARDING.md | 36 +++++++ html/index.html | 188 ++++++++++++++++++++++++++++----- html/project-data.js | 80 ++++++++++++++ html/work-package-suite-app.js | 44 +++++++- html/work-package-suite.html | 1 + html/wp-creation-app.js | 3 + html/wp-creation-index.html | 1 + server/app.py | 78 +++++++++++++- server/models.py | 45 +++++++- 9 files changed, 437 insertions(+), 39 deletions(-) create mode 100644 html/project-data.js diff --git a/ONBOARDING.md b/ONBOARDING.md index 171f605..bd93f2a 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -130,6 +130,42 @@ to `fetch('/api/wps…')` in Phase 2 and the UI is unchanged. > real data this is fine; once there is, add Alembic (see open question #2) and > migrate rather than relying on `create_all`. +## Multi-project support + +> **Note on layout:** the IT admin moved all static files into **`html/`** and +> added a Docker/NGINX deployment (`Dockerfile`, `docker-compose.yml`, `nginx/`). +> Front-end paths below are under `html/`. `server/` stayed at the repo root. + +The suite is now multi-project. **Projects are the top-level container**; every +SOP and Work Package belongs to one. + +- **Backend:** new `projects` table + CRUD (`/api/projects`). `sops` gained + `project_id` (FK, cascade) and `work_packages` gained `project_id`; list/latest/ + metrics endpoints accept a `project_id` filter. +- **Project layer:** [html/project-data.js](html/project-data.js) — a shared, + **API-first** `ProjectData` adapter (`list/get/save/remove` hit `/api/projects`) + that **falls back to a localStorage mirror** (`wp_projects`) when the API is + unreachable, plus active-project helpers (`getActive`/`setActive`, stored in + `wp_active_project` / `wp_active_project_obj`). +- **Home page** ([html/index.html](html/index.html)): "About This Suite" removed; + a **Project** picker added. With no projects it offers *Create Project* / *Use + Sample Project*; otherwise a dropdown to select. The tool cards stay hidden + until a project is active and then carry `&project=`; the hero shows the + active project. +- **Suite** ([html/work-package-suite-app.js](html/work-package-suite-app.js)): + reads `?project=`, resolves it via `ProjectData`, shows it in the header, + and **prefills the SOP project fields** (step 1) from the project record when + empty. Passes `&project` into the WP-creator iframe. +- **WP creator:** stamps `projectId` onto every saved package (for API sync). + +**Not yet done (the next fork):** SOP/WP *localStorage* is still global, not +namespaced per project — selecting a different project locally still shows the +same `wp_suite_sop` / `wp_iwp_v1` data. The intended end state is per-project +data via the API (`GET /api/sops/latest?project_id=…`, `GET /api/wps?project_id=…`). +Decide whether to (a) namespace the local keys by project id with a migration of +existing un-namespaced data into a "default" project, or (b) jump straight to the +API for SOP/WP reads. See the open question below. + **Pending — Phase 2: wire the front end to the API** - SOP: on *SOP Complete*, `POST /api/sops`; on load, `GET /api/sops/latest` to hydrate the Creator (currently uses `localStorage` key `wp_suite_sop`). - WP Creator: save packages via `POST /api/wps`; list/load via `GET /api/wps` diff --git a/html/index.html b/html/index.html index 5136b02..b1c037b 100644 --- a/html/index.html +++ b/html/index.html @@ -348,6 +348,22 @@ color: var(--cds-text-primary); } + /* PROJECT PICKER */ + .proj-loading { color: var(--cds-text-secondary); font-style: italic; font-size: 13px; } + .proj-row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; } + .proj-row select { flex: 1; min-width: 240px; padding: 0.6rem 0.7rem; font-size: 14px; + border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; background: #fff; } + .proj-empty { background: var(--cds-ui-01, #fff); border: 1px dashed var(--cds-border-strong, #8d8d8d); + border-radius: 6px; padding: 1.25rem; } + .proj-empty p { margin: 0 0 0.9rem; color: var(--cds-text-secondary); } + .proj-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; } + .proj-form { margin-top: 1rem; padding: 1rem; border: 1px solid var(--cds-ui-03, #e0e0e0); border-radius: 6px; background: var(--cds-ui-01, #fff); } + .proj-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; margin-bottom: 0.9rem; } + .proj-form-grid label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 12px; font-weight: 600; color: var(--cds-text-secondary); } + .proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; } + .proj-active { margin-top: 0.85rem; font-size: 13px; color: var(--cds-text-primary); } + .link-like { background: none; border: none; color: var(--cds-link-01, #0f62fe); cursor: pointer; font-size: 13px; padding: 0; text-decoration: underline; } + /* RESPONSIVE */ @media (max-width: 768px) { .header-content { flex-direction: column; text-align: center; } @@ -379,12 +395,19 @@
-

Work Package Suite

-

Standardized approach to Work Package creation for Prime Controls construction projects. Configure project parameters, define constraints, and generate compliant work packages.

+

Work Package Suite

+

Standardized Work Package creation for Prime Controls construction projects. Select a project to begin — or create one.

- -
+ +
+

Project

+

Projects are stored centrally. Pick the project you're working on, or set up a new one.

+
Loading projects…
+
+ + +
- -
-

About This Suite

-
-
-

Two-Step Workflow

-

Configure the project SOP once, then author every Work Package against it. The Creator stays locked until the SOP is complete, so packages always inherit a valid baseline.

-
-
-

Leave Feedback

-

Use the feedback section on this page or within any tool. All comments are stored locally and can be exported for team review and iteration.

-
-
-

Offline & Collaborative

-

All tools work entirely in your browser. Export SOP and Work Package data as JSON for sharing, version control, and integration.

-
-
-
- @@ -470,20 +474,150 @@ + + diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index e5c61b7..2b1ebb8 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -43,6 +43,7 @@ const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject": // ── STATE ──────────────────────────────────────────────────────────────────── let SOP=null, editingId=null, numberDirty=false; +let activeProjectId=''; // set at boot from ?project=; stamped onto saved WPs for the API let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[]; let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3) @@ -631,6 +632,7 @@ function collectPackage(){ const prev=editingId?savedPackages.find(p=>p.id===editingId):null; // carry instance/split linkage across edits return { id: editingId || ('wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)), + projectId: (prev&&prev.projectId) || activeProjectId || '', instanceOf: prev?prev.instanceOf:undefined, instanceLabel: prev?prev.instanceLabel:undefined, parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined, number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'), @@ -1016,6 +1018,7 @@ loadStore(); // and prefer the SOP the Suite just completed (persisted to localStorage). const params = new URLSearchParams(location.search); if(params.get('embedded')) document.body.classList.add('embedded'); + activeProjectId = params.get('project') || (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || ''; try { const raw = localStorage.getItem('wp_suite_sop'); if(raw){ diff --git a/html/wp-creation-index.html b/html/wp-creation-index.html index 9394494..846c12f 100644 --- a/html/wp-creation-index.html +++ b/html/wp-creation-index.html @@ -276,6 +276,7 @@ + diff --git a/server/app.py b/server/app.py index 4eee88a..499de33 100644 --- a/server/app.py +++ b/server/app.py @@ -41,8 +41,21 @@ def gen_id(prefix: str) -> str: # ── Request bodies ─────────────────────────────────────────────────────────── +class ProjectIn(BaseModel): + id: Optional[str] = None + name: str = "" + number: str = "" + client: str = "" + division: str = "" + site: str = "" + sample: bool = False + created_by: str = "" + data: dict[str, Any] = Field(default_factory=dict) + + class SopIn(BaseModel): id: Optional[str] = None + project_id: Optional[str] = None name: str = "" number: str = "" complete: bool = False @@ -52,6 +65,7 @@ class SopIn(BaseModel): class WpIn(BaseModel): id: Optional[str] = None + project_id: Optional[str] = None sop_id: Optional[str] = None parent_id: Optional[str] = None number: str = "" @@ -86,6 +100,50 @@ def health(): return {"ok": True} +# ── Projects ───────────────────────────────────────────────────────────────── +@app.post("/api/projects") +def upsert_project(body: ProjectIn, db: Session = Depends(get_db)): + proj = db.get(models.Project, body.id) if body.id else None + if proj is None: + proj = models.Project(id=body.id or gen_id("proj")) + db.add(proj) + proj.name = body.name + proj.number = body.number + proj.client = body.client + proj.division = body.division + proj.site = body.site + proj.sample = body.sample + proj.created_by = body.created_by or proj.created_by + proj.data = body.data + db.commit() + db.refresh(proj) + return proj.to_dict() + + +@app.get("/api/projects") +def list_projects(db: Session = Depends(get_db)): + rows = db.scalars(select(models.Project).order_by(models.Project.updated_at.desc())).all() + return [p.summary() for p in rows] + + +@app.get("/api/projects/{project_id}") +def get_project(project_id: str, db: Session = Depends(get_db)): + proj = db.get(models.Project, project_id) + if not proj: + raise HTTPException(status_code=404, detail="Project not found") + return proj.to_dict() + + +@app.delete("/api/projects/{project_id}") +def delete_project(project_id: str, db: Session = Depends(get_db)): + proj = db.get(models.Project, project_id) + if not proj: + raise HTTPException(status_code=404, detail="Project not found") + db.delete(proj) + db.commit() + return {"deleted": project_id} + + # ── SOPs ───────────────────────────────────────────────────────────────────── @app.post("/api/sops") def upsert_sop(body: SopIn, db: Session = Depends(get_db)): @@ -93,6 +151,7 @@ def upsert_sop(body: SopIn, db: Session = Depends(get_db)): if sop is None: sop = models.Sop(id=body.id or gen_id("sop")) db.add(sop) + sop.project_id = body.project_id sop.name = body.name sop.number = body.number sop.complete = body.complete @@ -104,16 +163,21 @@ def upsert_sop(body: SopIn, db: Session = Depends(get_db)): @app.get("/api/sops") -def list_sops(db: Session = Depends(get_db)): - rows = db.scalars(select(models.Sop).order_by(models.Sop.updated_at.desc())).all() +def list_sops(project_id: Optional[str] = Query(None), db: Session = Depends(get_db)): + stmt = select(models.Sop) + if project_id: + stmt = stmt.where(models.Sop.project_id == project_id) + rows = db.scalars(stmt.order_by(models.Sop.updated_at.desc())).all() return [s.summary() for s in rows] @app.get("/api/sops/latest") -def latest_sop(complete: Optional[bool] = None, db: Session = Depends(get_db)): +def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Query(None), db: Session = Depends(get_db)): stmt = select(models.Sop) if complete is not None: stmt = stmt.where(models.Sop.complete == complete) + if project_id: + stmt = stmt.where(models.Sop.project_id == project_id) sop = db.scalars(stmt.order_by(models.Sop.updated_at.desc()).limit(1)).first() if not sop: raise HTTPException(status_code=404, detail="No SOP found") @@ -145,6 +209,7 @@ def upsert_wp(body: WpIn, db: Session = Depends(get_db)): if wp is None: wp = models.WorkPackage(id=body.id or gen_id("wp")) db.add(wp) + wp.project_id = body.project_id wp.sop_id = body.sop_id wp.parent_id = body.parent_id wp.number = body.number @@ -160,12 +225,15 @@ def upsert_wp(body: WpIn, db: Session = Depends(get_db)): @app.get("/api/wps") def list_wps( + project_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None), parent_id: Optional[str] = Query(None), status: Optional[str] = Query(None), db: Session = Depends(get_db), ): stmt = select(models.WorkPackage) + if project_id: + stmt = stmt.where(models.WorkPackage.project_id == project_id) if sop_id: stmt = stmt.where(models.WorkPackage.sop_id == sop_id) if parent_id: @@ -177,11 +245,13 @@ def list_wps( @app.get("/api/wps/metrics") -def wp_metrics(sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)): +def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)): """Aggregates for the dashboard. Masters (data.split == true) are excluded from counts so a split package's hours aren't double-counted with its instances.""" stmt = select(models.WorkPackage) + if project_id: + stmt = stmt.where(models.WorkPackage.project_id == project_id) if sop_id: stmt = stmt.where(models.WorkPackage.sop_id == sop_id) rows = db.scalars(stmt).all() diff --git a/server/models.py b/server/models.py index 4696b57..5d4ee8c 100644 --- a/server/models.py +++ b/server/models.py @@ -21,10 +21,42 @@ def utcnow() -> datetime: return datetime.now(timezone.utc) +class Project(Base): + """A construction project — the top-level container. SOPs and Work Packages + belong to a project so the suite can be used for many jobs at once.""" + __tablename__ = "projects" + + id: Mapped[str] = mapped_column(String(40), primary_key=True) + name: Mapped[str] = mapped_column(String(300), default="") + number: Mapped[str] = mapped_column(String(100), default="", index=True) + client: Mapped[str] = mapped_column(String(300), default="") + division: Mapped[str] = mapped_column(String(200), default="") + site: Mapped[str] = mapped_column(String(300), default="") + sample: Mapped[bool] = mapped_column(Boolean, default=False) + data: Mapped[dict] = mapped_column(JSON, default=dict) + created_by: Mapped[str] = mapped_column(String(200), default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + + def summary(self) -> dict: + return { + "id": self.id, "name": self.name, "number": self.number, + "client": self.client, "division": self.division, "site": self.site, + "sample": self.sample, "created_by": self.created_by, + "created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at), + } + + def to_dict(self) -> dict: + return {**self.summary(), "data": self.data or {}} + + class Sop(Base): __tablename__ = "sops" id: Mapped[str] = mapped_column(String(40), primary_key=True) + project_id: Mapped[Optional[str]] = mapped_column( + String(40), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True + ) name: Mapped[str] = mapped_column(String(300), default="") number: Mapped[str] = mapped_column(String(100), default="") complete: Mapped[bool] = mapped_column(Boolean, default=False) @@ -35,8 +67,8 @@ class Sop(Base): def summary(self) -> dict: return { - "id": self.id, "name": self.name, "number": self.number, - "complete": self.complete, "created_by": self.created_by, + "id": self.id, "project_id": self.project_id, "name": self.name, + "number": self.number, "complete": self.complete, "created_by": self.created_by, "created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at), } @@ -48,6 +80,9 @@ class WorkPackage(Base): __tablename__ = "work_packages" id: Mapped[str] = mapped_column(String(40), primary_key=True) + project_id: Mapped[Optional[str]] = mapped_column( + String(40), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True + ) sop_id: Mapped[Optional[str]] = mapped_column( String(40), ForeignKey("sops.id", ondelete="SET NULL"), nullable=True, index=True ) @@ -65,9 +100,9 @@ class WorkPackage(Base): def summary(self) -> dict: return { - "id": self.id, "sop_id": self.sop_id, "parent_id": self.parent_id, - "number": self.number, "subject": self.subject, "type": self.type, - "status": self.status, "issued_at": _iso(self.issued_at), + "id": self.id, "project_id": self.project_id, "sop_id": self.sop_id, + "parent_id": self.parent_id, "number": self.number, "subject": self.subject, + "type": self.type, "status": self.status, "issued_at": _iso(self.issued_at), "created_by": self.created_by, "created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at), }