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

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