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=<id>; 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 15:39:45 -07:00
parent 4d111d608d
commit 3c40b58ff8
9 changed files with 437 additions and 39 deletions

View File

@@ -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()

View File

@@ -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),
}